mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 19:21:27 -03:00
Compare commits
18
Commits
v1.2.1
..
e57e11897e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57e11897e | ||
|
|
8a16034135 | ||
|
|
7fc3b7e5be | ||
|
|
b0c7a1baae | ||
|
|
6411d83d46 | ||
|
|
74a063b0e5 | ||
|
|
96376e5cce | ||
|
|
e7c26bf722 | ||
|
|
cef4129fc9 | ||
|
|
0a28500848 | ||
|
|
fc3f3f3bdb | ||
|
|
fa58297973 | ||
|
|
5d1a22fb8f | ||
|
|
d2f50f26f1 | ||
|
|
4a6042d0b4 | ||
|
|
846206d958 | ||
|
|
0daf4924f0 | ||
|
|
d38a3d091d |
@@ -0,0 +1,206 @@
|
|||||||
|
# Plan: Multi-File Downloads Within a Single CivitAI Model Version
|
||||||
|
|
||||||
|
**Issue:** [#1058 — Cannot download multiple file variants from the same model version](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1058)
|
||||||
|
**Status:** v2 — revised after adversarial review (backend correctness + frontend/tests)
|
||||||
|
**Scope:** CivitAI/CivArchive downloads of `lora`, `checkpoint`, `embedding` model types. HuggingFace downloads are out of scope (already per-file).
|
||||||
|
|
||||||
|
> v2 changelog: incorporated 18 review findings. Key changes vs v1:
|
||||||
|
> shared file resolver + `resolved_version_id` for the gate (R1); `file_params` normalization at API boundary (R2); D2 hash-matching rule fixed for empty-hash cases (R6/R7); D3 extended to re-point `version_index` on removal (R4); D4 replaced with a child table (R3); `delete_model_version` interaction documented (R5); `ModelVersionsTab` surface added to phase 2 (F6); phase-2 multi-file loop requires a reload-deferred download variant (F7); queue-retry `file_params=NULL` known issue recorded (R9); test-fixture gaps and revised estimates (F10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
A CivitAI model version can contain multiple downloadable weight files (e.g. fp16/fp32, safetensors/ckpt, different sizes). LoRA Manager already has a working file-selection pipeline (frontend file dialog → `fileParams` → backend file matching), but downloaded state is tracked at the **model-version** level. After any single file of a version is downloaded:
|
||||||
|
|
||||||
|
1. The version is marked **In Library** and the file-selection entry point disappears.
|
||||||
|
2. The backend rejects further download attempts for that version.
|
||||||
|
|
||||||
|
There is no way to download the remaining files of the same version through LoRA Manager.
|
||||||
|
|
||||||
|
## 2. Current State (verified against code; all references confirmed by review)
|
||||||
|
|
||||||
|
### 2.1 Download gating — backend (`py/services/download_manager.py`)
|
||||||
|
|
||||||
|
`_execute_original_download` enforces two version-level gates:
|
||||||
|
|
||||||
|
- **Library gate, early** (lines 1157–1184, before metadata fetch, fires when `model_version_id` given) and **late** (lines 1350–1376, fires only when `model_version_id is None`): `scanner.check_model_version_exists(version_id)` across lora/checkpoint/embedding scanners → hard error `"Model version already exists in ... library"`.
|
||||||
|
- **History gate** (lines 1238–1279): when `skip_previously_downloaded_model_versions` setting is on, `_has_been_downloaded(model_type, version_id)` → silent skip. History DB primary key is `(model_type, version_id)` (`py/services/downloaded_version_history_service.py:61`).
|
||||||
|
|
||||||
|
File selection works: `file_params {id, type, format, size, fp}` is matched against `version_info.files` (lines 1498–1569), **but only under `if file_params and model_version_id:` (line 1499)** — with `model_id`-only requests the selection silently falls back to the primary file (1571–1619). `file_params` currently carries no file `name` or hash.
|
||||||
|
|
||||||
|
### 2.2 Downloaded-state surfacing — backend (`py/routes/handlers/model_handlers.py`)
|
||||||
|
|
||||||
|
`get_civitai_versions` (lines 2148–2188) sets per-version `existsLocally` via `cache.version_index.get(version_id)` (plus a single `localPath` from that entry) and `hasBeenDownloaded` via the history service. No per-file granularity.
|
||||||
|
|
||||||
|
### 2.3 Frontend blockers (`static/js/managers/DownloadManager.js`)
|
||||||
|
|
||||||
|
Three independent gates prevent re-entering the file dialog:
|
||||||
|
|
||||||
|
1. **Line 598:** file-select badge rendered only when `modelFiles.length > 1 && !existsLocally`.
|
||||||
|
2. **Lines 666–681 (`updateNextButtonState`):** Next button disabled with "Already in Library" when `currentVersion.existsLocally`.
|
||||||
|
3. **Lines 784–787 (`proceedToLocation`):** toast + abort when `currentVersion.existsLocally`.
|
||||||
|
|
||||||
|
The badge path (`confirmFileSelection` lines 737–759 → `proceedToLocationContent` → `startDownload` single mode → `executeDownloadWithProgress` → POST `file_params`, `static/js/api/baseModelApi.js:1236–1250`) has **zero** `existsLocally` guards (all 12 occurrences enumerated; none on this path; `import/DownloadManager.js` has none either). The `.exists-locally` CSS class is purely visual (`download-modal.css:496–499`). **Making the badge visible again is sufficient to unlock the flow** for phase 1.
|
||||||
|
|
||||||
|
Post-download refresh is clean: the modal closes and `resetAndReload(true)` performs a full library refetch (`DownloadManager.js:1063`); dialog reopen resets state and refetches versions with no client-side cache. No same-session staleness.
|
||||||
|
|
||||||
|
### 2.4 Local identity of the downloaded file
|
||||||
|
|
||||||
|
`LoraMetadata/CheckpointMetadata/EmbeddingMetadata.from_civitai_info(version_info, file_info, ...)` (`py/utils/models.py:245–369`) persists:
|
||||||
|
|
||||||
|
- `sha256` = `file_info.hashes.SHA256` (lowercased, defaults to `""`) — a stable per-file identity;
|
||||||
|
- `civitai` = the full `version_info` payload (including the `files` list).
|
||||||
|
|
||||||
|
Metadata refresh (`metadata_sync_service.py:104–105`) replaces the `civitai` blob wholesale but never overwrites top-level `sha256`; `verify_duplicate_hashes` (481–526) corrects it to the on-disk hash. Top-level-sha256 matching is refresh-robust.
|
||||||
|
|
||||||
|
**Caveats (review R6/R7):**
|
||||||
|
- SHA256 is not guaranteed: CivArchive's transform only sets `hashes` when source data carries it (`civarchive_client.py:185–189`); `from_civitai_info` defaults to `""`.
|
||||||
|
- Name fallback is unreliable exactly when it matters: local `file_name` is extension-less (`models.py:264`) and `generate_unique_filename` rewrites it with a hash suffix on conflict (`download_manager.py:1125–1136`); checkpoints with `hash_status='pending'` keep empty sha256 until on-demand hashing (`model_scanner.py:1232–1240`).
|
||||||
|
|
||||||
|
### 2.5 Version index collision (pre-existing hazard)
|
||||||
|
|
||||||
|
`ModelCache.version_index` is single-valued (`model_cache.py:133`: `version_index[version_id] = item`). Two files of the same version in the library → second entry overwrites the first; `remove_from_version_index` (lines 151–181) drops the whole version key when the indexed entry is removed, even if a sibling file remains. ~10 read sites depend on this index (48 grep touch points total; readers include `recipe_scanner.py:2682–2726`, `recipe_format.py:37–40`, `misc_handlers.py:2440–2444`, `model_handlers.py`, `model_scanner.check_model_version_exists:2444`).
|
||||||
|
|
||||||
|
Review correction (F3): bulk paths `remove_models` (`model_scanner.py:2376`) and `update_single_model_cache` (`:1689`) call `rebuild_version_index()` right after, so a sibling re-enters the index in those flows — the hazard is narrower than v1 stated, but direct `remove_from_version_index` callers (e.g. `model_scanner.py:1018`) still drop the key, and the user-visible artifact in phase 1 is real: `localPath` in the dialog flips to whichever file was indexed last.
|
||||||
|
|
||||||
|
### 2.6 Entry points that send / don't send `file_params` (fully enumerated by review)
|
||||||
|
|
||||||
|
**Send `file_params` (user-initiated dialog flows only):** `DownloadManager.js:1611–1639` (single mode). API surface accepting arbitrary JSON `file_params`: GET `/api/lm/download-model-get` (`model_handlers.py:1634–1686`), POST `/api/lm/downloads/queue/add` (`model_handlers.py:1799–1832`).
|
||||||
|
|
||||||
|
**Never send `file_params` (keep version-level semantics):** batch download (`DownloadManager.js:1756–1766`; batch also filters out in-library versions at `:1648`), `downloadVersionWithDefaults` (`:1810–1830`), recipe import (`import/DownloadManager.js:269–276`), bulk missing-LoRA (`BulkMissingLoraDownloadManager.js:292–299`), `RecipeModal.js:1728–1736`, `ModelVersionsTab.js:1427`. `web/comfyui/` and `vue-widgets/src` contain **no** download triggers at all (grep-verified). `py/services/use_cases/` has only `download_model_use_case.py` (pass-through).
|
||||||
|
|
||||||
|
### 2.7 Paths that do NOT need changes (verified)
|
||||||
|
|
||||||
|
- **aria2 pause/resume** (`_resume_restored_aria2_download`, line 754+): resumes from persisted `resume_context`; never re-runs existence gates.
|
||||||
|
- **`download_coordinator.py:90`**: pure pass-through of `file_params`.
|
||||||
|
- **Update checker / plugin self-update** (`update_routes.py:496–501`): only closes the history DB handle.
|
||||||
|
- **History delete semantics**: `mark_as_deleted` sets `is_deleted_override=1` and `has_been_downloaded` then returns False (`downloaded_version_history_service.py:276`) — LM-initiated deletes already reset the history skip.
|
||||||
|
|
||||||
|
### 2.8 Related pre-existing issues (record, not necessarily fix)
|
||||||
|
|
||||||
|
- **Queue retry drops file selection** (R9): `download_queue_service.retry_from_history` / `retry_all_failed` re-queue with `file_params=NULL` (`download_queue_service.py:705, 758`) although the queue table has a `file_params` column (`:43`) — a retried non-primary download silently reverts to the primary file. Fix alongside phase 1 (small: persist and reuse the column).
|
||||||
|
- **`delete_model_version`** (`misc_handlers.py:2410–2487`): resolves the file via the single-valued `version_index` (2440–2444), deletes only that one file, and `mark_as_deleted` flags the **entire version** as deleted in history (2479) even when a sibling file remains in the library. See phase 2 item 6.1.5.
|
||||||
|
|
||||||
|
## 3. Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
|
||||||
|
- G1: A user can download any not-yet-downloaded file of a version already partially in the library (issue repro steps 6–8).
|
||||||
|
- G2: True duplicates stay blocked: downloading the *same* file of the same version twice is rejected.
|
||||||
|
- G3: Per-file downloaded state visible in the file dialog; multiple files selectable and downloadable in one pass.
|
||||||
|
- G4: No regression for version-level semantics relied on by batch download, recipe missing-LoRA detection, and `skip_previously_downloaded_model_versions`.
|
||||||
|
|
||||||
|
**Non-Goals**
|
||||||
|
|
||||||
|
- No change to recipe `inLibrary` semantics ("any file of the version present" remains sufficient).
|
||||||
|
- No change to the update-checker (version-level comparison).
|
||||||
|
- No primary-key rebuild of the history database.
|
||||||
|
- HuggingFace download flow untouched.
|
||||||
|
|
||||||
|
## 4. Design Decisions
|
||||||
|
|
||||||
|
- **D1 — Explicit file selection bypasses the history gate, version-level gates stay for everyone else.** The history skip exists to dedupe automated flows. A user explicitly picking a file is unambiguous intent; the file-level library gate (G2) still prevents real duplicates. **Guard conditions use normalized truthiness** (see D1a). All confirmed `file_params` senders are user-initiated dialog flows (2.6), and LM-initiated deletes already reset history (2.7), so the bypass only affects "downloaded but not LM-deleted" versions with the setting on — intended.
|
||||||
|
- **D1a — `file_params` normalization at the boundary (R2).** `download-model-get` and `downloads/queue/add` accept arbitrary JSON; `{}` is `not None` but falsy and would bypass gates while downloading the primary file. Normalize `file_params = file_params or None` in the coordinator/handlers, and treat the bypass as active only when a target file id is resolvable.
|
||||||
|
- **D2 — File identity matching rule (R6/R7):** hash-compare **only when both sides are non-empty** (lowercase SHA256 equality); name-compare when either side is empty. Never let `"" == ""` match. Name fallback caveats from 2.4 apply (renamed files, pending checkpoint hashes) — acceptable residual risk, worst case is a blocked re-download the user can retry after hashing completes.
|
||||||
|
- **D3 — Cache indexes: additive multi-index + removal re-pointing (R4).** Add `version_files_index: Dict[int, List[dict]]` maintained alongside `version_index` by the same add/remove/rebuild methods; existing readers of `version_index` untouched. Additionally fix `remove_from_version_index`: when the popped entry has a surviving sibling (per the multi-index), re-point `version_index[version_id]` to the sibling instead of dropping the key; same for the `model_id_index` descriptor. This closes the 2.5 hazard for existing readers (`check_model_version_exists`, `existsLocally`, recipe matching) without restructuring anything.
|
||||||
|
- **D4 — Per-file history via a child table (R3).** v1's additive-column approach is structurally impossible on a `(model_type, version_id)` PK (`ON CONFLICT DO UPDATE` would keep only the last file). Instead add `downloaded_version_files(model_type, version_id, file_id, file_name, downloaded_at, PRIMARY KEY(model_type, version_id, file_id))` — additive, no PK rebuild, honors the Non-Goal. Existing version-level table and queries unchanged. New per-file queries are opt-in. `_initialize_schema` uses `CREATE TABLE IF NOT EXISTS`, so the new table is created for existing DBs without any ALTER.
|
||||||
|
- **D5 — UI flow reuse, with an extracted inner download function for multi-file (F7).** Phase 1 unlocks the existing badge → file dialog → location → download pipeline. Phase 2 upgrades the dialog to multi-select; iterating `executeDownloadWithProgress` as-is would produce N full library reloads, N toasts, and competing failure-summary modals — so phase 2 extracts a reload-deferred, failure-aggregating inner variant and runs one reload + one summary at the end.
|
||||||
|
|
||||||
|
## 5. Implementation — Phase 1 (fix the issue; independently shippable)
|
||||||
|
|
||||||
|
### 5.1 Backend — `py/services/download_manager.py`
|
||||||
|
|
||||||
|
1. **Normalize `file_params`** at the boundary (D1a): `download_coordinator.schedule_download` and the two API handlers (`model_handlers.py:1649–1666`, `1810–1832`) apply `file_params = file_params or None`.
|
||||||
|
2. **Extract a shared file resolver** (R1): pull the matching logic at 1498–1569 into `_resolve_target_file(version_info, file_params) -> Optional[dict]`, used by **both** the new gate and the download-selection path. The selection path's condition (line 1499) switches from `model_version_id` to `resolved_version_id` (already computed at 1230–1236 from `version_info.id`), so gate and download always agree on the target file — including the `model_id`-only case.
|
||||||
|
3. **New helper** `_find_local_file_entry(version_id, target_file) -> Optional[dict]`: iterate the three scanners' cached `raw_data` (NOT `version_index` — single-valued); candidates = entries whose `civitai.id` normalizes to `version_id`; match per D2.
|
||||||
|
4. **Gate restructure in `_execute_original_download`**:
|
||||||
|
- Early scanner gate (1157–1184): add `file_params is None` guard; with normalized `file_params`, defer (file identity not resolvable before metadata fetch).
|
||||||
|
- After `version_info` fetch + `resolved_version_id` (~1229): when `file_params` present, resolve target file via the shared resolver; unresolvable → hard error "No matching file" (fail closed, prevents empty-dict bypass). Resolvable → `_find_local_file_entry`; hit → same hard error shape as today with the file name in the message.
|
||||||
|
- History gate (1238–1279): add `file_params is None` (D1). Base-model skip (1281–1324) unchanged — still applies.
|
||||||
|
- Late gate (1350–1376): add `file_params is None` guard (F2) — the post-fetch file-level check above already covers this case.
|
||||||
|
- Nothing between the early gate and the post-fetch point assumes the version is absent (review task 6: only provider selection + metadata fetch; no DB writes; `_persist_aria2_state` runs only when actually downloading at 1659).
|
||||||
|
5. **Queue retry fix** (2.8, small): persist `file_params` into the queue table on enqueue and reuse it in `retry_from_history` / `retry_all_failed`.
|
||||||
|
6. Logging: `[download]` lines for file-level allow/block, consistent with existing style.
|
||||||
|
|
||||||
|
**Estimated:** ~150–220 LOC + resolver extraction.
|
||||||
|
|
||||||
|
### 5.2 Frontend — `static/js/managers/DownloadManager.js`
|
||||||
|
|
||||||
|
1. Line 598: drop `&& !existsLocally` from the badge condition (badge shows whenever `modelFiles.length > 1`).
|
||||||
|
2. `fileParams` construction (1611–1616): add `name: this.selectedFile.name`.
|
||||||
|
3. Surface the backend "file already in library" hard error as a toast instead of only the batch-summary modal (R10/F12 nit; reuse existing error message field).
|
||||||
|
4. No changes to `updateNextButtonState` / `proceedToLocation` in phase 1; no template or CSS changes.
|
||||||
|
|
||||||
|
**Known phase-1 UX limitations (acknowledged, fixed in phase 2):** with all files downloaded the badge still renders and re-picking a downloaded file fails late (backend error after the location step); `localPath` may point at a sibling file; batch-preview "In Library" badge stays version-level and gives no hint of remaining files.
|
||||||
|
|
||||||
|
**Estimated:** ~10–30 LOC (confirmed realistic by review).
|
||||||
|
|
||||||
|
### 5.3 Phase 1 tests
|
||||||
|
|
||||||
|
Backend — extend `tests/services/test_download_manager_basic.py` (1694 lines; all fixture patterns exist):
|
||||||
|
|
||||||
|
- **Fixture gaps to add (F10):** `DummyScanner.get_cached_data()`/`raw_data` stub (~10 lines); `hashes.SHA256` in the metadata-provider payload's `files`.
|
||||||
|
- Cases: same version + different SHA256 in library + `file_params` → proceeds; same SHA256 → hard error; `file_params=None` + version in library → hard error (unchanged); history-skip on + `file_params` → not skipped; without → skipped (unchanged); empty-dict `file_params` normalized → version-level behavior; `model_id`-only + `file_params` → gate and selection resolve the same file; legacy metadata (empty local sha256) matched by name; target file with empty SHA256 → name fallback, no `""==""` false positive.
|
||||||
|
- Queue retry: `file_params` survives retry.
|
||||||
|
- Assert proceed/abort via the existing `_execute_download` mock pattern.
|
||||||
|
|
||||||
|
Frontend (`tests/frontend/`): badge renders for multi-file version with `existsLocally=true` (pattern from `downloadManager.history.test.js`).
|
||||||
|
|
||||||
|
**Estimated:** ~150–250 LOC (confirmed realistic).
|
||||||
|
|
||||||
|
## 6. Implementation — Phase 2 (per-file status + multi-select + index hardening)
|
||||||
|
|
||||||
|
### 6.1 Backend
|
||||||
|
|
||||||
|
1. **`py/services/model_cache.py`** (D3): add `version_files_index`; maintain in `add_to_version_index` / `remove_from_version_index` / `rebuild_version_index`; removal re-points `version_index[version_id]` (and the `model_id_index` descriptor) to a surviving sibling instead of dropping the key.
|
||||||
|
2. **`py/services/model_scanner.py`**: expose `get_files_for_version(version_id) -> List[dict]`.
|
||||||
|
3. **`py/routes/handlers/model_handlers.py` `get_civitai_versions`**: annotate each version with `downloadedFiles: [{fileId, fileName, filePath}]` via `version_files_index` + D2 matching against `version.files`.
|
||||||
|
4. **`py/services/downloaded_version_history_service.py`** (D4): new child table `downloaded_version_files`; `mark_downloaded` also upserts the child row when `file_id` known; `mark_as_deleted` clears the version's child rows only when no sibling remains in the library; new `get_downloaded_file_ids(model_type, version_id) -> set[int]`. `_record_downloaded_version_history` passes `file_info` through.
|
||||||
|
5. **`delete_model_version`** (`misc_handlers.py:2410–2487`, R5): resolve **all** local files of the version via `version_files_index`; delete all (current endpoint semantics are version-level) or — if kept per-file — only `mark_as_deleted` when no sibling remains. Decide at implementation time; minimum is documenting current behavior.
|
||||||
|
6. **`ModelVersionsTab` backend support**: none needed beyond item 3 (`downloadedFiles`); the tab consumes the same versions payload.
|
||||||
|
|
||||||
|
### 6.2 Frontend
|
||||||
|
|
||||||
|
1. **File dialog multi-select** — change surface (F8): option markup (`DownloadManager.js:712–724`), the single-select click handler (`727–734`), the `input[type="radio"]:checked` selector in `confirmFileSelection` (`738`); template `templates/components/modals/download_modal.html:48–60` (confirm-button label only); CSS `download-modal.css` — checkbox variant of `.file-option-radio input` (595–604) and a **new** `.file-option.disabled` style (does not exist). Files whose id ∈ `downloadedFiles` render disabled with an "In Library" tag.
|
||||||
|
2. **Mixed-type guard (F8):** multi-select is restricted to files sharing the same routing target (`_isDiffusionModel` is computed once from a single `selectedFile` at 798–803; e.g. "Model" + "UNet" files route to different roots). Disallow mixed-type multi-select (simplest, predictable); single-file selection unchanged.
|
||||||
|
3. **Multi-file download loop (D5/F7):** extract from `executeDownloadWithProgress` a reload-deferred, no-toast inner function; iterate per selected file with per-file progress; one `resetAndReload(true)` + one aggregated success/failure summary at the end (reuse `showDownloadBatchSummary`).
|
||||||
|
4. **`updateNextButtonState` / `proceedToLocation`:** for multi-file versions, Next routes into the file dialog; hard block only when *every* weight file is downloaded.
|
||||||
|
5. **`ModelVersionsTab.js` (F6):** the Download action (`:576` hidden when `isInLibrary`) — for multi-file versions with remaining files, show it and route into the download modal's file dialog; keep hidden when all files present.
|
||||||
|
6. **Batch preview (F5):** `batch-preview-local-badge` (`:1320`) gains a "partially downloaded" hint for multi-file versions with remaining files.
|
||||||
|
7. New i18n keys (`modals.download.fileSelection.inLibrary`, `downloadSelected`, partial-download tooltip, etc.) → run `python scripts/sync_translation_keys.py`.
|
||||||
|
|
||||||
|
### 6.3 Phase 2 tests
|
||||||
|
|
||||||
|
- `model_cache` (`tests/services/test_model_cache.py` already covers add/remove at 44–55): multi-valued index; sibling re-point on removal; rebuild.
|
||||||
|
- `get_civitai_versions`: `downloadedFiles` correctness (hash match, name fallback, no match, CivArchive no-hash payload).
|
||||||
|
- History service (`tests/services/test_downloaded_version_history_service.py` uses real SQLite on tmp_path): child-table creation on a legacy DB; per-file record/query; `mark_as_deleted` sibling semantics.
|
||||||
|
- Frontend: dialog checkbox rendering/disabled state and multi-file confirm — **greenfield behavior coverage** (F10: no existing test exercises `showFileSelectionStep`/`confirmFileSelection`; infra exists, patterns must be built).
|
||||||
|
|
||||||
|
## 7. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| History-gate bypass (D1) causes unwanted re-downloads in automated flows | Large checkpoint files re-downloaded | Bypass only with normalized, resolvable `file_params` (D1a); all such senders are user-initiated dialog flows (2.6, verified); tests pin batch/recipe/bulk behavior. |
|
||||||
|
| Empty-hash matching edge cases (R6) | Duplicate download of the same file, or false block | D2 rule: hash only when both non-empty; name otherwise; never `""==""`. Residual risk documented (2.4). |
|
||||||
|
| Phase-1 late-failure UX (F12) | User picks a downloaded file, fails only after location step | Toast surfacing (5.2.3); phase 2 disables downloaded files up front. |
|
||||||
|
| Phase-2 index change corrupts existing behavior | Recipe matching, delete flows | Additive index + re-point only; `version_index` read semantics unchanged; `remove_models`/`update_single_model_cache` already rebuild (F3); tests. |
|
||||||
|
| `delete_model_version` marks whole version deleted while sibling remains (R5) | History wrongly suppresses re-download of the surviving sibling's version | Phase 2 item 6.1.5; documented until then. |
|
||||||
|
| History child-table migration failure on user installs | Service init crash | `CREATE TABLE IF NOT EXISTS` in `_initialize_schema`; failure degrades to version-level behavior (per-file queries return empty). |
|
||||||
|
| Batch-preview badge misleading for partial versions (F5) | Minor UX confusion | Acknowledged in phase 1; fixed in phase 2 item 6.2.6. |
|
||||||
|
| UI confusion: version shows "In Library" while files remain downloadable | Support burden | Phase 2: per-file disabled state + partial-download tooltip. |
|
||||||
|
| Hash-identical sibling files (repacked content) | Second file blocked | Acceptable: scanner hash dedup already collapses them. |
|
||||||
|
|
||||||
|
## 8. Rollout
|
||||||
|
|
||||||
|
1. **Commit 1** — `fix(download): allow downloading additional files of an in-library model version (#1058)` → Phase 1 (5.1–5.3).
|
||||||
|
2. **Commit 2** — `feat(download): per-file download status and multi-file selection (#1058)` → Phase 2 (6.1–6.3).
|
||||||
|
|
||||||
|
Phase 1 alone resolves the issue as reported; phase 2 can ship in a later release if review prefers smaller increments.
|
||||||
|
|
||||||
|
## 9. Effort Estimate (revised after review)
|
||||||
|
|
||||||
|
| Phase | Backend | Frontend | Tests | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | ~150–220 LOC (+ queue-retry fix ~30) | ~10–30 LOC | ~150–250 LOC | Low |
|
||||||
|
| 2 | ~250–350 LOC | ~250–350 LOC (multi-file loop refactor + ModelVersionsTab + batch badge) | ~250–350 LOC (dialog tests greenfield) | Medium |
|
||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "Nur Early-Access-Updates"
|
"help": "Nur Early-Access-Updates"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "Bezahlte Updates ausblenden",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA-Rezepte",
|
"title": "LoRA-Rezepte",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Send to ComfyUI"
|
"sendCheckpoint": "Send to ComfyUI",
|
||||||
|
"sendRecipe": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "Heruntergeladen",
|
"downloaded": "Heruntergeladen",
|
||||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
|
"partiallyDownloaded": "Teilweise heruntergeladen",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "Dateiformat auswählen",
|
"title": "Dateiformat auswählen",
|
||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"select": "Datei auswählen"
|
"select": "Datei auswählen",
|
||||||
|
"inLibrary": "In Bibliothek"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "Beispiele werden geladen...",
|
"examples": "Beispiele werden geladen...",
|
||||||
"versions": "Versionen werden geladen..."
|
"versions": "Versionen werden geladen..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} durch Nur-SFW-Einstellung ausgeblendet",
|
||||||
|
"showExamples": "Beispiele anzeigen",
|
||||||
|
"showCount": "Beispiele anzeigen ({count})",
|
||||||
|
"hideExamples": "Beispiele ausblenden",
|
||||||
|
"addExamples": "Beispiele hinzufügen",
|
||||||
|
"previousExample": "Vorheriges Beispiel",
|
||||||
|
"nextExample": "Nächstes Beispiel",
|
||||||
|
"noExamples": "Keine Beispielbilder verfügbar",
|
||||||
|
"addMoreExamples": "Weitere Beispiele hinzufügen",
|
||||||
|
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
|
||||||
|
"or": "oder",
|
||||||
|
"selectFiles": "Dateien auswählen",
|
||||||
|
"supportedFormats": "Unterstützte Formate: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Dateien werden importiert...",
|
||||||
|
"noSupportedFiles": "Keine unterstützten Dateien ausgewählt. Bitte wählen Sie Bild- oder Videodateien aus.",
|
||||||
|
"allFiltered": "Alle Beispielbilder wurden aufgrund der NSFW-Inhaltseinstellungen herausgefiltert",
|
||||||
|
"sfwOnlyEnabled": "Ihre Einstellungen zeigen derzeit nur jugendfreie Inhalte an",
|
||||||
|
"changeInSettings": "Sie können dies in den Einstellungen ändern",
|
||||||
|
"nsfwMature": "Nicht jugendfreie Inhalte",
|
||||||
|
"nsfwR": "Inhalte ab 18 (R)",
|
||||||
|
"nsfwX": "Inhalte mit X-Einstufung",
|
||||||
|
"nsfwXxx": "Inhalte mit XXX-Einstufung"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Modellversionen",
|
"heading": "Modellversionen",
|
||||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "Bezahlt",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
"onSiteOnly": "Nur On-Site",
|
"onSiteOnly": "Nur On-Site",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
|
"downloadRemainingTooltip": "Verbleibende Dateien dieser Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Diese bezahlte Version von Civitai herunterladen",
|
||||||
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "Keine kompatiblen Prompt-Ziele im Workflow.\nKlicken Sie mit der rechten Maustaste auf einen Knoten in ComfyUI → Markieren als → Prompt-Ziel festlegen",
|
||||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
|
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
|
||||||
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
|
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
|
||||||
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
|
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
|
||||||
|
"pleaseSelectFile": "Bitte wählen Sie mindestens eine Datei aus",
|
||||||
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
|
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
|
||||||
"downloadCompleted": "Download erfolgreich abgeschlossen",
|
"downloadCompleted": "Download erfolgreich abgeschlossen",
|
||||||
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
|
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
|
||||||
|
|||||||
+32
-3
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA Recipes",
|
"title": "LoRA Recipes",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Send to ComfyUI"
|
"sendCheckpoint": "Send to ComfyUI",
|
||||||
|
"sendRecipe": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "Downloaded",
|
"downloaded": "Downloaded",
|
||||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
|
"partiallyDownloaded": "Partially downloaded",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "Select File Format",
|
"title": "Select File Format",
|
||||||
"files": "files",
|
"files": "files",
|
||||||
"select": "Select File"
|
"select": "Select File",
|
||||||
|
"inLibrary": "In Library"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "Loading examples...",
|
"examples": "Loading examples...",
|
||||||
"versions": "Loading versions..."
|
"versions": "Loading versions..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} hidden by SFW-only setting",
|
||||||
|
"showExamples": "Show examples",
|
||||||
|
"showCount": "Show examples ({count})",
|
||||||
|
"hideExamples": "Hide examples",
|
||||||
|
"addExamples": "Add examples",
|
||||||
|
"previousExample": "Previous example",
|
||||||
|
"nextExample": "Next example",
|
||||||
|
"noExamples": "No example images available",
|
||||||
|
"addMoreExamples": "Add more examples",
|
||||||
|
"dragDrop": "Drag & drop images or videos here",
|
||||||
|
"or": "or",
|
||||||
|
"selectFiles": "Select Files",
|
||||||
|
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Importing files...",
|
||||||
|
"noSupportedFiles": "No supported files selected. Please select image or video files.",
|
||||||
|
"allFiltered": "All example images are filtered due to NSFW content settings",
|
||||||
|
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
|
||||||
|
"changeInSettings": "You can change this in Settings",
|
||||||
|
"nsfwMature": "Mature Content",
|
||||||
|
"nsfwR": "R-rated Content",
|
||||||
|
"nsfwX": "X-rated Content",
|
||||||
|
"nsfwXxx": "XXX-rated Content"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Model versions",
|
"heading": "Model versions",
|
||||||
"copy": "Track and manage every version of this model in one place.",
|
"copy": "Track and manage every version of this model in one place.",
|
||||||
@@ -1569,6 +1596,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
|
"downloadRemainingTooltip": "Download remaining files of this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
"downloadPaidTooltip": "Download this paid version from Civitai",
|
"downloadPaidTooltip": "Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
|
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
|
||||||
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
|
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
|
||||||
"pleaseSelectVersion": "Please select a version",
|
"pleaseSelectVersion": "Please select a version",
|
||||||
|
"pleaseSelectFile": "Please select at least one file",
|
||||||
"versionExists": "This version already exists in your library",
|
"versionExists": "This version already exists in your library",
|
||||||
"downloadCompleted": "Download completed successfully",
|
"downloadCompleted": "Download completed successfully",
|
||||||
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
|
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
|
||||||
@@ -2332,4 +2361,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "Solo actualizaciones de acceso temprano"
|
"help": "Solo actualizaciones de acceso temprano"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "Ocultar actualizaciones de pago",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
"useNewStyle": "Usar iconos de licencia actualizados",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Recetas de LoRA",
|
"title": "Recetas de LoRA",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Enviar a ComfyUI"
|
"sendCheckpoint": "Enviar a ComfyUI",
|
||||||
|
"sendRecipe": "Enviar a ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "Descargado",
|
"downloaded": "Descargado",
|
||||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
|
"partiallyDownloaded": "Descargado parcialmente",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "Seleccionar formato de archivo",
|
"title": "Seleccionar formato de archivo",
|
||||||
"files": "archivos",
|
"files": "archivos",
|
||||||
"select": "Seleccionar archivo"
|
"select": "Seleccionar archivo",
|
||||||
|
"inLibrary": "En la biblioteca"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "Cargando ejemplos...",
|
"examples": "Cargando ejemplos...",
|
||||||
"versions": "Cargando versiones..."
|
"versions": "Cargando versiones..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} ocultas por el ajuste de solo contenido SFW",
|
||||||
|
"showExamples": "Mostrar ejemplos",
|
||||||
|
"showCount": "Mostrar ejemplos ({count})",
|
||||||
|
"hideExamples": "Ocultar ejemplos",
|
||||||
|
"addExamples": "Añadir ejemplos",
|
||||||
|
"previousExample": "Ejemplo anterior",
|
||||||
|
"nextExample": "Ejemplo siguiente",
|
||||||
|
"noExamples": "No hay imágenes de ejemplo disponibles",
|
||||||
|
"addMoreExamples": "Añadir más ejemplos",
|
||||||
|
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
|
||||||
|
"or": "o",
|
||||||
|
"selectFiles": "Seleccionar archivos",
|
||||||
|
"supportedFormats": "Formatos compatibles: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Importando archivos...",
|
||||||
|
"noSupportedFiles": "No se seleccionaron archivos compatibles. Selecciona archivos de imagen o video.",
|
||||||
|
"allFiltered": "Todas las imágenes de ejemplo están filtradas por los ajustes de contenido NSFW",
|
||||||
|
"sfwOnlyEnabled": "Tus ajustes están configurados actualmente para mostrar solo contenido apto para todo público",
|
||||||
|
"changeInSettings": "Puedes cambiarlo en Configuración",
|
||||||
|
"nsfwMature": "Contenido para adultos",
|
||||||
|
"nsfwR": "Contenido clasificación R",
|
||||||
|
"nsfwX": "Contenido clasificación X",
|
||||||
|
"nsfwXxx": "Contenido clasificación XXX"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Versiones del modelo",
|
"heading": "Versiones del modelo",
|
||||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "De pago",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "Esta versión requiere pago para descargarse",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
"onSiteOnly": "Solo en Sitio",
|
"onSiteOnly": "Solo en Sitio",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
|
"downloadRemainingTooltip": "Descargar los archivos restantes de esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Descargar esta versión de pago desde Civitai",
|
||||||
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "No hay destinos de prompt compatibles en el workflow.\nHaz clic derecho en un nodo de ComfyUI → Marcar como → Destino de envío de prompt",
|
||||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||||
"modelFailed": "Error al actualizar nodo de modelo",
|
"modelFailed": "Error al actualizar nodo de modelo",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
|
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
|
||||||
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
|
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
|
||||||
"pleaseSelectVersion": "Por favor selecciona una versión",
|
"pleaseSelectVersion": "Por favor selecciona una versión",
|
||||||
|
"pleaseSelectFile": "Por favor selecciona al menos un archivo",
|
||||||
"versionExists": "Esta versión ya existe en tu biblioteca",
|
"versionExists": "Esta versión ya existe en tu biblioteca",
|
||||||
"downloadCompleted": "Descarga completada exitosamente",
|
"downloadCompleted": "Descarga completada exitosamente",
|
||||||
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
|
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "Seulement les mises à jour en accès anticipé"
|
"help": "Seulement les mises à jour en accès anticipé"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "Masquer les mises à jour payantes",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA Recipes",
|
"title": "LoRA Recipes",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Envoyer vers ComfyUI"
|
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||||
|
"sendRecipe": "Envoyer vers ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "Téléchargé",
|
"downloaded": "Téléchargé",
|
||||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
|
"partiallyDownloaded": "Téléchargé partiellement",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "Choisir le format de fichier",
|
"title": "Choisir le format de fichier",
|
||||||
"files": "fichiers",
|
"files": "fichiers",
|
||||||
"select": "Choisir le fichier"
|
"select": "Choisir le fichier",
|
||||||
|
"inLibrary": "Dans la bibliothèque"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "Chargement des exemples...",
|
"examples": "Chargement des exemples...",
|
||||||
"versions": "Chargement des versions..."
|
"versions": "Chargement des versions..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} masqué(s) par le paramètre « Contenu SFW uniquement »",
|
||||||
|
"showExamples": "Afficher les exemples",
|
||||||
|
"showCount": "Afficher les exemples ({count})",
|
||||||
|
"hideExamples": "Masquer les exemples",
|
||||||
|
"addExamples": "Ajouter des exemples",
|
||||||
|
"previousExample": "Exemple précédent",
|
||||||
|
"nextExample": "Exemple suivant",
|
||||||
|
"noExamples": "Aucune image d'exemple disponible",
|
||||||
|
"addMoreExamples": "Ajouter d'autres exemples",
|
||||||
|
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
|
||||||
|
"or": "ou",
|
||||||
|
"selectFiles": "Sélectionner des fichiers",
|
||||||
|
"supportedFormats": "Formats pris en charge : jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Importation des fichiers...",
|
||||||
|
"noSupportedFiles": "Aucun fichier pris en charge sélectionné. Veuillez sélectionner des fichiers image ou vidéo.",
|
||||||
|
"allFiltered": "Toutes les images d'exemple sont filtrées en raison des paramètres de contenu NSFW",
|
||||||
|
"sfwOnlyEnabled": "Vos paramètres sont actuellement configurés pour n'afficher que du contenu tout public",
|
||||||
|
"changeInSettings": "Vous pouvez modifier cela dans les paramètres",
|
||||||
|
"nsfwMature": "Contenu pour adultes",
|
||||||
|
"nsfwR": "Contenu classé R",
|
||||||
|
"nsfwX": "Contenu classé X",
|
||||||
|
"nsfwXxx": "Contenu classé XXX"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Versions du modèle",
|
"heading": "Versions du modèle",
|
||||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "Payant",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
"onSiteOnly": "Uniquement sur Site",
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
|
"downloadRemainingTooltip": "Télécharger les fichiers restants de cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Télécharger cette version payante depuis Civitai",
|
||||||
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "Aucune cible de prompt compatible dans le workflow.\nFaites un clic droit sur un nœud dans ComfyUI → Marquer comme → Cible d'envoi du prompt",
|
||||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
|
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
|
||||||
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
|
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
|
||||||
"pleaseSelectVersion": "Veuillez sélectionner une version",
|
"pleaseSelectVersion": "Veuillez sélectionner une version",
|
||||||
|
"pleaseSelectFile": "Veuillez sélectionner au moins un fichier",
|
||||||
"versionExists": "Cette version existe déjà dans votre bibliothèque",
|
"versionExists": "Cette version existe déjà dans votre bibliothèque",
|
||||||
"downloadCompleted": "Téléchargement terminé avec succès",
|
"downloadCompleted": "Téléchargement terminé avec succès",
|
||||||
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
|
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "רק עדכוני גישה מוקדמת"
|
"help": "רק עדכוני גישה מוקדמת"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "הסתר עדכונים בתשלום",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "מתכוני LoRA",
|
"title": "מתכוני LoRA",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "שלח ל-ComfyUI"
|
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||||
|
"sendRecipe": "שלח ל-ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "הורד",
|
"downloaded": "הורד",
|
||||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
|
"partiallyDownloaded": "הורד חלקית",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "בחר פורמט קובץ",
|
"title": "בחר פורמט קובץ",
|
||||||
"files": "קבצים",
|
"files": "קבצים",
|
||||||
"select": "בחר קובץ"
|
"select": "בחר קובץ",
|
||||||
|
"inLibrary": "בספרייה"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "טוען דוגמאות...",
|
"examples": "טוען דוגמאות...",
|
||||||
"versions": "טוען גרסאות..."
|
"versions": "טוען גרסאות..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} הוסתרו עקב הגדרת SFW בלבד",
|
||||||
|
"showExamples": "הצג דוגמאות",
|
||||||
|
"showCount": "הצג דוגמאות ({count})",
|
||||||
|
"hideExamples": "הסתר דוגמאות",
|
||||||
|
"addExamples": "הוסף דוגמאות",
|
||||||
|
"previousExample": "דוגמה קודמת",
|
||||||
|
"nextExample": "דוגמה הבאה",
|
||||||
|
"noExamples": "אין תמונות דוגמה זמינות",
|
||||||
|
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||||
|
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||||
|
"or": "או",
|
||||||
|
"selectFiles": "בחר קבצים",
|
||||||
|
"supportedFormats": "פורמטים נתמכים: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "מייבא קבצים...",
|
||||||
|
"noSupportedFiles": "לא נבחרו קבצים נתמכים. בחר קבצי תמונה או וידאו.",
|
||||||
|
"allFiltered": "כל תמונות הדוגמה מסוננות עקב הגדרות תוכן NSFW",
|
||||||
|
"sfwOnlyEnabled": "ההגדרות שלך מוגדרות כעת להציג רק תוכן SFW",
|
||||||
|
"changeInSettings": "ניתן לשנות זאת בהגדרות",
|
||||||
|
"nsfwMature": "תוכן למבוגרים",
|
||||||
|
"nsfwR": "תוכן בדירוג R",
|
||||||
|
"nsfwX": "תוכן בדירוג X",
|
||||||
|
"nsfwXxx": "תוכן בדירוג XXX"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "גרסאות המודל",
|
"heading": "גרסאות המודל",
|
||||||
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "בתשלום",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
"onSiteOnly": "רק באתר",
|
"onSiteOnly": "רק באתר",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
|
"downloadRemainingTooltip": "הורד את הקבצים הנותרים של גרסה זו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "הורד את הגרסה בתשלום הזו מ-Civitai",
|
||||||
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "אין יעדי הנחיה תואמים ב-workflow.\nלחץ לחיצה ימנית על צומת ב-ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||||
"modelUpdated": "מודל עודכן ב-workflow",
|
"modelUpdated": "מודל עודכן ב-workflow",
|
||||||
"modelFailed": "עדכון צומת המודל נכשל",
|
"modelFailed": "עדכון צומת המודל נכשל",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
|
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
|
||||||
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
|
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
|
||||||
"pleaseSelectVersion": "אנא בחר גרסה",
|
"pleaseSelectVersion": "אנא בחר גרסה",
|
||||||
|
"pleaseSelectFile": "אנא בחר לפחות קובץ אחד",
|
||||||
"versionExists": "גרסה זו כבר קיימת בספרייה שלך",
|
"versionExists": "גרסה זו כבר קיימת בספרייה שלך",
|
||||||
"downloadCompleted": "ההורדה הושלמה בהצלחה",
|
"downloadCompleted": "ההורדה הושלמה בהצלחה",
|
||||||
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
|
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "早期アクセスのみの更新"
|
"help": "早期アクセスのみの更新"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "有料更新を非表示",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRAレシピ",
|
"title": "LoRAレシピ",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "ComfyUIへ送信"
|
"sendCheckpoint": "ComfyUIへ送信",
|
||||||
|
"sendRecipe": "ComfyUIへ送信"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "ダウンロード済み",
|
"downloaded": "ダウンロード済み",
|
||||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
|
"partiallyDownloaded": "一部ダウンロード済み",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "ファイル形式を選択",
|
"title": "ファイル形式を選択",
|
||||||
"files": "ファイル",
|
"files": "ファイル",
|
||||||
"select": "ファイルを選択"
|
"select": "ファイルを選択",
|
||||||
|
"inLibrary": "ライブラリ内"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "例を読み込み中...",
|
"examples": "例を読み込み中...",
|
||||||
"versions": "バージョンを読み込み中..."
|
"versions": "バージョンを読み込み中..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "SFWのみ設定により{count}件非表示",
|
||||||
|
"showExamples": "例を表示",
|
||||||
|
"showCount": "例を表示({count})",
|
||||||
|
"hideExamples": "例を非表示",
|
||||||
|
"addExamples": "例を追加",
|
||||||
|
"previousExample": "前の例",
|
||||||
|
"nextExample": "次の例",
|
||||||
|
"noExamples": "利用可能な例画像がありません",
|
||||||
|
"addMoreExamples": "さらに例を追加",
|
||||||
|
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||||
|
"or": "または",
|
||||||
|
"selectFiles": "ファイルを選択",
|
||||||
|
"supportedFormats": "対応形式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "ファイルをインポート中...",
|
||||||
|
"noSupportedFiles": "対応ファイルが選択されていません。画像または動画ファイルを選択してください。",
|
||||||
|
"allFiltered": "NSFWコンテンツ設定により、すべての例画像がフィルタリングされています",
|
||||||
|
"sfwOnlyEnabled": "現在の設定ではSFWコンテンツのみが表示されます",
|
||||||
|
"changeInSettings": "設定から変更できます",
|
||||||
|
"nsfwMature": "成人向けコンテンツ",
|
||||||
|
"nsfwR": "R指定コンテンツ",
|
||||||
|
"nsfwX": "X指定コンテンツ",
|
||||||
|
"nsfwXxx": "XXX指定コンテンツ"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "モデルバージョン",
|
"heading": "モデルバージョン",
|
||||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "有料",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
"onSiteOnly": "サイト内のみ",
|
"onSiteOnly": "サイト内のみ",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
|
"downloadRemainingTooltip": "このバージョンの残りのファイルをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
|
||||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "ワークフロー内に互換性のあるプロンプトターゲットがありません。\nComfyUIでノードを右クリック → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||||
"modelUpdated": "モデルがワークフローで更新されました",
|
"modelUpdated": "モデルがワークフローで更新されました",
|
||||||
"modelFailed": "モデルノードの更新に失敗しました",
|
"modelFailed": "モデルノードの更新に失敗しました",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
|
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
|
||||||
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
|
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
|
||||||
"pleaseSelectVersion": "バージョンを選択してください",
|
"pleaseSelectVersion": "バージョンを選択してください",
|
||||||
|
"pleaseSelectFile": "ファイルを1つ以上選択してください",
|
||||||
"versionExists": "このバージョンは既にライブラリに存在します",
|
"versionExists": "このバージョンは既にライブラリに存在します",
|
||||||
"downloadCompleted": "ダウンロードが正常に完了しました",
|
"downloadCompleted": "ダウンロードが正常に完了しました",
|
||||||
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
|
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "얼리 액세스 업데이트만"
|
"help": "얼리 액세스 업데이트만"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "유료 업데이트 숨기기",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 레시피",
|
"title": "LoRA 레시피",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "ComfyUI로 보내기"
|
"sendCheckpoint": "ComfyUI로 보내기",
|
||||||
|
"sendRecipe": "ComfyUI로 보내기"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "다운로드됨",
|
"downloaded": "다운로드됨",
|
||||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
|
"partiallyDownloaded": "부분적으로 다운로드됨",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "파일 형식 선택",
|
"title": "파일 형식 선택",
|
||||||
"files": "개 파일",
|
"files": "개 파일",
|
||||||
"select": "파일 선택"
|
"select": "파일 선택",
|
||||||
|
"inLibrary": "라이브러리에 있음"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "예시 로딩 중...",
|
"examples": "예시 로딩 중...",
|
||||||
"versions": "버전 로딩 중..."
|
"versions": "버전 로딩 중..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "SFW 전용 설정으로 {count}개 숨겨짐",
|
||||||
|
"showExamples": "예시 보기",
|
||||||
|
"showCount": "예시 보기 ({count})",
|
||||||
|
"hideExamples": "예시 숨기기",
|
||||||
|
"addExamples": "예시 추가",
|
||||||
|
"previousExample": "이전 예시",
|
||||||
|
"nextExample": "다음 예시",
|
||||||
|
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||||
|
"addMoreExamples": "예시 더 추가",
|
||||||
|
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||||
|
"or": "또는",
|
||||||
|
"selectFiles": "파일 선택",
|
||||||
|
"supportedFormats": "지원되는 형식: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "파일을 가져오는 중...",
|
||||||
|
"noSupportedFiles": "지원되는 파일이 선택되지 않았습니다. 이미지 또는 비디오 파일을 선택하세요.",
|
||||||
|
"allFiltered": "NSFW 콘텐츠 설정으로 인해 모든 예시 이미지가 필터링되었습니다",
|
||||||
|
"sfwOnlyEnabled": "현재 설정이 안전한(SFW) 콘텐츠만 표시하도록 설정되어 있습니다",
|
||||||
|
"changeInSettings": "설정에서 변경할 수 있습니다",
|
||||||
|
"nsfwMature": "성인 콘텐츠",
|
||||||
|
"nsfwR": "R등급 콘텐츠",
|
||||||
|
"nsfwX": "X등급 콘텐츠",
|
||||||
|
"nsfwXxx": "XXX등급 콘텐츠"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "모델 버전",
|
"heading": "모델 버전",
|
||||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "유료",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
"onSiteOnly": "사이트 내 전용",
|
"onSiteOnly": "사이트 내 전용",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
|
"downloadRemainingTooltip": "이 버전의 나머지 파일 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Civitai에서 이 유료 버전 다운로드",
|
||||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "워크플로우에 호환되는 프롬프트 타겟이 없습니다.\nComfyUI에서 노드를 우클릭 → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||||
"modelFailed": "모델 노드 업데이트 실패",
|
"modelFailed": "모델 노드 업데이트 실패",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
|
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
|
||||||
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
|
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
|
||||||
"pleaseSelectVersion": "버전을 선택해주세요",
|
"pleaseSelectVersion": "버전을 선택해주세요",
|
||||||
|
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
|
||||||
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
|
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
|
||||||
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
|
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
|
||||||
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
|
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
|
||||||
|
|||||||
+37
-8
@@ -623,8 +623,8 @@
|
|||||||
"help": "Только обновления раннего доступа"
|
"help": "Только обновления раннего доступа"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "Скрывать платные обновления",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Рецепты LoRA",
|
"title": "Рецепты LoRA",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Отправить в ComfyUI"
|
"sendCheckpoint": "Отправить в ComfyUI",
|
||||||
|
"sendRecipe": "Отправить в ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "Загружено",
|
"downloaded": "Загружено",
|
||||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
|
"partiallyDownloaded": "Загружено частично",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "Выбрать формат файла",
|
"title": "Выбрать формат файла",
|
||||||
"files": "файлов",
|
"files": "файлов",
|
||||||
"select": "Выбрать файл"
|
"select": "Выбрать файл",
|
||||||
|
"inLibrary": "В библиотеке"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "Загрузка примеров...",
|
"examples": "Загрузка примеров...",
|
||||||
"versions": "Загрузка версий..."
|
"versions": "Загрузка версий..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} скрыто настройкой «только SFW»",
|
||||||
|
"showExamples": "Показать примеры",
|
||||||
|
"showCount": "Показать примеры ({count})",
|
||||||
|
"hideExamples": "Скрыть примеры",
|
||||||
|
"addExamples": "Добавить примеры",
|
||||||
|
"previousExample": "Предыдущий пример",
|
||||||
|
"nextExample": "Следующий пример",
|
||||||
|
"noExamples": "Примеры изображений недоступны",
|
||||||
|
"addMoreExamples": "Добавить ещё примеры",
|
||||||
|
"dragDrop": "Перетащите изображения или видео сюда",
|
||||||
|
"or": "или",
|
||||||
|
"selectFiles": "Выбрать файлы",
|
||||||
|
"supportedFormats": "Поддерживаемые форматы: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Импорт файлов...",
|
||||||
|
"noSupportedFiles": "Не выбрано поддерживаемых файлов. Пожалуйста, выберите файлы изображений или видео.",
|
||||||
|
"allFiltered": "Все примеры изображений отфильтрованы из-за настроек NSFW-контента",
|
||||||
|
"sfwOnlyEnabled": "В настройках сейчас включён показ только безопасного для работы (SFW) контента",
|
||||||
|
"changeInSettings": "Вы можете изменить это в Настройках",
|
||||||
|
"nsfwMature": "Контент для взрослых",
|
||||||
|
"nsfwR": "Контент с рейтингом R",
|
||||||
|
"nsfwX": "Контент с рейтингом X",
|
||||||
|
"nsfwXxx": "Контент с рейтингом XXX"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Версии модели",
|
"heading": "Версии модели",
|
||||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "Платная",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "Скачивание этой версии платное",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
"onSiteOnly": "Только на Сайте",
|
"onSiteOnly": "Только на Сайте",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
|
"downloadRemainingTooltip": "Скачать оставшиеся файлы этой версии",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "Скачать эту платную версию с Civitai",
|
||||||
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
@@ -1740,7 +1768,7 @@
|
|||||||
"recipeReplaced": "Рецепт заменён в workflow",
|
"recipeReplaced": "Рецепт заменён в workflow",
|
||||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
"noPromptTargets": "В рабочем процессе нет совместимых целей для промпта.\nЩёлкните правой кнопкой мыши по узлу в ComfyUI → Отметить как → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||||
"modelUpdated": "Модель обновлена в workflow",
|
"modelUpdated": "Модель обновлена в workflow",
|
||||||
"modelFailed": "Не удалось обновить узел модели",
|
"modelFailed": "Не удалось обновить узел модели",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
|
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
|
||||||
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
|
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
|
||||||
"pleaseSelectVersion": "Пожалуйста, выберите версию",
|
"pleaseSelectVersion": "Пожалуйста, выберите версию",
|
||||||
|
"pleaseSelectFile": "Пожалуйста, выберите хотя бы один файл",
|
||||||
"versionExists": "Эта версия уже существует в вашей библиотеке",
|
"versionExists": "Эта версия уже существует в вашей библиотеке",
|
||||||
"downloadCompleted": "Загрузка успешно завершена",
|
"downloadCompleted": "Загрузка успешно завершена",
|
||||||
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
|
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
|
||||||
|
|||||||
+36
-7
@@ -623,8 +623,8 @@
|
|||||||
"help": "抢先体验更新"
|
"help": "抢先体验更新"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "隐藏付费更新",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "使用新版许可协议图标",
|
"useNewStyle": "使用新版许可协议图标",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 配方",
|
"title": "LoRA 配方",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "发送到 ComfyUI"
|
"sendCheckpoint": "发送到 ComfyUI",
|
||||||
|
"sendRecipe": "发送到 ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "已下载",
|
"downloaded": "已下载",
|
||||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
|
"partiallyDownloaded": "部分已下载",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "选择文件格式",
|
"title": "选择文件格式",
|
||||||
"files": "个文件",
|
"files": "个文件",
|
||||||
"select": "选择文件"
|
"select": "选择文件",
|
||||||
|
"inLibrary": "已在库中"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "正在加载示例...",
|
"examples": "正在加载示例...",
|
||||||
"versions": "正在加载版本..."
|
"versions": "正在加载版本..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} 张因仅显示 SFW 设置而被隐藏",
|
||||||
|
"showExamples": "显示示例",
|
||||||
|
"showCount": "显示示例({count})",
|
||||||
|
"hideExamples": "隐藏示例",
|
||||||
|
"addExamples": "添加示例",
|
||||||
|
"previousExample": "上一个示例",
|
||||||
|
"nextExample": "下一个示例",
|
||||||
|
"noExamples": "暂无示例图片",
|
||||||
|
"addMoreExamples": "添加更多示例",
|
||||||
|
"dragDrop": "将图片或视频拖放到此处",
|
||||||
|
"or": "或",
|
||||||
|
"selectFiles": "选择文件",
|
||||||
|
"supportedFormats": "支持的格式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "正在导入文件...",
|
||||||
|
"noSupportedFiles": "未选择受支持的文件。请选择图片或视频文件。",
|
||||||
|
"allFiltered": "所有示例图片均因 NSFW 内容设置而被过滤",
|
||||||
|
"sfwOnlyEnabled": "你当前的设置为仅显示 SFW 内容",
|
||||||
|
"changeInSettings": "你可以在设置中更改此选项",
|
||||||
|
"nsfwMature": "成熟内容",
|
||||||
|
"nsfwR": "R 级内容",
|
||||||
|
"nsfwX": "X 级内容",
|
||||||
|
"nsfwXxx": "XXX 级内容"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "模型版本",
|
"heading": "模型版本",
|
||||||
"copy": "在一个位置管理该模型的所有版本。",
|
"copy": "在一个位置管理该模型的所有版本。",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "付费",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "此版本需要付费后才能下载",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知",
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
"onSiteOnly": "仅站内生成",
|
"onSiteOnly": "仅站内生成",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
|
"downloadRemainingTooltip": "下载此版本的剩余文件",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "从 Civitai 下载此付费版本",
|
||||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
|
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
|
||||||
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
|
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
|
||||||
"pleaseSelectVersion": "请选择版本",
|
"pleaseSelectVersion": "请选择版本",
|
||||||
|
"pleaseSelectFile": "请至少选择一个文件",
|
||||||
"versionExists": "该版本已存在于你的库中",
|
"versionExists": "该版本已存在于你的库中",
|
||||||
"downloadCompleted": "下载成功完成",
|
"downloadCompleted": "下载成功完成",
|
||||||
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
|
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
|
||||||
|
|||||||
+36
-7
@@ -623,8 +623,8 @@
|
|||||||
"help": "搶先體驗更新"
|
"help": "搶先體驗更新"
|
||||||
},
|
},
|
||||||
"hidePaidUpdates": {
|
"hidePaidUpdates": {
|
||||||
"label": "[TODO: Translate] Hide Paid Updates",
|
"label": "隱藏付費更新",
|
||||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
|
||||||
},
|
},
|
||||||
"licenseIcons": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "使用新版許可協議圖標",
|
"useNewStyle": "使用新版許可協議圖標",
|
||||||
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 配方",
|
"title": "LoRA 配方",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "傳送到 ComfyUI"
|
"sendCheckpoint": "傳送到 ComfyUI",
|
||||||
|
"sendRecipe": "傳送到 ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1243,11 +1244,13 @@
|
|||||||
"downloaded": "已下載",
|
"downloaded": "已下載",
|
||||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
|
"partiallyDownloaded": "部分已下載",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
"fileSelection": {
|
"fileSelection": {
|
||||||
"title": "選擇檔案格式",
|
"title": "選擇檔案格式",
|
||||||
"files": "個檔案",
|
"files": "個檔案",
|
||||||
"select": "選擇檔案"
|
"select": "選擇檔案",
|
||||||
|
"inLibrary": "已在庫中"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
@@ -1532,6 +1535,30 @@
|
|||||||
"examples": "載入範例中...",
|
"examples": "載入範例中...",
|
||||||
"versions": "載入版本中..."
|
"versions": "載入版本中..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "因僅顯示 SFW 設定而隱藏 {count} 張",
|
||||||
|
"showExamples": "顯示範例",
|
||||||
|
"showCount": "顯示範例({count})",
|
||||||
|
"hideExamples": "隱藏範例",
|
||||||
|
"addExamples": "新增範例",
|
||||||
|
"previousExample": "上一個範例",
|
||||||
|
"nextExample": "下一個範例",
|
||||||
|
"noExamples": "沒有可用的範例圖片",
|
||||||
|
"addMoreExamples": "新增更多範例",
|
||||||
|
"dragDrop": "拖放圖片或影片到此處",
|
||||||
|
"or": "或",
|
||||||
|
"selectFiles": "選擇檔案",
|
||||||
|
"supportedFormats": "支援的格式:jpg、png、gif、webp、avif、jxl、mp4、webm",
|
||||||
|
"importing": "正在匯入檔案...",
|
||||||
|
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
|
||||||
|
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
|
||||||
|
"sfwOnlyEnabled": "你目前的設定為僅顯示安全(SFW)內容",
|
||||||
|
"changeInSettings": "你可以在設定中變更此選項",
|
||||||
|
"nsfwMature": "成熟內容",
|
||||||
|
"nsfwR": "R 級內容",
|
||||||
|
"nsfwX": "X 級內容",
|
||||||
|
"nsfwXxx": "XXX 級內容"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "模型版本",
|
"heading": "模型版本",
|
||||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||||
@@ -1559,8 +1586,8 @@
|
|||||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
"paid": "[TODO: Translate] Paid",
|
"paid": "付費",
|
||||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
"paidTooltip": "此版本需要付費才能下載",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知",
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
"onSiteOnly": "僅站內生成",
|
"onSiteOnly": "僅站內生成",
|
||||||
@@ -1569,8 +1596,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
|
"downloadRemainingTooltip": "下載此版本的剩餘檔案",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
"downloadPaidTooltip": "從 Civitai 下載此付費版本",
|
||||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
@@ -1917,6 +1945,7 @@
|
|||||||
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
|
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
|
||||||
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
|
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
|
||||||
"pleaseSelectVersion": "請選擇一個版本",
|
"pleaseSelectVersion": "請選擇一個版本",
|
||||||
|
"pleaseSelectFile": "請至少選擇一個檔案",
|
||||||
"versionExists": "此版本已存在於您的庫中",
|
"versionExists": "此版本已存在於您的庫中",
|
||||||
"downloadCompleted": "下載成功完成",
|
"downloadCompleted": "下載成功完成",
|
||||||
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
|
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
|
|||||||
|
|
||||||
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||||
extra folder paths, providing a unified interface for checkpoint loading.
|
extra folder paths, providing a unified interface for checkpoint loading.
|
||||||
|
The ckpt_name combo supports ComfyUI's control_after_generate, letting
|
||||||
|
users pick a random checkpoint on every run; the base_model input narrows
|
||||||
|
the random pool through a front-end extension that filters the combo
|
||||||
|
options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "Checkpoint Loader (LoraManager)"
|
NAME = "Checkpoint Loader (LoraManager)"
|
||||||
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||||
checkpoint_names = cls._get_checkpoint_names()
|
checkpoint_names = cls._get_checkpoint_names()
|
||||||
|
base_models = cls._get_available_base_models()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"ckpt_name": (
|
"ckpt_name": (
|
||||||
checkpoint_names,
|
checkpoint_names,
|
||||||
{"tooltip": "The name of the checkpoint (model) to load."},
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"The name of the checkpoint (model) to load. Use "
|
||||||
|
"control_after_generate to pick a random model on "
|
||||||
|
"every run."
|
||||||
|
),
|
||||||
|
"control_after_generate": "fixed",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"base_model": (
|
||||||
|
base_models,
|
||||||
|
{
|
||||||
|
"default": "Any",
|
||||||
|
"tooltip": (
|
||||||
|
"Restrict the random selection pool to this base "
|
||||||
|
"model. 'Any' uses the full pool."
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,15 +115,68 @@ class CheckpointLoaderLM:
|
|||||||
logger.error(f"Error getting checkpoint names: {e}")
|
logger.error(f"Error getting checkpoint names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
|
@classmethod
|
||||||
|
def _get_available_base_models(cls) -> List[str]:
|
||||||
|
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
async def _get_base_models():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
base_models = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != "checkpoint":
|
||||||
|
continue
|
||||||
|
base_model = item.get("base_model")
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if base_model and file_path and os.path.exists(file_path):
|
||||||
|
base_models.add(base_model)
|
||||||
|
|
||||||
|
return sorted(base_models)
|
||||||
|
|
||||||
|
return ["Any"] + cls._run_async(_get_base_models)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available base models: {e}")
|
||||||
|
return ["Any"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_async(coro_fn):
|
||||||
|
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(coro_fn())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro_fn())
|
||||||
|
|
||||||
|
def load_checkpoint(
|
||||||
|
self, ckpt_name: str, base_model: str = "Any"
|
||||||
|
) -> Tuple[Any, Any, Any]:
|
||||||
"""Load a checkpoint by name, supporting extra folder paths
|
"""Load a checkpoint by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||||
|
base_model: Only used by the front-end to filter the random pool
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (MODEL, CLIP, VAE)
|
Tuple of (MODEL, CLIP, VAE)
|
||||||
"""
|
"""
|
||||||
|
del base_model
|
||||||
# Get absolute path from cache using ComfyUI-style name
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -28,6 +28,10 @@ class UNETLoaderLM:
|
|||||||
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
||||||
extra folder paths, providing a unified interface for UNET loading.
|
extra folder paths, providing a unified interface for UNET loading.
|
||||||
Supports both regular diffusion models and GGUF format models.
|
Supports both regular diffusion models and GGUF format models.
|
||||||
|
The unet_name combo supports ComfyUI's control_after_generate, letting
|
||||||
|
users pick a random diffusion model on every run; the base_model input
|
||||||
|
narrows the random pool through a front-end extension that filters the
|
||||||
|
combo options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "Unet Loader (LoraManager)"
|
NAME = "Unet Loader (LoraManager)"
|
||||||
@@ -37,16 +41,34 @@ class UNETLoaderLM:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of unet names from scanner (includes extra folder paths)
|
# Get list of unet names from scanner (includes extra folder paths)
|
||||||
unet_names = cls._get_unet_names()
|
unet_names = cls._get_unet_names()
|
||||||
|
base_models = cls._get_available_base_models()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"unet_name": (
|
"unet_name": (
|
||||||
unet_names,
|
unet_names,
|
||||||
{"tooltip": "The name of the diffusion model to load."},
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"The name of the diffusion model to load. Use "
|
||||||
|
"control_after_generate to pick a random model on "
|
||||||
|
"every run."
|
||||||
|
),
|
||||||
|
"control_after_generate": "fixed",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
"weight_dtype": (
|
"weight_dtype": (
|
||||||
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||||
{"tooltip": "The dtype to use for the model weights."},
|
{"tooltip": "The dtype to use for the model weights."},
|
||||||
),
|
),
|
||||||
|
"base_model": (
|
||||||
|
base_models,
|
||||||
|
{
|
||||||
|
"default": "Any",
|
||||||
|
"tooltip": (
|
||||||
|
"Restrict the random selection pool to this base "
|
||||||
|
"model. 'Any' uses the full pool."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +130,69 @@ class UNETLoaderLM:
|
|||||||
logger.error(f"Error getting unet names: {e}")
|
logger.error(f"Error getting unet names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
|
@classmethod
|
||||||
|
def _get_available_base_models(cls) -> List[str]:
|
||||||
|
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
async def _get_base_models():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
base_models = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != "diffusion_model":
|
||||||
|
continue
|
||||||
|
base_model = item.get("base_model")
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if base_model and file_path and os.path.exists(file_path):
|
||||||
|
base_models.add(base_model)
|
||||||
|
|
||||||
|
return sorted(base_models)
|
||||||
|
|
||||||
|
return ["Any"] + cls._run_async(_get_base_models)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available base models: {e}")
|
||||||
|
return ["Any"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_async(coro_fn):
|
||||||
|
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(coro_fn())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro_fn())
|
||||||
|
|
||||||
|
def load_unet(
|
||||||
|
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
|
||||||
|
) -> Tuple[Any, ...]:
|
||||||
"""Load a diffusion model by name, supporting extra folder paths
|
"""Load a diffusion model by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
unet_name: The name of the diffusion model to load (relative path with extension)
|
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||||
weight_dtype: The dtype to use for model weights
|
weight_dtype: The dtype to use for model weights
|
||||||
|
base_model: Only used by the front-end to filter the random pool
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (MODEL,)
|
Tuple of (MODEL,)
|
||||||
"""
|
"""
|
||||||
|
del base_model
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
# Get absolute path from cache using ComfyUI-style name
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
|
|||||||
@@ -41,6 +41,40 @@ class RecipeMetadataParser(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_lora_from_local(lora_entry: Dict[str, Any], local_lora: Dict[str, Any], base_model_counts=None) -> Dict[str, Any]:
|
||||||
|
"""Populate a recipe LoRA entry from the local scanner cache."""
|
||||||
|
local_path = local_lora.get('file_path') or ''
|
||||||
|
file_name = local_lora.get('file_name') or os.path.splitext(os.path.basename(local_path))[0]
|
||||||
|
base_model = local_lora.get('base_model') or ''
|
||||||
|
|
||||||
|
lora_entry['name'] = local_lora.get('model_name') or file_name or lora_entry.get('name', '')
|
||||||
|
lora_entry['file_name'] = file_name
|
||||||
|
lora_entry['hash'] = (local_lora.get('sha256') or lora_entry.get('hash') or '').lower()
|
||||||
|
lora_entry['localPath'] = local_path or None
|
||||||
|
lora_entry['size'] = local_lora.get('size', 0) or 0
|
||||||
|
lora_entry['baseModel'] = base_model
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['isDeleted'] = False
|
||||||
|
|
||||||
|
preview_url = local_lora.get('preview_url')
|
||||||
|
if preview_url:
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
|
civitai_info = local_lora.get('civitai') or {}
|
||||||
|
if isinstance(civitai_info, dict):
|
||||||
|
if civitai_info.get('id') is not None:
|
||||||
|
lora_entry['id'] = civitai_info['id']
|
||||||
|
if civitai_info.get('modelId') is not None:
|
||||||
|
lora_entry['modelId'] = civitai_info['modelId']
|
||||||
|
if civitai_info.get('name'):
|
||||||
|
lora_entry['version'] = civitai_info['name']
|
||||||
|
|
||||||
|
if base_model_counts is not None and base_model:
|
||||||
|
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||||
|
|
||||||
|
return lora_entry
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
|
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
|
||||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
+201
-61
@@ -362,68 +362,208 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
checkpoint = checkpoint_entry
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
def normalize_lora_name(name, basename=False):
|
||||||
if not loras or len(loras) == 0:
|
normalized = str(name or '').replace('\\', '/')
|
||||||
# Extract lora weights from extranet tags in prompt (for later use)
|
if normalized.casefold().endswith('.safetensors'):
|
||||||
lora_weights = {}
|
normalized = normalized[:-12]
|
||||||
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt)
|
if basename:
|
||||||
for lora_type, lora_name, lora_weight in lora_matches:
|
normalized = normalized.rsplit('/', 1)[-1]
|
||||||
key = f"{lora_type}:{lora_name}"
|
return normalized.casefold()
|
||||||
lora_weights[key] = round(float(lora_weight), 2)
|
|
||||||
|
def get_version_id(lora):
|
||||||
# Use hashes from metadata as the primary source
|
version_id = lora.get('id')
|
||||||
if metadata.get("hashes"):
|
if version_id in (None, '', 0, '0'):
|
||||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
version_id = lora.get('modelVersionId')
|
||||||
# Only process lora or hypernet types
|
if version_id in (None, '', 0, '0'):
|
||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
return None
|
||||||
|
return str(version_id)
|
||||||
|
|
||||||
|
prompt_loras = {}
|
||||||
|
for match in re.findall(self.EXTRANETS_REGEX, prompt):
|
||||||
|
lora_type, lora_name, _ = match
|
||||||
|
prompt_loras[(lora_type, normalize_lora_name(lora_name))] = match
|
||||||
|
|
||||||
|
prompt_by_basename = {}
|
||||||
|
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||||
|
key = (lora_type, normalize_lora_name(lora_name, True))
|
||||||
|
prompt_by_basename.setdefault(key, []).append((lora_name, round(float(lora_weight), 2)))
|
||||||
|
|
||||||
|
hash_basenames = {
|
||||||
|
(hash_key.split(':', 1)[0], normalize_lora_name(hash_key.split(':', 1)[1], True))
|
||||||
|
for hash_key, hash_value in metadata.get("hashes", {}).items()
|
||||||
|
if hash_value and hash_key.startswith(("lora:", "hypernet:"))
|
||||||
|
}
|
||||||
|
recipe_base_model = checkpoint.get("baseModel") if checkpoint else None
|
||||||
|
if not recipe_base_model and len(base_model_counts) == 1:
|
||||||
|
recipe_base_model = next(iter(base_model_counts))
|
||||||
|
|
||||||
|
resource_lora_count = len(loras)
|
||||||
|
|
||||||
|
def make_lora_entry(lora_type, lora_name, weight, lora_hash=''):
|
||||||
|
return {
|
||||||
|
'name': lora_name,
|
||||||
|
'type': lora_type,
|
||||||
|
'weight': weight,
|
||||||
|
'hash': lora_hash,
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': lora_name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
def merge_or_append_civitai(civitai_entry, preserve_existing_weight=False):
|
||||||
|
civitai_id = get_version_id(civitai_entry)
|
||||||
|
civitai_hash = (civitai_entry.get('hash') or '').lower()
|
||||||
|
for index, existing in enumerate(loras):
|
||||||
|
existing_id = get_version_id(existing)
|
||||||
|
existing_hash = (existing.get('hash') or '').lower()
|
||||||
|
if not (
|
||||||
|
(civitai_id and existing_id == civitai_id)
|
||||||
|
or (civitai_hash and existing_hash == civitai_hash)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if preserve_existing_weight:
|
||||||
|
civitai_entry['weight'] = existing.get('weight', civitai_entry['weight'])
|
||||||
|
existing_base = existing.get('baseModel')
|
||||||
|
if not civitai_entry.get('baseModel'):
|
||||||
|
civitai_entry['baseModel'] = existing_base or ''
|
||||||
|
elif existing_base:
|
||||||
|
remaining = base_model_counts.get(existing_base, 0) - 1
|
||||||
|
if remaining > 0:
|
||||||
|
base_model_counts[existing_base] = remaining
|
||||||
|
else:
|
||||||
|
base_model_counts.pop(existing_base, None)
|
||||||
|
loras[index] = civitai_entry
|
||||||
|
return
|
||||||
|
loras.append(civitai_entry)
|
||||||
|
|
||||||
|
def merge_or_append_local(local_entry):
|
||||||
|
local_id = get_version_id(local_entry)
|
||||||
|
local_hash = (local_entry.get('hash') or '').lower()
|
||||||
|
for existing in loras:
|
||||||
|
existing_id = get_version_id(existing)
|
||||||
|
existing_hash = (existing.get('hash') or '').lower()
|
||||||
|
if not (
|
||||||
|
(local_id and existing_id == local_id)
|
||||||
|
or (local_hash and existing_hash == local_hash)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing['weight'] = local_entry['weight']
|
||||||
|
existing['hash'] = local_entry['hash']
|
||||||
|
existing['file_name'] = local_entry['file_name']
|
||||||
|
existing['existsLocally'] = True
|
||||||
|
existing['localPath'] = local_entry['localPath']
|
||||||
|
existing['size'] = local_entry['size']
|
||||||
|
existing['isDeleted'] = False
|
||||||
|
if not existing.get('modelId') and local_entry.get('modelId'):
|
||||||
|
existing['modelId'] = local_entry['modelId']
|
||||||
|
if not existing.get('baseModel') and local_entry.get('baseModel'):
|
||||||
|
existing['baseModel'] = local_entry['baseModel']
|
||||||
|
base_model_counts[local_entry['baseModel']] = base_model_counts.get(local_entry['baseModel'], 0) + 1
|
||||||
|
thumbnail_url = local_entry.get('thumbnailUrl')
|
||||||
|
if thumbnail_url and not thumbnail_url.endswith('/images/no-preview.png'):
|
||||||
|
existing['thumbnailUrl'] = thumbnail_url
|
||||||
|
return
|
||||||
|
|
||||||
|
if local_entry.get('baseModel'):
|
||||||
|
base_model = local_entry['baseModel']
|
||||||
|
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||||
|
loras.append(local_entry)
|
||||||
|
|
||||||
|
resolved_prompt_basenames = set()
|
||||||
|
queried_local_basenames = set()
|
||||||
|
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||||
|
weight = round(float(lora_weight), 2)
|
||||||
|
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||||
|
matching_resources = [
|
||||||
|
lora
|
||||||
|
for lora in loras[:resource_lora_count]
|
||||||
|
if lora.get('file_name')
|
||||||
|
and normalize_lora_name(lora['file_name'], True) == basename_key[1]
|
||||||
|
and (
|
||||||
|
(lora_type == 'hypernet' and str(lora.get('type', '')).casefold() in ('hypernet', 'hypernetwork'))
|
||||||
|
or (lora_type == 'lora' and str(lora.get('type', '')).casefold() not in ('hypernet', 'hypernetwork'))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if len(prompt_by_basename[basename_key]) == 1 and len(matching_resources) == 1:
|
||||||
|
matching_resources[0]['weight'] = weight
|
||||||
|
if basename_key not in hash_basenames:
|
||||||
|
resolved_prompt_basenames.add(basename_key)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if basename_key in hash_basenames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not recipe_scanner or lora_type != 'lora':
|
||||||
|
continue
|
||||||
|
queried_local_basenames.add(basename_key)
|
||||||
|
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||||
|
if not local_lora:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_entry = self.populate_lora_from_local(
|
||||||
|
make_lora_entry(lora_type, lora_name, weight),
|
||||||
|
local_lora,
|
||||||
|
)
|
||||||
|
merge_or_append_local(local_entry)
|
||||||
|
resolved_prompt_basenames.add(basename_key)
|
||||||
|
|
||||||
|
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||||
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
|
continue
|
||||||
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||||
|
if basename_key in resolved_prompt_basenames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_entries = prompt_by_basename.get(basename_key, [])
|
||||||
|
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
|
||||||
|
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
|
||||||
|
|
||||||
|
if lora_hash and recipe_scanner and lora_type == 'lora':
|
||||||
|
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
||||||
|
if local_lora:
|
||||||
|
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||||
|
merge_or_append_local(local_entry)
|
||||||
|
continue
|
||||||
|
|
||||||
|
hash_resolved = False
|
||||||
|
if lora_hash and metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash,
|
||||||
|
)
|
||||||
|
if populated_entry is None:
|
||||||
continue
|
continue
|
||||||
|
lora_entry = populated_entry
|
||||||
# Skip entries without a hash value — they can't be
|
hash_resolved = not lora_entry.get('isDeleted')
|
||||||
# resolved via CivitAI and would only produce a
|
except Exception as e:
|
||||||
# useless "Deleted" entry in the recipe.
|
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
||||||
if not lora_hash:
|
|
||||||
continue
|
if hash_resolved:
|
||||||
|
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
|
||||||
lora_type, lora_name = hash_key.split(':', 1)
|
continue
|
||||||
|
|
||||||
# Get weight from extranet tags if available, else default to 1.0
|
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
|
||||||
weight = lora_weights.get(hash_key, 1.0)
|
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||||
|
if local_lora:
|
||||||
# Initialize lora entry
|
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||||
lora_entry = {
|
merge_or_append_local(local_entry)
|
||||||
'name': lora_name,
|
continue
|
||||||
'type': lora_type, # 'lora' or 'hypernet'
|
|
||||||
'weight': weight,
|
if lora_hash and not resource_lora_count:
|
||||||
'hash': lora_hash,
|
loras.append(lora_entry)
|
||||||
'existsLocally': False,
|
|
||||||
'localPath': None,
|
|
||||||
'file_name': lora_name,
|
|
||||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
|
||||||
'baseModel': '',
|
|
||||||
'size': 0,
|
|
||||||
'downloadUrl': '',
|
|
||||||
'isDeleted': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to get info from Civitai
|
|
||||||
if metadata_provider:
|
|
||||||
try:
|
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
|
||||||
lora_entry,
|
|
||||||
civitai_info,
|
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash
|
|
||||||
)
|
|
||||||
if populated_entry is None:
|
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
lora_entry = populated_entry
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
|
||||||
|
|
||||||
loras.append(lora_entry)
|
|
||||||
|
|
||||||
# Try to get base model from resources or make educated guess
|
# Try to get base model from resources or make educated guess
|
||||||
base_model = None
|
base_model = None
|
||||||
|
|||||||
+95
-68
@@ -31,79 +31,15 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
metadata_provider = await get_default_metadata_provider()
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
data = json.loads(user_comment)
|
data = json.loads(user_comment)
|
||||||
loras = []
|
|
||||||
|
|
||||||
# Find all LoraLoader nodes
|
|
||||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
|
||||||
|
|
||||||
# Process each LoraLoader node
|
|
||||||
for node_id, node in lora_nodes.items():
|
|
||||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lora_name = node['inputs'].get('lora_name', '')
|
|
||||||
|
|
||||||
# Parse the URN to extract model ID and version ID
|
|
||||||
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
|
|
||||||
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
|
||||||
if not lora_id_match:
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_id = lora_id_match.group(1)
|
|
||||||
model_version_id = lora_id_match.group(2)
|
|
||||||
|
|
||||||
# Get strength from node inputs
|
|
||||||
weight = node['inputs'].get('strength_model', 1.0)
|
|
||||||
|
|
||||||
# Initialize lora entry with default values
|
|
||||||
lora_entry = {
|
|
||||||
'id': model_version_id,
|
|
||||||
'modelId': model_id,
|
|
||||||
'name': f"Lora {model_id}", # Default name
|
|
||||||
'version': '',
|
|
||||||
'type': 'lora',
|
|
||||||
'weight': weight,
|
|
||||||
'existsLocally': False,
|
|
||||||
'localPath': None,
|
|
||||||
'file_name': '',
|
|
||||||
'hash': '',
|
|
||||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
|
||||||
'baseModel': '',
|
|
||||||
'size': 0,
|
|
||||||
'downloadUrl': '',
|
|
||||||
'isDeleted': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get additional info from Civitai if metadata provider is available
|
|
||||||
if metadata_provider:
|
|
||||||
try:
|
|
||||||
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
|
||||||
# Populate lora entry with Civitai info
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
|
||||||
lora_entry,
|
|
||||||
civitai_info_tuple,
|
|
||||||
recipe_scanner
|
|
||||||
)
|
|
||||||
if populated_entry is None:
|
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
lora_entry = populated_entry
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
|
||||||
|
|
||||||
loras.append(lora_entry)
|
|
||||||
|
|
||||||
# Find checkpoint info
|
|
||||||
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
||||||
checkpoint = None
|
checkpoint = None
|
||||||
checkpoint_id = None
|
checkpoint_id = None
|
||||||
checkpoint_version_id = None
|
checkpoint_version_id = None
|
||||||
|
|
||||||
if checkpoint_nodes:
|
if checkpoint_nodes:
|
||||||
# Get the first checkpoint node
|
|
||||||
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
||||||
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
||||||
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
||||||
# Parse checkpoint URN
|
|
||||||
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
||||||
if checkpoint_match:
|
if checkpoint_match:
|
||||||
checkpoint_id = checkpoint_match.group(1)
|
checkpoint_id = checkpoint_match.group(1)
|
||||||
@@ -115,16 +51,107 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
'version': '',
|
'version': '',
|
||||||
'type': 'checkpoint'
|
'type': 'checkpoint'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get additional checkpoint info from Civitai
|
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
||||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
# Populate checkpoint with Civitai info
|
|
||||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
||||||
|
|
||||||
|
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
|
||||||
|
loras = []
|
||||||
|
lora_candidates = []
|
||||||
|
for node in data.values():
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
inputs = node.get('inputs')
|
||||||
|
if not isinstance(inputs, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if node.get('class_type') == 'LoraLoader':
|
||||||
|
lora_name = inputs.get('lora_name', '')
|
||||||
|
if isinstance(lora_name, str) and lora_name:
|
||||||
|
lora_candidates.append((lora_name, inputs.get('strength_model', 1.0)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if node.get('class_type') != 'LoraLoaderLM':
|
||||||
|
continue
|
||||||
|
|
||||||
|
loras_data = inputs.get('loras', [])
|
||||||
|
if isinstance(loras_data, dict):
|
||||||
|
loras_data = loras_data.get('__value__', [])
|
||||||
|
if isinstance(loras_data, list) and len(loras_data) == 1 and isinstance(loras_data[0], list):
|
||||||
|
loras_data = loras_data[0]
|
||||||
|
if not isinstance(loras_data, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for lora in loras_data:
|
||||||
|
if not isinstance(lora, dict) or not lora.get('active', False) or lora.get('_isDummy', False):
|
||||||
|
continue
|
||||||
|
lora_name = lora.get('name', '')
|
||||||
|
if isinstance(lora_name, str) and lora_name:
|
||||||
|
lora_candidates.append((lora_name, lora.get('strength', 1.0)))
|
||||||
|
|
||||||
|
for lora_name, weight in lora_candidates:
|
||||||
|
if isinstance(weight, str):
|
||||||
|
try:
|
||||||
|
weight = float(weight)
|
||||||
|
except ValueError:
|
||||||
|
weight = 1.0
|
||||||
|
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
||||||
|
if lora_id_match:
|
||||||
|
model_id = lora_id_match.group(1)
|
||||||
|
model_version_id = lora_id_match.group(2)
|
||||||
|
entry_name = f"Lora {model_id}"
|
||||||
|
else:
|
||||||
|
model_id = 0
|
||||||
|
model_version_id = 0
|
||||||
|
entry_name = re.split(r'[\\/]', lora_name)[-1]
|
||||||
|
entry_name = re.sub(r'\.[^.]+$', '', entry_name)
|
||||||
|
|
||||||
|
lora_entry = {
|
||||||
|
'id': model_version_id,
|
||||||
|
'modelId': model_id,
|
||||||
|
'name': entry_name,
|
||||||
|
'version': '',
|
||||||
|
'type': 'lora',
|
||||||
|
'weight': weight,
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': entry_name,
|
||||||
|
'hash': '',
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if lora_id_match:
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info_tuple,
|
||||||
|
recipe_scanner
|
||||||
|
)
|
||||||
|
if populated_entry is None:
|
||||||
|
continue
|
||||||
|
lora_entry = populated_entry
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||||
|
else:
|
||||||
|
if not recipe_scanner:
|
||||||
|
continue
|
||||||
|
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||||
|
if not local_lora:
|
||||||
|
continue
|
||||||
|
lora_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||||
|
|
||||||
|
loras.append(lora_entry)
|
||||||
|
|
||||||
# Extract generation parameters
|
# Extract generation parameters
|
||||||
gen_params = {}
|
gen_params = {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Any, Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
|
|||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
from ..utils.utils import _format_model_name_for_comfyui
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,7 +46,45 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
# Checkpoint roots and Unet roots
|
# Checkpoint roots and Unet roots
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
||||||
|
|
||||||
|
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
|
||||||
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
|
||||||
|
|
||||||
|
async def get_loader_pool(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return ComfyUI-formatted model names with their base_model.
|
||||||
|
|
||||||
|
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
|
||||||
|
filters the ckpt_name/unet_name combo options by base_model using this
|
||||||
|
pool, so control_after_generate randomizes within the narrowed set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sub_type = request.query.get("sub_type", "checkpoint")
|
||||||
|
if sub_type not in ("checkpoint", "diffusion_model"):
|
||||||
|
return web.json_response({"error": "invalid sub_type"}, status=400)
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
model_roots = scanner.get_model_roots()
|
||||||
|
items: List[Dict[str, str]] = []
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != sub_type:
|
||||||
|
continue
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
continue
|
||||||
|
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||||
|
if formatted_name:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"name": formatted_name,
|
||||||
|
"base_model": item.get("base_model", "") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.sort(key=lambda x: x["name"])
|
||||||
|
return web.json_response({"items": items})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting loader pool: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
"""Validate CivitAI model type for Checkpoint"""
|
"""Validate CivitAI model type for Checkpoint"""
|
||||||
return model_type.lower() == 'checkpoint'
|
return model_type.lower() == 'checkpoint'
|
||||||
|
|||||||
@@ -2428,8 +2428,8 @@ class ModelLibraryHandler:
|
|||||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
found_type = None
|
found_type = None
|
||||||
file_path = None
|
|
||||||
found_cache = None
|
found_cache = None
|
||||||
|
entries: list = []
|
||||||
|
|
||||||
for model_type, scanner in (
|
for model_type, scanner in (
|
||||||
("lora", lora_scanner),
|
("lora", lora_scanner),
|
||||||
@@ -2440,27 +2440,43 @@ class ModelLibraryHandler:
|
|||||||
if cache and model_version_id in cache.version_index:
|
if cache and model_version_id in cache.version_index:
|
||||||
found_type = model_type
|
found_type = model_type
|
||||||
found_cache = cache
|
found_cache = cache
|
||||||
entry = cache.version_index[model_version_id]
|
# A version can have several local files (#1058); collect
|
||||||
file_path = entry.get("file_path")
|
# them all so the delete below covers every file.
|
||||||
|
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||||
|
if files_getter is not None:
|
||||||
|
entries = files_getter(model_version_id)
|
||||||
|
else:
|
||||||
|
entries = [cache.version_index[model_version_id]]
|
||||||
break
|
break
|
||||||
|
|
||||||
if not file_path:
|
file_paths = [
|
||||||
|
entry.get("file_path")
|
||||||
|
for entry in entries
|
||||||
|
if isinstance(entry, dict) and entry.get("file_path")
|
||||||
|
]
|
||||||
|
|
||||||
|
if not file_paths:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Model version not found in any scanner cache"},
|
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_dir = os.path.dirname(file_path)
|
for file_path in file_paths:
|
||||||
base_name = os.path.basename(file_path)
|
target_dir = os.path.dirname(file_path)
|
||||||
file_name, extension = os.path.splitext(base_name)
|
base_name = os.path.basename(file_path)
|
||||||
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
file_name, extension = os.path.splitext(base_name)
|
||||||
|
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||||
|
|
||||||
if found_cache:
|
if found_cache:
|
||||||
|
removed_paths = set(file_paths)
|
||||||
found_cache.raw_data = [
|
found_cache.raw_data = [
|
||||||
item
|
item
|
||||||
for item in found_cache.raw_data
|
for item in found_cache.raw_data
|
||||||
if item.get("file_path") != file_path
|
if item.get("file_path") not in removed_paths
|
||||||
]
|
]
|
||||||
|
rebuild = getattr(found_cache, "rebuild_version_index", None)
|
||||||
|
if rebuild is not None:
|
||||||
|
rebuild()
|
||||||
await found_cache.resort()
|
await found_cache.resort()
|
||||||
|
|
||||||
scanner_map = {
|
scanner_map = {
|
||||||
@@ -2483,6 +2499,7 @@ class ModelLibraryHandler:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"modelType": found_type,
|
"modelType": found_type,
|
||||||
"modelVersionId": model_version_id,
|
"modelVersionId": model_version_id,
|
||||||
|
"deletedFiles": len(file_paths),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1659,7 +1659,8 @@ class ModelDownloadHandler:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data["file_params"] = json.loads(file_params_json)
|
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||||
|
data["file_params"] = json.loads(file_params_json) or None
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"Invalid file_params JSON: %s", file_params_json
|
"Invalid file_params JSON: %s", file_params_json
|
||||||
@@ -1811,7 +1812,8 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
model_id = int(model_id_str) if model_id_str else None
|
model_id = int(model_id_str) if model_id_str else None
|
||||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||||
file_params = json.loads(file_params_json) if file_params_json else None
|
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||||
|
file_params = (json.loads(file_params_json) if file_params_json else None) or None
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
item = await service.add_to_queue(
|
item = await service.add_to_queue(
|
||||||
@@ -2187,6 +2189,19 @@ class ModelCivitaiHandler:
|
|||||||
else:
|
else:
|
||||||
version.pop("localPath", None)
|
version.pop("localPath", None)
|
||||||
|
|
||||||
|
# Per-file downloaded state so multi-file versions can show
|
||||||
|
# which individual files are already in the library (#1058)
|
||||||
|
local_entries: List[Any] = []
|
||||||
|
if version_id is not None and cache:
|
||||||
|
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||||
|
if files_getter is not None:
|
||||||
|
local_entries = files_getter(version_id)
|
||||||
|
elif cache_entry is not None:
|
||||||
|
local_entries = [cache_entry]
|
||||||
|
version["downloadedFiles"] = self._match_downloaded_files(
|
||||||
|
version, local_entries
|
||||||
|
)
|
||||||
|
|
||||||
model_file = (
|
model_file = (
|
||||||
self._find_model_file(version.get("files", []))
|
self._find_model_file(version.get("files", []))
|
||||||
if isinstance(version.get("files"), Iterable)
|
if isinstance(version.get("files"), Iterable)
|
||||||
@@ -2201,6 +2216,64 @@ class ModelCivitaiHandler:
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text=str(exc))
|
return web.Response(status=500, text=str(exc))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_downloaded_files(
|
||||||
|
version: Mapping[str, Any], local_entries: List[Any]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Map local library entries back to individual files of a version.
|
||||||
|
|
||||||
|
Matching follows rule D2 (#1058): SHA256 is authoritative when the
|
||||||
|
local entry carries one; otherwise fall back to extension-less file
|
||||||
|
name equality. Returns ``[{fileId, fileName, filePath}]``.
|
||||||
|
"""
|
||||||
|
files = version.get("files")
|
||||||
|
if not isinstance(files, list) or not local_entries:
|
||||||
|
return []
|
||||||
|
|
||||||
|
by_hash: Dict[str, Mapping[str, Any]] = {}
|
||||||
|
by_name: Dict[str, Mapping[str, Any]] = {}
|
||||||
|
for file_info in files:
|
||||||
|
if not isinstance(file_info, Mapping):
|
||||||
|
continue
|
||||||
|
sha = str(
|
||||||
|
(file_info.get("hashes") or {}).get("SHA256") or ""
|
||||||
|
).strip().lower()
|
||||||
|
if sha:
|
||||||
|
by_hash.setdefault(sha, file_info)
|
||||||
|
name = str(file_info.get("name") or "").strip()
|
||||||
|
if name:
|
||||||
|
by_name.setdefault(os.path.splitext(name)[0], file_info)
|
||||||
|
|
||||||
|
downloaded: List[Dict[str, Any]] = []
|
||||||
|
seen_keys: set = set()
|
||||||
|
for entry in local_entries:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
matched: Optional[Mapping[str, Any]] = None
|
||||||
|
local_hash = str(entry.get("sha256") or "").strip().lower()
|
||||||
|
if local_hash:
|
||||||
|
matched = by_hash.get(local_hash)
|
||||||
|
if matched is None:
|
||||||
|
local_name = str(entry.get("file_name") or "").strip()
|
||||||
|
if local_name:
|
||||||
|
matched = by_name.get(local_name)
|
||||||
|
if matched is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_id = matched.get("id")
|
||||||
|
dedupe_key = file_id if file_id is not None else matched.get("name")
|
||||||
|
if dedupe_key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(dedupe_key)
|
||||||
|
downloaded.append(
|
||||||
|
{
|
||||||
|
"fileId": file_id,
|
||||||
|
"fileName": matched.get("name"),
|
||||||
|
"filePath": entry.get("file_path"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return downloaded
|
||||||
|
|
||||||
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_version_id = request.match_info.get("modelVersionId")
|
model_version_id = request.match_info.get("modelVersionId")
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ class DownloadCoordinator:
|
|||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
download_id=download_id,
|
download_id=download_id,
|
||||||
source=payload.get("source"),
|
source=payload.get("source"),
|
||||||
file_params=payload.get("file_params"),
|
# Normalize falsy file_params (e.g. {}) to None so download gates
|
||||||
|
# treat it as "no explicit file selection" (#1058).
|
||||||
|
file_params=payload.get("file_params") or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
result["download_id"] = download_id
|
result["download_id"] = download_id
|
||||||
|
|||||||
+237
-69
@@ -213,6 +213,162 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _get_scanner_for_model_type(self, model_type: str):
|
||||||
|
"""Return the scanner responsible for the given model type."""
|
||||||
|
if model_type == "checkpoint":
|
||||||
|
return await self._get_checkpoint_scanner()
|
||||||
|
if model_type == "embedding":
|
||||||
|
return await ServiceRegistry.get_embedding_scanner()
|
||||||
|
return await self._get_lora_scanner()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_target_file(
|
||||||
|
files: Any, file_params: Dict[str, Any] | None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resolve the target file within a version's file list from file_params.
|
||||||
|
|
||||||
|
Shared by the existence gate and the actual file selection so both
|
||||||
|
always agree on which file a download refers to (#1058). Returns None
|
||||||
|
when file_params is None or no file matches.
|
||||||
|
"""
|
||||||
|
if not file_params or not isinstance(files, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_file_id = file_params.get("id")
|
||||||
|
target_type = file_params.get("type", "Model")
|
||||||
|
target_format = file_params.get("format")
|
||||||
|
target_size = file_params.get("size")
|
||||||
|
target_fp = file_params.get("fp")
|
||||||
|
is_primary = file_params.get("isPrimary", False)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, "
|
||||||
|
"isPrimary=%s, total_files=%d",
|
||||||
|
target_file_id, target_type, target_format, target_size, target_fp,
|
||||||
|
is_primary, len(files),
|
||||||
|
)
|
||||||
|
|
||||||
|
file_info: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
if target_file_id:
|
||||||
|
target_id_str = str(target_file_id)
|
||||||
|
for f in files:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
f_id = f.get("id")
|
||||||
|
if str(f_id) == target_id_str:
|
||||||
|
file_info = f
|
||||||
|
logger.debug(
|
||||||
|
"[download] MATCH by ID: id=%s name='%s'",
|
||||||
|
f_id, f.get("name"),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not file_info:
|
||||||
|
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||||
|
|
||||||
|
elif is_primary:
|
||||||
|
file_info = next(
|
||||||
|
(
|
||||||
|
f
|
||||||
|
for f in files
|
||||||
|
if isinstance(f, dict)
|
||||||
|
and f.get("primary")
|
||||||
|
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Lenient metadata match: only compare fields present on both sides
|
||||||
|
for f in files:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
f_type = f.get("type", "")
|
||||||
|
if f_type != target_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
f_meta = f.get("metadata", {})
|
||||||
|
f_format = f_meta.get("format") or f.get("format")
|
||||||
|
f_size = f_meta.get("size") or f.get("size")
|
||||||
|
f_fp = f_meta.get("fp") or f.get("fp")
|
||||||
|
|
||||||
|
if target_format and f_format != target_format:
|
||||||
|
continue
|
||||||
|
if target_size and f_size and f_size != target_size:
|
||||||
|
continue
|
||||||
|
if target_fp and f_fp and f_fp != target_fp:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_info = f
|
||||||
|
break
|
||||||
|
|
||||||
|
return file_info
|
||||||
|
|
||||||
|
async def _find_local_file_entry(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
model_version_id: int,
|
||||||
|
target_file: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find a local library entry for a specific file of a model version.
|
||||||
|
|
||||||
|
Matches per design rule D2 (#1058): SHA256 is only compared when both
|
||||||
|
sides carry a non-empty hash; otherwise fall back to (extension-less)
|
||||||
|
file name equality. Never let two empty hashes compare equal.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized_version_id = int(model_version_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner = await self._get_scanner_for_model_type(model_type)
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to scan local entries for version %s file check: %s",
|
||||||
|
model_version_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_data = getattr(cache, "raw_data", None) if cache else None
|
||||||
|
if not raw_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_hash = str(
|
||||||
|
(target_file.get("hashes") or {}).get("SHA256") or ""
|
||||||
|
).strip().lower()
|
||||||
|
target_name = str(target_file.get("name") or "").strip()
|
||||||
|
target_base = os.path.splitext(target_name)[0] if target_name else ""
|
||||||
|
|
||||||
|
for item in raw_data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
civitai_data = item.get("civitai")
|
||||||
|
if not isinstance(civitai_data, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item_version_id = int(civitai_data.get("id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if item_version_id != normalized_version_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_hash = str(item.get("sha256") or "").strip().lower()
|
||||||
|
if target_hash and local_hash:
|
||||||
|
if local_hash == target_hash:
|
||||||
|
return item
|
||||||
|
# Both sides carry hashes that differ: this is a different
|
||||||
|
# file of the same version — do not fall back to name match.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_base:
|
||||||
|
local_name = str(item.get("file_name") or "").strip()
|
||||||
|
if local_name == target_base:
|
||||||
|
return item
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def download_from_civitai(
|
async def download_from_civitai(
|
||||||
self,
|
self,
|
||||||
model_id: int | None = None,
|
model_id: int | None = None,
|
||||||
@@ -242,6 +398,10 @@ class DownloadManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with download result
|
Dict with download result
|
||||||
"""
|
"""
|
||||||
|
# Normalize falsy file_params (e.g. an empty dict from API JSON
|
||||||
|
# parsing) to None so gate conditions behave consistently (#1058).
|
||||||
|
file_params = file_params or None
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||||
"source=%s, file_params=%s",
|
"source=%s, file_params=%s",
|
||||||
@@ -816,6 +976,7 @@ class DownloadManager:
|
|||||||
version_info,
|
version_info,
|
||||||
record.get("model_version_id"),
|
record.get("model_version_id"),
|
||||||
record.get("save_path") or record.get("file_path"),
|
record.get("save_path") or record.get("file_path"),
|
||||||
|
file_info=file_info,
|
||||||
)
|
)
|
||||||
await self._sync_downloaded_version(
|
await self._sync_downloaded_version(
|
||||||
model_type,
|
model_type,
|
||||||
@@ -1152,9 +1313,13 @@ class DownloadManager:
|
|||||||
use_save_dir_as_root: bool = False,
|
use_save_dir_as_root: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Wrapper for original download_from_civitai implementation"""
|
"""Wrapper for original download_from_civitai implementation"""
|
||||||
|
file_params = file_params or None
|
||||||
try:
|
try:
|
||||||
# Check if model version already exists in library
|
# Check if model version already exists in library.
|
||||||
if model_version_id is not None:
|
# With an explicit file selection (file_params) the version-level
|
||||||
|
# check is deferred until after the metadata fetch, when the target
|
||||||
|
# file can be resolved and checked individually (#1058).
|
||||||
|
if model_version_id is not None and file_params is None:
|
||||||
# Check both scanners
|
# Check both scanners
|
||||||
lora_scanner = await self._get_lora_scanner()
|
lora_scanner = await self._get_lora_scanner()
|
||||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||||
@@ -1235,8 +1400,26 @@ class DownloadManager:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
resolved_version_id = None
|
resolved_version_id = None
|
||||||
|
|
||||||
|
# Resolve the explicitly selected file (if any) up front so the
|
||||||
|
# existence gates and the actual file selection below always agree
|
||||||
|
# on the target file (#1058).
|
||||||
|
target_file: Optional[Dict[str, Any]] = None
|
||||||
|
if file_params is not None:
|
||||||
|
target_file = self._resolve_target_file(
|
||||||
|
version_info.get("files") or [], file_params
|
||||||
|
)
|
||||||
|
if target_file is None:
|
||||||
|
logger.warning(
|
||||||
|
"[download] file_params provided but no file matched; "
|
||||||
|
"falling back to version-level checks and primary file "
|
||||||
|
"selection (model_version_id=%s)",
|
||||||
|
resolved_version_id,
|
||||||
|
)
|
||||||
|
explicit_file = target_file is not None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
get_settings_manager().get_skip_previously_downloaded_model_versions()
|
not explicit_file
|
||||||
|
and get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||||
and resolved_version_id is not None
|
and resolved_version_id is not None
|
||||||
and await self._has_been_downloaded(model_type, resolved_version_id)
|
and await self._has_been_downloaded(model_type, resolved_version_id)
|
||||||
):
|
):
|
||||||
@@ -1346,9 +1529,38 @@ class DownloadManager:
|
|||||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Case 2: model_version_id was None, check after getting version_info
|
# Existence check after the metadata fetch (#1058):
|
||||||
if model_version_id is None:
|
# - An explicit file selection only blocks when THIS file is
|
||||||
version_id = version_info.get("id")
|
# already in the library; other files of the same version
|
||||||
|
# remain downloadable.
|
||||||
|
# - Without file_params (or when file_params failed to resolve),
|
||||||
|
# keep version-level protection. The case "model_version_id
|
||||||
|
# given + no file_params" was already covered by the early
|
||||||
|
# gate above.
|
||||||
|
if explicit_file and resolved_version_id is not None:
|
||||||
|
existing_entry = await self._find_local_file_entry(
|
||||||
|
model_type, resolved_version_id, target_file
|
||||||
|
)
|
||||||
|
if existing_entry is not None:
|
||||||
|
error_message = (
|
||||||
|
f"File '{target_file.get('name')}' from model version "
|
||||||
|
f"{resolved_version_id} already exists in {model_type} library"
|
||||||
|
)
|
||||||
|
logger.info("[download] %s", error_message)
|
||||||
|
return {"success": False, "error": error_message}
|
||||||
|
logger.info(
|
||||||
|
"[download] File '%s' of model version %s not in %s library — "
|
||||||
|
"download allowed (other files of this version may exist locally)",
|
||||||
|
target_file.get("name"), resolved_version_id, model_type,
|
||||||
|
)
|
||||||
|
elif file_params is not None or model_version_id is None:
|
||||||
|
# Case 2: model_version_id was None, or file_params did not
|
||||||
|
# resolve to a concrete file — check at version level.
|
||||||
|
version_id = (
|
||||||
|
resolved_version_id
|
||||||
|
if resolved_version_id is not None
|
||||||
|
else version_info.get("id")
|
||||||
|
)
|
||||||
|
|
||||||
if model_type == "lora":
|
if model_type == "lora":
|
||||||
# Check lora scanner
|
# Check lora scanner
|
||||||
@@ -1495,73 +1707,16 @@ class DownloadManager:
|
|||||||
files = version_info.get("files", [])
|
files = version_info.get("files", [])
|
||||||
file_info = None
|
file_info = None
|
||||||
|
|
||||||
# If file_params is provided, try to find matching file
|
# If file_params is provided, reuse the file resolved right after
|
||||||
if file_params and model_version_id:
|
# the metadata fetch so the existence gate and this selection
|
||||||
target_file_id = file_params.get("id")
|
# always agree on the target file (#1058).
|
||||||
target_type = file_params.get("type", "Model")
|
if file_params is not None:
|
||||||
target_format = file_params.get("format")
|
file_info = target_file
|
||||||
target_size = file_params.get("size")
|
|
||||||
target_fp = file_params.get("fp")
|
|
||||||
is_primary = file_params.get("isPrimary", False)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
|
||||||
"model_version_id=%s, total_files=%d",
|
|
||||||
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
|
||||||
model_version_id, len(files),
|
|
||||||
)
|
|
||||||
|
|
||||||
if target_file_id:
|
|
||||||
target_id_str = str(target_file_id)
|
|
||||||
for f in files:
|
|
||||||
f_id = f.get("id")
|
|
||||||
if str(f_id) == target_id_str:
|
|
||||||
file_info = f
|
|
||||||
logger.debug(
|
|
||||||
"[download] MATCH by ID: id=%s name='%s'",
|
|
||||||
f_id, f.get("name"),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if not file_info:
|
|
||||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
|
||||||
|
|
||||||
elif is_primary:
|
|
||||||
file_info = next(
|
|
||||||
(
|
|
||||||
f
|
|
||||||
for f in files
|
|
||||||
if f.get("primary")
|
|
||||||
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Lenient metadata match: only compare fields present on both sides
|
|
||||||
for f in files:
|
|
||||||
f_type = f.get("type", "")
|
|
||||||
if f_type != target_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
f_meta = f.get("metadata", {})
|
|
||||||
f_format = f_meta.get("format") or f.get("format")
|
|
||||||
f_size = f_meta.get("size") or f.get("size")
|
|
||||||
f_fp = f_meta.get("fp") or f.get("fp")
|
|
||||||
|
|
||||||
if target_format and f_format != target_format:
|
|
||||||
continue
|
|
||||||
if target_size and f_size and f_size != target_size:
|
|
||||||
continue
|
|
||||||
if target_fp and f_fp and f_fp != target_fp:
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_info = f
|
|
||||||
break
|
|
||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[download] No match found via file_params — falling back to primary file lookup",
|
"[download] No match found via file_params — falling back to primary file lookup",
|
||||||
)
|
)
|
||||||
elif not file_params:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||||
"model_version_id=%s, total_files=%d",
|
"model_version_id=%s, total_files=%d",
|
||||||
@@ -1706,6 +1861,7 @@ class DownloadManager:
|
|||||||
version_info,
|
version_info,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
save_path,
|
save_path,
|
||||||
|
file_info=file_info,
|
||||||
)
|
)
|
||||||
await self._sync_downloaded_version(
|
await self._sync_downloaded_version(
|
||||||
model_type,
|
model_type,
|
||||||
@@ -1748,6 +1904,7 @@ class DownloadManager:
|
|||||||
version_info: Dict[str, Any],
|
version_info: Dict[str, Any],
|
||||||
fallback_version_id=None,
|
fallback_version_id=None,
|
||||||
file_path: str | None = None,
|
file_path: str | None = None,
|
||||||
|
file_info: Dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
@@ -1773,6 +1930,15 @@ class DownloadManager:
|
|||||||
if version_id is None:
|
if version_id is None:
|
||||||
version_id = fallback_version_id
|
version_id = fallback_version_id
|
||||||
|
|
||||||
|
# Per-file identity for multi-file versions (#1058)
|
||||||
|
file_id = None
|
||||||
|
file_name = None
|
||||||
|
if isinstance(file_info, dict):
|
||||||
|
file_id = file_info.get("id")
|
||||||
|
raw_file_name = file_info.get("name")
|
||||||
|
if isinstance(raw_file_name, str) and raw_file_name.strip():
|
||||||
|
file_name = raw_file_name.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await history_service.mark_downloaded(
|
await history_service.mark_downloaded(
|
||||||
model_type,
|
model_type,
|
||||||
@@ -1780,6 +1946,8 @@ class DownloadManager:
|
|||||||
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
||||||
source="download",
|
source="download",
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
|
file_id=file_id,
|
||||||
|
file_name=file_name,
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DownloadQueueService:
|
|||||||
model_name TEXT NOT NULL DEFAULT '',
|
model_name TEXT NOT NULL DEFAULT '',
|
||||||
version_name TEXT DEFAULT '',
|
version_name TEXT DEFAULT '',
|
||||||
thumbnail_url TEXT DEFAULT '',
|
thumbnail_url TEXT DEFAULT '',
|
||||||
|
file_params TEXT,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
file_path TEXT,
|
file_path TEXT,
|
||||||
@@ -120,6 +121,18 @@ class DownloadQueueService:
|
|||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.executescript(self._SCHEMA_TABLES)
|
conn.executescript(self._SCHEMA_TABLES)
|
||||||
|
|
||||||
|
# Databases created by older versions lack
|
||||||
|
# download_history.file_params; add it so retry-from-history can
|
||||||
|
# restore the originally selected file (#1058).
|
||||||
|
history_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in conn.execute("PRAGMA table_info(download_history)")
|
||||||
|
}
|
||||||
|
if "file_params" not in history_columns:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE download_history ADD COLUMN file_params TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
# Creating the unique index on download_history.download_id can
|
# Creating the unique index on download_history.download_id can
|
||||||
# fail if pre-existing rows have duplicate values (e.g. from a
|
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||||
# previous version that lacked the index). Deduplicate first so
|
# previous version that lacked the index). Deduplicate first so
|
||||||
@@ -418,6 +431,12 @@ class DownloadQueueService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
now = completed_at if completed_at is not None else time.time()
|
now = completed_at if completed_at is not None else time.time()
|
||||||
|
# Guard against legacy databases whose download_queue table
|
||||||
|
# predates the file_params column.
|
||||||
|
queue_columns = set(row.keys())
|
||||||
|
file_params_json = (
|
||||||
|
row["file_params"] if "file_params" in queue_columns else None
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM download_queue WHERE download_id = ?",
|
"DELETE FROM download_queue WHERE download_id = ?",
|
||||||
(download_id,),
|
(download_id,),
|
||||||
@@ -426,9 +445,9 @@ class DownloadQueueService:
|
|||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO download_history (
|
INSERT OR IGNORE INTO download_history (
|
||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, status, error, file_path,
|
version_name, thumbnail_url, file_params, status, error,
|
||||||
bytes_downloaded, total_bytes, completed_at
|
file_path, bytes_downloaded, total_bytes, completed_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
row["download_id"],
|
row["download_id"],
|
||||||
@@ -437,6 +456,7 @@ class DownloadQueueService:
|
|||||||
row["model_name"],
|
row["model_name"],
|
||||||
row["version_name"],
|
row["version_name"],
|
||||||
row["thumbnail_url"],
|
row["thumbnail_url"],
|
||||||
|
file_params_json,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
file_path,
|
file_path,
|
||||||
@@ -503,6 +523,7 @@ class DownloadQueueService:
|
|||||||
bytes_downloaded: int = 0,
|
bytes_downloaded: int = 0,
|
||||||
total_bytes: Optional[int] = None,
|
total_bytes: Optional[int] = None,
|
||||||
is_already_exists: int = 0,
|
is_already_exists: int = 0,
|
||||||
|
file_params: Optional[dict[str, Any]] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Insert a record into the download history.
|
"""Insert a record into the download history.
|
||||||
|
|
||||||
@@ -510,6 +531,7 @@ class DownloadQueueService:
|
|||||||
inserted row.
|
inserted row.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
@@ -517,9 +539,10 @@ class DownloadQueueService:
|
|||||||
"""
|
"""
|
||||||
INSERT INTO download_history (
|
INSERT INTO download_history (
|
||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, status, error, file_path,
|
version_name, thumbnail_url, file_params, status, error,
|
||||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
file_path, bytes_downloaded, total_bytes, completed_at,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
is_already_exists
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
download_id,
|
download_id,
|
||||||
@@ -528,6 +551,7 @@ class DownloadQueueService:
|
|||||||
model_name,
|
model_name,
|
||||||
version_name,
|
version_name,
|
||||||
thumbnail_url,
|
thumbnail_url,
|
||||||
|
file_params_json,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
file_path,
|
file_path,
|
||||||
@@ -702,7 +726,7 @@ class DownloadQueueService:
|
|||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, source, file_params,
|
version_name, thumbnail_url, source, file_params,
|
||||||
status, priority, added_at
|
status, priority, added_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
new_id,
|
new_id,
|
||||||
@@ -712,6 +736,7 @@ class DownloadQueueService:
|
|||||||
row["version_name"],
|
row["version_name"],
|
||||||
row["thumbnail_url"],
|
row["thumbnail_url"],
|
||||||
"retry",
|
"retry",
|
||||||
|
row["file_params"],
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -755,7 +780,7 @@ class DownloadQueueService:
|
|||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, source, file_params,
|
version_name, thumbnail_url, source, file_params,
|
||||||
status, priority, added_at
|
status, priority, added_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
new_id,
|
new_id,
|
||||||
@@ -765,6 +790,7 @@ class DownloadQueueService:
|
|||||||
row["version_name"],
|
row["version_name"],
|
||||||
row["thumbnail_url"],
|
row["thumbnail_url"],
|
||||||
"retry",
|
"retry",
|
||||||
|
row["file_params"],
|
||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||||
ON downloaded_model_versions(model_type, model_id);
|
ON downloaded_model_versions(model_type, model_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS downloaded_version_files (
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
version_id INTEGER NOT NULL,
|
||||||
|
file_id INTEGER NOT NULL,
|
||||||
|
file_name TEXT,
|
||||||
|
downloaded_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (model_type, version_id, file_id)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||||
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
|
|||||||
source: str = "manual",
|
source: str = "manual",
|
||||||
file_path: str | None = None,
|
file_path: str | None = None,
|
||||||
library_name: str | None = None,
|
library_name: str | None = None,
|
||||||
|
file_id: int | None = None,
|
||||||
|
file_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
normalized_version_id = _normalize_int(version_id)
|
normalized_version_id = _normalize_int(version_id)
|
||||||
normalized_model_id = _normalize_int(model_id)
|
normalized_model_id = _normalize_int(model_id)
|
||||||
|
normalized_file_id = _normalize_int(file_id)
|
||||||
if normalized_type is None or normalized_version_id is None:
|
if normalized_type is None or normalized_version_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
|
|||||||
active_library_name,
|
active_library_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if normalized_file_id is not None:
|
||||||
|
# Per-file history for multi-file versions (#1058)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO downloaded_version_files (
|
||||||
|
model_type, version_id, file_id, file_name, downloaded_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
|
||||||
|
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
|
||||||
|
downloaded_at = excluded.downloaded_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
normalized_type,
|
||||||
|
normalized_version_id,
|
||||||
|
normalized_file_id,
|
||||||
|
file_name,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_downloaded_bulk(
|
async def mark_downloaded_bulk(
|
||||||
@@ -255,8 +285,63 @@ class DownloadedVersionHistoryService:
|
|||||||
self._get_active_library_name(),
|
self._get_active_library_name(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Whole-version deletion also clears the per-file records (#1058)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM downloaded_version_files
|
||||||
|
WHERE model_type = ? AND version_id = ?
|
||||||
|
""",
|
||||||
|
(normalized_type, normalized_version_id),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
async def mark_file_deleted(
|
||||||
|
self, model_type: str, version_id: int, file_id: int
|
||||||
|
) -> None:
|
||||||
|
"""Drop a single file record of a version, keeping siblings (#1058)."""
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
normalized_file_id = _normalize_int(file_id)
|
||||||
|
if (
|
||||||
|
normalized_type is None
|
||||||
|
or normalized_version_id is None
|
||||||
|
or normalized_file_id is None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
conn = self._get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM downloaded_version_files
|
||||||
|
WHERE model_type = ? AND version_id = ? AND file_id = ?
|
||||||
|
""",
|
||||||
|
(normalized_type, normalized_version_id, normalized_file_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
async def get_downloaded_file_ids(
|
||||||
|
self, model_type: str, version_id: int
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return the CivitAI file ids recorded as downloaded for a version."""
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
if normalized_type is None or normalized_version_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
conn = self._get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT file_id
|
||||||
|
FROM downloaded_version_files
|
||||||
|
WHERE model_type = ? AND version_id = ?
|
||||||
|
ORDER BY file_id ASC
|
||||||
|
""",
|
||||||
|
(normalized_type, normalized_version_id),
|
||||||
|
).fetchall()
|
||||||
|
return [int(row["file_id"]) for row in rows]
|
||||||
|
|
||||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
normalized_version_id = _normalize_int(version_id)
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class ModelCache:
|
|||||||
folders: List[str]
|
folders: List[str]
|
||||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||||
|
# Multi-valued companion to version_index: every local file entry of a
|
||||||
|
# CivitAI model version, so versions with several downloaded files stay
|
||||||
|
# consistent (#1058).
|
||||||
|
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||||
name_display_mode: str = "model_name"
|
name_display_mode: str = "model_name"
|
||||||
_lock: Any = field(init=False, repr=False, default=None)
|
_lock: Any = field(init=False, repr=False, default=None)
|
||||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||||
@@ -116,6 +120,7 @@ class ModelCache:
|
|||||||
|
|
||||||
self.version_index = {}
|
self.version_index = {}
|
||||||
self.model_id_index = {}
|
self.model_id_index = {}
|
||||||
|
self.version_files_index = {}
|
||||||
for item in self.raw_data:
|
for item in self.raw_data:
|
||||||
self.add_to_version_index(item)
|
self.add_to_version_index(item)
|
||||||
|
|
||||||
@@ -132,6 +137,17 @@ class ModelCache:
|
|||||||
|
|
||||||
self.version_index[version_id] = item
|
self.version_index[version_id] = item
|
||||||
|
|
||||||
|
# Register in the multi-valued index, deduplicated by file_path (#1058)
|
||||||
|
files = self.version_files_index.setdefault(version_id, [])
|
||||||
|
for entry in files:
|
||||||
|
if entry is item or (
|
||||||
|
isinstance(entry, dict)
|
||||||
|
and entry.get('file_path') == item.get('file_path')
|
||||||
|
):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
files.append(item)
|
||||||
|
|
||||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||||
if model_id is None:
|
if model_id is None:
|
||||||
return
|
return
|
||||||
@@ -159,12 +175,37 @@ class ModelCache:
|
|||||||
if version_id is None:
|
if version_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Drop only this file's entry from the multi-valued index (#1058)
|
||||||
|
files = self.version_files_index.get(version_id)
|
||||||
|
if files:
|
||||||
|
remaining = [
|
||||||
|
entry
|
||||||
|
for entry in files
|
||||||
|
if not (
|
||||||
|
entry is item
|
||||||
|
or (
|
||||||
|
isinstance(entry, dict)
|
||||||
|
and entry.get('file_path') == item.get('file_path')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if remaining:
|
||||||
|
self.version_files_index[version_id] = remaining
|
||||||
|
else:
|
||||||
|
self.version_files_index.pop(version_id, None)
|
||||||
|
|
||||||
|
# A surviving sibling file keeps the version present in the indexes
|
||||||
|
sibling = (self.version_files_index.get(version_id) or [None])[0]
|
||||||
|
|
||||||
existing = self.version_index.get(version_id)
|
existing = self.version_index.get(version_id)
|
||||||
if existing is item or (
|
if existing is item or (
|
||||||
isinstance(existing, dict)
|
isinstance(existing, dict)
|
||||||
and existing.get('file_path') == item.get('file_path')
|
and existing.get('file_path') == item.get('file_path')
|
||||||
):
|
):
|
||||||
self.version_index.pop(version_id, None)
|
if sibling is not None:
|
||||||
|
self.version_index[version_id] = sibling
|
||||||
|
else:
|
||||||
|
self.version_index.pop(version_id, None)
|
||||||
|
|
||||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||||
if model_id is None:
|
if model_id is None:
|
||||||
@@ -174,6 +215,20 @@ class ModelCache:
|
|||||||
if not versions:
|
if not versions:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if sibling is not None:
|
||||||
|
# Update the descriptor to reflect the surviving sibling file
|
||||||
|
descriptor = self._build_version_descriptor(
|
||||||
|
sibling,
|
||||||
|
sibling.get('civitai') if isinstance(sibling, dict) else {},
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
for index, existing_desc in enumerate(versions):
|
||||||
|
if existing_desc.get('versionId') == version_id:
|
||||||
|
if descriptor is not None:
|
||||||
|
versions[index] = descriptor
|
||||||
|
break
|
||||||
|
return
|
||||||
|
|
||||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||||
if filtered:
|
if filtered:
|
||||||
self.model_id_index[model_id] = filtered
|
self.model_id_index[model_id] = filtered
|
||||||
@@ -206,6 +261,15 @@ class ModelCache:
|
|||||||
versions = self.model_id_index.get(normalized_id, [])
|
versions = self.model_id_index.get(normalized_id, [])
|
||||||
return [dict(version) for version in versions]
|
return [dict(version) for version in versions]
|
||||||
|
|
||||||
|
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Return every local file entry for a CivitAI model version (#1058)."""
|
||||||
|
|
||||||
|
normalized_id = self._normalize_version_id(version_id)
|
||||||
|
if normalized_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(self.version_files_index.get(normalized_id, []))
|
||||||
|
|
||||||
async def resort(self):
|
async def resort(self):
|
||||||
"""Resort cached data according to last sort mode if set"""
|
"""Resort cached data according to last sort mode if set"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Canonical set of weight-file extensions stripped when normalizing model
|
||||||
|
# names for matching (ModelScanner.find_matching_models and the recipe rematch
|
||||||
|
# filename key share this set). It is the union of the LoRA scanner set
|
||||||
|
# ({".safetensors"}) and the Checkpoint scanner set (ComfyUI's
|
||||||
|
# supported_pt_extensions plus ".gguf") so type-blind lookups (lora +
|
||||||
|
# checkpoint merged) cover every format either scanner indexes. ".safebin"
|
||||||
|
# is deliberately absent — no scanner indexes it, so a recipe entry
|
||||||
|
# "model.safebin" must not be bound to a local "model.safetensors".
|
||||||
|
WEIGHT_FILE_EXTENSIONS = frozenset(
|
||||||
|
{
|
||||||
|
".safetensors",
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_excluded_dir(name: str) -> bool:
|
def _is_excluded_dir(name: str) -> bool:
|
||||||
"""Return True when a directory entry must be skipped during model walks.
|
"""Return True when a directory entry must be skipped during model walks.
|
||||||
@@ -2140,8 +2162,98 @@ class ModelScanner:
|
|||||||
return sorted_models
|
return sorted_models
|
||||||
return sorted_models[:limit]
|
return sorted_models[:limit]
|
||||||
|
|
||||||
async def get_model_info_by_name(self, name):
|
@staticmethod
|
||||||
"""Get model information by name"""
|
def find_matching_models(
|
||||||
|
raw_data: List[Dict[str, Any]],
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
base_model: Optional[str] = None,
|
||||||
|
extensions: Optional[Set[str]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return all cached models matching ``name`` (case-insensitive).
|
||||||
|
|
||||||
|
A name containing a path separator must equal the model's
|
||||||
|
folder-relative path; a bare name matches on basename. When
|
||||||
|
``base_model`` is given, confident mismatches are rejected while
|
||||||
|
unknowns on either side stay eligible (lenient guard).
|
||||||
|
``extensions`` should be the scanner's own ``file_extensions`` so
|
||||||
|
suffix stripping only covers formats the scanner actually indexes;
|
||||||
|
when omitted, the shared :data:`WEIGHT_FILE_EXTENSIONS` set is used.
|
||||||
|
"""
|
||||||
|
# Longest first so overlapping suffixes strip correctly.
|
||||||
|
exts = sorted(extensions or WEIGHT_FILE_EXTENSIONS, key=len, reverse=True)
|
||||||
|
|
||||||
|
normalized_name = str(name).replace("\\", "/").casefold()
|
||||||
|
for ext in exts:
|
||||||
|
if normalized_name.endswith(ext):
|
||||||
|
normalized_name = normalized_name[: -len(ext)]
|
||||||
|
break
|
||||||
|
has_path = "/" in normalized_name
|
||||||
|
basename = normalized_name.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for model in raw_data:
|
||||||
|
file_name = str(model.get("file_name") or "").replace("\\", "/")
|
||||||
|
folder = str(model.get("folder") or "").replace("\\", "/").strip("/")
|
||||||
|
model_path = f"{folder}/{file_name}" if folder else file_name
|
||||||
|
for ext in exts:
|
||||||
|
if model_path.casefold().endswith(ext):
|
||||||
|
model_path = model_path[: -len(ext)]
|
||||||
|
break
|
||||||
|
if (has_path and model_path.casefold() == normalized_name) or (
|
||||||
|
not has_path and model_path.rsplit("/", 1)[-1].casefold() == basename
|
||||||
|
):
|
||||||
|
matches.append(model)
|
||||||
|
|
||||||
|
expected_base = str(base_model or "").strip().casefold()
|
||||||
|
if expected_base and expected_base != "unknown":
|
||||||
|
matches = [
|
||||||
|
model
|
||||||
|
for model in matches
|
||||||
|
if str(model.get("base_model") or "").strip().casefold()
|
||||||
|
in ("", "unknown", expected_base)
|
||||||
|
]
|
||||||
|
return matches
|
||||||
|
|
||||||
|
async def find_models_by_name(
|
||||||
|
self, name: str, *, base_model: Optional[str] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return every cached model matching ``name`` (see ``find_matching_models``)."""
|
||||||
|
try:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
return self.find_matching_models(
|
||||||
|
cache.raw_data,
|
||||||
|
name,
|
||||||
|
base_model=base_model,
|
||||||
|
extensions=self.file_extensions,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding models by name: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_model_info_by_name(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
require_unique: bool = False,
|
||||||
|
base_model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Get model information by name.
|
||||||
|
|
||||||
|
Default mode keeps the legacy first-match/fallback semantics. With
|
||||||
|
``require_unique`` an ambiguous name is a miss, and ``base_model``
|
||||||
|
rejects confident base-model mismatches (unknowns stay eligible).
|
||||||
|
"""
|
||||||
|
if require_unique or base_model:
|
||||||
|
try:
|
||||||
|
matches = await self.find_models_by_name(name, base_model=base_model)
|
||||||
|
if require_unique and len(matches) != 1:
|
||||||
|
return None
|
||||||
|
return matches[0] if matches else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -2446,6 +2558,39 @@ class ModelScanner:
|
|||||||
logger.error(f"Error checking model version existence: {e}")
|
logger.error(f"Error checking model version existence: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all local file entries for a specific model version (#1058).
|
||||||
|
|
||||||
|
A Civitai model version can have several weight files downloaded;
|
||||||
|
unlike the single-valued version_index this returns every entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_version_id: Civitai model version ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Dict]: Cache entries (may be empty)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalized_id = int(model_version_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
if not cache:
|
||||||
|
return []
|
||||||
|
|
||||||
|
getter = getattr(cache, "get_files_by_version_id", None)
|
||||||
|
if getter is not None:
|
||||||
|
return getter(normalized_id)
|
||||||
|
|
||||||
|
# Fallback for cache implementations without the multi-file index
|
||||||
|
entry = cache.version_index.get(normalized_id)
|
||||||
|
return [entry] if entry is not None else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting files for model version: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
||||||
"""Get all versions of a model by its ID
|
"""Get all versions of a model by its ID
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..config import config
|
|||||||
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
||||||
from ..utils.file_utils import calculate_autov3
|
from ..utils.file_utils import calculate_autov3
|
||||||
from ..utils.recipe_open_stats import RecipeOpenStats
|
from ..utils.recipe_open_stats import RecipeOpenStats
|
||||||
|
from .model_scanner import WEIGHT_FILE_EXTENSIONS
|
||||||
from .recipe_cache import RecipeCache
|
from .recipe_cache import RecipeCache
|
||||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
@@ -36,11 +37,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||||
|
|
||||||
# Known weight-file extensions stripped by _normalize_filename_key. Names are
|
|
||||||
# stored extensionless on both sides, so splitext would misread dotted stems
|
|
||||||
# ("my.mix" -> "my") and silently collide distinct models.
|
|
||||||
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeScanner:
|
class RecipeScanner:
|
||||||
"""Service for scanning and managing recipe images"""
|
"""Service for scanning and managing recipe images"""
|
||||||
@@ -179,13 +175,15 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Only known weight-file extensions are stripped — names are stored
|
Only known weight-file extensions are stripped — names are stored
|
||||||
extensionless on both sides, so splitext would misread dotted stems
|
extensionless on both sides, so splitext would misread dotted stems
|
||||||
("my.mix" -> "my") and collide distinct models.
|
("my.mix" -> "my") and collide distinct models. The extension set is
|
||||||
|
shared with ModelScanner.find_matching_models, and is iterated longest
|
||||||
|
first to keep the strip ordering identical to that function.
|
||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
basename = os.path.basename(name.replace("\\", "/"))
|
basename = os.path.basename(name.replace("\\", "/"))
|
||||||
lower = basename.lower()
|
lower = basename.lower()
|
||||||
for ext in _WEIGHT_FILE_EXTS:
|
for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
|
||||||
if lower.endswith(ext):
|
if lower.endswith(ext):
|
||||||
basename = basename[: -len(ext)]
|
basename = basename[: -len(ext)]
|
||||||
break
|
break
|
||||||
@@ -2926,13 +2924,45 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
async def get_local_lora(self, name: str) -> Optional[Dict[str, Any]]:
|
async def get_local_lora(
|
||||||
"""Lookup a local LoRA model by name."""
|
self, name: str, base_model: Optional[str] = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Lookup an unambiguous local LoRA by name and optional base model."""
|
||||||
|
|
||||||
if not self._lora_scanner or not name:
|
if not self._lora_scanner or not name:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await self._lora_scanner.get_model_info_by_name(name)
|
return await self._lora_scanner.get_model_info_by_name(
|
||||||
|
name, require_unique=True, base_model=base_model
|
||||||
|
)
|
||||||
|
|
||||||
|
async def find_local_loras_by_name(
|
||||||
|
self, name: str, base_model: Optional[str] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return every local LoRA matching ``name`` (used to explain lookup misses)."""
|
||||||
|
|
||||||
|
if not self._lora_scanner or not name:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
|
||||||
|
|
||||||
|
async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Lookup a local LoRA through the scanner's hash index."""
|
||||||
|
|
||||||
|
if not self._lora_scanner or not hash_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = self._lora_scanner.get_path_by_hash(hash_value)
|
||||||
|
if not file_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_path = os.path.normcase(os.path.abspath(file_path))
|
||||||
|
cached_data = await self._lora_scanner.get_cached_data()
|
||||||
|
for model in cached_data.raw_data:
|
||||||
|
model_path = model.get("file_path")
|
||||||
|
if model_path and os.path.normcase(os.path.abspath(model_path)) == target_path:
|
||||||
|
return model
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
|
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Lookup a local checkpoint model by name."""
|
"""Lookup a local checkpoint model by name."""
|
||||||
@@ -3590,9 +3620,6 @@ class RecipeScanner:
|
|||||||
|
|
||||||
syntax_parts: List[str] = []
|
syntax_parts: List[str] = []
|
||||||
for lora in loras:
|
for lora in loras:
|
||||||
if lora.get("isDeleted", False):
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
folder = ""
|
folder = ""
|
||||||
hash_value = (lora.get("hash") or "").lower()
|
hash_value = (lora.get("hash") or "").lower()
|
||||||
@@ -3627,6 +3654,8 @@ class RecipeScanner:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not file_name:
|
if not file_name:
|
||||||
|
if lora.get("isDeleted", False):
|
||||||
|
continue
|
||||||
file_name = lora.get("file_name", "unknown-lora")
|
file_name = lora.get("file_name", "unknown-lora")
|
||||||
folder = lora.get("folder", "")
|
folder = lora.get("folder", "")
|
||||||
|
|
||||||
|
|||||||
@@ -426,8 +426,21 @@ class RecipePersistenceService:
|
|||||||
if not recipe_path or not os.path.exists(recipe_path):
|
if not recipe_path or not os.path.exists(recipe_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||||
|
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||||
|
|
||||||
|
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
|
||||||
if not target_lora:
|
if not target_lora:
|
||||||
|
matches = await recipe_scanner.find_local_loras_by_name(target_name)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
f"Multiple local LoRAs match '{target_name}'; "
|
||||||
|
"include the folder path to disambiguate"
|
||||||
|
)
|
||||||
|
if len(matches) == 1:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
f"Local LoRA '{target_name}' has a different base model than the recipe"
|
||||||
|
)
|
||||||
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
|
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
|
||||||
|
|
||||||
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
|
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/* Blurred backdrop for the model modal to match the recipe modal */
|
||||||
|
#modelModal {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Lora Modal Header */
|
/* Lora Modal Header */
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,19 +4,268 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel {
|
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
|
||||||
transition: max-height 0.3s ease-in-out;
|
|
||||||
|
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
|
||||||
|
.gallery-indicator-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-preview-thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-preview-thumb img,
|
||||||
|
.gallery-preview-thumb video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-indicator-bar .gallery-show-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-indicator-bar .gallery-import-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded gallery toolbar */
|
||||||
|
.gallery-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position badge floats over the main media, bottom-right */
|
||||||
|
.gallery-position-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--space-2);
|
||||||
|
bottom: var(--space-2);
|
||||||
|
z-index: 6;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While the gallery is expanded the thumbnail strip sits in the modal's
|
||||||
|
bottom-right corner, where the back-to-top button would overlap it */
|
||||||
|
.modal-content.showcase-expanded .back-to-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-toolbar .gallery-import-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-show-btn,
|
||||||
|
.gallery-import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-show-btn:hover,
|
||||||
|
.gallery-import-btn:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-filter-notification {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main viewer — the container hugs the active media's aspect ratio
|
||||||
|
(--media-aspect = width/height, set per item) so no dead space remains.
|
||||||
|
overflow: hidden also clips the hoisted metadata panel while it is
|
||||||
|
translated below the bottom edge, so it never extends the modal's
|
||||||
|
scrollable height (which caused a scroll jump when it appeared) */
|
||||||
|
.gallery-main {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-media-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
|
||||||
|
aspect-ratio: var(--media-aspect, 1.3333);
|
||||||
|
max-height: min(75vh, 800px);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel.collapsed {
|
.main-media-container .media-wrapper {
|
||||||
max-height: 0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-container {
|
.main-media-container .media-wrapper img,
|
||||||
|
.main-media-container .media-wrapper video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav buttons float over the media, visible on hover */
|
||||||
|
.gallery-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav.prev {
|
||||||
|
left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav.next {
|
||||||
|
right: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-main:hover .gallery-nav,
|
||||||
|
.gallery-nav:focus-visible {
|
||||||
|
opacity: 0.9;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail strip */
|
||||||
|
.gallery-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: var(--space-1);
|
||||||
gap: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb:hover {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb.active {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-media.blurred {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-video-badge,
|
||||||
|
.gallery-thumb .thumb-nsfw-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
right: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 1px 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-nsfw-badge {
|
||||||
|
top: 3px;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-strip::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-strip::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline import zone toggled from the toolbar */
|
||||||
|
.gallery-import-zone {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-import-zone.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-import-zone .example-import-area {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper {
|
.media-wrapper {
|
||||||
@@ -31,16 +280,6 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper img,
|
|
||||||
.media-wrapper video {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-examples {
|
.no-examples {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
@@ -48,11 +287,6 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust the media wrapper for tab system */
|
|
||||||
#showcase-tab .carousel-container {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add styles for blurred showcase content */
|
/* Add styles for blurred showcase content */
|
||||||
.nsfw-media-wrapper {
|
.nsfw-media-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -217,6 +451,24 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
|
||||||
|
.gallery-main > .image-metadata-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 7;
|
||||||
|
max-height: 60%;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-main > .image-metadata-panel.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.98;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Adjust to dark theme */
|
/* Adjust to dark theme */
|
||||||
[data-theme="dark"] .image-metadata-panel {
|
[data-theme="dark"] .image-metadata-panel {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -388,31 +640,6 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scroll Indicator */
|
|
||||||
.scroll-indicator {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--lora-surface);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
transition: background-color 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-indicator:hover {
|
|
||||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-indicator span {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lazy {
|
.lazy {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|||||||
@@ -603,6 +603,51 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-option-radio input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Files already in the library are greyed out and not clickable */
|
||||||
|
.file-option.disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.disabled:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.disabled input[type="checkbox"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options of the other routing group are temporarily disabled once a
|
||||||
|
selection is made (mixed-type multi-select is not allowed) */
|
||||||
|
.file-option.group-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.group-disabled:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.group-disabled input[type="checkbox"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.in-library {
|
||||||
|
background: oklch(var(--lora-accent) / 0.15);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.file-option-info {
|
.file-option-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -104,15 +104,29 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Darker, blurred backdrop keeps the busy page behind the modal from bleeding through */
|
||||||
|
#recipeModal {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
#recipeModal .modal-content {
|
#recipeModal .modal-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
|
||||||
|
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
|
||||||
|
width: min(1600px, 94vw);
|
||||||
|
max-width: min(1600px, 94vw);
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#recipeModal .modal-body {
|
#recipeModal .modal-body {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 320px minmax(0, 1fr) 420px;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -174,19 +188,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Section: Preview and Gen Params */
|
/* Left Column: Preview */
|
||||||
.recipe-top-section {
|
.recipe-media-column {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 280px 1fr;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-shrink: 0;
|
min-height: 0;
|
||||||
margin-bottom: var(--space-2);
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Preview */
|
/* Recipe Preview */
|
||||||
.recipe-preview-container {
|
.recipe-preview-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 360px;
|
box-sizing: border-box; /* Keep the 1px border inside the column width */
|
||||||
|
height: auto;
|
||||||
|
max-height: 42vh;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
@@ -196,18 +213,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-preview-container img,
|
.recipe-preview-container img,
|
||||||
.recipe-preview-container video {
|
.recipe-preview-container video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 42vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-preview-media {
|
.recipe-preview-media {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 42vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,9 +358,10 @@
|
|||||||
|
|
||||||
/* Generation Parameters */
|
/* Generation Parameters */
|
||||||
.recipe-gen-params {
|
.recipe-gen-params {
|
||||||
height: 360px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-params-header-row {
|
.gen-params-header-row {
|
||||||
@@ -399,8 +418,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-group {
|
.param-group {
|
||||||
@@ -453,8 +470,6 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -526,14 +541,12 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom Section: Resources */
|
/* Right Column: Resources */
|
||||||
.recipe-bottom-section {
|
.recipe-bottom-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-section-header {
|
.recipe-section-header {
|
||||||
@@ -1010,18 +1023,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1500px) {
|
||||||
.recipe-top-section {
|
#recipeModal .modal-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 300px minmax(0, 1fr) 380px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.recipe-preview-container {
|
|
||||||
height: 200px;
|
@media (max-width: 1000px) {
|
||||||
|
#recipeModal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-media-column {
|
||||||
|
overflow-y: visible;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container,
|
||||||
|
.recipe-preview-container img,
|
||||||
|
.recipe-preview-container video,
|
||||||
|
.recipe-preview-media {
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-gen-params {
|
.recipe-gen-params {
|
||||||
height: auto;
|
overflow-y: visible;
|
||||||
max-height: 300px;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-bottom-section {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-loras-list {
|
||||||
|
max-height: 45vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,19 +1083,11 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-top-section {
|
.recipe-preview-container,
|
||||||
grid-template-columns: 1fr;
|
.recipe-preview-container img,
|
||||||
gap: var(--space-1);
|
.recipe-preview-container video,
|
||||||
margin-bottom: var(--space-1);
|
.recipe-preview-media {
|
||||||
}
|
max-height: 32vh;
|
||||||
|
|
||||||
.recipe-preview-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-gen-params {
|
|
||||||
height: auto;
|
|
||||||
max-height: 210px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-gen-params h3 {
|
.recipe-gen-params h3 {
|
||||||
@@ -1070,7 +1100,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.param-content {
|
.param-content {
|
||||||
max-height: 90px;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,10 +1112,6 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-bottom-section {
|
|
||||||
padding-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-section-header {
|
.recipe-section-header {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ class RecipeModal {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsContainer.innerHTML = '';
|
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
|
||||||
|
|
||||||
const sourcePath = this.currentRecipe?.source_path || '';
|
const sourcePath = this.currentRecipe?.source_path || '';
|
||||||
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||||
@@ -719,7 +719,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||||
@@ -1180,11 +1180,10 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup copy buttons for prompts and recipe syntax
|
// Setup copy buttons for prompts and send recipe button
|
||||||
setupCopyButtons() {
|
setupCopyButtons() {
|
||||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
|
||||||
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
@@ -1207,13 +1206,6 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyRecipeSyntaxBtn) {
|
|
||||||
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
|
||||||
// Use backend API to get recipe syntax
|
|
||||||
this.fetchAndCopyRecipeSyntax();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sendRecipeBtn) {
|
if (sendRecipeBtn) {
|
||||||
sendRecipeBtn.addEventListener('click', () => {
|
sendRecipeBtn.addEventListener('click', () => {
|
||||||
// Send recipe to ComfyUI workflow
|
// Send recipe to ComfyUI workflow
|
||||||
@@ -1299,35 +1291,6 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recipe syntax from backend and copy to clipboard
|
|
||||||
async fetchAndCopyRecipeSyntax() {
|
|
||||||
if (!this.recipeId) {
|
|
||||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch recipe syntax from backend
|
|
||||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.syntax) {
|
|
||||||
// Use the centralized copyToClipboard utility function
|
|
||||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'No syntax returned from server');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recipe syntax:', error);
|
|
||||||
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to copy text to clipboard
|
// Helper method to copy text to clipboard
|
||||||
copyToClipboard(text, successMessage) {
|
copyToClipboard(text, successMessage) {
|
||||||
copyToClipboard(text, successMessage);
|
copyToClipboard(text, successMessage);
|
||||||
@@ -1632,7 +1595,7 @@ class RecipeModal {
|
|||||||
let headerAction = '';
|
let headerAction = '';
|
||||||
if (existsLocally && localPath) {
|
if (existsLocally && localPath) {
|
||||||
headerAction = `
|
headerAction = `
|
||||||
<button class="resource-action primary compact checkpoint-send">
|
<button class="resource-action compact checkpoint-send">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { bulkManager } from '../managers/BulkManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||||
|
|
||||||
export class SidebarManager {
|
export class SidebarManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -252,6 +253,9 @@ export class SidebarManager {
|
|||||||
if (dataTransfer) {
|
if (dataTransfer) {
|
||||||
dataTransfer.effectAllowed = 'move';
|
dataTransfer.effectAllowed = 'move';
|
||||||
dataTransfer.setData('text/plain', filePaths.join(','));
|
dataTransfer.setData('text/plain', filePaths.join(','));
|
||||||
|
// Tag the drag as an internal card drag so preview-drop handlers on
|
||||||
|
// other cards ignore it (no highlight, no preview replacement).
|
||||||
|
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
|
||||||
try {
|
try {
|
||||||
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
|
|||||||
|
|
||||||
const keyHandler = (e) => {
|
const keyHandler = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// Stop propagation so bubble-phase handlers (e.g. ModalManager's
|
||||||
|
// Escape handler) do not also close the modal underneath.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
closeMediaViewer();
|
closeMediaViewer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
@@ -304,10 +303,20 @@ function handleCardClick(card, modelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview URL is not in the dataset; read it from the card's rendered media
|
||||||
|
function getCardPreviewUrl(card) {
|
||||||
|
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
|
||||||
|
if (!cardMedia) return '';
|
||||||
|
return cardMedia.tagName === 'VIDEO'
|
||||||
|
? (cardMedia.dataset.src || '')
|
||||||
|
: (cardMedia.src || '');
|
||||||
|
}
|
||||||
|
|
||||||
async function showModelModalFromCard(card, modelType) {
|
async function showModelModalFromCard(card, modelType) {
|
||||||
// Create model metadata object
|
// Create model metadata object
|
||||||
const modelMeta = {
|
const modelMeta = {
|
||||||
sha256: card.dataset.sha256,
|
sha256: card.dataset.sha256,
|
||||||
|
preview_url: getCardPreviewUrl(card),
|
||||||
file_path: card.dataset.filepath,
|
file_path: card.dataset.filepath,
|
||||||
model_name: card.dataset.name,
|
model_name: card.dataset.name,
|
||||||
file_name: card.dataset.file_name,
|
file_name: card.dataset.file_name,
|
||||||
@@ -397,6 +406,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||||
const modelMeta = {
|
const modelMeta = {
|
||||||
sha256: card.dataset.sha256,
|
sha256: card.dataset.sha256,
|
||||||
|
preview_url: getCardPreviewUrl(card),
|
||||||
file_path: card.dataset.filepath,
|
file_path: card.dataset.filepath,
|
||||||
model_name: card.dataset.name,
|
model_name: card.dataset.name,
|
||||||
file_name: card.dataset.file_name,
|
file_name: card.dataset.file_name,
|
||||||
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Show the model modal
|
// Show the model modal
|
||||||
await showModelModal(modelMeta, modelType);
|
await showModelModal(modelMeta, modelType);
|
||||||
|
|
||||||
// Scroll to import area after modal is visible
|
// Reveal the import entry once the modal content has rendered
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const importArea = document.querySelector('.example-import-area');
|
// Gallery mode: the import button is always visible — expand the zone
|
||||||
|
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
|
||||||
|
if (importBtn) {
|
||||||
|
importBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Empty state: the import area is the whole tab content — scroll to it
|
||||||
|
const importArea = document.querySelector('#modelModal .example-import-area');
|
||||||
if (importArea) {
|
if (importArea) {
|
||||||
const showcaseTab = document.getElementById('showcase-tab');
|
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
if (showcaseTab) {
|
|
||||||
// First make sure showcase tab is visible
|
|
||||||
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
|
|
||||||
if (tabBtn && !tabBtn.classList.contains('active')) {
|
|
||||||
tabBtn.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then toggle showcase if collapsed
|
|
||||||
const carousel = showcaseTab.querySelector('.carousel');
|
|
||||||
if (carousel && carousel.classList.contains('collapsed')) {
|
|
||||||
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
|
||||||
if (scrollIndicator) {
|
|
||||||
toggleShowcase(scrollIndicator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally scroll to the import area
|
|
||||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
@@ -457,6 +455,9 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
export function createModelCard(model, modelType) {
|
export function createModelCard(model, modelType) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'model-card'; // Reuse the same class for styling
|
card.className = 'model-card'; // Reuse the same class for styling
|
||||||
|
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
|
||||||
|
// from click jitter are rendered harmless by the preview-drop handlers
|
||||||
|
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
|
||||||
card.draggable = true;
|
card.draggable = true;
|
||||||
card.dataset.sha256 = model.sha256;
|
card.dataset.sha256 = model.sha256;
|
||||||
card.dataset.filepath = model.file_path;
|
card.dataset.filepath = model.file_path;
|
||||||
@@ -649,7 +650,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
@@ -743,6 +744,11 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
// Dropping an image/video onto the card replaces the model preview via the
|
// Dropping an image/video onto the card replaces the model preview via the
|
||||||
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
||||||
|
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
|
||||||
|
// SidebarManager and must be ignored here entirely: no highlight, no upload.
|
||||||
|
const isInternalCardDrag = (event) =>
|
||||||
|
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
|
||||||
|
|
||||||
const preventDragDefaults = (event) => {
|
const preventDragDefaults = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -750,17 +756,20 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
['dragenter', 'dragover'].forEach((eventName) => {
|
['dragenter', 'dragover'].forEach((eventName) => {
|
||||||
card.addEventListener(eventName, (event) => {
|
card.addEventListener(eventName, (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.add('drag-over');
|
card.classList.add('drag-over');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addEventListener('dragleave', (event) => {
|
card.addEventListener('dragleave', (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.remove('drag-over');
|
card.classList.remove('drag-over');
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addEventListener('drop', (event) => {
|
card.addEventListener('drop', (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.remove('drag-over');
|
card.classList.remove('drag-over');
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
|
|||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import {
|
import {
|
||||||
toggleShowcase,
|
|
||||||
setupShowcaseScroll,
|
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
loadExampleImages
|
loadExampleImages
|
||||||
} from './showcase/ShowcaseView.js';
|
} from './showcase/ShowcaseView.js';
|
||||||
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
updateCardUpdateAvailability(hasUpdate);
|
updateCardUpdateAvailability(hasUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
let showcaseCleanup;
|
|
||||||
|
|
||||||
const onCloseCallback = function () {
|
const onCloseCallback = function () {
|
||||||
// Clean up all handlers when modal closes for LoRA
|
// Clean up all handlers when modal closes for LoRA
|
||||||
const modalElement = document.getElementById(modalId);
|
const modalElement = document.getElementById(modalId);
|
||||||
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||||
delete modalElement._clickHandler;
|
delete modalElement._clickHandler;
|
||||||
}
|
}
|
||||||
if (showcaseCleanup) {
|
|
||||||
showcaseCleanup();
|
|
||||||
showcaseCleanup = null;
|
|
||||||
}
|
|
||||||
cleanupNavigationShortcuts();
|
cleanupNavigationShortcuts();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
||||||
activeModalElement.dataset.folder = modelWithFullData.folder;
|
activeModalElement.dataset.folder = modelWithFullData.folder;
|
||||||
}
|
}
|
||||||
|
// Show the back-to-top button once the modal content is scrolled
|
||||||
|
const modalContent = activeModalElement.querySelector('.modal-content');
|
||||||
|
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
|
||||||
|
if (modalContent && backToTopBtn) {
|
||||||
|
modalContent.addEventListener('scroll', () => {
|
||||||
|
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
||||||
const versionsTabController = initVersionsTab({
|
const versionsTabController = initVersionsTab({
|
||||||
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
onUpdateStatusChange: handleUpdateStatusChange,
|
onUpdateStatusChange: handleUpdateStatusChange,
|
||||||
});
|
});
|
||||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||||
showcaseCleanup = setupShowcaseScroll(modalId);
|
|
||||||
setupTabSwitching({
|
setupTabSwitching({
|
||||||
onTabChange: async (tab) => {
|
onTabChange: async (tab) => {
|
||||||
if (tab === 'versions') {
|
if (tab === 'versions') {
|
||||||
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||||
// Combine images - regular images first, then custom images
|
// Combine images - regular images first, then custom images
|
||||||
const allImages = [...regularImages, ...customImages];
|
const allImages = [...regularImages, ...customImages];
|
||||||
loadExampleImages(allImages, modelWithFullData.sha256);
|
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLoraSpecificContent(lora, escapedWords) {
|
function renderLoraSpecificContent(lora, escapedWords) {
|
||||||
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
|
|||||||
// Export the model modal API
|
// Export the model modal API
|
||||||
const modelModal = {
|
const modelModal = {
|
||||||
show: showModelModal,
|
show: showModelModal,
|
||||||
toggleShowcase,
|
|
||||||
scrollToTop
|
scrollToTop
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -573,46 +573,53 @@ function renderRow(version, options) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (!version.isInLibrary) {
|
const canDownload = isDownloadAllowed(version);
|
||||||
const canDownload = isDownloadAllowed(version);
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
let downloadTitle;
|
||||||
let downloadTitle;
|
if (!canDownload) {
|
||||||
if (!canDownload) {
|
downloadTitle = translate(
|
||||||
downloadTitle = translate(
|
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||||
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
{},
|
||||||
{},
|
'This version is only available for on-site generation on Civitai'
|
||||||
'This version is only available for on-site generation on Civitai'
|
);
|
||||||
);
|
} else if (version.isInLibrary) {
|
||||||
} else if (isPaidPermanent(version)) {
|
// In-library versions may still have undownloaded weight files; the
|
||||||
downloadTitle = translate(
|
// download modal's file dialog decides what remains (#1058).
|
||||||
'modals.model.versions.actions.downloadPaidTooltip',
|
downloadTitle = translate(
|
||||||
{},
|
'modals.model.versions.actions.downloadRemainingTooltip',
|
||||||
'Download this paid version from Civitai'
|
{},
|
||||||
);
|
'Download remaining files of this version'
|
||||||
} else if (isEarlyAccess) {
|
);
|
||||||
downloadTitle = translate(
|
} else if (isPaidPermanent(version)) {
|
||||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
downloadTitle = translate(
|
||||||
{},
|
'modals.model.versions.actions.downloadPaidTooltip',
|
||||||
'Download this early access version from Civitai'
|
{},
|
||||||
);
|
'Download this paid version from Civitai'
|
||||||
} else {
|
);
|
||||||
downloadTitle = translate(
|
} else if (isEarlyAccess) {
|
||||||
'modals.model.versions.actions.downloadTooltip',
|
downloadTitle = translate(
|
||||||
{},
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
'Download this version'
|
{},
|
||||||
);
|
'Download this early access version from Civitai'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadTooltip',
|
||||||
|
{},
|
||||||
|
'Download this version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
actions.push(buildActionButton(
|
||||||
|
downloadLabel,
|
||||||
|
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||||
|
canDownload ? 'download' : '',
|
||||||
|
{
|
||||||
|
title: downloadTitle,
|
||||||
|
iconMarkup: downloadIcon,
|
||||||
|
disabled: !canDownload,
|
||||||
}
|
}
|
||||||
actions.push(buildActionButton(
|
));
|
||||||
downloadLabel,
|
if (version.isInLibrary && version.filePath) {
|
||||||
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
|
||||||
canDownload ? 'download' : '',
|
|
||||||
{
|
|
||||||
title: downloadTitle,
|
|
||||||
iconMarkup: downloadIcon,
|
|
||||||
disabled: !canDownload,
|
|
||||||
}
|
|
||||||
));
|
|
||||||
} else if (version.filePath) {
|
|
||||||
actions.push(buildActionButton(
|
actions.push(buildActionButton(
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
'version-action-danger',
|
'version-action-danger',
|
||||||
@@ -1422,6 +1429,15 @@ export function initVersionsTab({
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// In-library versions may still have undownloaded weight files
|
||||||
|
// (#1058). The tab payload has no per-file state, so open the
|
||||||
|
// download modal's file dialog, which refetches the full version
|
||||||
|
// payload and shows what remains.
|
||||||
|
if (version.isInLibrary) {
|
||||||
|
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
||||||
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
||||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate video wrapper HTML
|
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
|
||||||
|
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||||
* @param {Object} media - Media metadata
|
* @param {Object} media - Media metadata
|
||||||
* @param {number} heightPercent - Height percentage for container
|
|
||||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||||
* @param {string} nsfwText - NSFW warning text
|
* @param {string} nsfwText - NSFW warning text
|
||||||
* @param {string} metadataPanel - Metadata panel HTML
|
* @param {string} metadataPanel - Metadata panel HTML
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate image wrapper HTML
|
* Generate image wrapper HTML. The wrapper fills its container (the gallery's
|
||||||
|
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||||
* @param {Object} media - Media metadata
|
* @param {Object} media - Media metadata
|
||||||
* @param {number} heightPercent - Height percentage for container
|
|
||||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||||
* @param {string} nsfwText - NSFW warning text
|
* @param {string} nsfwText - NSFW warning text
|
||||||
* @param {string} metadataPanel - Metadata panel HTML
|
* @param {string} metadataPanel - Metadata panel HTML
|
||||||
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
|||||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
|
|||||||
@@ -213,190 +213,170 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize metadata panel interaction handlers
|
* Initialize metadata panel interaction handlers: hover over the media reveals
|
||||||
|
* the panel and media controls (same as the legacy carousel). Panel-internal
|
||||||
|
* buttons and wheel isolation are bound here as well.
|
||||||
* @param {HTMLElement} container - Container element with media wrappers
|
* @param {HTMLElement} container - Container element with media wrappers
|
||||||
*/
|
*/
|
||||||
export function initMetadataPanelHandlers(container) {
|
export function initMetadataPanelHandlers(container) {
|
||||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||||
|
|
||||||
mediaWrappers.forEach(wrapper => {
|
mediaWrappers.forEach(wrapper => {
|
||||||
// Get the metadata panel and media element (img or video)
|
|
||||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||||
|
if (!metadataPanel) return;
|
||||||
|
|
||||||
const mediaControls = wrapper.querySelector('.media-controls');
|
const mediaControls = wrapper.querySelector('.media-controls');
|
||||||
const mediaElement = wrapper.querySelector('img, video');
|
const mediaElement = wrapper.querySelector('img, video');
|
||||||
|
|
||||||
if (!mediaElement) return;
|
if (mediaElement) {
|
||||||
|
let isOverMetadataPanel = false;
|
||||||
let isOverMetadataPanel = false;
|
|
||||||
|
// Hovering the actual media content reveals the metadata panel and controls
|
||||||
// Add event listeners to the wrapper for mouse tracking
|
wrapper.addEventListener('mousemove', (e) => {
|
||||||
wrapper.addEventListener('mousemove', (e) => {
|
const rect = wrapper.getBoundingClientRect();
|
||||||
// Get mouse position relative to wrapper
|
const mouseX = e.clientX - rect.left;
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const mouseY = e.clientY - rect.top;
|
||||||
const mouseX = e.clientX - rect.left;
|
|
||||||
const mouseY = e.clientY - rect.top;
|
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||||
|
const isOverMedia = (
|
||||||
// Get the actual displayed dimensions of the media element
|
mouseX >= mediaRect.left &&
|
||||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
mouseX <= mediaRect.right &&
|
||||||
|
mouseY >= mediaRect.top &&
|
||||||
// Check if mouse is over the actual media content
|
mouseY <= mediaRect.bottom
|
||||||
const isOverMedia = (
|
);
|
||||||
mouseX >= mediaRect.left &&
|
|
||||||
mouseX <= mediaRect.right &&
|
if (isOverMedia || isOverMetadataPanel) {
|
||||||
mouseY >= mediaRect.top &&
|
metadataPanel.classList.add('visible');
|
||||||
mouseY <= mediaRect.bottom
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
);
|
} else {
|
||||||
|
metadataPanel.classList.remove('visible');
|
||||||
// Show metadata panel and controls when over media content or metadata panel itself
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
if (isOverMedia || isOverMetadataPanel) {
|
}
|
||||||
if (metadataPanel) metadataPanel.classList.add('visible');
|
});
|
||||||
if (mediaControls) mediaControls.classList.add('visible');
|
|
||||||
} else {
|
wrapper.addEventListener('mouseleave', () => {
|
||||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
if (!isOverMetadataPanel) {
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
metadataPanel.classList.remove('visible');
|
||||||
}
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
});
|
}
|
||||||
|
});
|
||||||
wrapper.addEventListener('mouseleave', () => {
|
|
||||||
if (!isOverMetadataPanel) {
|
|
||||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add mouse enter/leave events for the metadata panel itself
|
|
||||||
if (metadataPanel) {
|
|
||||||
metadataPanel.addEventListener('mouseenter', () => {
|
metadataPanel.addEventListener('mouseenter', () => {
|
||||||
isOverMetadataPanel = true;
|
isOverMetadataPanel = true;
|
||||||
metadataPanel.classList.add('visible');
|
metadataPanel.classList.add('visible');
|
||||||
if (mediaControls) mediaControls.classList.add('visible');
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
metadataPanel.addEventListener('mouseleave', () => {
|
metadataPanel.addEventListener('mouseleave', () => {
|
||||||
isOverMetadataPanel = false;
|
isOverMetadataPanel = false;
|
||||||
// Only hide if mouse is not over the media
|
metadataPanel.classList.remove('visible');
|
||||||
const rect = wrapper.getBoundingClientRect();
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
|
||||||
const mouseX = event.clientX - rect.left;
|
|
||||||
const mouseY = event.clientY - rect.top;
|
|
||||||
|
|
||||||
const isOverMedia = (
|
|
||||||
mouseX >= mediaRect.left &&
|
|
||||||
mouseX <= mediaRect.right &&
|
|
||||||
mouseY >= mediaRect.top &&
|
|
||||||
mouseY <= mediaRect.bottom
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isOverMedia) {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prevent events from bubbling
|
|
||||||
metadataPanel.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle copy prompt buttons
|
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
|
||||||
copyBtns.forEach(copyBtn => {
|
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle send prompt buttons
|
|
||||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
|
||||||
sendBtns.forEach(sendBtn => {
|
|
||||||
const promptIndex = sendBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
sendBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
let promptText = promptElement.textContent || '';
|
|
||||||
if (!promptText.trim()) {
|
|
||||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect strip <lora> setting from global state
|
|
||||||
if (state.global.settings?.strip_lora_on_copy) {
|
|
||||||
promptText = stripLoraTags(promptText);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPromptToWorkflow(promptText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle send params buttons
|
|
||||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
|
||||||
if (paramsBtn) {
|
|
||||||
paramsBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Collect gen params from the param-tag elements
|
|
||||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
|
||||||
if (!tagsContainer) return;
|
|
||||||
|
|
||||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
|
||||||
const genParams = {};
|
|
||||||
|
|
||||||
// Map display labels to genParams keys
|
|
||||||
const labelToKey = {
|
|
||||||
'Seed': 'seed',
|
|
||||||
'Steps': 'steps',
|
|
||||||
'Sampler': 'sampler',
|
|
||||||
'CFG': 'cfg_scale',
|
|
||||||
};
|
|
||||||
|
|
||||||
paramTags.forEach(tag => {
|
|
||||||
const nameEl = tag.querySelector('.param-name');
|
|
||||||
const valueEl = tag.querySelector('.param-value');
|
|
||||||
if (!nameEl || !valueEl) return;
|
|
||||||
|
|
||||||
const label = nameEl.textContent.replace(':', '').trim();
|
|
||||||
const key = labelToKey[label];
|
|
||||||
if (key) {
|
|
||||||
genParams[key] = valueEl.textContent.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(genParams).length === 0) {
|
|
||||||
showToast('No sendable parameters found', {}, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendGenParamsToWorkflow(genParams);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent panel scroll from causing modal scroll
|
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
|
||||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
|
||||||
|
|
||||||
// Only prevent default if scrolling would cause the panel to scroll
|
|
||||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent events from bubbling
|
||||||
|
metadataPanel.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle copy prompt buttons
|
||||||
|
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||||
|
copyBtns.forEach(copyBtn => {
|
||||||
|
const promptIndex = copyBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle send prompt buttons
|
||||||
|
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||||
|
sendBtns.forEach(sendBtn => {
|
||||||
|
const promptIndex = sendBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
let promptText = promptElement.textContent || '';
|
||||||
|
if (!promptText.trim()) {
|
||||||
|
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect strip <lora> setting from global state
|
||||||
|
if (state.global.settings?.strip_lora_on_copy) {
|
||||||
|
promptText = stripLoraTags(promptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPromptToWorkflow(promptText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle send params buttons
|
||||||
|
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||||
|
if (paramsBtn) {
|
||||||
|
paramsBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Collect gen params from the param-tag elements
|
||||||
|
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||||
|
const genParams = {};
|
||||||
|
|
||||||
|
// Map display labels to genParams keys
|
||||||
|
const labelToKey = {
|
||||||
|
'Seed': 'seed',
|
||||||
|
'Steps': 'steps',
|
||||||
|
'Sampler': 'sampler',
|
||||||
|
'CFG': 'cfg_scale',
|
||||||
|
};
|
||||||
|
|
||||||
|
paramTags.forEach(tag => {
|
||||||
|
const nameEl = tag.querySelector('.param-name');
|
||||||
|
const valueEl = tag.querySelector('.param-value');
|
||||||
|
if (!nameEl || !valueEl) return;
|
||||||
|
|
||||||
|
const label = nameEl.textContent.replace(':', '').trim();
|
||||||
|
const key = labelToKey[label];
|
||||||
|
if (key) {
|
||||||
|
genParams[key] = valueEl.textContent.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(genParams).length === 0) {
|
||||||
|
showToast('No sendable parameters found', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendGenParamsToWorkflow(genParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent panel scroll from causing modal scroll
|
||||||
|
metadataPanel.addEventListener('wheel', (e) => {
|
||||||
|
const isAtTop = metadataPanel.scrollTop === 0;
|
||||||
|
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||||
|
|
||||||
|
// Only prevent default if scrolling would cause the panel to scroll
|
||||||
|
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
|
||||||
|
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { shortId }
|
||||||
|
}));
|
||||||
|
|
||||||
// Success: remove the media wrapper from the DOM
|
// Success: remove the media wrapper from the DOM
|
||||||
mediaWrapper.style.opacity = '0';
|
mediaWrapper.style.opacity = '0';
|
||||||
mediaWrapper.style.height = '0';
|
mediaWrapper.style.height = '0';
|
||||||
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
// Initialize NSFW level buttons
|
// Initialize NSFW level buttons
|
||||||
initSetNsfwHandlers(container);
|
initSetNsfwHandlers(container);
|
||||||
|
|
||||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
// Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
|
||||||
// Any click handlers or other functionality can still be added here
|
// Any click handlers or other functionality can still be added here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,12 @@ export class DownloadManager {
|
|||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.useDefaultPath = false;
|
this.useDefaultPath = false;
|
||||||
|
|
||||||
|
// Multi-file selection state: selectedFile stays the first selected
|
||||||
|
// file for backward compatibility with single-file flows (#1058).
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.selectedFiles = [];
|
||||||
|
this._lastDownloadError = null;
|
||||||
|
|
||||||
// Batch mode state
|
// Batch mode state
|
||||||
this.batchModels = [];
|
this.batchModels = [];
|
||||||
this.isBatchMode = false;
|
this.isBatchMode = false;
|
||||||
@@ -160,6 +166,8 @@ export class DownloadManager {
|
|||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.selectedFile = null;
|
this.selectedFile = null;
|
||||||
|
this.selectedFiles = [];
|
||||||
|
this._lastDownloadError = null;
|
||||||
this._isDiffusionModel = false;
|
this._isDiffusionModel = false;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
@@ -546,6 +554,64 @@ export class DownloadManager {
|
|||||||
await this.fetchVersionsForCurrentModel();
|
await this.fetchVersionsForCurrentModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the download modal directly on the file-selection step for a
|
||||||
|
* specific model version (#1058). Used by entry points (e.g.
|
||||||
|
* ModelVersionsTab) whose version payloads lack per-file downloaded
|
||||||
|
* state, so the full versions payload is fetched here first.
|
||||||
|
*/
|
||||||
|
async openFileSelectionForVersion(modelType, modelId, versionId, { source = null } = {}) {
|
||||||
|
try {
|
||||||
|
this.apiClient = getModelApiClient(modelType);
|
||||||
|
} catch (error) {
|
||||||
|
this.apiClient = getModelApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showDownloadModal();
|
||||||
|
|
||||||
|
this.modelId = modelId ? modelId.toString() : null;
|
||||||
|
this.modelVersionId = versionId ? versionId.toString() : null;
|
||||||
|
this.source = source;
|
||||||
|
|
||||||
|
if (!this.modelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||||
|
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
this.loadingManager.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = this.versions.find(v => v.id.toString() === this.modelVersionId);
|
||||||
|
if (!version) {
|
||||||
|
console.warn('[download] openFileSelectionForVersion: version %s not found for model %s',
|
||||||
|
this.modelVersionId, this.modelId);
|
||||||
|
this.showVersionStep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||||
|
&& this._getRemainingFiles(version).length > 0;
|
||||||
|
|
||||||
|
if (hasRemainingFiles) {
|
||||||
|
this.showFileSelectionStep(version.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left to download for this version (single file or all
|
||||||
|
// files already in the library) — fall back to the version step.
|
||||||
|
if (version.existsLocally) {
|
||||||
|
showToast('toast.loras.versionExists', {}, 'info');
|
||||||
|
}
|
||||||
|
this.currentVersion = version;
|
||||||
|
this.showVersionStep();
|
||||||
|
}
|
||||||
|
|
||||||
showVersionStep() {
|
showVersionStep() {
|
||||||
document.getElementById('urlStep').style.display = 'none';
|
document.getElementById('urlStep').style.display = 'none';
|
||||||
document.getElementById('versionStep').style.display = 'block';
|
document.getElementById('versionStep').style.display = 'block';
|
||||||
@@ -595,7 +661,10 @@ export class DownloadManager {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileBadge = modelFiles.length > 1 && !existsLocally
|
// Always offer the file-selection entry for multi-file versions,
|
||||||
|
// even when the version is already (partially) in the library, so
|
||||||
|
// remaining files can still be downloaded (#1058).
|
||||||
|
const fileBadge = modelFiles.length > 1
|
||||||
? `<span class="file-select-badge" data-version-id="${version.id}">
|
? `<span class="file-select-badge" data-version-id="${version.id}">
|
||||||
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
|
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
|
||||||
</span>`
|
</span>`
|
||||||
@@ -667,9 +736,14 @@ export class DownloadManager {
|
|||||||
const nextButton = document.getElementById('nextFromVersion');
|
const nextButton = document.getElementById('nextFromVersion');
|
||||||
if (!nextButton) return;
|
if (!nextButton) return;
|
||||||
|
|
||||||
const existsLocally = this.currentVersion?.existsLocally;
|
const version = this.currentVersion;
|
||||||
|
const existsLocally = version?.existsLocally;
|
||||||
|
// A partially downloaded multi-file version still has downloadable
|
||||||
|
// files, so Next routes into the file dialog instead of blocking (#1058).
|
||||||
|
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||||
|
&& this._getRemainingFiles(version).length > 0;
|
||||||
|
|
||||||
if (existsLocally) {
|
if (existsLocally && !hasRemainingFiles) {
|
||||||
nextButton.disabled = true;
|
nextButton.disabled = true;
|
||||||
nextButton.classList.add('disabled');
|
nextButton.classList.add('disabled');
|
||||||
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
||||||
@@ -680,14 +754,41 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getWeightFiles(version) {
|
||||||
|
return (version?.files || []).filter(f => isModelWeightFile(f.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRemainingFiles(version) {
|
||||||
|
const downloadedIds = new Set(
|
||||||
|
(version?.downloadedFiles || []).map(f => String(f.fileId))
|
||||||
|
);
|
||||||
|
return this._getWeightFiles(version).filter(f => !downloadedIds.has(String(f.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files of type UNet / Diffusion Model are routed to the diffusion_model
|
||||||
|
// root while regular files go to the model-type root, so a single
|
||||||
|
// multi-file selection session must stay within one routing group.
|
||||||
|
_getFileRoutingGroup(file) {
|
||||||
|
return (file.type === 'UNet' || file.type === 'Diffusion Model') ? 'diffusion' : 'model';
|
||||||
|
}
|
||||||
|
|
||||||
showFileSelectionStep(versionId) {
|
showFileSelectionStep(versionId) {
|
||||||
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
|
|
||||||
this.currentVersion = version;
|
this.currentVersion = version;
|
||||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
// Start each file-selection session with a clean selection
|
||||||
|
this.selectedFiles = [];
|
||||||
|
this.selectedFile = null;
|
||||||
|
const modelFiles = this._getWeightFiles(version);
|
||||||
|
const downloadedIds = new Set(
|
||||||
|
(version.downloadedFiles || []).map(f => String(f.fileId))
|
||||||
|
);
|
||||||
|
|
||||||
document.getElementById('versionStep').style.display = 'none';
|
// Hide every other step — this dialog can be entered directly from
|
||||||
|
// entry points like ModelVersionsTab, where the URL step would
|
||||||
|
// otherwise remain visible (#1058).
|
||||||
|
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
||||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
|
|
||||||
const nameEl = document.getElementById('fileSelectionVersionName');
|
const nameEl = document.getElementById('fileSelectionVersionName');
|
||||||
@@ -699,9 +800,12 @@ export class DownloadManager {
|
|||||||
container.innerHTML = modelFiles.map(file => {
|
container.innerHTML = modelFiles.map(file => {
|
||||||
const meta = file.metadata || {};
|
const meta = file.metadata || {};
|
||||||
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
||||||
const isSelected = this.selectedFile?.id === file.id;
|
const isDownloaded = downloadedIds.has(String(file.id));
|
||||||
|
|
||||||
const tags = [];
|
const tags = [];
|
||||||
|
if (isDownloaded) {
|
||||||
|
tags.push(`<span class="file-tag in-library">${translate('modals.download.fileSelection.inLibrary', {}, 'In Library')}</span>`);
|
||||||
|
}
|
||||||
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
||||||
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
||||||
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
||||||
@@ -709,9 +813,9 @@ export class DownloadManager {
|
|||||||
const fileName = file.name || '';
|
const fileName = file.name || '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
<div class="file-option ${isDownloaded ? 'disabled' : ''}" data-file-id="${file.id}">
|
||||||
<div class="file-option-radio">
|
<div class="file-option-radio">
|
||||||
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
|
<input type="checkbox" name="fileSelection" value="${file.id}" ${isDownloaded ? 'disabled' : ''}>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-option-info">
|
<div class="file-option-info">
|
||||||
<div class="file-option-tags">
|
<div class="file-option-tags">
|
||||||
@@ -725,33 +829,80 @@ export class DownloadManager {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.querySelectorAll('.file-option').forEach(el => {
|
container.querySelectorAll('.file-option').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', (event) => {
|
||||||
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
|
// Already-downloaded files stay disabled regardless
|
||||||
el.classList.add('selected');
|
if (el.classList.contains('disabled')) {
|
||||||
const radio = el.querySelector('input[type="radio"]');
|
event.preventDefault();
|
||||||
if (radio) radio.checked = true;
|
return;
|
||||||
|
}
|
||||||
|
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||||
|
if (!checkbox || checkbox.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clicking the checkbox directly toggles natively; clicking
|
||||||
|
// anywhere else on the option toggles it programmatically.
|
||||||
|
if (event.target !== checkbox) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
}
|
||||||
|
this._syncFileSelectionState();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmFileSelection() {
|
// Sync this.selectedFiles with the DOM checkboxes and enforce the
|
||||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
// mixed-type routing guard by disabling the other routing group.
|
||||||
if (!selectedRadio) {
|
_syncFileSelectionState() {
|
||||||
console.warn('[download] confirmFileSelection: no radio button checked');
|
const container = document.getElementById('fileSelectionList');
|
||||||
return;
|
if (!container || !this.currentVersion) return;
|
||||||
}
|
|
||||||
|
|
||||||
|
const checkedValues = new Set(
|
||||||
|
Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
||||||
|
.map(cb => cb.value)
|
||||||
|
);
|
||||||
|
const modelFiles = this._getWeightFiles(this.currentVersion);
|
||||||
|
this.selectedFiles = modelFiles.filter(f => checkedValues.has(f.id.toString()));
|
||||||
|
this.selectedFile = this.selectedFiles[0] || null;
|
||||||
|
|
||||||
|
const activeGroup = this.selectedFiles.length > 0
|
||||||
|
? this._getFileRoutingGroup(this.selectedFiles[0])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
container.querySelectorAll('.file-option').forEach(el => {
|
||||||
|
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||||
|
if (!checkbox || el.classList.contains('disabled')) return;
|
||||||
|
|
||||||
|
const file = modelFiles.find(f => f.id.toString() === el.dataset.fileId);
|
||||||
|
const groupBlocked = activeGroup !== null
|
||||||
|
&& file
|
||||||
|
&& this._getFileRoutingGroup(file) !== activeGroup
|
||||||
|
&& !checkbox.checked;
|
||||||
|
|
||||||
|
el.classList.toggle('selected', checkbox.checked);
|
||||||
|
el.classList.toggle('group-disabled', groupBlocked);
|
||||||
|
checkbox.disabled = groupBlocked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmFileSelection() {
|
||||||
const version = this.currentVersion;
|
const version = this.currentVersion;
|
||||||
if (!version) {
|
if (!version) {
|
||||||
console.warn('[download] confirmFileSelection: no currentVersion set');
|
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
// Sync from the DOM first so programmatically checked boxes count too
|
||||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
this._syncFileSelectionState();
|
||||||
|
|
||||||
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
if (this.selectedFiles.length === 0) {
|
||||||
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
console.warn('[download] confirmFileSelection: no file selected');
|
||||||
|
showToast('toast.loras.pleaseSelectFile', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[download] confirmFileSelection: %d file(s) selected — %o',
|
||||||
|
this.selectedFiles.length,
|
||||||
|
this.selectedFiles.map(f => ({ id: f.id, name: f.name, type: f.type })));
|
||||||
|
|
||||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
document.getElementById('downloadLocationStep').style.display = 'block';
|
document.getElementById('downloadLocationStep').style.display = 'block';
|
||||||
@@ -782,6 +933,13 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.currentVersion.existsLocally) {
|
if (this.currentVersion.existsLocally) {
|
||||||
|
// Multi-file versions with remaining undownloaded files route
|
||||||
|
// into the file dialog instead of being blocked outright (#1058).
|
||||||
|
if (this._getWeightFiles(this.currentVersion).length > 1
|
||||||
|
&& this._getRemainingFiles(this.currentVersion).length > 0) {
|
||||||
|
this.showFileSelectionStep(this.currentVersion.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
showToast('toast.loras.versionExists', {}, 'info');
|
showToast('toast.loras.versionExists', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -916,6 +1074,9 @@ export class DownloadManager {
|
|||||||
source = null,
|
source = null,
|
||||||
fileParams = null,
|
fileParams = null,
|
||||||
closeModal = false,
|
closeModal = false,
|
||||||
|
deferReload = false,
|
||||||
|
suppressSuccessToast = false,
|
||||||
|
suppressFailureSummary = false,
|
||||||
}) {
|
}) {
|
||||||
const config = this.apiClient?.apiConfig?.config;
|
const config = this.apiClient?.apiConfig?.config;
|
||||||
|
|
||||||
@@ -924,7 +1085,8 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayName = versionName || `#${versionId}`;
|
const displayName = versionName || `#${versionId}`;
|
||||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
|
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
|
||||||
|
this._lastDownloadError = null;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let updateProgress = () => { };
|
let updateProgress = () => { };
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1007,7 +1169,9 @@ export class DownloadManager {
|
|||||||
if (response?.skipped) {
|
if (response?.skipped) {
|
||||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
updateProgress(100, 0, displayName);
|
updateProgress(100, 0, displayName);
|
||||||
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
if (!suppressSuccessToast) {
|
||||||
|
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
||||||
|
}
|
||||||
if (closeModal) {
|
if (closeModal) {
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
}
|
}
|
||||||
@@ -1016,6 +1180,22 @@ export class DownloadManager {
|
|||||||
|
|
||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
|
const errorMessage = response?.error || 'Unknown error';
|
||||||
|
// When the caller aggregates failures itself (multi-file
|
||||||
|
// loop), just record the error and return (#1058).
|
||||||
|
if (suppressFailureSummary) {
|
||||||
|
this._lastDownloadError = errorMessage;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A file-level "already in library" rejection is an expected
|
||||||
|
// outcome when browsing files of a partially downloaded
|
||||||
|
// version — surface it as a lightweight toast instead of the
|
||||||
|
// failure summary modal so the user can simply go back and
|
||||||
|
// pick another file (#1058).
|
||||||
|
if (typeof errorMessage === 'string' && errorMessage.includes('already exists in')) {
|
||||||
|
showToast(errorMessage, {}, 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
showDownloadBatchSummary({
|
showDownloadBatchSummary({
|
||||||
total: 1,
|
total: 1,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -1026,7 +1206,7 @@ export class DownloadManager {
|
|||||||
source,
|
source,
|
||||||
url: this._buildSingleItemUrl({ modelId, versionId, source }),
|
url: this._buildSingleItemUrl({ modelId, versionId, source }),
|
||||||
},
|
},
|
||||||
error: response?.error || 'Unknown error',
|
error: errorMessage,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
}],
|
}],
|
||||||
onRetry: () => this.executeDownloadWithProgress(retryParams),
|
onRetry: () => this.executeDownloadWithProgress(retryParams),
|
||||||
@@ -1034,7 +1214,9 @@ export class DownloadManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
if (!suppressSuccessToast) {
|
||||||
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
if (closeModal) {
|
if (closeModal) {
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
@@ -1045,29 +1227,35 @@ export class DownloadManager {
|
|||||||
ws = null;
|
ws = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageState = this.apiClient.getPageState();
|
if (!deferReload) {
|
||||||
|
const pageState = this.apiClient.getPageState();
|
||||||
|
|
||||||
if (!useDefaultPaths && targetFolder) {
|
if (!useDefaultPaths && targetFolder) {
|
||||||
pageState.activeFolder = targetFolder;
|
pageState.activeFolder = targetFolder;
|
||||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||||
|
|
||||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||||
const isActive = tag.dataset.folder === targetFolder;
|
const isActive = tag.dataset.folder === targetFolder;
|
||||||
tag.classList.toggle('active', isActive);
|
tag.classList.toggle('active', isActive);
|
||||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAndReload(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
await resetAndReload(true);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
console.log('Download cancelled by user:', downloadId);
|
console.log('Download cancelled by user:', downloadId);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to download model version:', error);
|
console.error('Failed to download model version:', error);
|
||||||
|
if (suppressFailureSummary) {
|
||||||
|
this._lastDownloadError = error?.message || 'Unknown error';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
showDownloadBatchSummary({
|
showDownloadBatchSummary({
|
||||||
total: 1,
|
total: 1,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -1097,6 +1285,89 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download multiple selected files of the same version sequentially,
|
||||||
|
* reusing the location-step choices for every file. Per-file toasts,
|
||||||
|
* reloads and failure modals are suppressed; a single aggregated result
|
||||||
|
* is shown at the end (design decision D5, #1058).
|
||||||
|
*/
|
||||||
|
async _downloadSelectedFilesSequentially({ modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot = false, files = null }) {
|
||||||
|
const filesToDownload = files || this.selectedFiles;
|
||||||
|
const totalFiles = filesToDownload.length;
|
||||||
|
const failedItems = [];
|
||||||
|
let completedDownloads = 0;
|
||||||
|
|
||||||
|
for (const file of filesToDownload) {
|
||||||
|
const fileParams = {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name || null,
|
||||||
|
type: file.type || 'Model',
|
||||||
|
format: file.metadata?.format || null,
|
||||||
|
size: file.metadata?.size || null,
|
||||||
|
fp: file.metadata?.fp || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[download] multi-file loop: downloading file id=%s, name="%s" (%d/%d)',
|
||||||
|
fileParams.id, fileParams.name, completedDownloads + failedItems.length + 1, totalFiles);
|
||||||
|
|
||||||
|
const success = await this.executeDownloadWithProgress({
|
||||||
|
modelId: this.modelId,
|
||||||
|
versionId: this.currentVersion.id,
|
||||||
|
versionName: file.name || `${this.currentVersion.name} #${file.id}`,
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
useSaveDirAsRoot,
|
||||||
|
source: this.source,
|
||||||
|
fileParams,
|
||||||
|
closeModal: false,
|
||||||
|
deferReload: true,
|
||||||
|
suppressSuccessToast: true,
|
||||||
|
suppressFailureSummary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
completedDownloads++;
|
||||||
|
} else {
|
||||||
|
failedItems.push({
|
||||||
|
item: {
|
||||||
|
modelId: this.modelId,
|
||||||
|
versionId: this.currentVersion.id,
|
||||||
|
source: this.source,
|
||||||
|
file,
|
||||||
|
url: this._buildSingleItemUrl({
|
||||||
|
modelId: this.modelId,
|
||||||
|
versionId: this.currentVersion.id,
|
||||||
|
source: this.source,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
error: this._lastDownloadError || 'Unknown error',
|
||||||
|
name: file.name || `#${file.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedItems.length === 0) {
|
||||||
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
|
} else {
|
||||||
|
showDownloadBatchSummary({
|
||||||
|
total: totalFiles,
|
||||||
|
completed: completedDownloads,
|
||||||
|
failedItems,
|
||||||
|
onRetry: () => this._downloadSelectedFilesSequentially({
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
useSaveDirAsRoot,
|
||||||
|
files: failedItems.map(f => f.item.file),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAndReload(true);
|
||||||
|
return failedItems.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
this.loadingManager.restoreProgressBar();
|
this.loadingManager.restoreProgressBar();
|
||||||
@@ -1307,6 +1578,14 @@ export class DownloadManager {
|
|||||||
? (ver.modelSizeKB / 1024).toFixed(1)
|
? (ver.modelSizeKB / 1024).toFixed(1)
|
||||||
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
||||||
const existsLocally = ver?.existsLocally;
|
const existsLocally = ver?.existsLocally;
|
||||||
|
// Multi-file versions that are only partially downloaded get a
|
||||||
|
// distinct hint instead of the plain in-library badge (#1058).
|
||||||
|
const isPartiallyDownloaded = existsLocally
|
||||||
|
&& this._getWeightFiles(ver).length > 1
|
||||||
|
&& this._getRemainingFiles(ver).length > 0;
|
||||||
|
const localBadgeLabel = isPartiallyDownloaded
|
||||||
|
? translate('modals.download.partiallyDownloaded', {}, 'Partially downloaded')
|
||||||
|
: translate('modals.download.inLibrary');
|
||||||
return `
|
return `
|
||||||
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
||||||
<div class="batch-preview-thumbnail">
|
<div class="batch-preview-thumbnail">
|
||||||
@@ -1317,7 +1596,7 @@ export class DownloadManager {
|
|||||||
<div class="batch-preview-meta">
|
<div class="batch-preview-meta">
|
||||||
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
|
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
|
||||||
<span>${fileSize} MB</span>
|
<span>${fileSize} MB</span>
|
||||||
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
|
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${localBadgeLabel}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${item.versions.length > 1 ? `
|
${item.versions.length > 1 ? `
|
||||||
@@ -1608,8 +1887,20 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-file selection: download all selected files sequentially,
|
||||||
|
// reusing the chosen location for every file (#1058).
|
||||||
|
if (this.selectedFiles.length > 1) {
|
||||||
|
modalManager.closeModal('downloadModal');
|
||||||
|
return this._downloadSelectedFilesSequentially({
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fileParams = this.selectedFile ? {
|
const fileParams = this.selectedFile ? {
|
||||||
id: this.selectedFile.id,
|
id: this.selectedFile.id,
|
||||||
|
name: this.selectedFile.name || null,
|
||||||
type: this.selectedFile.type || 'Model',
|
type: this.selectedFile.type || 'Model',
|
||||||
format: this.selectedFile.metadata?.format || null,
|
format: this.selectedFile.metadata?.format || null,
|
||||||
size: this.selectedFile.metadata?.size || null,
|
size: this.selectedFile.metadata?.size || null,
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
|
|||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
|
||||||
|
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
|
||||||
|
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
|
||||||
|
|
||||||
// Model sub-type display names (new canonical field: sub_type)
|
// Model sub-type display names (new canonical field: sub_type)
|
||||||
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
||||||
// LoRA sub-types
|
// LoRA sub-types
|
||||||
|
|||||||
@@ -4,21 +4,27 @@
|
|||||||
|
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
|
||||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Top Section: Preview and Generation Parameters -->
|
<!-- Left Column: Preview -->
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
|
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
|
||||||
|
<!-- Center Column: Generation Parameters -->
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-header-row">
|
<div class="gen-params-header-row">
|
||||||
<h3>Generation Parameters</h3>
|
<h3>Generation Parameters</h3>
|
||||||
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||||
@@ -103,9 +109,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Right Column: Resources -->
|
||||||
<!-- Bottom Section: Resources -->
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -114,12 +119,6 @@
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
|
||||||
<i class="fas fa-paper-plane"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-resources-list">
|
<div class="recipe-resources-list">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
|
||||||
|
const MEDIA_VIEWER_MODULE = new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname;
|
||||||
|
|
||||||
|
function setupDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="modelModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<img class="media-wrapper" src="" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediaViewer Escape handling', () => {
|
||||||
|
let ModalManager;
|
||||||
|
let manager;
|
||||||
|
let openMediaViewer;
|
||||||
|
let isMediaViewerOpen;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
setupDom();
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
|
||||||
|
manager = new ModalManager();
|
||||||
|
manager.initialize();
|
||||||
|
({ openMediaViewer, isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes only the media viewer, not the underlying modal, on Escape', () => {
|
||||||
|
manager.showModal('modelModal');
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||||
|
|
||||||
|
openMediaViewer('https://example.com/image.png');
|
||||||
|
expect(isMediaViewerOpen()).toBe(true);
|
||||||
|
|
||||||
|
// Dispatch on document.body (real keydown target is the focused element,
|
||||||
|
// never document itself) so the capture handler fires before the bubble one.
|
||||||
|
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(isMediaViewerOpen()).toBe(false);
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets Escape close the modal when no viewer is open', () => {
|
||||||
|
manager.showModal('modelModal');
|
||||||
|
|
||||||
|
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -246,13 +246,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -284,7 +290,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -293,9 +298,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -370,13 +372,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -408,7 +416,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -417,9 +424,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -464,13 +468,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -502,7 +512,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -511,9 +520,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -573,13 +579,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -611,7 +623,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -620,9 +631,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -662,13 +670,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -700,7 +714,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -709,9 +722,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -765,13 +775,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -803,7 +819,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -812,9 +827,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -885,13 +897,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -923,7 +941,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -932,9 +949,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1019,13 +1033,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1057,7 +1077,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1068,9 +1087,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1138,7 +1154,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeLorasList"></div>
|
<div id="recipeLorasList"></div>
|
||||||
<span id="recipeLorasCount"></span>
|
<span id="recipeLorasCount"></span>
|
||||||
<button id="viewRecipeLorasBtn"></button>
|
<button id="viewRecipeLorasBtn"></button>
|
||||||
<button id="copyRecipeSyntaxBtn"></button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1191,7 +1207,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeLorasList"></div>
|
<div id="recipeLorasList"></div>
|
||||||
<span id="recipeLorasCount"></span>
|
<span id="recipeLorasCount"></span>
|
||||||
<button id="viewRecipeLorasBtn"></button>
|
<button id="viewRecipeLorasBtn"></button>
|
||||||
<button id="copyRecipeSyntaxBtn"></button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1255,13 +1271,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1293,7 +1315,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1304,9 +1325,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1368,13 +1386,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1406,7 +1430,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1417,9 +1440,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1486,13 +1506,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1524,7 +1550,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1533,9 +1558,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1594,13 +1616,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1632,7 +1660,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1641,9 +1668,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1711,13 +1735,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1749,7 +1779,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1758,9 +1787,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1808,13 +1834,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1846,7 +1878,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1932,13 +1963,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1970,7 +2007,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
MODEL_CARD_MODULE,
|
MODEL_CARD_MODULE,
|
||||||
@@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => {
|
|||||||
return createModelCard(model, 'loras');
|
return createModelCard(model, 'loras');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchDrop(card, files) {
|
function dispatchDrop(card, files, types = []) {
|
||||||
const event = new Event('drop', { bubbles: true, cancelable: true });
|
const event = new Event('drop', { bubbles: true, cancelable: true });
|
||||||
Object.defineProperty(event, 'dataTransfer', { value: { files } });
|
Object.defineProperty(event, 'dataTransfer', { value: { files, types } });
|
||||||
card.dispatchEvent(event);
|
card.dispatchEvent(event);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => {
|
|||||||
expect(event.defaultPrevented).toBe(true);
|
expect(event.defaultPrevented).toBe(true);
|
||||||
expect(card.classList.contains('drag-over')).toBe(false);
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores drops tagged as internal card drags (move-to-folder)', () => {
|
||||||
|
const card = createCard();
|
||||||
|
const file = new File(['data'], 'preview.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
const event = dispatchDrop(card, [file], [MODEL_CARD_DRAG_MIME_TYPE]);
|
||||||
|
|
||||||
|
expect(uploadPreviewMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not highlight or intercept internal card drags during dragover', () => {
|
||||||
|
const card = createCard();
|
||||||
|
|
||||||
|
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(dragOverEvent, 'dataTransfer', {
|
||||||
|
value: { types: [MODEL_CARD_DRAG_MIME_TYPE] },
|
||||||
|
});
|
||||||
|
card.dispatchEvent(dragOverEvent);
|
||||||
|
|
||||||
|
expect(dragOverEvent.defaultPrevented).toBe(false);
|
||||||
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the card draggable (move-to-folder) but the preview image non-draggable', () => {
|
||||||
|
const card = createCard();
|
||||||
|
|
||||||
|
// The card itself must stay draggable for sidebar move-to-folder drags.
|
||||||
|
expect(card.draggable).toBe(true);
|
||||||
|
// The preview image must not start a native image drag: the browser would
|
||||||
|
// synthesize a File payload from it, which the drop handler would mistake
|
||||||
|
// for an external preview replacement.
|
||||||
|
const img = card.querySelector('.card-preview img');
|
||||||
|
expect(img.getAttribute('draggable')).toBe('false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(SHOWCASE_MODULE, () => ({
|
vi.mock(SHOWCASE_MODULE, () => ({
|
||||||
toggleShowcase: vi.fn(),
|
|
||||||
setupShowcaseScroll: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
scrollToTop: vi.fn(),
|
||||||
loadExampleImages: vi.fn(),
|
loadExampleImages: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(SHOWCASE_MODULE, () => ({
|
vi.mock(SHOWCASE_MODULE, () => ({
|
||||||
toggleShowcase: vi.fn(),
|
|
||||||
setupShowcaseScroll: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
scrollToTop: vi.fn(),
|
||||||
loadExampleImages: vi.fn(),
|
loadExampleImages: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||||
|
|
||||||
|
const { SHOWCASE_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
|
||||||
|
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||||
|
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
|
||||||
|
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MEDIA_UTILS_MODULE, () => ({
|
||||||
|
initLazyLoading: vi.fn(),
|
||||||
|
initNsfwBlurHandlers: vi.fn(),
|
||||||
|
initMetadataPanelHandlers: vi.fn(),
|
||||||
|
initMediaControlHandlers: vi.fn(),
|
||||||
|
positionAllMediaControls: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||||
|
openMediaViewer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||||
|
|
||||||
|
const IMAGES = [
|
||||||
|
{ url: 'https://image.civitai.com/abc/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/abc/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/abc/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Showcase gallery', () => {
|
||||||
|
let state;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
state = stateModule.state;
|
||||||
|
state.settings.show_only_sfw = false;
|
||||||
|
state.settings.blur_mature_content = false;
|
||||||
|
state.global.settings.example_images_path = '/tmp/examples';
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts collapsed: slim indicator bar only, no remote examples rendered', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.showcase-gallery')).toBeTruthy();
|
||||||
|
expect(host.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
// Collapsed bar carries the count and the local preview thumbnail
|
||||||
|
expect(host.querySelector('#galleryShowBtn')?.textContent).toContain('3');
|
||||||
|
expect(host.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||||
|
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||||
|
// No thumbnails / media wrappers → no remote fetches until expanded
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
expect(host.querySelector('.media-wrapper')).toBeNull();
|
||||||
|
// Import zone exists but stays collapsed
|
||||||
|
const zone = host.querySelector('.gallery-import-zone');
|
||||||
|
expect(zone?.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expanded render shows toolbar, main viewer, thumbnails and nav controls', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||||
|
expect(host.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||||
|
expect(host.querySelector('.main-media-container .media-wrapper')).toBeTruthy();
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||||
|
expect(host.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||||
|
expect(host.querySelector('#galleryPrevBtn')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#galleryNextBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show/hide button toggles between indicator bar and gallery', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
document.querySelector('#galleryShowBtn').click();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||||
|
|
||||||
|
// Collapse back to the indicator bar
|
||||||
|
document.querySelector('#galleryShowBtn').click();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
expect(document.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the import zone when the example images path is not configured', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.global.settings.example_images_path = '';
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||||
|
expect(host.querySelector('.gallery-import-zone')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters NSFW examples and reports the hidden count', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.settings.show_only_sfw = true;
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
IMAGES[0],
|
||||||
|
{ url: 'https://image.civitai.com/abc/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
|
||||||
|
];
|
||||||
|
const html = renderShowcaseContent(images, [], '', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(1);
|
||||||
|
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
|
||||||
|
// Only one example left → no prev/next controls
|
||||||
|
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the import interface when there are no examples', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent([], [], PREVIEW_URL);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.example-import-area.empty')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#selectExampleFilesBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the setup guidance when the path is missing and there are no examples', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.global.settings.example_images_path = '';
|
||||||
|
|
||||||
|
const html = renderShowcaseContent([], []);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.import-container--needs-setup')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#openExampleSettingsBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the main display, position and active thumbnail when expanded', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
updateMainDisplay(2);
|
||||||
|
|
||||||
|
const activeThumb = document.querySelector('.gallery-thumb.active');
|
||||||
|
expect(activeThumb?.dataset.index).toBe('2');
|
||||||
|
expect(document.querySelector('#galleryPosition')?.textContent).toBe('3 / 3');
|
||||||
|
const mainWrapper = document.querySelector('#mainMediaContainer .media-wrapper');
|
||||||
|
expect(mainWrapper).toBeTruthy();
|
||||||
|
// The third example is a video
|
||||||
|
expect(mainWrapper.querySelector('video')).toBeTruthy();
|
||||||
|
|
||||||
|
// Wraps around past the end
|
||||||
|
updateMainDisplay(3);
|
||||||
|
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||||
|
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits the main viewer to the active media aspect ratio', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
// First image is portrait 512x768 → aspect 0.667
|
||||||
|
const container = document.getElementById('mainMediaContainer');
|
||||||
|
expect(container.style.getPropertyValue('--media-aspect')).toBe(String(512 / 768));
|
||||||
|
|
||||||
|
// Second image is landscape 768x512 → aspect 1.5
|
||||||
|
updateMainDisplay(1);
|
||||||
|
expect(container.style.getPropertyValue('--media-aspect')).toBe('1.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores main-display updates while collapsed', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||||
|
|
||||||
|
updateMainDisplay(1);
|
||||||
|
|
||||||
|
// Still collapsed: indicator bar untouched, no gallery rendered
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
|
||||||
|
|
||||||
const { SHOWCASE_MODULE } = vi.hoisted(() => ({
|
|
||||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Showcase listener metrics', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div id="modelModal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="showcase-section">
|
|
||||||
<div class="carousel collapsed">
|
|
||||||
<div class="scroll-indicator"></div>
|
|
||||||
</div>
|
|
||||||
<button class="back-to-top"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks wheel/mutation/back-to-top listeners and resets after cleanup', async () => {
|
|
||||||
const {
|
|
||||||
setupShowcaseScroll,
|
|
||||||
resetShowcaseListenerMetrics,
|
|
||||||
showcaseListenerMetrics,
|
|
||||||
} = await import(SHOWCASE_MODULE);
|
|
||||||
|
|
||||||
resetShowcaseListenerMetrics();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
|
|
||||||
const cleanup = setupShowcaseScroll('modelModal');
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(1);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(1);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(1);
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('remains stable after repeated setup/cleanup cycles', async () => {
|
|
||||||
const {
|
|
||||||
setupShowcaseScroll,
|
|
||||||
resetShowcaseListenerMetrics,
|
|
||||||
showcaseListenerMetrics,
|
|
||||||
} = await import(SHOWCASE_MODULE);
|
|
||||||
|
|
||||||
resetShowcaseListenerMetrics();
|
|
||||||
|
|
||||||
const cleanupA = setupShowcaseScroll('modelModal');
|
|
||||||
cleanupA();
|
|
||||||
|
|
||||||
const cleanupB = setupShowcaseScroll('modelModal');
|
|
||||||
cleanupB();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
DOWNLOAD_MANAGER_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
LOADING_MANAGER_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
STORAGE_HELPERS_MODULE,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||||
|
LoadingManager: vi.fn(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: {
|
||||||
|
config: {
|
||||||
|
displayName: 'LoRA',
|
||||||
|
singularName: 'lora',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||||
|
FolderTreeManager: vi.fn(() => ({
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
init: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MULTI_FILE_VERSION = {
|
||||||
|
id: 201,
|
||||||
|
name: 'Multi-file version',
|
||||||
|
images: [],
|
||||||
|
files: [
|
||||||
|
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
|
||||||
|
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
|
||||||
|
],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SINGLE_FILE_VERSION = {
|
||||||
|
id: 202,
|
||||||
|
name: 'Single-file version',
|
||||||
|
images: [],
|
||||||
|
files: [{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' }],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DownloadManager multi-file version badge (#1058)', () => {
|
||||||
|
let DownloadManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="urlStep"></div>
|
||||||
|
<div id="versionStep"></div>
|
||||||
|
<div id="versionList"></div>
|
||||||
|
<button id="nextFromVersion"></button>
|
||||||
|
`;
|
||||||
|
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the file-select badge for a multi-file version already in library', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [
|
||||||
|
{
|
||||||
|
...MULTI_FILE_VERSION,
|
||||||
|
existsLocally: true,
|
||||||
|
localPath: '/models/loras/file-a.safetensors',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.showVersionStep();
|
||||||
|
|
||||||
|
const badge = document.querySelector('.file-select-badge');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.dataset.versionId).toBe('201');
|
||||||
|
expect(badge.textContent).toContain('2');
|
||||||
|
// The in-library badge is still shown alongside
|
||||||
|
expect(document.querySelector('.local-badge')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows the file-select badge for a multi-file version not in library', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [{ ...MULTI_FILE_VERSION, existsLocally: false }];
|
||||||
|
|
||||||
|
manager.showVersionStep();
|
||||||
|
|
||||||
|
const badge = document.querySelector('.file-select-badge');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.dataset.versionId).toBe('201');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the file-select badge for a single-file version in library', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [
|
||||||
|
{
|
||||||
|
...SINGLE_FILE_VERSION,
|
||||||
|
existsLocally: true,
|
||||||
|
localPath: '/models/loras/file-c.safetensors',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.showVersionStep();
|
||||||
|
|
||||||
|
expect(document.querySelector('.file-select-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
DOWNLOAD_MANAGER_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
LOADING_MANAGER_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
STORAGE_HELPERS_MODULE,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
SUMMARY_MODULE,
|
||||||
|
mockApiClient,
|
||||||
|
mockLoadingManager,
|
||||||
|
showToastMock,
|
||||||
|
showDownloadBatchSummaryMock,
|
||||||
|
resetAndReloadMock,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
// Shared API client returned by the mocked getModelApiClient factory.
|
||||||
|
const mockApiClient = {
|
||||||
|
modelType: 'loras',
|
||||||
|
apiConfig: {
|
||||||
|
config: {
|
||||||
|
displayName: 'LoRA',
|
||||||
|
singularName: 'lora',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetchCivitaiVersions: vi.fn(),
|
||||||
|
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
|
||||||
|
fetchUnifiedFolderTree: vi.fn(async () => ({ success: false })),
|
||||||
|
downloadModel: vi.fn(),
|
||||||
|
cancelDownload: vi.fn(),
|
||||||
|
getPageState: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoadingManager = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||||
|
showCancelButton: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||||
|
mockApiClient,
|
||||||
|
mockLoadingManager,
|
||||||
|
showToastMock: vi.fn(),
|
||||||
|
showDownloadBatchSummaryMock: vi.fn(),
|
||||||
|
resetAndReloadMock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
setupAutoNewlineOnPaste: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
loadingManager: mockLoadingManager,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||||
|
LoadingManager: vi.fn(() => mockLoadingManager),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(() => mockApiClient),
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||||
|
FolderTreeManager: vi.fn(() => ({
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
init: vi.fn(),
|
||||||
|
getSelectedPath: vi.fn(() => ''),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(SUMMARY_MODULE, () => ({
|
||||||
|
showDownloadBatchSummary: showDownloadBatchSummaryMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** DOM covering the file-selection, version and location steps. */
|
||||||
|
function setupDownloadDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="downloadModal">
|
||||||
|
<div class="download-step" id="urlStep"></div>
|
||||||
|
<div class="download-step" id="versionStep"></div>
|
||||||
|
<div class="download-step" id="fileSelectionStep"></div>
|
||||||
|
<div class="download-step" id="downloadLocationStep"></div>
|
||||||
|
<div id="fileSelectionList"></div>
|
||||||
|
<div id="fileSelectionVersionName"></div>
|
||||||
|
<button id="nextFromVersion"></button>
|
||||||
|
<div id="downloadModalTitle"></div>
|
||||||
|
<select id="modelRoot"></select>
|
||||||
|
<input id="folderPath" />
|
||||||
|
<div id="targetPathDisplay"></div>
|
||||||
|
<input id="useDefaultPath" type="checkbox" />
|
||||||
|
<div id="manualPathSelection"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMultiFileVersion(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 201,
|
||||||
|
name: 'Multi-file version',
|
||||||
|
baseModel: 'SDXL',
|
||||||
|
images: [],
|
||||||
|
files: [
|
||||||
|
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
|
||||||
|
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
|
||||||
|
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
|
||||||
|
],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
existsLocally: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileOption(fileId) {
|
||||||
|
return document.querySelector(`.file-option[data-file-id="${fileId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DownloadManager multi-select file dialog (#1058)', () => {
|
||||||
|
let DownloadManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupDownloadDom();
|
||||||
|
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders downloaded files disabled with an In Library tag', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [makeMultiFileVersion({
|
||||||
|
downloadedFiles: [
|
||||||
|
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||||
|
],
|
||||||
|
})];
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
|
||||||
|
const downloadedOption = getFileOption('1001');
|
||||||
|
expect(downloadedOption.classList.contains('disabled')).toBe(true);
|
||||||
|
expect(downloadedOption.querySelector('input[type="checkbox"]').disabled).toBe(true);
|
||||||
|
expect(downloadedOption.querySelector('.file-tag.in-library').textContent).toBe('In Library');
|
||||||
|
|
||||||
|
// Remaining files stay selectable
|
||||||
|
const otherOption = getFileOption('1002');
|
||||||
|
expect(otherOption.classList.contains('disabled')).toBe(false);
|
||||||
|
expect(otherOption.querySelector('input[type="checkbox"]').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores clicks on already-downloaded options', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [makeMultiFileVersion({
|
||||||
|
downloadedFiles: [
|
||||||
|
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||||
|
],
|
||||||
|
})];
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
|
||||||
|
getFileOption('1001').click();
|
||||||
|
|
||||||
|
expect(getFileOption('1001').querySelector('input[type="checkbox"]').checked).toBe(false);
|
||||||
|
expect(manager.selectedFiles).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirmFileSelection collects multiple checked files into selectedFiles', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.apiClient = mockApiClient;
|
||||||
|
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
|
||||||
|
getFileOption('1001').click();
|
||||||
|
getFileOption('1003').click();
|
||||||
|
|
||||||
|
manager.confirmFileSelection();
|
||||||
|
|
||||||
|
expect(manager.selectedFiles.map(f => f.id)).toEqual([1001, 1003]);
|
||||||
|
// selectedFile stays the first selected file for single-file flows
|
||||||
|
expect(manager.selectedFile?.id).toBe(1001);
|
||||||
|
expect(document.getElementById('fileSelectionStep').style.display).toBe('none');
|
||||||
|
expect(document.getElementById('downloadLocationStep').style.display).toBe('block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirmFileSelection warns when nothing is selected', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
manager.confirmFileSelection();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.loras.pleaseSelectFile', {}, 'error');
|
||||||
|
expect(manager.selectedFiles).toHaveLength(0);
|
||||||
|
expect(document.getElementById('downloadLocationStep').style.display).not.toBe('block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the other routing group once a file is checked and re-enables when unchecked', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [makeMultiFileVersion({
|
||||||
|
downloadedFiles: [],
|
||||||
|
files: [
|
||||||
|
{ id: 1001, type: 'UNet', sizeKB: 2048, name: 'unet-a.safetensors' },
|
||||||
|
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
|
||||||
|
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
|
||||||
|
],
|
||||||
|
})];
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
|
||||||
|
// Checking a regular Model file disables the UNet option
|
||||||
|
getFileOption('1002').click();
|
||||||
|
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(true);
|
||||||
|
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(true);
|
||||||
|
expect(getFileOption('1003').classList.contains('group-disabled')).toBe(false);
|
||||||
|
|
||||||
|
// Clicking a group-disabled option does nothing
|
||||||
|
getFileOption('1001').click();
|
||||||
|
expect(manager.selectedFiles.map(f => f.id)).toEqual([1002]);
|
||||||
|
|
||||||
|
// Unchecking everything re-enables the other group
|
||||||
|
getFileOption('1002').click();
|
||||||
|
expect(manager.selectedFiles).toHaveLength(0);
|
||||||
|
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(false);
|
||||||
|
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Next enabled for a partially downloaded multi-file version', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.currentVersion = makeMultiFileVersion({
|
||||||
|
downloadedFiles: [
|
||||||
|
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.updateNextButtonState();
|
||||||
|
|
||||||
|
const nextButton = document.getElementById('nextFromVersion');
|
||||||
|
expect(nextButton.disabled).toBe(false);
|
||||||
|
expect(nextButton.classList.contains('disabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Next when every weight file is already downloaded', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.currentVersion = makeMultiFileVersion({
|
||||||
|
downloadedFiles: [
|
||||||
|
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||||
|
{ fileId: 1002, fileName: 'file-b.safetensors', filePath: '/models/loras/file-b.safetensors' },
|
||||||
|
{ fileId: 1003, fileName: 'file-c.safetensors', filePath: '/models/loras/file-c.safetensors' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.updateNextButtonState();
|
||||||
|
|
||||||
|
const nextButton = document.getElementById('nextFromVersion');
|
||||||
|
expect(nextButton.disabled).toBe(true);
|
||||||
|
expect(nextButton.classList.contains('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Next for an in-library single-file version', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.currentVersion = {
|
||||||
|
id: 202,
|
||||||
|
name: 'Single-file version',
|
||||||
|
files: [{ id: 1004, type: 'Model', sizeKB: 2048, name: 'file-d.safetensors' }],
|
||||||
|
existsLocally: true,
|
||||||
|
downloadedFiles: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.updateNextButtonState();
|
||||||
|
|
||||||
|
const nextButton = document.getElementById('nextFromVersion');
|
||||||
|
expect(nextButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides every other step (including the URL step) when the file dialog shows', () => {
|
||||||
|
// Regression: entering via openFileSelectionForVersion (ModelVersionsTab)
|
||||||
|
// left the URL step visible alongside the file selection step (#1058).
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [makeMultiFileVersion()];
|
||||||
|
document.getElementById('urlStep').style.display = 'block';
|
||||||
|
document.getElementById('versionStep').style.display = 'block';
|
||||||
|
|
||||||
|
manager.showFileSelectionStep('201');
|
||||||
|
|
||||||
|
expect(document.getElementById('urlStep').style.display).toBe('none');
|
||||||
|
expect(document.getElementById('versionStep').style.display).toBe('none');
|
||||||
|
expect(document.getElementById('fileSelectionStep').style.display).toBe('block');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,3 +83,64 @@ def test_checkpoint_names_empty_when_scanner_fails(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
||||||
assert CheckpointLoaderLM._get_checkpoint_names() == []
|
assert CheckpointLoaderLM._get_checkpoint_names() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_available_base_models(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
sd15 = tmp_path / "sd15.safetensors"
|
||||||
|
sd15.write_bytes(b"x")
|
||||||
|
flux = tmp_path / "flux.safetensors"
|
||||||
|
flux.write_bytes(b"x")
|
||||||
|
missing = tmp_path / "missing.safetensors" # referenced but never created
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(sd15), "base_model": "SD1.5"},
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "Flux.1 D"},
|
||||||
|
# Deleted files must drop out; wrong sub_type must be excluded.
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
|
||||||
|
{"sub_type": "diffusion_model", "file_path": str(flux), "base_model": "Flux.1 D"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
assert CheckpointLoaderLM._get_available_base_models() == [
|
||||||
|
"Any",
|
||||||
|
"Flux.1 D",
|
||||||
|
"SD1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unet_available_base_models(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
flux = tmp_path / "flux.safetensors"
|
||||||
|
flux.write_bytes(b"x")
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{
|
||||||
|
"sub_type": "diffusion_model",
|
||||||
|
"file_path": str(flux),
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
},
|
||||||
|
# Checkpoint entries must stay excluded by the sub_type filter.
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "SD1.5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
assert UNETLoaderLM._get_available_base_models() == ["Any", "Flux.1 D"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_base_models_empty_when_scanner_fails(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("scanner not available")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
||||||
|
assert CheckpointLoaderLM._get_available_base_models() == ["Any"]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Unit tests for per-file downloaded-state matching (#1058)."""
|
||||||
|
|
||||||
|
from py.routes.handlers.model_handlers import ModelCivitaiHandler
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = {
|
||||||
|
"id": 42,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"name": "file-a.safetensors",
|
||||||
|
"hashes": {"SHA256": "AAA111"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1002,
|
||||||
|
"name": "file-b.safetensors",
|
||||||
|
"hashes": {"SHA256": "BBB222"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(file_name: str, sha256: str = "", file_path: str | None = None):
|
||||||
|
return {
|
||||||
|
"file_name": file_name,
|
||||||
|
"file_path": file_path or f"/models/{file_name}.safetensors",
|
||||||
|
"sha256": sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_by_sha256():
|
||||||
|
entries = [_entry("renamed-locally", "bbb222")]
|
||||||
|
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||||
|
assert result == [
|
||||||
|
{
|
||||||
|
"fileId": 1002,
|
||||||
|
"fileName": "file-b.safetensors",
|
||||||
|
"filePath": "/models/renamed-locally.safetensors",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_name_when_hash_missing():
|
||||||
|
entries = [_entry("file-a", "")]
|
||||||
|
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||||
|
assert [r["fileId"] for r in result] == [1001]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_takes_precedence_over_name():
|
||||||
|
# Hash points at file-b while the name points at file-a: hash wins.
|
||||||
|
entries = [_entry("file-a", "bbb222")]
|
||||||
|
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||||
|
assert [r["fileId"] for r in result] == [1002]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmatched_entries_are_skipped():
|
||||||
|
entries = [
|
||||||
|
_entry("unrelated", "ccc333"),
|
||||||
|
_entry("file-b", ""), # name match
|
||||||
|
]
|
||||||
|
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||||
|
assert [r["fileId"] for r in result] == [1002]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_files_of_same_version():
|
||||||
|
entries = [
|
||||||
|
_entry("file-a", "aaa111"),
|
||||||
|
_entry("file-b", "bbb222"),
|
||||||
|
]
|
||||||
|
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||||
|
assert [r["fileId"] for r in result] == [1001, 1002]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_inputs():
|
||||||
|
assert ModelCivitaiHandler._match_downloaded_files(VERSION, []) == []
|
||||||
|
assert ModelCivitaiHandler._match_downloaded_files({"id": 1}, [_entry("x")]) == []
|
||||||
|
assert ModelCivitaiHandler._match_downloaded_files(VERSION, None) == []
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests for the loader-pool endpoint backing the Random Checkpoint/Unet
|
||||||
|
Loader nodes' front-end base_model filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.checkpoint_routes import CheckpointRoutes
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCache:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScanner:
|
||||||
|
def __init__(self, raw_data, model_roots):
|
||||||
|
self._raw_data = raw_data
|
||||||
|
self._model_roots = model_roots
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh=False):
|
||||||
|
return _FakeCache(self._raw_data)
|
||||||
|
|
||||||
|
def get_model_roots(self):
|
||||||
|
return self._model_roots
|
||||||
|
|
||||||
|
|
||||||
|
class DummyRequest:
|
||||||
|
def __init__(self, query=None):
|
||||||
|
self.query = query or {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def routes(tmp_path, monkeypatch):
|
||||||
|
existing = tmp_path / "flux.safetensors"
|
||||||
|
existing.write_bytes(b"x")
|
||||||
|
missing = tmp_path / "missing.safetensors" # referenced but never created
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(existing), "base_model": "Flux.1 D"},
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
|
||||||
|
{
|
||||||
|
"sub_type": "diffusion_model",
|
||||||
|
"file_path": str(existing),
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
return CheckpointRoutes()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_checkpoint_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "checkpoint"}))
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_diffusion_model_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(
|
||||||
|
DummyRequest(query={"sub_type": "diffusion_model"})
|
||||||
|
)
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_default_subtype_is_checkpoint(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest())
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_invalid_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "lora"}))
|
||||||
|
assert response.status == 400
|
||||||
@@ -3,6 +3,40 @@ import pytest
|
|||||||
from py.recipes.parsers.automatic import AutomaticMetadataParser
|
from py.recipes.parsers.automatic import AutomaticMetadataParser
|
||||||
|
|
||||||
|
|
||||||
|
class LocalRecipeScanner:
|
||||||
|
class LoraScanner:
|
||||||
|
@staticmethod
|
||||||
|
def has_hash(model_hash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __init__(self, models):
|
||||||
|
self.models = models
|
||||||
|
self.queries = []
|
||||||
|
self.hash_queries = []
|
||||||
|
self._lora_scanner = self.LoraScanner()
|
||||||
|
|
||||||
|
async def get_local_lora(self, name, base_model=None):
|
||||||
|
self.queries.append(name)
|
||||||
|
return self.models.get(name)
|
||||||
|
|
||||||
|
async def get_local_lora_by_hash(self, hash_value):
|
||||||
|
self.hash_queries.append(hash_value)
|
||||||
|
return next((model for model in self.models.values() if model.get("sha256") == hash_value), None)
|
||||||
|
|
||||||
|
|
||||||
|
def local_lora(file_name="local_only"):
|
||||||
|
return {
|
||||||
|
"file_path": f"/models/loras/styles/{file_name}.safetensors",
|
||||||
|
"file_name": file_name,
|
||||||
|
"model_name": "Local Only",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"size": 123456,
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
"preview_url": f"/models/loras/styles/{file_name}.preview.png",
|
||||||
|
"civitai": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
@@ -132,6 +166,218 @@ async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypa
|
|||||||
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
raise AssertionError("Local and empty-hash LoRAs must not query Civitai")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local_only": local_lora()})
|
||||||
|
metadata_text = (
|
||||||
|
"portrait <lora:local_only:0.65> <lora:missing:0.4>\n"
|
||||||
|
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 1, "
|
||||||
|
'Hashes: {"lora:local_only": "", "lora:missing": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
entry = result["loras"][0]
|
||||||
|
assert entry["name"] == "Local Only"
|
||||||
|
assert entry["file_name"] == "local_only"
|
||||||
|
assert entry["weight"] == 0.65
|
||||||
|
assert entry["hash"] == "a" * 64
|
||||||
|
assert entry["localPath"].endswith("local_only.safetensors")
|
||||||
|
assert entry["size"] == 123456
|
||||||
|
assert entry["baseModel"] == "Flux.1 D"
|
||||||
|
assert entry["existsLocally"] is True
|
||||||
|
assert entry["isDeleted"] is False
|
||||||
|
assert scanner.queries == ["local_only", "missing"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_resolves_prompt_lora_without_hashes(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
model = local_lora()
|
||||||
|
scanner = LocalRecipeScanner({"styles/local_only": model})
|
||||||
|
metadata_text = "portrait <lora:styles/local_only:0.7>\nSteps: 20, Seed: 1"
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
assert result["loras"][0]["weight"] == 0.7
|
||||||
|
assert result["loras"][0]["hash"] == "a" * 64
|
||||||
|
assert scanner.queries == ["styles/local_only"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_prefers_hash_over_colliding_local_name(monkeypatch):
|
||||||
|
remote_info = {
|
||||||
|
"id": 100,
|
||||||
|
"modelId": 200,
|
||||||
|
"model": {"name": "Hash Match", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"files": [{"type": "Model", "primary": True, "name": "hash_match.safetensors", "hashes": {"SHA256": "b" * 64}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "deadbeef00"
|
||||||
|
return remote_info, None
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local_only": local_lora()})
|
||||||
|
metadata_text = (
|
||||||
|
"portrait <lora:local_only:0.8>\n"
|
||||||
|
"Steps: 20, Seed: 1, "
|
||||||
|
'Hashes: {"lora:local_only": "deadbeef00"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
assert result["loras"][0]["id"] == 100
|
||||||
|
assert result["loras"][0]["weight"] == 0.8
|
||||||
|
assert scanner.queries == []
|
||||||
|
assert scanner.hash_queries == ["deadbeef00"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_falls_back_to_name_when_hash_is_unresolved(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local_only": local_lora()})
|
||||||
|
metadata_text = (
|
||||||
|
"portrait <lora:local_only:0.8>\nSteps: 20, Seed: 1, "
|
||||||
|
'Hashes: {"lora:local_only": "deadbeef00"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert result["loras"][0]["hash"] == "a" * 64
|
||||||
|
assert scanner.queries == ["local_only"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_uses_prompt_weight_for_civitai_resource(monkeypatch):
|
||||||
|
remote_info = {
|
||||||
|
"id": 100,
|
||||||
|
"modelId": 200,
|
||||||
|
"model": {"name": "local_only", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"name": "remote_file.safetensors",
|
||||||
|
"hashes": {"SHA256": "b" * 64},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
assert version_id == 100
|
||||||
|
return remote_info, None
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
raise AssertionError("The Civitai resource should not be fetched again by hash")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local_only": local_lora()})
|
||||||
|
metadata_text = (
|
||||||
|
"portrait <lora:remote_file:0.35> <lora:local_only:0.6>\n"
|
||||||
|
"Steps: 20, Seed: 1, "
|
||||||
|
'Civitai resources: [{"type":"lora","modelVersionId":100,"modelName":"local_only"}]'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert len(result["loras"]) == 2
|
||||||
|
assert [entry["file_name"] for entry in result["loras"]] == ["remote_file", "local_only"]
|
||||||
|
assert [entry["weight"] for entry in result["loras"]] == [0.35, 0.6]
|
||||||
|
assert result["loras"][1]["existsLocally"] is True
|
||||||
|
assert scanner.queries == ["local_only"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_keeps_mixed_local_and_civitai_loras(monkeypatch):
|
||||||
|
remote_info = {
|
||||||
|
"id": 100,
|
||||||
|
"modelId": 200,
|
||||||
|
"model": {"name": "Remote LoRA", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"name": "remote.safetensors",
|
||||||
|
"hashes": {"SHA256": "b" * 64},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "bbbbbbbbbb"
|
||||||
|
return remote_info, None
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local_only": local_lora()})
|
||||||
|
metadata_text = (
|
||||||
|
"portrait <lora:local_only:0.6> <lora:remote:0.9>\n"
|
||||||
|
"Steps: 20, Seed: 1, "
|
||||||
|
'Hashes: {"lora:local_only": "", "lora:remote": "bbbbbbbbbb"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
|
||||||
|
|
||||||
|
assert [entry["name"] for entry in result["loras"]] == ["Local Only", "Remote LoRA"]
|
||||||
|
assert [entry["weight"] for entry in result["loras"]] == [0.6, 0.9]
|
||||||
|
assert result["loras"][0]["existsLocally"] is True
|
||||||
|
assert result["loras"][1]["existsLocally"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import pytest
|
|||||||
import json
|
import json
|
||||||
from py.recipes.parsers.comfy import ComfyMetadataParser
|
from py.recipes.parsers.comfy import ComfyMetadataParser
|
||||||
|
|
||||||
|
|
||||||
|
class LocalRecipeScanner:
|
||||||
|
class LoraScanner:
|
||||||
|
@staticmethod
|
||||||
|
def has_hash(model_hash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __init__(self, models):
|
||||||
|
self.models = models
|
||||||
|
self.queries = []
|
||||||
|
self.base_models = []
|
||||||
|
self._lora_scanner = self.LoraScanner()
|
||||||
|
|
||||||
|
async def get_local_lora(self, name, base_model=None):
|
||||||
|
self.queries.append(name)
|
||||||
|
self.base_models.append(base_model)
|
||||||
|
return self.models.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def local_lora(file_name):
|
||||||
|
return {
|
||||||
|
"file_path": f"/models/loras/{file_name}.safetensors",
|
||||||
|
"file_name": file_name.rsplit("/", 1)[-1],
|
||||||
|
"model_name": file_name.rsplit("/", 1)[-1],
|
||||||
|
"sha256": file_name[0] * 64,
|
||||||
|
"size": 4096,
|
||||||
|
"base_model": "SDXL 1.0",
|
||||||
|
"preview_url": "",
|
||||||
|
"civitai": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_without_loras(monkeypatch):
|
async def test_parse_metadata_without_loras(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
@@ -84,6 +116,140 @@ async def test_parse_metadata_without_loras(monkeypatch):
|
|||||||
assert result["gen_params"]["size"] == "1024x1024"
|
assert result["gen_params"]["size"] == "1024x1024"
|
||||||
assert result["from_comfy_metadata"] is True
|
assert result["from_comfy_metadata"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_resolves_standard_and_manager_local_loras(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("Local LoRAs must not query Civitai")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({
|
||||||
|
"styles/standard.safetensors": local_lora("standard"),
|
||||||
|
"manager": local_lora("manager"),
|
||||||
|
})
|
||||||
|
metadata_json = {
|
||||||
|
"1": {
|
||||||
|
"class_type": "LoraLoader",
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": "styles/standard.safetensors",
|
||||||
|
"strength_model": 0.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "LoraLoaderLM",
|
||||||
|
"inputs": {
|
||||||
|
"loras": {
|
||||||
|
"__value__": [
|
||||||
|
{"name": "manager", "strength": "0.80", "active": True},
|
||||||
|
{"name": "disabled", "strength": 1.0, "active": False},
|
||||||
|
{"name": "dummy", "strength": 1.0, "active": True, "_isDummy": True},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
|
||||||
|
|
||||||
|
assert [entry["file_name"] for entry in result["loras"]] == ["standard", "manager"]
|
||||||
|
assert [entry["weight"] for entry in result["loras"]] == [0.55, 0.8]
|
||||||
|
assert all(isinstance(entry["weight"], float) for entry in result["loras"])
|
||||||
|
assert all(entry["existsLocally"] is True for entry in result["loras"])
|
||||||
|
assert all(entry["isDeleted"] is False for entry in result["loras"])
|
||||||
|
assert scanner.queries == ["styles/standard.safetensors", "manager"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_defaults_malformed_weight_and_passes_checkpoint_base_model(monkeypatch):
|
||||||
|
checkpoint_info = {
|
||||||
|
"id": 456,
|
||||||
|
"modelId": 123,
|
||||||
|
"model": {"name": "Checkpoint", "type": "checkpoint"},
|
||||||
|
"name": "v1",
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
return checkpoint_info, None
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"style": local_lora("style")})
|
||||||
|
metadata_json = {
|
||||||
|
"1": {"class_type": "LoraLoader", "inputs": {"lora_name": "style", "strength_model": "invalid"}},
|
||||||
|
"2": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "civitai:123@456"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
|
||||||
|
|
||||||
|
assert result["loras"][0]["weight"] == 1.0
|
||||||
|
assert scanner.base_models == ["SDXL 1.0"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_keeps_civitai_urn_with_local_lora(monkeypatch):
|
||||||
|
remote_info = {
|
||||||
|
"id": 456,
|
||||||
|
"modelId": 123,
|
||||||
|
"model": {"name": "Remote LoRA", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"name": "remote.safetensors",
|
||||||
|
"hashes": {"SHA256": "c" * 64},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
assert version_id == "456"
|
||||||
|
return remote_info, None
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
scanner = LocalRecipeScanner({"local": local_lora("local")})
|
||||||
|
metadata_json = {
|
||||||
|
"1": {
|
||||||
|
"class_type": "LoraLoader",
|
||||||
|
"inputs": {"lora_name": "local", "strength_model": 0.4},
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "LoraLoader",
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": "urn:air:sdxl:lora:civitai:123@456",
|
||||||
|
"strength_model": 0.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
|
||||||
|
|
||||||
|
assert [entry["name"] for entry in result["loras"]] == ["local", "Remote LoRA"]
|
||||||
|
assert [entry["weight"] for entry in result["loras"]] == [0.4, 0.9]
|
||||||
|
assert result["loras"][0]["existsLocally"] is True
|
||||||
|
assert result["loras"][1]["id"] == 456
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
||||||
async def fake_metadata_provider():
|
async def fake_metadata_provider():
|
||||||
|
|||||||
@@ -82,14 +82,18 @@ def stub_metadata(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
class DummyScanner:
|
class DummyScanner:
|
||||||
def __init__(self, exists: bool = False):
|
def __init__(self, exists: bool = False, raw_data=None):
|
||||||
self.exists = exists
|
self.exists = exists
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self._cache = SimpleNamespace(raw_data=list(raw_data or []))
|
||||||
|
|
||||||
async def check_model_version_exists(self, version_id):
|
async def check_model_version_exists(self, version_id):
|
||||||
self.calls.append(version_id)
|
self.calls.append(version_id)
|
||||||
return self.exists
|
return self.exists
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def scanners(monkeypatch):
|
def scanners(monkeypatch):
|
||||||
@@ -1692,3 +1696,310 @@ async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanner
|
|||||||
assert result.get("skipped") is not True
|
assert result.get("skipped") is not True
|
||||||
history_service.has_been_downloaded.assert_not_called()
|
history_service.has_been_downloaded.assert_not_called()
|
||||||
execute_download.assert_awaited_once()
|
execute_download.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multi-file downloads within a single model version (#1058)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_file_payload(include_hashes: bool = True):
|
||||||
|
"""Version payload with two weight files under the same version."""
|
||||||
|
files = [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"downloadUrl": "https://example.invalid/file-a.safetensors",
|
||||||
|
"name": "file-a.safetensors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1002,
|
||||||
|
"type": "Model",
|
||||||
|
"primary": False,
|
||||||
|
"downloadUrl": "https://example.invalid/file-b.safetensors",
|
||||||
|
"name": "file-b.safetensors",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if include_hashes:
|
||||||
|
files[0]["hashes"] = {"SHA256": "AAA111"}
|
||||||
|
files[1]["hashes"] = {"SHA256": "BBB222"}
|
||||||
|
return {
|
||||||
|
"id": 42,
|
||||||
|
"modelId": 7,
|
||||||
|
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||||
|
"baseModel": "BaseModel",
|
||||||
|
"creator": {"username": "Author"},
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_entry(file_name: str, sha256: str, version_id: int = 42):
|
||||||
|
"""A library cache entry for one already-downloaded file of a version."""
|
||||||
|
return {
|
||||||
|
"file_name": file_name,
|
||||||
|
"file_path": f"/tmp/{file_name}.safetensors",
|
||||||
|
"sha256": sha256,
|
||||||
|
"civitai": {"id": version_id, "modelId": 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_history_service(monkeypatch, has_been_downloaded: bool = False):
|
||||||
|
history_service = AsyncMock()
|
||||||
|
history_service.has_been_downloaded = AsyncMock(
|
||||||
|
return_value=has_been_downloaded
|
||||||
|
)
|
||||||
|
history_service.mark_downloaded = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_downloaded_version_history_service",
|
||||||
|
AsyncMock(return_value=history_service),
|
||||||
|
)
|
||||||
|
return history_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_allows_other_file_of_in_library_version(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""A different file of an in-library version must still download (#1058)."""
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
execute_download.assert_awaited_once()
|
||||||
|
# Version-level gates must not run for an explicit file selection
|
||||||
|
assert scanners.lora.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_blocks_same_file_of_in_library_version(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""Re-downloading the SAME file of an in-library version stays blocked."""
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1001, "type": "Model", "name": "file-a.safetensors"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "file-a.safetensors" in result["error"]
|
||||||
|
assert "already exists in lora library" in result["error"]
|
||||||
|
execute_download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_unresolvable_file_params_falls_back_to_version_gate(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""file_params that match no file must not bypass version-level gates."""
|
||||||
|
scanners.lora.exists = True
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 9999, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "Model version already exists in lora library"
|
||||||
|
execute_download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_empty_file_params_treated_as_no_selection(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""An empty file_params dict is normalized to None (version-level gates)."""
|
||||||
|
scanners.lora.exists = True
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "Model version already exists in lora library"
|
||||||
|
# Early version-level gate fired (file_params normalized to None)
|
||||||
|
assert scanners.lora.calls == [42]
|
||||||
|
execute_download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_explicit_file_bypasses_history_skip(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""Explicit file selection bypasses the previously-downloaded skip."""
|
||||||
|
get_settings_manager().settings[
|
||||||
|
"skip_previously_downloaded_model_versions"
|
||||||
|
] = True
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
history_service = _stub_history_service(monkeypatch, has_been_downloaded=True)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
execute_download.assert_awaited_once()
|
||||||
|
# History gate is bypassed before it even queries the service
|
||||||
|
history_service.has_been_downloaded.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_file_match_by_name_when_local_hash_missing(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""Legacy local metadata without sha256 falls back to name matching."""
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-a", ""))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1001, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "already exists in lora library" in result["error"]
|
||||||
|
execute_download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_no_false_positive_when_both_hashes_empty(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""Two empty hashes must never compare equal; name decides instead."""
|
||||||
|
# Local entry for a DIFFERENT file of the same version, no hash stored
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-b", ""))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload(include_hashes=False)
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1001, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
execute_download.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_model_id_only_with_file_params_resolves_file(
|
||||||
|
monkeypatch, scanners, metadata_provider
|
||||||
|
):
|
||||||
|
"""model_id-only requests with file_params resolve the file post-fetch."""
|
||||||
|
scanners.lora.exists = True # version-level index says "in library"
|
||||||
|
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value=_multi_file_payload()
|
||||||
|
)
|
||||||
|
_stub_history_service(monkeypatch)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = DownloadManager()
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_id=7,
|
||||||
|
save_dir="/tmp",
|
||||||
|
file_params={"id": 1002, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
execute_download.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_file_by_id():
|
||||||
|
files = _multi_file_payload()["files"]
|
||||||
|
resolved = DownloadManager._resolve_target_file(files, {"id": 1002})
|
||||||
|
assert resolved is files[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_file_by_primary_flag():
|
||||||
|
files = _multi_file_payload()["files"]
|
||||||
|
resolved = DownloadManager._resolve_target_file(files, {"isPrimary": True})
|
||||||
|
assert resolved is files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_file_returns_none_for_no_match():
|
||||||
|
files = _multi_file_payload()["files"]
|
||||||
|
assert DownloadManager._resolve_target_file(files, {"id": 9999}) is None
|
||||||
|
assert DownloadManager._resolve_target_file(files, None) is None
|
||||||
|
assert DownloadManager._resolve_target_file(files, {}) is None
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Covers the new ``download_id``-based code paths in
|
|||||||
compatibility with ``id``.
|
compatibility with ``id``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -191,3 +193,131 @@ async def test_retry_unknown_download_id(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
item = await svc.retry_from_history(download_id="dl-nope")
|
item = await svc.retry_from_history(download_id="dl-nope")
|
||||||
assert item is None
|
assert item is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# file_params persistence across queue -> history -> retry (#1058)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file_params_survive_queue_to_history(tmp_path: Path) -> None:
|
||||||
|
"""complete_download copies the queue row's file_params into history."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await svc.add_to_queue(
|
||||||
|
download_id="dl-fp",
|
||||||
|
model_id=1,
|
||||||
|
model_version_id=100,
|
||||||
|
file_params={"id": 1002, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.complete_download("dl-fp", status="failed", error="boom")
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
assert json.loads(history["items"][0]["file_params"]) == {
|
||||||
|
"id": 1002,
|
||||||
|
"type": "Model",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_restores_file_params(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history re-queues with the originally selected file."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await svc.add_to_history(
|
||||||
|
download_id="dl-fail-fp",
|
||||||
|
model_id=1,
|
||||||
|
model_version_id=100,
|
||||||
|
status="failed",
|
||||||
|
file_params={"id": 1002, "type": "Model"},
|
||||||
|
)
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id="dl-fail-fp")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
assert json.loads(item["file_params"]) == {"id": 1002, "type": "Model"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_all_restores_file_params(tmp_path: Path) -> None:
|
||||||
|
"""retry_all_failed preserves file_params for every re-queued item."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await svc.add_to_history(
|
||||||
|
download_id="dl-f1", status="failed", file_params={"id": 1001}
|
||||||
|
)
|
||||||
|
await svc.add_to_history(download_id="dl-f2", status="canceled")
|
||||||
|
|
||||||
|
count = await svc.retry_all_failed()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
queue = await svc.get_queue()
|
||||||
|
restored = sorted(
|
||||||
|
(q["file_params"] or "") for q in queue
|
||||||
|
)
|
||||||
|
assert restored[0] == "" # dl-f2 never had file_params
|
||||||
|
assert json.loads(restored[1]) == {"id": 1001}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_history_db_gains_file_params_column(tmp_path: Path) -> None:
|
||||||
|
"""Databases created before the file_params column get migrated (#1058)."""
|
||||||
|
db_path = tmp_path / "queue.sqlite"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE download_queue (
|
||||||
|
download_id TEXT PRIMARY KEY,
|
||||||
|
model_id INTEGER,
|
||||||
|
model_version_id INTEGER,
|
||||||
|
model_name TEXT NOT NULL DEFAULT '',
|
||||||
|
version_name TEXT DEFAULT '',
|
||||||
|
thumbnail_url TEXT DEFAULT '',
|
||||||
|
source TEXT,
|
||||||
|
file_params TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
total_bytes INTEGER,
|
||||||
|
bytes_per_second REAL DEFAULT 0.0,
|
||||||
|
error TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
added_at REAL NOT NULL,
|
||||||
|
started_at REAL,
|
||||||
|
completed_at REAL
|
||||||
|
);
|
||||||
|
CREATE TABLE download_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
download_id TEXT,
|
||||||
|
model_id INTEGER,
|
||||||
|
model_version_id INTEGER,
|
||||||
|
model_name TEXT NOT NULL DEFAULT '',
|
||||||
|
version_name TEXT DEFAULT '',
|
||||||
|
thumbnail_url TEXT DEFAULT '',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
total_bytes INTEGER,
|
||||||
|
completed_at REAL NOT NULL,
|
||||||
|
is_already_exists INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
svc = DownloadQueueService(db_path=str(db_path))
|
||||||
|
|
||||||
|
# The migrated table accepts file_params writes
|
||||||
|
await svc.add_to_history(
|
||||||
|
download_id="dl-legacy-fp", status="failed", file_params={"id": 5}
|
||||||
|
)
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert json.loads(history["items"][0]["file_params"]) == {"id": 5}
|
||||||
|
|
||||||
|
# And retry restores them
|
||||||
|
item = await svc.retry_from_history(download_id="dl-legacy-fp")
|
||||||
|
assert item is not None
|
||||||
|
assert json.loads(item["file_params"]) == {"id": 5}
|
||||||
|
|||||||
@@ -68,3 +68,94 @@ async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
|
|||||||
5: {501, 502},
|
5: {501, 502},
|
||||||
6: {601},
|
6: {601},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_per_file_history_tracking(tmp_path: Path) -> None:
|
||||||
|
"""Per-file records coexist with the version-level row (#1058)."""
|
||||||
|
db_path = tmp_path / "download-history.sqlite"
|
||||||
|
service = DownloadedVersionHistoryService(
|
||||||
|
str(db_path),
|
||||||
|
settings_manager=DummySettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora", 101, model_id=11, source="download",
|
||||||
|
file_path="/models/a.safetensors", file_id=1001, file_name="a.safetensors",
|
||||||
|
)
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora", 101, model_id=11, source="download",
|
||||||
|
file_path="/models/b.safetensors", file_id=1002, file_name="b.safetensors",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
|
||||||
|
# Version-level tracking remains single-row per version
|
||||||
|
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||||
|
|
||||||
|
# Re-downloading the same file updates in place, no duplicate
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora", 101, source="download", file_id=1001, file_name="a.safetensors",
|
||||||
|
)
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
|
||||||
|
|
||||||
|
# Single-file deletion keeps the sibling record
|
||||||
|
await service.mark_file_deleted("lora", 101, 1001)
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 101) == [1002]
|
||||||
|
|
||||||
|
# Whole-version deletion clears per-file records
|
||||||
|
await service.mark_as_deleted("lora", 101)
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 101) == []
|
||||||
|
assert await service.has_been_downloaded("lora", 101) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_per_file_history_ignores_invalid_ids(tmp_path: Path) -> None:
|
||||||
|
service = DownloadedVersionHistoryService(
|
||||||
|
str(tmp_path / "download-history.sqlite"),
|
||||||
|
settings_manager=DummySettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# mark_downloaded without a file id only touches the version-level table
|
||||||
|
await service.mark_downloaded("lora", 201, model_id=21, source="scan")
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 201) == []
|
||||||
|
|
||||||
|
# Invalid inputs are no-ops
|
||||||
|
await service.mark_file_deleted("lora", 201, None) # type: ignore[arg-type]
|
||||||
|
assert await service.get_downloaded_file_ids("unknown-type", 201) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file_history_table_created_for_legacy_db(tmp_path: Path) -> None:
|
||||||
|
"""Existing databases gain the per-file table via CREATE IF NOT EXISTS."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = tmp_path / "download-history.sqlite"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE downloaded_model_versions (
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
version_id INTEGER NOT NULL,
|
||||||
|
model_id INTEGER,
|
||||||
|
first_seen_at REAL NOT NULL,
|
||||||
|
last_seen_at REAL NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
last_file_path TEXT,
|
||||||
|
last_library_name TEXT,
|
||||||
|
is_deleted_override INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (model_type, version_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
service = DownloadedVersionHistoryService(
|
||||||
|
str(db_path),
|
||||||
|
settings_manager=DummySettings(),
|
||||||
|
)
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora", 301, model_id=31, source="download",
|
||||||
|
file_id=9001, file_name="file.safetensors",
|
||||||
|
)
|
||||||
|
assert await service.get_downloaded_file_ids("lora", 301) == [9001]
|
||||||
|
assert await service.has_been_downloaded("lora", 301) is True
|
||||||
|
|||||||
@@ -61,3 +61,87 @@ async def test_model_cache_tracks_versions_by_model_id():
|
|||||||
assert cache.get_versions_by_model_id(2) == [
|
assert cache.get_versions_by_model_id(2) == [
|
||||||
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
|
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_files_index_tracks_multiple_files_per_version():
|
||||||
|
"""Two downloaded files of the same version both stay indexed (#1058)."""
|
||||||
|
item_a = {
|
||||||
|
'file_path': '/models/v1-a.safetensors',
|
||||||
|
'file_name': 'model-v1-a',
|
||||||
|
'folder': '',
|
||||||
|
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
|
||||||
|
}
|
||||||
|
item_b = {
|
||||||
|
'file_path': '/models/v1-b.safetensors',
|
||||||
|
'file_name': 'model-v1-b',
|
||||||
|
'folder': '',
|
||||||
|
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = ModelCache(
|
||||||
|
raw_data=[item_a, item_b],
|
||||||
|
folders=[],
|
||||||
|
name_display_mode='model_name',
|
||||||
|
)
|
||||||
|
|
||||||
|
files = cache.get_files_by_version_id(301)
|
||||||
|
assert {f['file_path'] for f in files} == {
|
||||||
|
'/models/v1-a.safetensors',
|
||||||
|
'/models/v1-b.safetensors',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-adding an existing entry must not duplicate it
|
||||||
|
cache.add_to_version_index(item_a)
|
||||||
|
assert len(cache.get_files_by_version_id(301)) == 2
|
||||||
|
|
||||||
|
# Removing the indexed file re-points version_index to the sibling
|
||||||
|
indexed = cache.version_index[301]
|
||||||
|
sibling = item_b if indexed is item_a else item_a
|
||||||
|
cache.remove_from_version_index(indexed)
|
||||||
|
|
||||||
|
assert 301 in cache.version_index
|
||||||
|
assert cache.version_index[301]['file_path'] == sibling['file_path']
|
||||||
|
assert cache.get_versions_by_model_id(3) == [
|
||||||
|
{'versionId': 301, 'name': 'Multi', 'fileName': sibling['file_name']},
|
||||||
|
]
|
||||||
|
remaining = cache.get_files_by_version_id(301)
|
||||||
|
assert [f['file_path'] for f in remaining] == [sibling['file_path']]
|
||||||
|
|
||||||
|
# Removing the last file drops the version from all indexes
|
||||||
|
cache.remove_from_version_index(sibling)
|
||||||
|
assert 301 not in cache.version_index
|
||||||
|
assert cache.get_files_by_version_id(301) == []
|
||||||
|
assert cache.get_versions_by_model_id(3) == []
|
||||||
|
assert 3 not in cache.model_id_index
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_files_index_rebuild_from_raw_data():
|
||||||
|
"""rebuild_version_index reconstructs the multi-valued index (#1058)."""
|
||||||
|
item_a = {
|
||||||
|
'file_path': '/models/v1-a.safetensors',
|
||||||
|
'file_name': 'model-v1-a',
|
||||||
|
'folder': '',
|
||||||
|
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
|
||||||
|
}
|
||||||
|
item_b = {
|
||||||
|
'file_path': '/models/v1-b.safetensors',
|
||||||
|
'file_name': 'model-v1-b',
|
||||||
|
'folder': '',
|
||||||
|
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = ModelCache(
|
||||||
|
raw_data=[item_a, item_b],
|
||||||
|
folders=[],
|
||||||
|
name_display_mode='model_name',
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.version_files_index = {}
|
||||||
|
cache.rebuild_version_index()
|
||||||
|
|
||||||
|
assert len(cache.get_files_by_version_id(401)) == 2
|
||||||
|
# Invalid ids normalize to empty results
|
||||||
|
assert cache.get_files_by_version_id('not-an-int') == []
|
||||||
|
assert cache.get_files_by_version_id(None) == []
|
||||||
|
|||||||
@@ -57,9 +57,33 @@ class StubLoraScanner:
|
|||||||
meta = self._hash_meta.get(hash_value.lower())
|
meta = self._hash_meta.get(hash_value.lower())
|
||||||
return meta.get("path") if meta else None
|
return meta.get("path") if meta else None
|
||||||
|
|
||||||
async def get_model_info_by_name(self, name: str):
|
async def get_model_info_by_name(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
require_unique: bool = False,
|
||||||
|
base_model: str | None = None,
|
||||||
|
):
|
||||||
|
if require_unique or base_model:
|
||||||
|
matches = ModelScanner.find_matching_models(
|
||||||
|
self._cache.raw_data,
|
||||||
|
name,
|
||||||
|
base_model=base_model,
|
||||||
|
extensions={".safetensors"},
|
||||||
|
)
|
||||||
|
if require_unique and len(matches) != 1:
|
||||||
|
return None
|
||||||
|
return matches[0] if matches else None
|
||||||
return self._models_by_name.get(name)
|
return self._models_by_name.get(name)
|
||||||
|
|
||||||
|
async def find_models_by_name(self, name: str, *, base_model: str | None = None):
|
||||||
|
return ModelScanner.find_matching_models(
|
||||||
|
self._cache.raw_data,
|
||||||
|
name,
|
||||||
|
base_model=base_model,
|
||||||
|
extensions={".safetensors"},
|
||||||
|
)
|
||||||
|
|
||||||
def register_model(self, name: str, info: Dict[str, Any]) -> None:
|
def register_model(self, name: str, info: Dict[str, Any]) -> None:
|
||||||
self._models_by_name[name] = info
|
self._models_by_name[name] = info
|
||||||
hash_value = (info.get("sha256") or "").lower()
|
hash_value = (info.get("sha256") or "").lower()
|
||||||
@@ -107,6 +131,39 @@ def recipe_scanner(tmp_path: Path, monkeypatch):
|
|||||||
settings_manager_module.reset_settings_manager()
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_model(recipe_scanner):
|
||||||
|
scanner, stub = recipe_scanner
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
"file_name": "style.safetensors",
|
||||||
|
"folder": "sd15",
|
||||||
|
"file_path": "/models/loras/sd15/style.safetensors",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"base_model": "SD 1.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "style.safetensors",
|
||||||
|
"folder": "sdxl",
|
||||||
|
"file_path": "/models/loras/sdxl/style.safetensors",
|
||||||
|
"sha256": "b" * 64,
|
||||||
|
"base_model": "SDXL 1.0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
stub._cache.raw_data = models
|
||||||
|
stub._hash_meta["b" * 64] = {"path": models[1]["file_path"]}
|
||||||
|
|
||||||
|
assert await scanner.get_local_lora("style") is None
|
||||||
|
assert await scanner.get_local_lora("style", "SDXL 1.0") is models[1]
|
||||||
|
assert await scanner.get_local_lora("sdxl/style.safetensors", "SDXL 1.0") is models[1]
|
||||||
|
# The lora scanner only indexes .safetensors, so a .pt name must not be
|
||||||
|
# stripped into a cross-extension match.
|
||||||
|
assert await scanner.get_local_lora("sdxl/style.pt", "SDXL 1.0") is None
|
||||||
|
assert await scanner.get_local_lora("sdxl/style.safetensors", "SD 1.5") is None
|
||||||
|
assert await scanner.get_local_lora("other/style.safetensors") is None
|
||||||
|
assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]
|
||||||
|
|
||||||
|
|
||||||
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
|
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
|
||||||
RecipeScanner._instance = None
|
RecipeScanner._instance = None
|
||||||
settings_manager_module.reset_settings_manager()
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from py.services.recipes.errors import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from py.services.recipes.persistence_service import RecipePersistenceService
|
from py.services.recipes.persistence_service import RecipePersistenceService
|
||||||
|
from py.services.model_scanner import ModelScanner
|
||||||
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
from py.utils.exif_utils import ExifUtils
|
from py.utils.exif_utils import ExifUtils
|
||||||
|
|
||||||
@@ -1156,3 +1157,72 @@ async def test_analyze_local_image_fingerprint_uses_sha256_normalized_hash(tmp_p
|
|||||||
|
|
||||||
assert result.payload["loras"][0]["hash"] == sha256
|
assert result.payload["loras"][0]["hash"] == sha256
|
||||||
assert result.payload["fingerprint"] == f"{sha256}:1.0"
|
assert result.payload["fingerprint"] == f"{sha256}:1.0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp_path):
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=DummyExifUtils(),
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
"file_name": "style.safetensors",
|
||||||
|
"folder": "sd15",
|
||||||
|
"file_path": "/models/loras/sd15/style.safetensors",
|
||||||
|
"base_model": "SD 1.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "style.safetensors",
|
||||||
|
"folder": "sdxl",
|
||||||
|
"file_path": "/models/loras/sdxl/style.safetensors",
|
||||||
|
"base_model": "SDXL 1.0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, recipe_path):
|
||||||
|
self._recipe_path = recipe_path
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, recipe_id):
|
||||||
|
return str(self._recipe_path)
|
||||||
|
|
||||||
|
async def get_local_lora(self, name, base_model=None):
|
||||||
|
matches = ModelScanner.find_matching_models(models, name, base_model=base_model)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
async def find_local_loras_by_name(self, name, base_model=None):
|
||||||
|
return ModelScanner.find_matching_models(models, name, base_model=base_model)
|
||||||
|
|
||||||
|
def write_recipe(base_model):
|
||||||
|
recipe_path = tmp_path / "recipe.json"
|
||||||
|
recipe_path.write_text(
|
||||||
|
json.dumps({"id": "r1", "base_model": base_model, "loras": []})
|
||||||
|
)
|
||||||
|
return DummyScanner(recipe_path)
|
||||||
|
|
||||||
|
# Ambiguous bare name: two candidates survive (recipe base model unknown)
|
||||||
|
scanner = write_recipe("")
|
||||||
|
with pytest.raises(RecipeValidationError, match="include the folder path"):
|
||||||
|
await service.reconnect_lora(
|
||||||
|
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="style"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Confident base-model mismatch: the only candidate belongs to another family
|
||||||
|
scanner = write_recipe("SD 1.5")
|
||||||
|
with pytest.raises(RecipeValidationError, match="different base model"):
|
||||||
|
await service.reconnect_lora(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
recipe_id="r1",
|
||||||
|
lora_index=0,
|
||||||
|
target_name="sdxl/style",
|
||||||
|
)
|
||||||
|
|
||||||
|
# No candidate at all
|
||||||
|
scanner = write_recipe("SDXL 1.0")
|
||||||
|
with pytest.raises(RecipeNotFoundError, match="not found"):
|
||||||
|
await service.reconnect_lora(
|
||||||
|
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
const NODE_CONFIGS = {
|
||||||
|
"Checkpoint Loader (LoraManager)": {
|
||||||
|
modelWidget: "ckpt_name",
|
||||||
|
subType: "checkpoint",
|
||||||
|
},
|
||||||
|
"Unet Loader (LoraManager)": {
|
||||||
|
modelWidget: "unet_name",
|
||||||
|
subType: "diffusion_model",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const poolCache = new Map();
|
||||||
|
|
||||||
|
async function fetchPool(subType) {
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/api/lm/checkpoints/loader-pool?sub_type=${encodeURIComponent(subType)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data.items) ? data.items : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("LoRA Manager: failed to fetch loader pool", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPoolCache() {
|
||||||
|
const subTypes = new Set(Object.values(NODE_CONFIGS).map((c) => c.subType));
|
||||||
|
await Promise.all(
|
||||||
|
[...subTypes].map(async (subType) => {
|
||||||
|
poolCache.set(subType, await fetchPool(subType));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBaseModelFilter(node, config) {
|
||||||
|
const modelWidget = node.widgets?.find(
|
||||||
|
(widget) => widget.name === config.modelWidget
|
||||||
|
);
|
||||||
|
const baseModelWidget = node.widgets?.find(
|
||||||
|
(widget) => widget.name === "base_model"
|
||||||
|
);
|
||||||
|
if (!modelWidget || !baseModelWidget) return;
|
||||||
|
|
||||||
|
const wired = node.inputs?.some(
|
||||||
|
(input) =>
|
||||||
|
input.widget?.name === config.modelWidget && input.link != null
|
||||||
|
);
|
||||||
|
if (wired) return;
|
||||||
|
|
||||||
|
const pool = poolCache.get(config.subType) ?? [];
|
||||||
|
const filter = baseModelWidget.value;
|
||||||
|
const filtered =
|
||||||
|
filter === "Any"
|
||||||
|
? pool
|
||||||
|
: pool.filter((model) => model.base_model === filter);
|
||||||
|
const names = filtered.map((model) => model.name);
|
||||||
|
|
||||||
|
modelWidget.options.values = names;
|
||||||
|
if (!names.includes(modelWidget.value)) {
|
||||||
|
modelWidget.value = names[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToAllNodes() {
|
||||||
|
app.graph?.nodes?.forEach((node) => {
|
||||||
|
const config = NODE_CONFIGS[node.comfyClass];
|
||||||
|
if (config) applyBaseModelFilter(node, config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGraphConfigureHook(graph) {
|
||||||
|
if (!graph || graph.__loraManagerConfigureHooked) return;
|
||||||
|
graph.__loraManagerConfigureHooked = true;
|
||||||
|
|
||||||
|
const originalConfigure = graph.onConfigure;
|
||||||
|
graph.onConfigure = function (data) {
|
||||||
|
const result = originalConfigure?.call(this, data);
|
||||||
|
// Workflow reload restores widget values after onNodeCreated fires, so the
|
||||||
|
// per-node hook runs too early; re-apply the filter once the whole graph
|
||||||
|
// has been configured.
|
||||||
|
setTimeout(() => applyToAllNodes(), 0);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.RandomLoaderControl",
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
await refreshPoolCache();
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
const config = NODE_CONFIGS[nodeType.comfyClass];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
|
|
||||||
|
const baseModelWidget = this.widgets?.find(
|
||||||
|
(widget) => widget.name === "base_model"
|
||||||
|
);
|
||||||
|
if (baseModelWidget) {
|
||||||
|
const originalCallback = baseModelWidget.callback;
|
||||||
|
baseModelWidget.callback = (value, canvas, node, pos, event) => {
|
||||||
|
applyBaseModelFilter(node ?? this, config);
|
||||||
|
return originalCallback?.call(this, value, canvas, node, pos, event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBaseModelFilter(this, config);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// onNodeCreated fires inside LGraph.createNode, before the node is added to
|
||||||
|
// a graph (this.graph is null there), so the graph-level configure hook
|
||||||
|
// must be installed from onAdded, where the graph reference is available.
|
||||||
|
const onAdded = nodeType.prototype.onAdded;
|
||||||
|
nodeType.prototype.onAdded = function () {
|
||||||
|
const result = onAdded?.apply(this, arguments);
|
||||||
|
ensureGraphConfigureHook(this.graph);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshComboInNodes() {
|
||||||
|
await refreshPoolCache();
|
||||||
|
applyToAllNodes();
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user