Compare commits

..
82 Commits
Author SHA1 Message Date
pixelpaws f94c6b7497 Merge pull request #1135 from willmiao/feat/openmodeldb-support
feat(metadata): add OpenModelDB support for upscalers
2026-10-03 21:50:17 +08:00
Will Miao cf3bff9922 Merge remote-tracking branch 'origin/main' into feat/openmodeldb-support
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-03 21:48:37 +08:00
Will Miao 43ef1e86f6 feat(i18n): translate OpenModelDB settings keys in all 9 locales 2026-10-03 21:19:24 +08:00
Will Miao 35f1ced41a feat(metadata): add OpenModelDB metadata provider and model source for upscalers
Add OpenModelDB (openmodeldb.info) as a metadata and download source for
the existing upscaler model type.

Metadata:
- New OpenModelDBClient: fetches the site's bulk JSON dumps, caches them
  on disk (24h TTL + ETag revalidation), and builds a local sha256 index
- New OpenModelDBModelMetadataProvider adapts catalogue entries to the
  CivitAI-shaped version dict contract; registered in the fallback chain
  behind the enable_openmodeldb_api setting (default on), gated to the
  upscaler sub-type so other model types never trigger the dump download
- Persisted provenance uses metadata_source "openmodeldb" plus a nested
  openmodeldb block (page URL, architecture, scale, license)

Images: paired-image LR/SR URLs are ephemeral imgdiff.net sessions, so
displayable images come from the site-hosted auto-generated thumbnails
(model-level cover leads images[], per-image thumbs for the rest); the
original comparison URL is kept in meta.comparisonUrl.

Downloads:
- New OpenModelDBSource (flat model ids, omdb: group prefix) with
  resource filename derivation that recovers names hidden mid-path
  (mediafire) or synthesizes {id}.{type} for folder links
- HTML-gateway mirrors (mediafire/mega/drive) are rejected with a clear
  manual-download hint instead of silently saving an HTML page as .pth
- ModelSource base gains is_valid_source_id / default_subdir_parts /
  resolve_download_url hooks so flat-id sources need no platform branches

UI: "View on OpenModelDB" link in the model modal (downloaded and
hash-enriched models), settings toggle next to the CivArchive one.
2026-10-03 21:13:07 +08:00
pixelpaws b36f2099ee Merge pull request #1134 from willmiao/perf/bulk-rename-apply
perf(rename): make bulk filename-template apply O(n) instead of O(n²)
2026-10-03 15:39:58 +08:00
Will Miao 896ce5eddb perf(rename): make bulk filename-template apply O(n) instead of O(n^2)
Applying a filename template to a large library re-did O(library) work for
every renamed file: a full natsort resort plus whole-table SQLite rewrite and
download-history resync after each rename, and a full scan plus resort of the
entire recipe collection per renamed LoRA. On a 20k-model library with 300k
recipes on a HDD this pushed "Apply to Library" into multi-day runs.

- ModelScanner.defer_cache_persist(): bulk loops update the in-memory entry
  and indexes only; resort + persist + download-history sync run once at
  context exit, forced even on cancellation/error since files are already
  renamed on disk. Single-rename callers keep immediate per-call behavior.
- RecipeScanner.build_lora_hash_index(): one-shot hash -> recipes index so
  per-file lookups are O(1); update_lora_filename_by_hash gains hash_index /
  defer_maintenance params, with a single finalize_bulk_filename_updates()
  resort at the end of a bulk session.
- ModelLifecycleService.bulk_rename_session() / BulkRenameContext wire the
  deferred path through rename_model (hash index built lazily on first
  recipe-touching rename).
- Blocking os.rename sequence offloaded via asyncio.to_thread so one file's
  HDD I/O no longer stalls the event loop (no cross-file parallelism).
- Skip logic, per-batch WebSocket progress, cancellation, and result
  counters unchanged.
2026-10-03 14:31:10 +08:00
Will Miao f8aba393fe feat(models): show Civitai model/version ids in model modal, always-on hash/id search
- Model modal hash footnote now shows Civitai model id and version id
  (right-aligned, quick-copy buttons); hidden for non-Civitai models
- Hash/id exact search (sha256/autov2/autov3/civitai ids) is now always
  on: the search-options "hash" toggle is removed and the search_hash
  query param is silently ignored for API compatibility
- Footnote render condition relaxed so autov3-only and id-only models
  still show the line
- i18n: 4 new keys translated in all 9 locales; filters.hash key removed
2026-10-03 09:46:46 +08:00
Will Miao 515469054c feat(i18n): translate routing-override keys in all 9 locales 2026-10-02 21:50:19 +08:00
Will Miao a5bd3845da feat(downloads): allow manual checkpoint/diffusion root override in download modal 2026-10-02 21:40:06 +08:00
Will Miao 034660d8c4 feat(downloads): return structured 429 rate-limit responses with retry_after
On a CivitAI/CivArchive 429, download-model and download-model-get now
return HTTP 429 with {"reason": "rate_limited", "retry_after": N}
instead of a generic 500 string, and the queue row goes back to
"queued" rather than history as failed — so queue drivers can
auto-pause and retry later instead of burning through the queue.

- new DownloadRateLimitError carrying retry_after/host (opt-in via
  raise_on_rate_limit on Downloader; other call sites keep the legacy
  string behavior)
- fail-fast pre-flight gate in DownloadManager consults
  RateLimitCoordinator before acquiring the semaphore slot: hosts in
  cooldown get an immediate structured 429, no HTTP request attempted
- best-effort 429 detection for the aria2 backend
2026-10-02 21:16:06 +08:00
pixelpaws 2a667df98c Merge pull request #1132 from willmiao/fix/diffusion-routing-default
fix(downloads): route unknown checkpoint baseModels to diffusion models by default
2026-10-02 17:09:56 +08:00
Will Miao 693e7e8dca feat(i18n): translate unknown-base-model routing keys in all 9 locales
Fills the 4 settings.unknownBaseModelRouting.* placeholders plus the
leftover doctor.issues.sidecar_mirror_orphans.title placeholder per the
feature owner's request, following docs/i18n-translation-guidelines.md:
option labels reuse each locale's checkpoints.modelTypes renderings,
base model follows the §5 matrix, and punctuation/register match each
file's conventions. Adds the feature's Status note and a §2 term-map
subsection for 'routing'.
2026-10-02 10:58:54 +08:00
Will Miao 4ece4b42ff refactor(settings): move unknown-base-model routing to Library > Folder Settings
The setting decides which library an unknown-model download lands in, so
it belongs next to the default-root selects rather than General >
Downloads. Element id, settings key and i18n keys are unchanged, and
loadSettingsToUI() populates it by getElementById on every modal open,
so no JS changes are needed.

Also drop "(recommended)" from the diffusion-models option label; the
default is already conveyed by pre-selection.
2026-10-02 10:47:17 +08:00
Will Miao 7b2a108596 fix(downloads): close routing gaps and map CivitAI ModelType.UNet to the checkpoint branch
Cross-checked both baseModel lists against CivitAI's official
baseModelRecords (packages/civitai-shared src/basemodel.constants.ts):

- CHECKPOINT_BASE_MODELS gains SD 2.0/2.1 768, SD 2.1 Unclip, SDXL 0.9 /
  1.0 LCM / Turbo / Distilled, Playground v2 and Stable Cascade
  (unCLIP-style but CheckpointLoader-loaded).
- DIFFUSION_MODEL_BASE_MODELS gains SVD XT, LTXV 2.5, Flux 3 Video,
  Wan Image 2.7, Wan Video 2.7 / 3.0, HiDream-O1, Boogu and the Ming
  Image Design families. API-only (Kling/Sora/Veo/Imagen...), 3D and
  audio baseModels are intentionally skipped.
- Pony V7 exclusion now backed by live-API evidence (model 1901521 is
  AuraFlow-architecture shipping .gguf variants).

CivitAI has no model-level diffusion ModelType: DiT models are uploaded
as "Checkpoint" or "UNet", with only uploader-chosen file types to tell
them apart. model.type "unet" previously fell through type derivation
and failed with 'not supported for download'; it now goes through the
checkpoint branch in both the download manager and the download routing
endpoint, so the standard chain (file type -> baseModel lists -> unknown
default) applies.
2026-10-02 10:34:59 +08:00
Will Miao 3aa32120df fix(downloads): route unknown checkpoint baseModels to diffusion models by default
CivitAI labels new DiT architectures (MiniMax H3, future Flux/Wan/Qwen
variants) as model.type "Checkpoint" with plain "Model" file entries,
so the DIFFUSION_MODEL_BASE_MODELS allowlist could never keep up and
such downloads were mis-routed to the checkpoint roots (e.g. model
2877206 / version 3374439). The set of true full-checkpoint families is
closed, so the baseModel fallback is inverted:

1. file type UNet/Diffusion Model -> unet (unchanged)
2. baseModel in DIFFUSION_MODEL_BASE_MODELS (now incl. MiniMax H3) -> unet
3. baseModel in new CHECKPOINT_BASE_MODELS (SD 1.x/2.x/3.x, SDXL, Pony,
   Illustrious, NoobAI) -> checkpoint
4. unknown/empty baseModel -> new unknown_base_model_routing setting,
   defaulting to diffusion models

The setting is exposed under Settings > Downloads, validated in
SettingsManager, and threaded into both the download manager and the
download routing endpoint so they keep agreeing.
2026-10-02 09:58:01 +08:00
Will Miao 2193ec8f38 fix(example-images): support importing for Other models and pending hashes
Two gaps kept 'import example images' from working on the Other page:

- import_images/delete_custom_image/set_example_image_nsfw_level only
  searched the lora/checkpoint/embedding scanners, so Other-category
  models were never found ('Model with hash ... not found in cache').
  All three now go through a shared scanner list that includes the
  Other scanner.
- Other models (and fresh checkpoints) carry hash_status=pending with
  an empty sha256, so the frontend sent an empty model_hash and the
  import was rejected with 'Missing model_hash parameter'. The modal
  now also sends the model's file path, and the import use case
  resolves the hash on demand via the scanner's lazy-hash calculation.
  The resolved hash is returned to the UI and persisted on the
  showcase element so follow-up operations target the same
  hash-keyed folder.
2026-10-01 10:16:34 +08:00
Will Miao eeb9270827 fix(scanner): make move_model resilient to missing source files (#1126)
Concurrent or repeated move requests for the same model raced each other:
the first move succeeded, the rest failed with FileNotFoundError, leaving
the model card pointing at stale/empty paths.

- Serialize moves per source file with an asyncio.Lock keyed on the
  normalized source path
- When the source file is already gone, reconcile instead of failing:
  locate the model via the hash index or the expected target paths, repair
  the metadata sidecar and cache entry, and reuse the stale cache entry
  when no sidecar exists at the new location
- Avoid duplicate cache entries when the cache already tracks the moved
  file; only drop the stale source entry
- Move via business paths (abspath) instead of realpath, matching every
  other file mutation and the containment check; realpath stays reserved
  for scanner dedup per project convention
2026-10-01 08:55:47 +08:00
Will Miao 42fa8294df fix(widgets): hide DOM widgets from Properties Panel to avoid WidgetLegacy width pollution
The frontend's right-side Properties Panel falls back to WidgetLegacy for
unregistered widget types, and WidgetLegacy.draw() writes widget.width
(≈panel width) onto the real widget object. The canvas DOM overlay honors
widget.width ?? node.width, so clicking a node with the panel open squashes
the widget content to the left until undo/recreate (ComfyUI_frontend #11574).

Setting hideInPanel: true on all addDOMWidget options keeps LM widgets out
of the panel entirely. Older frontends without the option ignore it safely.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behavior change: the default 'alongside' storage mode resolves every
path exactly as before. .civitai.info (third-party sidecar) derivation is
intentionally left co-located.
2026-09-26 09:02:37 +08:00
willmiao 20d8c22390 docs: auto-update supporters list in README 2026-09-26 00:53:58 +00:00
Will Miao 3555ddb588 chore(release): bump version to v1.2.4 2026-09-26 08:53:42 +08:00
Will Miao ede15032ce fix: expose source_model_id/source_version_id in model list payloads
format_response whitelists fields explicitly, so the new ModelScope
identity fields never reached the frontend: group badges rendered, but
card.dataset.modelId stayed empty and clicking 'N versions' silently
no-oped (handleViewLocalVersionsFromCard early-returns without it).
Found via browser E2E against a sandboxed standalone server.
2026-09-26 07:35:36 +08:00
Will Miao c48feeddb6 fix: stop grouping HF/ModelScope models by repository
A repository is not a model identity: collection repos on Hugging Face
and ModelScope host many unrelated models, which were wrongly shown as
versions of each other.

- Hugging Face models no longer auto-group (the Hub exposes no
  site-native model id)
- ModelScope models group by the site's native published-model id
  (MuseInfo modelVersion.modelId), extracted during enrichment and
  persisted on the sidecar as source_model_id/source_version_id;
  unenriched models stay standalone instead of collapsing a whole repo
  into one group
- TensorArt grouping unchanged (its URL id is already model-level)
- Frontend group-key derivation mirrors the new backend semantics
2026-09-25 23:29:30 +08:00
Will Miao 8a80f82d93 docs: clarify .civitai.info is a read-only third-party file, not an LM sidecar 2026-09-25 22:40:32 +08:00
Will Miao c4676183b5 i18n: translate huggingfaceApiKey settings strings in all locales
Fill the six settings.huggingfaceApiKey* placeholders left by the HF
access-token feature in all 9 locales, reusing each locale's existing
civitaiApiKey* status renderings; document the new terminology
(access token, gated repository) in the translation guidelines.
2026-09-25 18:46:31 +08:00
Will Miao 8b7ba59263 feat: support gated/private Hugging Face repos via access token
Add a huggingface_api_key setting (Settings UI, HF_TOKEN /
HUGGING_FACE_HUB_TOKEN env override) and attach it as a Bearer token
to Hugging Face file listing, model card fetching and downloads, so
gated and private repositories can be downloaded once the user has
accepted the repo terms.

- fetch_json/fetch_text accept custom headers; ModelSource gains an
  auth_headers() hook so handlers stay platform-agnostic
- 401/403 from the tree API now explain how to fix (configure token /
  accept gated terms)
- aria2 pre-resolves huggingface.co redirects and strips credentials
  before handing the signed CDN URL to aria2, mirroring the CivitAI
  handling so the token never leaks to the CDN
- settings API exposes huggingface_api_key_set only; the raw key joins
  _NO_SYNC_KEYS
2026-09-25 18:44:06 +08:00
Will Miao 067e605e75 fix: keep bypass/mute state on frontend 1.53+ node shell state (#1123)
ComfyUI frontend 1.53 turned LGraphNode.mode into a prototype accessor
backed by node._state, and serialize() now reads that state directly.
Redefining mode on the instance shadowed the setter, so bypass/mute
never reached the serialized workflow and silently reverted to Always
on save/reload or workflow tab switch.

Add interceptModeChange() in web/comfyui/utils.js: it delegates to the
prototype accessor when one exists (observing changes only), and falls
back to the legacy closure accessor on older frontends. Use it in
lora_loader.js and in the Vue widgets' setupModeChangeHandler, which
covers the LoRA provider/aggregator nodes with the same latent bug.
2026-09-25 18:12:01 +08:00
Will Miao dae18b3d1d fix: re-run dynamic prompts fed through linked text inputs
IS_CHANGED only receives constant inputs, so a linked text always
arrived as None and the node kept serving its cached first expansion.
Declare hidden PROMPT/UNIQUE_ID inputs and walk the prompt graph to
the upstream node: rerun only when its constants contain dynamic
syntax or cannot be statically resolved, keep caching for static
linked text.

Fixes #1120
2026-09-24 09:33:27 +08:00
Will Miao 2f9bd3ee7d feat: support reverse-proxy URL subpaths (llama-swap, SwarmUI) (#1122) 2026-09-24 08:04:00 +08:00
Will Miao 755e1a5bca fix: fall back to source image dimensions for missing width/height
When metadata extraction succeeds but no recognized latent source
provides dimensions (e.g. img2img via VAEEncode), width/height now fall
back to the source image size from the loaded pixels instead of the
synthetic 1024x1024 starter preset. The starter preset for metadata-free
images keeps its fixed size, and explicit overrides still win.
2026-09-23 20:31:59 +08:00
pixelpaws c202654d49 Merge pull request #1121 from mmartial/loader
Add Load Image Metadata node for reusing generation settings
2026-09-23 20:31:44 +08:00
Will Miao 74736f7560 fix(organize): exclude Civitai meta tags from folder names (#1119)
Follow-up to the keyword-dump guard. The reported model's tag list is
["lora, character, ... face", "base model"], so skipping the dump left the
"base model" label to be picked as the folder name. That label describes
Civitai's listing rather than the model's content, which makes it as
meaningless as a folder as the blob was.

Add CIVITAI_META_TAGS and is_civitai_meta_tag(), and skip those labels in
the automatic fallback. An explicit priority entry still matches them, so a
user who does want a "base model" folder can configure one.

The reported model now resolves to "Krea 2/no tags" instead of
"Krea 2/base model".

Also correct a comment that listed ".civitai.info" among the files sitting
next to a model. LoRA Manager only reads that sidecar -- other tools write
it -- and writes ".metadata.json" itself.
2026-09-23 13:33:59 +08:00
Will Miao 0ada32d0c7 fix(organize): stop keyword-dump tags from becoming folder names (#1119)
CivitAI tags are normally short single-concept labels, but some uploaders
pack their entire keyword list into one tag. The model in #1119 carries
"lora, character, rosie, irish, ... face" as a single 181-character tag.
Priority resolution matches aliases by exact equality, so that tag matched
nothing and resolve_priority_tag_for_model fell back to tags[0] -- the blob.
With the default "{base_model}/{first_tag}" template the model was filed
under "Krea 2/<181-character blob>/", and the full path plus the
".civitai.info" sidecar and the preview images next to it ran into the
Windows MAX_PATH limit.

Tags also bypassed sanitization on the way into a path: both
calculate_relative_path_for_model and DownloadManager._calculate_relative_path
sanitized model_name and version_name but interpolated {first_tag} verbatim,
so a tag containing "/" or ":" silently produced nested or illegal folders.

Two changes:

- The fallback skips tags that cannot serve as a folder name.
  is_usable_path_tag rejects comma-separated keyword dumps and tags longer
  than MAX_PATH_TAG_LENGTH; the resolver returns "" when nothing usable is
  left, which callers already render as "no tags". Whole-tag priority
  matching is untouched, so existing priority configurations behave the
  same.
- sanitize_folder_name gains an optional max_length, and every tag-derived
  segment now goes through it. Tags are capped at MAX_PATH_TAG_LENGTH, model
  and version names at MAX_FOLDER_NAME_LENGTH, and rendered filename stems at
  MAX_FILENAME_STEM_LENGTH.

For the reported model the folder becomes "Krea 2/base model" instead of the
blob, and the full path drops from 235 to 64 characters.

Existing libraries are not migrated up front: a path is only recomputed on
download, on an auto-organize run or when a filename template is applied, and
values already inside the caps are left byte-identical. Models previously
filed under a keyword-dump folder move on the next auto-organize run.
2026-09-23 13:16:26 +08:00
Martial Michel e9aff35957 feat: add image metadata loader with native LoRA Manager integration
Add Load Image Metadata (LoraManager) to extract reusable prompts,
model references, LoRA stacks, and sampling settings from images.

Prefer saved A1111-style parameters by default, with optional workflow
and subgraph sampler selection. Resolve local model and LoRA names,
report missing resources, and recover extraction failures with explicit
defaults and readable diagnostics.

Include parser, resource-resolution, and node regression tests, plus
usage documentation.
2026-09-22 22:18:03 -04:00
Will Miao 521531111a i18n: translate the Filename Templates feature into all locales
26 keys (settings.filenameTemplates.*, filenameTemplateProgress,
modals.filenameTemplateConfirm, related toasts) translated into the 9
non-English locales, reusing each locale's autoOrganizeProgress /
downloadPathTemplates renderings. Terminology recorded in
docs/i18n-translation-guidelines.md.
2026-09-19 11:00:39 +08:00
Will Miao 474da1b264 feat(settings): empty filename template reverts to recorded original filename (#1071)
Redefine the empty download filename template from a no-op to a bulk
revert: FilenameTemplateUseCase resolves the target from each model's
recorded original_file_name sidecar entry (skipping models without one),
which resolves follow-ups 1 and 2 with a single coherent semantic shared
by the download and bulk-apply paths.

Also replace the browser-native confirm() with a self-managed
confirmation modal (filenameTemplateConfirmModal) that stacks above the
settings modal, since ModalManager would close the settings modal when
opening a registered one.
2026-09-19 10:44:43 +08:00
Will Miao 78d38b449e docs: record filename template follow-ups for #1071 2026-09-19 09:05:39 +08:00
Will Miao 2bc9860b24 feat(settings): filename templates for download and bulk rename (#1071)
Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).

- apply template automatically after downloads; rename conflicts keep
  the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
  WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
  preview, and per-type 'apply to library' actions
2026-09-19 09:04:24 +08:00
Will Miao 327da0465b feat(settings): split overloaded Library tab into a new Organization tab
Move download path templates, priority tags, and auto-organize
exclusions out of the Library settings section into a dedicated
Organization section, so Library keeps location-focused settings
(roots, extra paths, example images, metadata) and Organization holds
file-arrangement rules. Translated settings.nav.organization for all
locales.
2026-09-19 07:38:07 +08:00
Will Miao c8c84bfc54 feat(loras): warn when widget strength leaves the usage-tips range
The cycler-list payload now carries usage_tips, and the LORAS widget
parses strength_min/strength_max/strength_range into a cached lookup.
Strength inputs (model and clip) turn amber with an explanatory tooltip
when dragged, typed, or stepped outside the recommended range.

Related: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1090
2026-09-19 05:49:01 +08:00
Will Miao 3b9e8efb3d feat(banners): rotate active banners one at a time with a pager
Stacking every active banner vertically ate header height when several
were active at once. Only the highest-priority banner renders now; a
‹ 1/N › pager cycles through the rest, and all active banners are still
recorded in the notification-center history so cycled-away ones stay
reachable. Newly registered banners preempt the displayed one only when
they outrank it.

Also fix the startup flow: the restart-required banner (now priority 80)
outranks the model-folders setup warning (60), and the setup banner is
retired once a non-empty folder path is saved.

New banners.pager.* keys translated in all 9 locales.
2026-09-18 21:40:33 +08:00
Will Miao d45a523fb5 feat(settings): directory picker and live validation for path settings
Add a reusable directory-picker modal backed by a new generic
POST /api/lm/browse-directory endpoint (browse logic extracted from the
recipe batch-import handler into py/utils/directory_browser.py) and wire
a browse button plus advisory validate-path feedback (POST
/api/lm/validate-path) into the settings path inputs: recipes path,
example images path/local root, and the extra-folder/model-path rows.

The browse button insets into the right edge of static inputs so narrow
settings rows keep their single-control layout.

Translations for the new settings.directoryPicker and
settings.pathValidation keys are filled in for all 9 locales.
2026-09-18 21:05:32 +08:00
Will Miao 6dc9f34f7d i18n: translate the Model Paths settings section into all locales 2026-09-18 19:43:46 +08:00
Will Miao 5adfa3be36 feat(settings): editable model library paths for standalone mode
Standalone users previously had to hand-edit settings.json to configure
primary folder_paths. Add a standalone-only Model Paths section to the
settings modal:

- Backend exposes standalone_mode, folder_paths (with template placeholder
  values filtered out) and a data-driven folder_path_schema derived from
  OTHER_MODEL_FOLDER_SUBTYPES via GET /api/lm/settings
- The new section renders multi-path editors per model type from the
  schema, with inline enable_other_models / sub-type controls so other
  model types are configured without leaving the tab
- Persistent restart-required cues after a save: nav dot, inline notice
  and a global banner (unique id per change so dismissals don't mute
  future reminders)
- The missing-model-paths startup banner and the Other Models no-paths
  empty state now deep-link into the new section instead of pointing at
  settings.json
2026-09-18 19:36:19 +08:00
Will Miao d4b82d98b2 test(recipes): pin the manual rebuild as the escape hatch from a skipped prune
The prune guard intentionally leaves the in-memory view empty while the
stored cache keeps the user's recipes, so there has to be a documented
way to accept the on-disk truth. That route is an explicit rebuild, which
clears the stored cache before a full directory scan. Cover it so the
FAQ recovery steps stay true.
2026-09-18 00:09:34 +08:00
Will Miao 8c1c1691e3 feat(settings): add an explicit opt-out from persisted portable mode
Setting LORA_MANAGER_PORTABLE=1 once wrote use_portable_settings: true
into the plugin's own settings.json, and every later run of every
instance sharing that plugin folder then read and wrote the portable
settings directory. There was no way back except editing the file by
hand, which is exactly the trap a user hit while following the FAQ's
instructions for isolating a second instance (#1114).

LORA_MANAGER_PORTABLE=0 is now the explicit exit:

- _should_use_portable_settings honours "0" as a forced off, so the
  resolved settings directory no longer depends on the persisted flag.
- SettingsManager clears the persisted flag in that case, so later runs
  without the variable stay on the shared settings directory.

Unset or unrecognised values keep the previous behaviour: the persisted
flag decides, so existing portable installs are unaffected.
LORA_MANAGER_SETTINGS_DIR still takes precedence over both.
2026-09-18 00:05:47 +08:00
Will Miao e14a084f0d fix(cache): make shared cache state survive a second instance
Installing a second LoRA Manager instance (standalone or a second
ComfyUI install) that shares the settings directory puts two processes
on the same cache databases. Three things made that unsafe.

- The updater preserved cache/ and model_cache/ but not a legacy
  recipe_cache/ directory, so a portable install predating the cache/
  move lost its recipe database on a git-based update. Add it to
  _PRESERVE_DIRS and to .gitignore.
- Cache connections used the sqlite3 default 5s timeout, which a
  scanning instance can exceed, turning a concurrent write into
  "database is locked". Route every shared cache connection through
  connect_cache_db(), which raises the timeout to 30s and sets
  busy_timeout + synchronous=NORMAL to match the existing WAL mode.
  App-private databases (download queue, update history) are unchanged.
- A full-table cache replace is a read-modify-write that SQLite cannot
  make atomic across processes, so two instances could interleave and
  one snapshot could overwrite the other. Guard the recipe and model
  save_cache paths with a cross-process advisory lock (flock on POSIX,
  msvcrt on Windows). Locking is best-effort: if it is unavailable the
  call proceeds and the SQLite busy timeout is the fallback.

The lock file is a hidden sibling of the database and is deliberately
never unlinked, so a second process cannot lock a fresh inode.
2026-09-17 23:59:22 +08:00
Will Miao c55c6f0a41 fix(recipes): stop an all-missing scan from wiping the recipe cache
A scan that finds no recipe files at all is not a reliable deletion
signal: an unmounted drive, a recipes_path that silently falls back to
another LoRA root, or a cache shared with a second instance all look
exactly like a real wipe. The reconcile step treated them all as
deletions and overwrote the persistent cache with an empty one, so
DELETE FROM recipes destroyed the user's only record of their recipes
and the FTS index was rebuilt from the empty view (#1116).

Guard the prune:
- _reconcile_recipe_cache reports an all-missing result when every
  persisted recipe file is gone AND the stored rows match the recorded
  file stats. An internally inconsistent cache (leftover orphans) is
  stale, not evidence of a fresh disappearance, and still prunes.
- The caller keeps the stored cache and logs a warning naming the
  directory it scanned and the number of recipes it preserved, instead
  of writing the empty result. It also skips the FTS rebuild so the
  index stays aligned with the stored rows.
- save_cache gains skip_if_empty as a storage-level backstop: refuse to
  empty a populated cache. Intentional clears (manual rebuild) keep the
  default behaviour.
- Log the resolved scan directory per run so a support reader can tell a
  real wipe apart from a scan that looked elsewhere.

Partial orphans (ordinary manual deletions) keep pruning as before.
2026-09-17 23:54:40 +08:00
Will Miao 7d963b27b5 fix(example-images): read real dimensions for imported videos, fixes #1115
Example videos added through the "Add examples" flow were stored with a
hardcoded 720x1280 entry. The dimension probe next to it only ran for
images (PIL cannot open .mp4/.webm files), so every video entry stayed
portrait regardless of the source. The showcase viewer then sizes its
container straight from that value (--media-aspect in showcase.css), so
landscape clips were letterboxed inside a 9:16 box. CivitAI-sourced
examples were unaffected because their dimensions come from the API.

PIL cannot read video containers, so add a dependency-free reader that
parses the container headers instead: moov/trak/tkhd for ISO base media
(with the sample description as a fallback), Segment/Tracks/Pixel* for
WebM/Matroska, and RIFF/WebP for animated examples saved with a video
extension. The sniffed signature decides which reader runs, so a .mp4
that is really WebM still reports the right size; the extension is only
a fallback. Both readers seek past mdat rather than reading it, so a
large file costs the same as a small one.

Imported entries now record the file's real size and keep the previous
placeholder only when the file cannot be parsed.

Existing libraries keep their wrong entries, so backfill them once via
the existing naming migration: bump CURRENT_NAMING_VERSION to 3 and
repair each model's empty-url entries from the files on disk, then sync
the scanner cache. Only entries with no remote url are touched -- those
have no other source, which makes the rewrite lossless -- and entries
already carrying the right size are left byte-identical, so the pass is
idempotent and a no-op for libraries that never imported a video.
2026-09-17 21:42:32 +08:00
Will Miao eba03800b9 feat(other): answer model-versions-status read-only for unsupported types
Civitai types with no scanner at all (Wildcards, Workflows, Hypernetwork,
Poses, AestheticGradient) used to get a 400 'Model type "x" is not
supported', which hid the Civitai version list from clients.

The handler now answers 200 with supported:false, a machine-readable
reason (model_type_unsupported, or other_models_disabled when the opt-in
master switch is off) and the versions marked read-only. The interactive
payload gains an explicit supported:true. Legacy clients only read
success/versions, so they are unaffected.
2026-09-17 20:46:42 +08:00
Will Miao bf497d5144 i18n: translate the standalone no-paths guidance into all locales 2026-09-17 10:39:45 +08:00
Will Miao 369613f811 feat(other): guide standalone users to settings.json from the no-paths empty state
The standalone empty state showed the folder_paths keys but not where to
put them, and its Open Settings button led to a modal that cannot edit
primary folder paths. Now the page shows the real settings.json path and
an Open Settings Folder button backed by the existing open-location API.

Also stop open_settings_location from claiming success on headless Linux
sessions: with no DISPLAY/WAYLAND_DISPLAY, xdg-open cannot work, so the
handler now returns clipboard mode and the browser copies/shows the path
instead.
2026-09-17 10:34:42 +08:00
Will Miao 9eeebac40b fix(e2e): resolve project root from the script's actual location
start_server.py computed the project root three levels up from scripts/,
assuming it lived under .agents/skills/<skill>/scripts/. After moving to
scripts/e2e/ that resolved to the ComfyUI root, so the launcher failed
with "can't open file 'standalone.py'".
2026-09-17 10:34:42 +08:00
Will Miao b9a516c9f8 fix(settings): restore the Other Models master toggle state on load
updateOtherModelsControls() synced the sub-type checkboxes and default-root
selects but never set the master toggle's checked state, and the
setting_toggle macro renders no checked attribute, so after a page refresh
the toggle always appeared off regardless of the saved setting.
2026-09-17 10:34:42 +08:00
Will Miao ef7fa7d3dd docs(readme): document other-model folder paths for standalone mode 2026-09-17 10:34:42 +08:00
willmiao 9c67dbbf15 docs: auto-update supporters list in README 2026-09-17 01:12:54 +00:00
Will Miao 16b0bdf70a chore(release): bump version to v1.2.3 2026-09-17 09:12:34 +08:00
Will Miao e09fe5888b refactor(reorder): drop the Alt + Arrow shortcut, keep drag only
The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is
the browser's tab-history / back-forward gesture on several platforms,
and the modal already binds bare `ArrowLeft`/`ArrowRight` to model
navigation, so the binding either did nothing — a keypress with nothing
focused never reaches a listener on the tag list — or fought the browser.
An affordance that occasionally navigates the page away is worse than
having no keyboard path at all, so drop it.

Reordering is pointer-only again: drag the chip (tags) or its `⠿` grip
(trigger words, whose chip body is click-to-edit). Everything that existed
only to serve the shortcut goes with it — the keydown listener, the hover
tracking used to resolve the target chip, the aria-live announcements, the
per-grip position labels and `moveItemWithinContainer`. The grip becomes a
decorative, non-focusable `<span>` (`aria-hidden`, behind a 5px drag
threshold) instead of a `<button>`, so it no longer promises a keyboard
action it cannot perform.

The tooltip and hint drop the shortcut mention in all 10 locales
(`common.reorder.dragHandle` = "Drag to reorder" and the localised
equivalents); `common.reorder.ariaLabel` and `common.reorder.announcement`
are pruned from every locale by the sync script. The i18n guidelines
record the decision so no shortcut is re-added without re-adding the keys.
2026-09-17 09:07:24 +08:00
Will Miao f67689b0f9 fix(css): keep full-width modal fields inside their clipped container
Two stacked defects cut the side edges off the URL textareas in the
download and batch-import modals.

`#modelUrl` and `#batchUrlInput` are `width: 100%` with padding and a
border but no `box-sizing: border-box`, so the border box was wider than
the containing block and its right edge landed in the region the modal
clips: the right border column is missing in both screenshots while the
corner pixels of the top/bottom borders are drawn, and the batch
textarea's resize handle sits a padding-width to the right of the mode
toggle above it.

The download modal's `#downloadModal .download-step` additionally
scrolls with `overflow-x: hidden` and has no horizontal padding, so the
global `:focus-visible { outline-offset: 2px }` lost both vertical edges
there and only the top and bottom lines survived. Draw that ring inset
inside `#downloadModal`, mirroring the existing `#importModal` fix in
import-modal.css.

`.input-group input, .input-group select` gets the same border-box
treatment, which also repairs the standing clipped right border on the
other full-width fields the shared rule styles (the import modal's URL,
recipe-name and tag inputs, the batch directory and tags inputs, the
model root select and the target folder path).

Verified: `npx vitest run` 130 files / 1259 tests passed.
2026-09-17 07:54:57 +08:00
Will Miao 1d6da1787a i18n: translate the download progress stage strings
Fill in the 4 `modals.download.progress.*` keys added by the previous
commit across all 9 locales, so no `[TODO: Translate]` placeholder remains
and the "no remaining placeholders" claim in the guidelines holds again.

No new terminology: `metadata` reuses the §5 row (fr métadonnées, de
Metadaten, es metadatos, ru метаданные, he מטא-נתונים, ja メタデータ,
ko 메타데이터, zh-CN 元数据, zh-TW 中繼資料) and the fetching phrasing
mirrors each locale's existing `download.fetchingRepoFiles` /
`fetchingVersions` (de passive "werden abgerufen", es "Obteniendo", fr
"Récupération des", ru "Получение", he "מביא", ja "取得中", ko "가져오는
중"). "model file" follows `errors.noModelFiles` in each file.

`{name}` and `{source}` are verbatim §1-R2 placeholders. `{source}` is
replaced at runtime with the *untranslated* platform name, so its
surrounding spacing follows each locale's `modelCard.actions.viewOnSource`
precedent — ja `{source} から`, ko `{source}에서`, zh `从 {source}` /
`從 {source}`, he `מ-{source}` (as in the existing `מ-CivitAI`), ru
`из {source}` (as in `из Workflow`) — and no brand ever appears inside the
translated text.

Punctuation: ASCII `:` for the Latin / Cyrillic / Hebrew locales and for
ja / ko, whose four sibling keys in the same `progress` block already use
ASCII; French keeps this file's ` : `; zh-CN / zh-TW use full-width `:`
like their siblings.

The guidelines gain a status block recording the pass and those spacing
precedents, so a future source added to the same slot does not have to
re-derive them.

Verified: `pytest tests/i18n/test_i18n.py` 20 passed,
`sync_translation_keys.py --dry-run` reports no drift, `npm test` exits 0
(1259 JS + 91 Vue). Each locale file gains exactly 4 lines — the values
were substituted as literals rather than re-serialising the JSON, so no
formatting churn.
2026-09-17 07:47:17 +08:00
Will Miao d572292142 feat(download): fill model metadata from the source API on download
A ModelScope or Hugging Face download landed as a bare filename, hash and
source link; the model card stayed empty until the user ran "Enrich
Metadata with AI" by hand. But everything that makes a CivitAI download
useful — the display name, the description, the tags, the trigger words,
the example images, the preview — is already published by those sites'
public APIs, so asking for it at download time is deterministic work, not
model work.

Add `py/services/model_sources/hydration.py`, called by
`_save_source_metadata()` once the sidecar exists and the file is in the
scanner cache. It fetches the model card plus the site's card extras and
hands them to the same `PostProcessor` the AI skill uses, with an empty
`llm_output`, so the two paths cannot drift apart. What lands:

* `model_name` from the site's own display name (ModelScope's `Name`), so
  the card stops showing the local filename — written only while the value
  still equals the file stem, since once a user renames a model that
  choice is theirs to keep
* `civitai.name` from the matched version's label (`showName`), which the
  card renders as the version chip
* `civitai.description` / `modelDescription` from the author summary plus
  the README as HTML
* `civitai.images` / `preview_url` from the per-file example images
* `civitai.trainedWords` from the per-file trigger words
* `base_model`, `tags` and `usage_tips` as before

Provenance stays honest: the pass records
`metadata_source = "source:<platform>"` rather than the skill's
`agent:enrich_hf_metadata`, and — because no provider ran — it no longer
stamps `llm_enriched_at`; that stamp is now conditional on the LLM
actually answering, which is what the field means. The five hand-rolled
`civitai` dict merges in the post-processor collapse into one
`_merge_civitai()` helper.

Two guards keep it safe. Only a model whose stored
`source_platform`/`source_url` match the repository being downloaded is
updated, so a local file that merely shares a name never receives another
model's card; and a file already on disk is topped up too, which
back-fills models downloaded before this existed. READMEs and detail
payloads describe the repository rather than the file, so a short-lived
process-wide `ModelSourceCache` (300 s, 32 entries) keeps a batch over one
repository to two HTTP requests. Every failure is logged and swallowed:
hydration can never fail a download.

Fix the hash policy while here. `_save_source_metadata()` went straight to
`MetadataManager.create_default_metadata()`, bypassing the per-type
factory on the owning scanner, so a checkpoint paid a full SHA256 inside
the download request — `CheckpointScanner`/`OtherScanner` deliberately
record `hash_status="pending"` with an empty `sha256` for their multi-GB
files. Metadata is now created through `scanner._create_default_metadata()`.
Hydration copes with the empty hash: `_matching_versions()` falls back to
the repository basename, which is exactly what the download just wrote.

Report both post-transfer stages, which advance no byte counter and so
read as a stall: the bar sat at 100% showing `0 B/s` for the seconds spent
hashing and fetching. `_report_phase()` broadcasts
`{"status": "metadata", "stage": "indexing" | "source", "platform": ...}`,
and `LoadingManager` names the stage in the status line (keeping the batch
position), retitles the item line, replaces the dead speed figure and runs
a sheen over the bar. `stage`/`platform` are machine-readable; the wording
is localised in the frontend.

Finally, `modelscope.ai` is its own catalogue rather than an alias of
`modelscope.cn` — `referall13/EM1` exists only on `.ai` and
`jj3550945163/Krea-2-LORA` only on `.cn` — so its URLs were rejected with
"Invalid model URL format". Register it as `ModelScopeIntlSource`
(`platform="modelscope-ai"`, `msai:` group prefix, its own default
download directory) and derive every URL either deployment builds from a
per-class `base_url`. `modelscope.com` stays an alias of `.cn`, which is
what it redirects to. The frontend source table, the link dialog hints and
the docs mirror the split.

Verified against the live APIs: both reported `.ai` repositories list
their files, read their READMEs and yield name / version / base model /
trigger words / example images. Backend 3092 passed; frontend 1259 JS +
91 Vue passed. The nine locales carry the new progress copy in the next
commit.
2026-09-17 07:47:07 +08:00
Will Miao 1b1a8d63db feat(recipes): show the recipe base model in the modal header
Adds a base model pill at the front of the recipe modal's tags row,
showing the full base model name (cards keep the abbreviation since
their overlay width is constrained). Falls back to a dimmed Unknown so
the header layout does not shift when hydration fills the value in.
Hydration now also merges base_model. Translated in all 9 locales.
2026-09-16 19:58:44 +08:00
294 changed files with 33594 additions and 2742 deletions
+4
View File
@@ -10,11 +10,15 @@ civitai/
stats/
wildcards/
backups/
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
# must survive pulls and stay out of git status
/sidecars/
logs/
node_modules/
coverage/
.coverage
model_cache/
recipe_cache/
# agent / dev tooling
.opencode/
+21
View File
@@ -192,6 +192,15 @@ The system runs in two modes:
- Auto-saves paths to `settings.json` in ComfyUI mode
- `settings.json.example` is intentionally minimal (see Important Notes); all
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:**
- `folder_paths` (primary model roots): in ComfyUI plugin mode these come
from the ComfyUI host; in standalone mode they are the ONLY source of
model library paths and are currently edited by hand in `settings.json`.
- `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible
ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large
model library slows ComfyUI itself down, while LoRA Manager handles large
libraries without performance issues — so users keep ComfyUI's library
small and add the bulk via `extra_folder_paths`.
### Frontend UI Architecture
@@ -252,6 +261,18 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md)
- **`.civitai.info` files are NOT LoRA Manager sidecars.** They are written by
third-party apps; LoRA Manager treats them as read-only and only consumes
them during migration/import. Never write, modify, or delete them, and never
propose doing so as a fix — LoRA Manager's own metadata lives in the
`.metadata.json` sidecar it owns.
- **Sidecar/preview path derivation must go through `py/utils/sidecar_paths.py`**
helpers (never inline `splitext + ".metadata.json"`): the centralized storage
mode (`sidecar_storage_mode` / `sidecar_storage_path` settings) relocates
`.metadata.json` files and preview images under a mirror tree, so any
hand-built path is wrong in that mode. `.civitai.info` stays co-located with
the model file in both modes. The new settings keys live only in
`DEFAULT_SETTINGS` — `settings.json.example` stays minimal (see below).
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
+29 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -18,6 +18,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.nodes.create_hook_lora import CreateHookLoraLM
from .py.nodes.load_image_metadata import LoadImageMetadataLM
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
from .py.metadata_collector import init as init_metadata_collector
except (
@@ -70,6 +71,7 @@ except (
MetadataOverwriteLM = importlib.import_module(
"py.nodes.metadata_overwrite"
).MetadataOverwriteLM
LoadImageMetadataLM = importlib.import_module("py.nodes.load_image_metadata").LoadImageMetadataLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -93,6 +95,7 @@ NODE_CLASS_MAPPINGS = {
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
CreateHookLoraLM.NAME: CreateHookLoraLM,
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
LoadImageMetadataLM.NAME: LoadImageMetadataLM,
}
WEB_DIRECTORY = "./web/comfyui"
+402 -380
View File
File diff suppressed because it is too large Load Diff
+56 -1
View File
@@ -71,11 +71,23 @@ Enriches models linked to an external model site with metadata extracted by an L
| Platform | Link | AI enrichment | Direct download |
| --- | --- | --- | --- |
| Hugging Face | yes | yes | yes |
| ModelScope | yes | yes | yes |
| ModelScope (`modelscope.cn`) | yes | yes | yes |
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
| TensorArt | yes | no (see below) | no |
| OpenModelDB | yes | yes (card data from the catalogue, no README) | yes |
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
repository published on one is routinely absent from the other — so each is
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
`py/services/model_sources/modelscope.py`). The host therefore decides which
API and CDN a model resolves against, and the two deployments get separate
version groups (`ms:` / `msai:`) and default download directories. Keep the two
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
OpenModelDB is the upscaler catalogue: model ids are flat tokens (no `owner/name`), there are no revisions and no README — `fetch_model_card_context()` reads everything (description, license, tags, example images) from the disk-cached bulk catalogue in `py/services/openmodeldb_client.py`. Only PyTorch resources (`.pth`/`.safetensors`) are downloadable, and only via mirrors that serve raw bytes: HTML-gateway hosts (`mediafire.com`, `mega.nz`, `drive.google.com`) are skipped in favour of a direct mirror, and a model with only gateway mirrors reports a manual-download hint instead of a file list. Filenames are derived from URL path segments (mediafire buries them mid-path) or synthesized as `{model_id}.{type}` for folder links.
**What it does**:
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
@@ -133,7 +145,9 @@ gaps the LLM leaves behind:
| Field | Deterministic source | LLM role |
| --- | --- | --- |
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
| `modelDescription` | author summary + README as HTML | — |
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
| `civitai.images` | site example images, then README images | — |
| `preview_url` | first available example image | may propose one from the README |
| `tags` | site-curated tags, always merged in | proposes additional content tags |
@@ -147,6 +161,47 @@ Models with no source, an unknown source, or a source without model-card access
**Model types**: LoRA, Checkpoint, Embedding
### Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded
from a model source, so a ModelScope or Hugging Face download lands with the
populated card a CivitAI download produces instead of a bare filename and
hash. Nothing needs to be triggered by hand and no provider is called.
`py/services/model_sources/hydration.py` owns this path:
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
creates the sidecar (hash, source link, scanner-cache entry) and then calls
`hydrate_from_source()`. It also runs for a file that was already on disk, so
models downloaded before this existed get topped up on the next attempt.
* Metadata is created through the **owning scanner**
(`scanner._create_default_metadata()`) rather than
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
applies: `CheckpointScanner` and `OtherScanner` store
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
the generic helper would read a 10 GB checkpoint end to end inside the
download request. Hydration copes with the empty hash — `_matching_versions()`
falls back to the repository basename, which the download just wrote.
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
it does not stamp `llm_enriched_at`.
* `model_name` is only written while it still equals the file stem: once a user
renames a model, that choice is kept.
* Only a model whose stored `source_platform`/`source_url` match the repository
being downloaded is updated; a local file that merely shares a name must not
receive another model's card.
* The README and repository payload describe the *repository*, so a short-lived
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
keeps a batch over one repository to two HTTP requests.
* Every failure — unreachable site, changed payload shape, broken post-processor
— is logged and swallowed. Metadata hydration can never fail a download.
* Neither stage advances the byte counter, so both are announced to the
progress UI (`_report_phase()` → `{"status": "metadata", "stage": ...}`) as
they start. Without that the bar sits at 100% reporting `0 B/s` for several
seconds and the download looks stuck. `stage` and `platform` are
machine-readable; the wording is localised in `LoadingManager`.
## Adding a New Skill
### 1. Create the skill directory
+269 -25
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`.
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
Source of truth: `locales/en.json` (10 locales, 2128 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -51,12 +51,87 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> "Folder sidebar feature".
>
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
> grip reorder affordance with `Alt + ↑/↓` keyboard support, which added the 3
> `common.reorder.*` keys. They live under `common` (not a feature namespace) because both
> editors render them; all 9 locales are translated (renderings in §2, "Chip reordering").
> `Alt` and the `↑/↓` glyphs stay Latin/verbatim in every locale, the same precedent as
> `Shift+Enter` in `modals.model.metadata.notesHint`; zh-CN / zh-TW / ja use full-width
> parentheses and ko keeps this file's ASCII style.
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
> under `common` because both editors render it). All 9 locales are translated (renderings in
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
> modal's arrow-key navigation.
> **Status (2026-09, standalone no-paths guidance):** the standalone branch of the
> `other.noPaths` empty state now shows the real `settings.json` path plus an
> `other.noPaths.openSettingsFolder` button (each locale reuses its
> `settings.openSettingsFileLocation.label` rendering), and `descriptionStandalone` was
> reworded in `en.json` — from "none of the configured folders exist on disk" to "no
> other-model folders were found; add the folder keys you need to the `folder_paths`
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
> the ComfyUI variant (`descriptionComfyUI`).
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
> placeholders" claim holds again.
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
> locales are translated (terminology in §2, "Filename Templates feature").
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
> real weight files on disk) is refused with an explanation instead of contradicting itself.
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
> "no remaining placeholders" claim holds again.
> **Status (2026-09, sidecar storage):** optional centralized storage for `.metadata.json`
> sidecars and preview images added 23 keys — `settings.sections.sidecarStorage`,
> the 18 `settings.sidecarStorage.*` labels/help/status/confirm strings, and the 4
> `modals.sidecarMigrationConfirm.*` titles/button. The pull request merged them as
> `[TODO: Translate]` copies; all 9 locales are now translated (terminology in §2,
> "Sidecar storage feature"), so no placeholder remains and the "no remaining placeholders"
> claim holds again.
> **Status (2026-09, sidecar storage UX follow-up):** the migration UX follow-up added
> 7 `settings.sidecarStorage.open*`/path-display keys, `modals.sidecarMigrationConfirm.destination`,
> and the 13-key `modals.sidecarMigrationResult.*` summary block (which replaces
> `settings.sidecarStorage.migrateSuccess` — the result modal is now the success feedback,
> mirroring `modals.metadataFetchSummary.*`/`modals.downloadBatchSummary.*` stat-card and
> failure-table conventions; reuse each locale's existing renderings of those sibling keys).
> All 9 locales are translated in the same pass (terminology in §2, "Sidecar storage feature").
> **Status (2026-10, unknown base model routing):** the download-routing inversion added
> 4 keys (`settings.unknownBaseModelRouting.label`, `.help`, `.options.diffusionModel`,
> `.options.checkpoint`) — the option labels reuse each locale's existing
> `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings. The same pass also
> translated the leftover `doctor.issues.sidecar_mirror_orphans.title` ("Centralized
> Sidecars", §2 "Sidecar storage feature" terminology). All 9 locales are translated,
> so the "no remaining placeholders" claim holds again. Terminology in §2, "Download
> routing feature".
> **Status (2026-10, routing-override follow-up):** the download modal's location step
> gained a manual "Destination type" toggle (Checkpoint | Diffusion Model) for when the
> auto routing misdetects, adding 2 keys (`modals.download.routingOverride.label`,
> `.tooltip`). The tooltip quotes each locale's `modals.download.useDefaultPath` label
> verbatim (switching turns it off for the session), using that locale's UI-label quoting
> style. All 9 locales are translated (terminology in §2, "Download routing feature"),
> so the "no remaining placeholders" claim holds again.
> **Status (2026-10, OpenModelDB):** the OpenModelDB metadata-provider toggle added 2 keys
> (`settings.metadataArchive.enableOpenmodeldbApi(Help)`); all 9 locales are translated
> (terminology in §2, "OpenModelDB feature"), so the "no remaining placeholders" claim
> holds again.
> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now
> shows the Civitai model id and version id (right-aligned, with copy buttons), adding
> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`,
> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the
> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is
> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"),
> so the "no remaining placeholders" claim holds again.
---
@@ -345,6 +420,20 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed).
The gated/private-repository download support added `settings.huggingfaceApiKey*` (label,
placeholder, help, and the three status strings). "Access token" renderings, and the status
strings reuse each locale's existing `civitaiApiKey*` forms ("Configured" / "Not configured" /
"Set up") verbatim:
| Term | Rendering |
|---|---|
| access token | zh-CN 访问令牌 · zh-TW 存取權杖 · ja アクセストークン · ko 액세스 토큰 · fr jeton d'accès · de Access Token (Latin, like `CivitAI API Key`) · es token de acceso · ru токен доступа · he אסימון גישה |
| gated repository | zh-CN 受限(gated)仓库 · zh-TW 受限(gated)倉庫 · ja ゲート付きリポジトリ · ko 게이트가 설정된 저장소 · fr dépôt restreint (gated) · de gated Repository (loanword) · es repositorio restringido (gated) · ru закрытый (gated) репозиторий · he מאגר מוגבל (gated) |
The help text tells the user to create a **read-only** token at
`huggingface.co/settings/tokens` and to accept the repository's terms on its page first —
keep both clauses: a token alone does not unlock a gated repository.
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
@@ -357,33 +446,188 @@ The model-root sidebar manages on-disk folders. "Folder" reuses the noun already
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
Deleting a folder **never cascades over model files** — the backend refuses it and the
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
| Term | Rendering |
|---|---|
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
carrying `{excluded}`.
### Settings Organization tab
The settings modal's fourth nav tab groups everything about how files are arranged on
disk: download path templates, priority tags, and auto-organize exclusions. The label is
the **noun for arranging files**, matching each locale's existing
`settings.sections.autoOrganize` rendering minus the "auto":
| Locale | `settings.nav.organization` |
|---|---|
| fr | Organisation |
| zh-CN | 整理 |
| zh-TW | 整理 |
| ja | 整理 |
| ko | 정리 |
| de | Organisation |
| es | Organización |
| ru | Организация |
| he | ארגון |
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
### Sidecar storage feature (centralized `.metadata.json` / preview storage)
The Library settings tab hosts an optional mode that stores `.metadata.json` sidecars and
preview images either **alongside** each model file or in a single **centralized** mirror tree,
plus the manual migration that moves existing files between the two. Everything lives in
`settings.sections.sidecarStorage` (the section header inside the Library tab),
`settings.sidecarStorage.*` and `modals.sidecarMigrationConfirm.*`.
- **`sidecar` is a technical noun, not a brand**, so each locale either borrows it or uses its
own companion-file word — one rendering per file:
| Term | Rendering |
|---|---|
| sidecar (noun) | zh-CN 附属文件 · zh-TW 附屬檔案 · ja サイドカーファイル · ko 사이드카 파일 · fr fichier sidecar · de Sidecar-Datei · es archivo sidecar · ru sidecar-файл · he קובץ לוואי |
| centralized storage | zh-CN 集中存储 · zh-TW 集中儲存 · ja 集中保存 · ko 중앙 집중식 저장 · fr stockage centralisé · de zentrale Speicherung · es almacenamiento centralizado · ru централизованное хранилище · he אחסון מרכזי |
| alongside model files | zh-CN 与模型文件放在一起 · zh-TW 與模型檔案放在一起 · ja モデルファイルの隣 · ko 모델 파일 옆 · fr à côté des fichiers de modèle · de neben den Modelldateien · es junto a los archivos de modelo · ru рядом с файлами моделей · he לצד קובצי המודלים |
| migrate (verb/noun) | zh-CN 迁移 · zh-TW 遷移 · ja 移動 · ko 이동 · fr migrer / migration · de verschieben / Migration · es migrar / migración · ru перенести / перенос · he להעביר / העברה |
| mirror (verb) | zh-CN 镜像 · zh-TW 對應 · ja ミラーリング · ko 미러링 · fr refléter · de spiegeln · es reflejar · ru повторять структуру · he לשקף |
| preview images | zh-CN 预览图片 · zh-TW 預覽圖片 · ja プレビュー画像 · ko 미리보기 이미지 · fr images d’aperçu · de Vorschaubilder · es imágenes de vista previa · ru изображения превью · he תמונות תצוגה מקדימה |
| (effective) storage location | zh-CN (实际)存储位置 · zh-TW (實際)儲存位置 · ja (実際の)保存場所 · ko (실제) 저장 위치 · fr emplacement de stockage (effectif) · de (tatsächlicher) Speicherort · es ubicación de almacenamiento (efectiva) · ru (фактическое) расположение хранилища · he מיקום האחסון (בפועל) |
| Open Folder (button) | zh-CN 打开文件夹 · zh-TW 開啟資料夾 · ja フォルダを開く · ko 폴더 열기 · fr Ouvrir le dossier · de Ordner öffnen · es Abrir carpeta · ru Открыть папку · he פתח תיקייה |
| installation folder | zh-CN 安装目录 · zh-TW 安裝目錄 · ja インストールフォルダ · ko 설치 폴더 · fr dossier d’installation · de Installationsordner · es carpeta de instalación · ru папка установки · he תיקיית ההתקנה |
- `ja`/`ko` follow the file's existing storage-relocation verb (ja 移動, ko 이동, from
`settings.folderSettings.recipesPathMigrating`) rather than a transliteration of "migration";
`ru` uses перенос for the same reason, and `de` keeps the loan noun `Migration` while the verbs
use `verschieben`.
- **`.metadata.json`**, **`.civitai.info`** and the default-path literal
`(<settings dir>/sidecars)` stay byte-identical in every locale — they are file names and a
path, not prose (§6 exception). Hebrew drops the wrapping parentheses to avoid bidi mirroring
and writes the literal bare.
- `migrationDeferred` names a navigation path ("Settings → Library → Sidecar Storage"), so each
locale renders it with its **own** settings label and Library tab label
(`common.actions.settings` + `settings.nav.library` + the new section label), using the same
arrow and quoting style its other nav-path strings already use — zh-CN “设置 → 库 → …”,
zh-TW/ja 「設定 > … > …」, ko `설정 → …` bare, fr/de/es bare
(`Paramètres` / `Einstellungen` / `Configuración` → …), ru «Настройки → …»,
he `הגדרות > …` bare (cf. `other.noPaths.descriptionStandalone`).
- The migrate-button label is quoted inside `confirmToCentralized` / `confirmToAlongside` with
each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr/ru/es/he « »,
de „ “), matching `settings.sidecarStorage.migrateButton` verbatim so the two never drift.
### Filename Templates feature
Per-model-type templates that name downloaded model files; "Apply to Library Now"
bulk-renames existing files, and an **empty template restores the recorded original
filenames** (recorded in each model's metadata at its first rename). "Template" follows
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
`autoOrganize*` / `downloadTemplates*` toast shapes.
| Term | Rendering |
|---|---|
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
### Chip reordering (model tags / trigger words)
Model tags and trigger-word chips share a single reorder affordance (drag the chip or its
`⠿` grip, or move it with `Alt + ↑/↓`), so the copy sits in `common.reorder.*` instead of a
feature namespace. `dragHandle` is both the grip tooltip and the hint shown in the edit
controls row; `ariaLabel` is the per-grip screen-reader label (`{item}` is the tag/word text);
`announcement` is the aria-live message after a keyboard move and deliberately has no
`{item}`. Keep `{item}` / `{position}` / `{total}` verbatim (§1-R2) — the caller supplies
exactly those.
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
re-adding the corresponding keys.
`Alt` and the `↑/↓` glyphs stay Latin/verbatim in every locale (same precedent as
`Shift+Enter`), and `position X of Y` reuses each locale's established ordering phrasing
(ja `{total} 件中 … 番目`, ko `총 {total}개 중 …번째`, fr `sur {total}`, ru `из {total}`, …).
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
short and imperative and do not append a keyboard hint in any locale.
| Term | Rendering |
|---|---|
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
| position {position} of {total} | zh-CN 第 {position} 个,共 {total} 个 · zh-TW 第 {position} 個,共 {total} 個 · ja {total} 件中 {position} 番目 · ko 총 {total}개 중 {position}번째 · fr position {position} sur {total} · de Position {position} von {total} · es posición {position} de {total} · ru позиция {position} из {total} · he מיקום {position} מתוך {total} |
The grip/handle noun itself is never translated (it is an icon); the hint carries the whole
instruction, so no locale needs a separate "grip" term.
The grip itself is an icon and is never translated.
### Download routing feature (unknown base model routing)
The `settings.unknownBaseModelRouting.*` keys (Settings → Library → Folder Settings) decide
which library a checkpoint download lands in when CivitAI reports a baseModel that is in
neither the known-checkpoint nor the known-diffusion-model list. The option labels reuse
each locale's `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings
(model-type names, R3 — ja/ko keep the Latin loanword), pluralized only where the locale
pluralizes (de Diffusionsmodelle, es Modelos de difusión, fr Modèles de diffusion,
ru Диффузионные модели, he מודלי דיפוזיה; CJK stays singular, "Checkpoint(s)" follows
`header.navigation.checkpoints`).
| Term | Rendering |
|---|---|
| routing (noun, of a download into a library) | zh-CN 路由 · zh-TW 路由 · ja 振り分け · ko 라우팅 · fr routage · de Routing · es enrutamiento · ru маршрутизация · he ניתוב |
| unknown base model | zh-CN 未知基础模型 · zh-TW 未知基礎模型 · ja 不明なベースモデル · ko 알 수 없는 베이스 모델 · fr modèle de base inconnu · de unbekanntes Basismodell · es modelo base desconocido · ru неизвестная базовая модель · he מודל בסיס לא מוכר |
| destination type (download-modal toggle label) | zh-CN 目标类型 · zh-TW 目標類型 · ja 保存先タイプ · ko 대상 유형 · fr type de destination · de Zieltyp · es tipo de destino · ru тип назначения · he סוג יעד |
The baseModel family names in the help text (`SD 1.x/2.x/3.x, SDXL, Pony, Illustrious,
NoobAI`) are CivitAI baseModel values and stay verbatim in every locale.
The routing-override toggle (`modals.download.routingOverride.*`) sits on the checkpoints
page of the download modal; its two button labels come from `checkpoints.modelTypes.*`
directly (model-type names, R3). The tooltip quotes the `modals.download.useDefaultPath`
label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」,
ko `' '`, fr « … », de „ … “, es/ru/he «…»).
### OpenModelDB feature
**OpenModelDB** is a brand name and stays Latin in every locale (R3, same as CivitAI /
CivArchive); `openmodeldb.info` is a URL and stays verbatim. **Upscaler** follows the
Other Models rule (model-type name, Latin everywhere). The label/help mirror each
locale's existing `settings.metadataArchive.enableCivarchiveApi(Help)` phrasing, and
"metadata" uses the §5 rendering per locale.
| Term | Rendering |
|---|---|
| catalogue (the OpenModelDB catalogue) | zh-CN 目录 · zh-TW 目錄 · ja カタログ · ko 카탈로그 · fr catalogue · de Katalog · es catálogo · ru каталог · he קטלוג |
### Civitai ids feature (model/version id in the model modal)
The model modal's hash footnote shows the Civitai **model id** and **version id** with
copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels,
`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays
Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the
brand (R3) — it is never translated or transliterated; the casing mirrors `en.json`
verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns
(`modals.model.actions.copyHash` / `openFileLocation.copied`).
| Term | Rendering |
|---|---|
| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל |
| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה |
Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these
labels, matching `recipes.*.copyId` (העתק מזהה מתכון).
---
+216
View File
@@ -0,0 +1,216 @@
# Load Image Metadata (LoraManager)
Load a source image and reuse its prompts, local models, LoRAs, and sampling settings.
The node lives under **Lora Manager → loaders**. Restart ComfyUI after installing
this change and refresh the page. This Python node needs no Vue widget build.
## Wiring a checkpoint workflow
1. Upload/select an image in **Load Image Metadata (LoraManager)**.
2. Convert `ckpt_name` on **Checkpoint Loader (LoraManager)** to an input and
connect `model_name`. Leave its randomization control fixed.
3. Connect the checkpoint's MODEL and CLIP to **Lora Loader (LoraManager)**.
Connect the metadata node's `lora_stack` to that loader. Leave its LoRA widget
empty unless you intentionally want additional LoRAs.
4. Connect the LoRA loader's CLIP to two CLIP Text Encode nodes. Connect metadata
`positive` and `negative` to their text inputs, and their conditioning outputs
to KSampler. Connect the LoRA loader's MODEL to KSampler.
5. Convert KSampler's seed, steps, cfg, sampler_name, scheduler, and denoise
widgets to inputs and connect the corresponding metadata outputs.
6. For text-to-image, connect width/height to an appropriate Empty Latent node.
For img2img, encode the `image` output with the appropriate VAE instead.
7. Connect KSampler's samples and the checkpoint's VAE to VAE Decode, then Save Image.
8. Connect `readable_report` to a text display node for prompts, sampling settings,
model/LoRA names, local resolution status and warnings. The original `report`
output remains notes followed by formatted JSON; it is not a pure JSON string.
`model_name`, `sampler_name`, and `scheduler` are declared as untyped (`*`)
outputs so they can feed the dropdown widget inputs on both
**Load Checkpoint**/**KSampler** and the LoRA Manager loaders. Typing them
`COMBO` does not work: ComfyUI only accepts a `COMBO` output into a node that
declares its dropdown as `COMBO`/`IO.Combo`, while classic dropdowns expose a
plain option list, and the server rejects the link with "Return type mismatch
between linked nodes". `model_name` contains the matching local checkpoint or
diffusion-model filename. The report identifies the resolved type; connect it to
the appropriate loader. Lookup searches both categories regardless of how the
original metadata labels the model.
For a diffusion-model workflow, connect `model_name` to **Unet Loader
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
architecture-specific conditioning separately. These settings do not reconstruct
an entire workflow or guarantee pixel-identical reproduction.
## Selection and overrides
`prefer_saved_image_metadata` is enabled by default. It prefers the saved
A1111-style generation parameters (including ComfyUI exports in that format)
over the workflow. The report identifies this source; `sampler_node_id` is
ignored in this mode when valid saved parameters are available. If saved
parameters are absent or malformed, the node tries workflow metadata and
reports any parsing failure.
Disable the flag to prefer workflow extraction. Only active samplers are
eligible: muted/bypassed sampler nodes and samplers inside muted/bypassed
subgraph instances are excluded. This uses the saved UI workflow's mode flags
when available, including nested subgraphs, and any modes in the API graph.
Explicitly selecting an inactive sampler produces an error report and the
usual saved-parameter/default recovery; it never extracts that inactive stage.
With one supported active sampler, leave `sampler_node_id` blank. With several, enter
its original node ID. Reports list candidate IDs when selection is ambiguous.
Native subgraphs in API prompt metadata use colon-qualified paths: `1481:1783`
means node 1783 inside subgraph instance 1481. Nested paths such as `10:20:30`
are supported; slash notation (`1481/1783`) is also accepted. A container ID
(`1481`) or leaf ID (`1783`) is accepted only if it identifies one sampler.
An exact sampler ID takes precedence over abbreviated matching.
Selection follows that sampler's graph, rather than mixing branches. Supported
sampling nodes include KSampler, KSamplerAdvanced and SamplerCustomAdvanced with
standard RandomNoise, CFGGuider/BasicGuider, BasicScheduler and KSamplerSelect
components. BasicGuider's CFG is 1; its architecture-specific lack of negative
conditioning is reported. Known Image Saver parameter/selector outputs and
rgthree seed values can be read without executing those nodes.
Detail Daemon's underlying sampler name is recovered, but its sampling effects
are explicitly unsupported. Other custom model/conditioning nodes can still
require defaults or overrides. If a requested stage cannot be read and global
image parameters are used instead, the report explicitly says those parameters
cannot verify the selected stage. Subgraph traversal requires the expanded API
prompt; UI-workflow-only subgraph definitions are not expanded or executed.
Extraction errors do not stop this node. If an API prompt uses unsupported
samplers, the node first tries the image's saved generation parameters. Any
remaining unavailable or invalid extracted fields use the SDXL starter defaults
(width/height fall back to the source image dimensions instead);
valid extracted fields are preserved. `readable_report` starts with **❌ ERROR**
and explains each recovery or substitution. This also applies to existing nodes
saved with `missing_settings=strict`; that legacy option no longer blocks
extraction recovery. New nodes default to `use_defaults`.
The report uses emoji section markers (🖼️ image, 📦 model, ⚙️ sampling, 🧩 LoRAs,
➕/➖ prompts) and ❌/⚠️/ℹ️ status markers. It is plain text, so colors depend on the
connected display node. Missing/ambiguous local files still appear in
`missing_files`. An empty model output requires selecting a local model manually.
Invalid explicit overrides and unreadable image files remain execution errors.
`overrides_json` replaces extracted values, for example:
```json
{
"scheduler": "normal",
"model_name": "portraits/model.safetensors",
"seed": 12345,
"loras": [["styles/ink.safetensors", 0.7, 0.3]]
}
```
Supported keys: `positive`, `negative`, `model_name`, `seed`,
`steps`, `cfg`, `sampler_name`, `scheduler`, `width`, `height`, `denoise`, `loras`.
LoRA entries are `[name, model_strength, clip_strength]`; `"loras": []` explicitly
clears the extracted stack. Legacy `checkpoint_name` and `unet_name` override
keys remain accepted as aliases for `model_name`; supply only one model key.
Exact relative or absolute local
business paths disambiguate duplicate basenames. Matching falls back to a unique
filename or extensionless filename, then an exact unique catalog `file_name` or
`model_name` alias. Version dots are preserved when stripping known file
extensions. It never downloads or fuzzy-matches models, and stale entries whose
files no longer exist are excluded.
Images with no metadata automatically use a bottle-inspired SDXL starter preset,
even with an existing saved `strict` setting: a glass-bottle/galaxy landscape
prompt, negative `text, watermark`, seed 0, 20 steps, CFG 7, Euler/normal,
1024×1024 and denoise 1, with no LoRAs. These settings are clearly identified as
synthetic defaults in both reports. Source image pixels and mask are unchanged.
Overrides take precedence. The node selects `sd_xl_base_1.0.safetensors` only
when uniquely indexed; otherwise choose an SDXL checkpoint manually or supply
`model_name`. Malformed or unsupported metadata also recovers with an explicit ERROR report.
## Supported metadata and limits
- PNG API prompt metadata; JPEG/WebP EXIF parameter comments; ComfyUI WebP
`prompt:`/`workflow:` EXIF fields.
- Standard KSampler, core checkpoint/UNet/LoRA loaders, LoRA Manager checkpoint,
UNet, LoRA/text loaders and LoRA stacks. LoRA application order and separate
model/CLIP strengths are preserved, including intentional repeated entries.
Different LoRA chains on model and prompt CLIP branches require an explicit
stack override rather than being silently merged.
- Literal CLIPTextEncode text and supported primitive value connections. Prompt
polarity comes from sampler wiring, never from words such as “ugly”.
- A1111/Forge generation text with explicit sampler alias mappings. Recognized
LoRA directives become stack entries and are removed from prompt text. Literal
tags in ComfyUI encoder text remain literal; graph loaders determine its stack.
- A1111 `Automatic`/absent schedules do not reliably identify a ComfyUI schedule.
The node substitutes `normal` and reports the missing information as an ERROR;
an explicit override can select a different schedule.
- UI-workflow-only fallback supports known core widget layouts, with a report
warning. Saved widgets can differ from executed values (for example a seed
randomized after generation). Custom widget layouts are not guessed.
- KSamplerAdvanced partial/noise settings require an explicit denoise override;
this is an intentional approximation, not a reconstruction of those controls.
- Distinct SDXL/Flux encoder prompts, combined/regional/zeroed conditioning,
arbitrary custom nodes, dynamic wildcards and unsupported custom sampling components are
not automatically reconstructed. Supply explicit overrides or retain the
original workflow for those cases.
- Width/height come from a recognized latent source or fall back to source-image
dimensions; resized/upscaled images can therefore need dimension overrides.
Only the synthetic starter preset for metadata-free images uses a fixed
1024×1024 regardless of the source image size.
- VAE, text encoder choice, CLIP skip, ControlNet and architecture-specific
conditioning still need the appropriate nodes. No embedded code is executed
and no external metadata service is contacted.
LoRA Manager must have indexed the required models. Library resolution includes
its configured extra folders and preserves business paths through symlinks.
## Extraction without a local catalog
The parser extracts names before attempting local resolution. In recovery mode,
`report` includes `source_resources` with original model names, LoRA names and
strengths, and embedded resource hashes even when none are installed. The model
output sockets remain empty and the resolved stack excludes missing files.
Combined sampler labels such as `Euler a SGM Uniform`, `Euler Normal` and
`er_sde simple` are split into sampler and scheduler. Multiline parameter blocks
and their nested JSON resource lists are supported. If prompt LoRA tags are
absent, one hash-name entry and one weighted resource can be matched offline;
multiple entries require an explicit mapping rather than guessing from order.
A single resource also disambiguates duplicated identical prompt tags.
The `Model` field in A1111-style metadata does not distinguish checkpoints from
standalone diffusion models. The node searches both indexed categories by name,
then reports the matched type. Local model type and filename cannot be verified
without an indexed library. Multiple equally good matches are reported as
ambiguous; specify a relative path through `model_name` to disambiguate.
## “Image contains no supported generation metadata”
For older versions, this means extraction failed before any library lookup.
The current node uses the starter preset when metadata is entirely absent. The error identifies the
actual server file, its format, byte size and metadata keys. PNG text chunks are
read both before and after pixel data. If no generation metadata remains, upload
the original saved file: clipboard copies and re-encoded/exported images may
lose it. `use_defaults` supplies replacement settings; it does not recover the
original prompts or seed.
## Missing local resources
`missing_files` is a text output listing unresolved checkpoints/UNets and LoRAs.
LoRA entries include both model and CLIP weights and the resolution failure.
It is empty when all requested resources resolve. Missing and ambiguous LoRAs
are excluded from `lora_stack`, including in strict mode, so downstream loaders
receive only resolved files. Valid entries keep their original order and weights.
Unresolved model-name sockets are empty: select a model manually or override its
name before connecting that socket to a loader.
## Output layout and upgrade
The outputs start with `image`, `mask`, `positive`, `negative`, **`model_name`**,
**`lora_stack`**, **`lora_stack_text`**, followed by the sampling settings and reports.
`lora_stack_text` lists each resolved stack path with model and CLIP weights in
application order. It is empty for an empty stack; unresolved files appear only
in `missing_files`, with their requested weights.
This replaces the former separate checkpoint/UNet sockets and renames `lost_list`
to `missing_files`. Restart ComfyUI, refresh, and recreate existing instances of
this node; reconnect the model and stack outputs to avoid stale saved slot indices.
Sampling and report output indices remain unchanged. No Vue build is required.
+47
View File
@@ -11,6 +11,33 @@ This document defines the complete schema for `.metadata.json` files used by Lor
---
## Storage Location (Alongside vs Centralized)
By default, `.metadata.json` sidecars and preview images live **alongside** their model files. An optional centralized mode stores them under a single root directory instead. Two settings control this (Settings → Library → Sidecar Storage):
| Setting | Values | Default |
|---------|--------|---------|
| `sidecar_storage_mode` | `"alongside"` \| `"centralized"` | `"alongside"` |
| `sidecar_storage_path` | Absolute path string; empty = `<settings dir>/sidecars` | `""` |
In centralized mode, sidecars and previews mirror each model root's directory structure:
```
<sidecar_root>/<root_component>/<rel_dir>/<name>.metadata.json
```
- `<rel_dir>` is the model's directory relative to the model root containing the file; the longest matching root wins, so nested roots mirror under the most specific root.
- `<root_component>` identifies the model root and **survives the root being moved or renamed**. It starts as the deterministic `<sanitized basename>-<path digest>` — so mirrors created by older builds, and mirrors left behind by a relocated sidecar root, still resolve — and is then pinned in `<sidecar_root>/.lm-sidecar-roots.json` alongside the root's last known path and a few sample subdirectories. Two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) always get distinct components and never collide. Each path component is sanitized to filesystem-safe characters.
- **Moving or renaming a model root does not strand its sidecars.** On the next run the mirror identity is re-anchored to the root's new path (matched by basename and recorded sample directories), so favorites, notes, tags and usage tips keep resolving. An existing hash-named mirror from an older build is adopted as-is on first use.
- If an identity cannot be re-anchored unambiguously (e.g. two same-named candidate roots), nothing is guessed: the mirror stays on disk untouched and surfaces as an orphan in **Doctor → Centralized Sidecars** (and in the log). Restoring the original root path re-links it automatically.
- `.civitai.info` files always stay next to the model file, in both modes.
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically). The identity map travels with the tree, and its entries win over any map the destination acquired beforehand — so a mirror that was re-anchored earlier keeps its name even if something resolved against the new path before the relocation ran.
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline. In the default `alongside` mode these helpers do no extra I/O at all — the identity map is only loaded and reconciled when centralized storage is actually in use.
---
## Base Fields (All Model Types)
These fields are present in all model metadata files.
@@ -272,9 +299,29 @@ The `metadata_source` field indicates which provider last updated the metadata:
|-------|--------|
| `"civitai_api"` | Civitai API |
| `"civarchive"` | CivArchive API |
| `"openmodeldb"` | OpenModelDB catalogue (upscaler models only; hash-matched) |
| `"archive_db"` | Metadata Archive Database |
| `null` | No external source (user-defined only) |
When `metadata_source` is `"openmodeldb"`, the `civitai` payload is a
CivitAI-shaped version dict synthesized from the OpenModelDB catalogue entry
(no numeric `id`/`modelId`), and the OpenModelDB-native details live in its
`openmodeldb` block (`id`, `url`, `authors`, `architecture`,
`architectureName`, `scale`, `license`, `date`).
In that payload, `images[].url` is always a displayable asset: paired
comparisons use the site-hosted thumbnail because the `LR`/`SR` originals
are ephemeral imgdiff viewer sessions that 404 outside them (the original
viewer link is kept as `images[].meta.comparisonUrl` for reference), and the
model-level thumbnail leads the list since the card preview derives from
`images[0]`. `files[].name` is derived from any URL path segment with a
model extension (mediafire-style mirrors bury it mid-path) or synthesized as
`{model_id}.{type}` for folder links.
Models downloaded from OpenModelDB additionally carry `source_platform:
"openmodeldb"` and `source_url` (the model page URL); their download-time
hydration is recorded as `metadata_source: "source:openmodeldb"`.
---
## Auto-Update Behavior
@@ -0,0 +1,107 @@
# Plan: Filename Template Follow-ups
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
resolved together on 2026-09-19 by redefining the empty template as
"revert to recorded original filename" (see below). Follow-up 3 remains open.
## What shipped in `2bc9860b`
- Per-model-type `download_filename_templates` setting (empty = keep current
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
`{original_name}`.
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
template; templates containing path separators are rejected.
- Downloads apply the template post-download
(`DownloadManager._apply_download_filename_template`); rename conflicts
keep the original name and never fail the download.
- `ModelLifecycleService.rename_model` records `original_file_name` in the
`.metadata.json` sidecar (first rename wins via `setdefault`).
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
`filename_template_progress`).
- Settings UI: "Filename Templates" subsection in the new **Organization**
settings tab (`templates/components/modals/settings/organization.html`),
with validation, live preview, and per-type "Apply to Library Now".
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
metadata pointer updates, `original_file_name` recording, idempotency,
conflict handling (failure counted, batch continues), empty-template no-op,
GET variant.
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
`original_file_name`. Both were resolved by a single semantic change: **an
empty template now means "restore the recorded original filename"** instead of
"leave the current filename untouched".
Rationale: for never-renamed models a revert is a no-op (no recorded
original), for renamed models it restores the pre-rename name, and new
downloads with an empty template keep the download name as before — so the
two contexts (download path and bulk apply) share one coherent meaning, and
no separate revert feature or `{recorded_original}` placeholder is needed.
Implemented changes:
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
the target name from the sidecar's `original_file_name` via the injected
`metadata_loader` (default `load_local_metadata`); models without a
recorded original or whose original matches the current name are skipped.
Cache entries do not project `original_file_name`, so the sidecar is read
per model.
- `SettingsManager.js`: removed the empty-template early return and the
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
button is now always enabled). The browser-native `confirm()` was replaced
with `filenameTemplateConfirmModal`
(`templates/components/modals/confirm_modals.html`), a **self-managed**
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
ModalManager's "close current modal on open" behavior would kill the
settings modal underneath. It stacks via `z-index: 10010`
(`delete-modal.css`), handles ESC in capture phase with
`stopPropagation`, and shows apply vs revert wording
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
`confirmRevert`).
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
`validation.keepOriginal` with `validation.restoreOriginal`
("Valid (empty template restores original filenames)"), added
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
- Tests: revert / no-record-skip / same-name-skip cases in
`tests/services/test_use_cases.py`; modal confirm-and-revert and
cancel paths in
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
Sandbox E2E verified (standalone server, sandboxed settings + library under
`/tmp`, 2026-09-19): template apply renames and records
`original_file_name`; empty-template apply reverts to the recorded name;
revert target occupied by a newer file counts as failure and keeps the
current name; models without a recorded original are skipped;
apply → revert → re-apply cycles repeat cleanly.
Standing caveats (unchanged):
- The revert target may collide with an existing file — the existing conflict
handling (count as failure, keep current name) covers this.
- `original_file_name` only exists for models renamed after `2bc9860b`;
older renames have no recorded original and are skipped.
- `original_file_name` is kept (not cleared) after a revert, so
apply → revert → re-apply stays repeatable.
## Follow-up 3 — Cross-page refresh after bulk apply
**Problem:** the settings-modal "Apply to Library Now" button calls
`resetAndReload(true)`, which refreshes only the page type currently open.
Applying the checkpoint template while on the loras page leaves the loras
view refreshed but does not touch the checkpoints page state (same
limitation as the existing bulk auto-organize flow in
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
**Fix options:** broadcast a generic "library changed" event that every
page's state listens to, or accept the limitation (the other page reloads
its cache on next visit). Low priority.
+7 -1
View File
@@ -30,7 +30,9 @@ Aliases live inside `()` and are separated with `|`. The canonical name is what
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
- If no priority tags are found but the model has tags, the very first model tag is used.
- If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen.
- Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`.
- Civitai's structural labels, such as `base model`, describe the listing rather than the model, so the automatic fallback skips them too. Add one to your priority list if you really want it as a folder name.
- If the model has no tags at all, the folder falls back to `no tags`.
### Example
@@ -42,6 +44,8 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
| `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. |
| `["lora, character, ... face", "base model"]` | `no tags` | A keyword dump plus a Civitai label: nothing usable is left. |
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
## 3. Save the Settings
@@ -61,10 +65,12 @@ After editing the entry list, press **Enter** to save. Use **Shift+Enter** whene
- Keep canonical names short and meaningful—they become folder names.
- Place the most important categories first; the first match wins.
- Avoid duplicate canonical names within the same list; only the first instance is used.
- Folder names built from tags are sanitized for filesystem safety and truncated to 50 characters.
## Troubleshooting
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
- **Folder named `no tags`?** Every model tag was either missing or unusable (a comma-separated keyword dump, or longer than 50 characters). Add the tags you care about to your priority list so they match by name instead.
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"reorder": {
"dragHandle": "Zum Neuordnen ziehen (Alt + ↑/↓)",
"ariaLabel": "{item} neu anordnen, Position {position} von {total}",
"announcement": "An Position {position} von {total} verschoben"
"dragHandle": "Zum Neuordnen ziehen"
},
"actions": {
"save": "Speichern",
@@ -253,7 +251,6 @@
"modelname": "Modellname",
"tags": "Tags",
"creator": "Ersteller",
"hash": "Hash",
"title": "Rezept-Titel",
"loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "Konfiguriert",
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
"civitaiApiKeySet": "Einrichten",
"huggingfaceApiKey": "Hugging Face Access Token",
"huggingfaceApiKeyPlaceholder": "Geben Sie Ihren Hugging Face Access Token ein",
"huggingfaceApiKeyHelp": "Erforderlich für Downloads aus gated oder privaten Hugging Face Repositories. Erstellen Sie ein Read-only-Token unter huggingface.co/settings/tokens und akzeptieren Sie zuerst die Nutzungsbedingungen des Repositories auf dessen Seite.",
"huggingfaceApiKeyConfigured": "Konfiguriert",
"huggingfaceApiKeyNotConfigured": "Nicht konfiguriert",
"huggingfaceApiKeySet": "Einrichten",
"civitaiHost": {
"label": "CivitAI-Host",
"help": "Wählen Sie aus, welche CivitAI-Seite geöffnet wird, wenn Sie „View on CivitAI“-Links verwenden.",
@@ -349,6 +352,14 @@
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
},
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
"unknownBaseModelRouting": {
"label": "Routing unbekannter Basismodelle",
"help": "Legt fest, wohin Checkpoint-Downloads gehen, wenn CivitAI ein Basismodell meldet, das weder ein bekanntes Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) noch ein bekanntes Diffusionsmodell ist. Neue Diffusionsarchitekturen erscheinen häufig, daher ist das Routing zu Diffusionsmodellen meist korrekt.",
"options": {
"diffusionModel": "Diffusionsmodelle",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "CivitAI-Host-Einstellung verfügbar",
"content": "CivitAI verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
@@ -379,12 +390,15 @@
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
"sidecarStorage": "Sidecar-Speicherung",
"proxySettings": "Proxy-Einstellungen"
},
"nav": {
"general": "Allgemein",
"interface": "Oberfläche",
"library": "Bibliothek"
"library": "Bibliothek",
"organization": "Organisation",
"modelPaths": "Modellpfade"
},
"search": {
"placeholder": "Einstellungen durchsuchen...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
}
},
"modelPaths": {
"title": "Modellbibliothek-Pfade",
"description": "Stammordner, die LoRA Manager nach Ihren Modellen durchsucht. Dies sind die primären Modellspeicherorte, die im Standalone-Modus aus der settings.json gelesen werden.",
"restartRequired": "Neustart erforderlich, damit die Änderung wirksam wird",
"coreTypes": "Kern-Modelltypen",
"otherTypes": "Weitere Modelltypen",
"otherTypesDisabledHint": "Es sind keine weiteren Modelltypen aktiviert. Aktivieren Sie oben die benötigten Typen, um deren Ordner zu konfigurieren.",
"saveSuccessRestart": "Modellbibliothek-Pfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"pendingRestartNotice": "Pfadänderungen gespeichert. Starten Sie LoRA Manager neu, damit sie wirksam werden.",
"pendingRestartBannerTitle": "Neustart erforderlich, um Pfadänderungen anzuwenden",
"pendingRestartBannerMessage": "Die Modellbibliothek-Pfade wurden aktualisiert. Starten Sie den LoRA Manager-Server neu, um die neuen Ordner zu scannen.",
"folderKeys": {
"loras": "LoRA-Pfade",
"checkpoints": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embeddings": "Embedding-Pfade",
"vae": "VAE-Pfade",
"upscale_models": "Upscaler-Pfade",
"text_encoders": "Text-Encoder-Pfade",
"clip": "CLIP-Pfade (Legacy)",
"clip_vision": "CLIP-Vision-Pfade",
"controlnet": "ControlNet-Pfade"
}
},
"directoryPicker": {
"title": "Ordner durchsuchen",
"selectFolder": "Diesen Ordner auswählen",
"goUp": "Nach oben",
"pathPlaceholder": "Pfad eingeben...",
"go": "Los",
"emptyFolder": "Keine Unterordner",
"loadError": "Verzeichnis konnte nicht geladen werden"
},
"pathValidation": {
"valid": "Pfad ist gültig",
"pathNotFound": "Pfad existiert nicht",
"notADirectory": "Kein Verzeichnis",
"notReadable": "Pfad ist nicht lesbar",
"notWritable": "Pfad ist nicht beschreibbar"
},
"priorityTags": {
"title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "Gültige Vorlage"
}
},
"filenameTemplates": {
"title": "Dateinamen-Vorlagen",
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
"availablePlaceholders": "Verfügbare Platzhalter:",
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
"applyButton": "Jetzt auf Bibliothek anwenden",
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
"validation": {
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
"validTemplate": "Gültige Vorlage"
}
},
"exampleImages": {
"downloadLocation": "Download-Speicherort",
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
@@ -722,11 +792,41 @@
"downloadComplete": "Download erfolgreich abgeschlossen",
"enableCivarchiveApi": "CivArchive API als Metadaten-Anbieter aktivieren",
"enableCivarchiveApiHelp": "Wenn aktiviert, wird die CivArchive API als alternative Quelle für Modell-Metadaten verwendet (z. B. für von CivitAI gelöschte Modelle). Deaktivieren, um die Ratenbegrenzungen von CivArchive vollständig zu vermeiden.",
"enableOpenmodeldbApi": "OpenModelDB als Metadaten-Anbieter aktivieren",
"enableOpenmodeldbApiHelp": "Wenn aktiviert, werden Upscaler-Metadaten zusätzlich im OpenModelDB-Katalog (openmodeldb.info) nachgeschlagen, wenn CivitAI keinen Eintrag hat. Der Katalog wird lokal zwischengespeichert und täglich aktualisiert.",
"providerOrder": "Reihenfolge der Metadaten-Anbieter",
"providerOrderHelp": "Die CivitAI API wird immer zuerst versucht. Wählen Sie die Reihenfolge der übrigen Anbieter bei der Metadatensuche.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "Sidecar-Speichermodus",
"modeHelp": "Wählen Sie, wo .metadata.json-Sidecar-Dateien und Vorschaubilder gespeichert werden: neben jeder Modelldatei oder in einem einzelnen zentralen Verzeichnis, das Ihre Bibliotheksstruktur spiegelt. .civitai.info-Dateien bleiben immer neben der Modelldatei.",
"modeOptions": {
"alongside": "Neben den Modelldateien (Standard)",
"centralized": "Zentrale Speicherung"
},
"path": "Pfad der zentralen Speicherung",
"pathHelp": "Stammverzeichnis für die zentrale Sidecar-Speicherung. Leer lassen, um den Standardspeicherort (<settings dir>/sidecars) zu verwenden.",
"pathPlaceholder": "Leer = <settings dir>/sidecars",
"management": "Sidecar-Migration",
"managementHelp": "Verschieben Sie vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder zwischen der Ablage neben den Modelldateien und der zentralen Speicherung, passend zum aktuell gewählten Modus. Ein Moduswechsel verschiebt vorhandene Dateien nicht automatisch.",
"migrateButton": "Sidecar-Dateien jetzt verschieben",
"migratingButton": "Wird verschoben...",
"migrating": "Sidecar-Dateien werden verschoben...",
"migrateFailed": "Sidecar-Migration fehlgeschlagen: {message}",
"migrationDeferred": "Vorhandene Sidecar-Dateien wurden nicht verschoben. Sie können sie später unter Einstellungen → Bibliothek → Sidecar-Speicherung verschieben.",
"confirmToCentralized": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt in das zentrale Speicherverzeichnis verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
"confirmToAlongside": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt wieder neben ihre Modelldateien verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?",
"effectivePathLabel": "Tatsächlicher Speicherort:",
"openFolderButton": "Ordner öffnen",
"repoWarning": "Der tatsächliche Speicherort liegt innerhalb des LoRA-Manager-Installationsordners. Eine Neuinstallation des Plugins oder eine saubere Aktualisierung kann ihn löschen — legen Sie einen expliziten Speicherpfad außerhalb des Installationsordners fest.",
"openLocationSuccess": "Sidecar-Speicherordner geöffnet",
"openLocationCopied": "Sidecar-Speicherpfad in die Zwischenablage kopiert: {path}",
"openLocationClipboardFallback": "Kopieren Sie den Sidecar-Speicherpfad manuell: {path}",
"openLocationFailed": "Sidecar-Speicherordner konnte nicht geöffnet werden"
},
"proxySettings": {
"enableProxy": "App-Proxy aktivieren",
"enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
@@ -873,6 +973,14 @@
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
},
"filenameTemplateProgress": {
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
"processing": "Verarbeitung ({processed}/{total}) – {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
"error": "Fehler: {error}"
},
"enrichHfAgent": "Metadaten mit KI anreichern"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
},
"actions": {
"openFileLocation": "Dateispeicherort öffnen",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "Keine Ordner für weitere Modelle gefunden",
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie Ihre Modellordner unter Einstellungen → Modellpfade hinzu und starten Sie LoRA Manager anschließend neu.",
"hintStandalone": "Es werden nur aktivierte Modelltypen gescannt. Aktivieren Sie die benötigten Typen unter Bibliothek → Standard-Roots.",
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
"openSettings": "Einstellungen öffnen"
"openSettings": "Einstellungen öffnen",
"openModelPaths": "Modellordner konfigurieren",
"openSettingsFolder": "Einstellungsordner öffnen"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
"notEmptyTitle": "Ordner ist nicht leer",
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"notEmptyMessageCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"notEmptyMessageExcluded": "Dieser Ordner enthält noch {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen. Heben Sie den Ausschluss unter „Ausgeschlossene Modelle verwalten“ auf und löschen Sie sie zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"busyTitle": "Eine Löschung steht noch aus",
"checking": "Ordnerinhalt wird geprüft...",
"confirm": "Ordner löschen"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "Ordner wiederhergestellt",
"failed": "Ordner konnte nicht gelöscht werden: {message}",
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"notEmptyWithCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "Wählen Sie ein Stammverzeichnis",
"selectModelRoot": "Modell-Stammverzeichnis auswählen:",
"selectTypeRoot": "{type}-Stammverzeichnis auswählen:",
"routingOverride": {
"label": "Zieltyp:",
"tooltip": "Automatisch aus den Modell-Metadaten erkannt. Wechseln Sie, wenn die Erkennung falsch war; ein Wechsel deaktiviert „Standardpfad verwenden“ für diesen Download."
},
"targetFolderPath": "Zielordnerpfad:",
"browseFolders": "Ordner durchsuchen:",
"createNewFolder": "Neuen Ordner erstellen",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}",
"metadata": "Metadaten: {name}",
"indexingFile": "Modelldatei wird gelesen...",
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
"fetchingMetadata": "Metadaten werden abgerufen...",
"transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --",
@@ -1554,6 +1679,33 @@
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
"action": "Alles prüfen"
},
"filenameTemplateConfirm": {
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "Sidecar-Dateien in die zentrale Speicherung verschieben?",
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
"confirmButton": "Jetzt verschieben",
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
"destination": "Ziel: {path}"
},
"sidecarMigrationResult": {
"title": "Sidecar-Migrationsübersicht",
"completedSuccessfully": "Migration erfolgreich abgeschlossen",
"completedWithErrors": "Abgeschlossen mit {count} Fehler(n)",
"statMoved": "Verschobene Dateien",
"statModels": "Modelle",
"statSkipped": "Übersprungen",
"statConflicts": "Gelöste Konflikte",
"statErrors": "Fehler",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"columnModel": "Modell",
"columnError": "Fehler",
"successMessage": "{moved} Dateien für {models} Modelle verschoben",
"location": "Speicherort: {path}"
},
"bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden",
"copyHash": "Hash kopieren",
"copyCivitaiId": "Civitai-ID kopieren",
"civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert",
"deleteModelWithShortcut": "Modell löschen (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "Basismodell",
"size": "Größe",
"hashes": "Hashes",
"civitaiModelId": "Modell-ID",
"civitaiVersionId": "Versions-ID",
"unknown": "Unbekannt",
"usageTips": "Nutzungstipps",
"additionalNotes": "Zusätzliche Notizen",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
"noModelsSelected": "Keine Modelle ausgewählt"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "Konflikte durch doppelte Dateinamen"
},
"sidecar_mirror_orphans": {
"title": "Zentralisierte Sidecar-Dateien"
},
"ui_version": {
"title": "UI-Version"
}
@@ -2694,6 +2858,11 @@
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
"enable": "Weitere Modelle aktivieren",
"openSettings": "Einstellungen öffnen"
},
"pager": {
"previous": "Vorherige Mitteilung",
"next": "Nächste Mitteilung",
"position": "Mitteilung {current} von {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "Cancel",
"confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "Reorder {item}, position {position} of {total}",
"announcement": "Moved to position {position} of {total}"
"dragHandle": "Drag to reorder"
},
"actions": {
"save": "Save",
@@ -253,7 +251,6 @@
"modelname": "Model Name",
"tags": "Tags",
"creator": "Creator",
"hash": "Hash",
"title": "Recipe Title",
"loraName": "LoRA Filename",
"loraModel": "LoRA Model Name",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "Configured",
"civitaiApiKeyNotConfigured": "Not configured",
"civitaiApiKeySet": "Set up",
"huggingfaceApiKey": "Hugging Face Access Token",
"huggingfaceApiKeyPlaceholder": "Enter your Hugging Face access token",
"huggingfaceApiKeyHelp": "Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
"huggingfaceApiKeyConfigured": "Configured",
"huggingfaceApiKeyNotConfigured": "Not configured",
"huggingfaceApiKeySet": "Set up",
"civitaiHost": {
"label": "CivitAI host",
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
@@ -349,6 +352,14 @@
"placeholder": "Leave empty to use aria2c from PATH"
},
"aria2HelpLink": "Learn how to set up the aria2 download backend",
"unknownBaseModelRouting": {
"label": "Unknown base model routing",
"help": "Decides where checkpoint downloads go when CivitAI reports a base model that is neither a known checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) nor a known diffusion model. New diffusion architectures appear frequently, so routing them to diffusion models is usually correct.",
"options": {
"diffusionModel": "Diffusion models",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "CivitAI host preference available",
"content": "CivitAI now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
@@ -379,12 +390,15 @@
"exampleImages": "Example Images",
"autoOrganize": "Auto-organize",
"metadata": "Metadata",
"sidecarStorage": "Sidecar Storage",
"proxySettings": "Proxy Settings"
},
"nav": {
"general": "General",
"interface": "Interface",
"library": "Library"
"library": "Library",
"organization": "Organization",
"modelPaths": "Model Paths"
},
"search": {
"placeholder": "Search settings...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
}
},
"modelPaths": {
"title": "Model Library Paths",
"description": "Root folders LoRA Manager scans for your models. These are the primary model locations read from settings.json in standalone mode.",
"restartRequired": "Requires restart to take effect",
"coreTypes": "Core Model Types",
"otherTypes": "Other Model Types",
"otherTypesDisabledHint": "No other model types are enabled. Turn on the types you need above to configure their folders.",
"saveSuccessRestart": "Model library paths updated. Restart required to apply changes.",
"pendingRestartNotice": "Path changes saved. Restart LoRA Manager for them to take effect.",
"pendingRestartBannerTitle": "Restart required to apply path changes",
"pendingRestartBannerMessage": "Model library paths were updated. Restart the LoRA Manager server to scan the new folders.",
"folderKeys": {
"loras": "LoRA Paths",
"checkpoints": "Checkpoint Paths",
"unet": "Diffusion Model Paths",
"embeddings": "Embedding Paths",
"vae": "VAE Paths",
"upscale_models": "Upscaler Paths",
"text_encoders": "Text Encoder Paths",
"clip": "CLIP Paths (legacy)",
"clip_vision": "CLIP Vision Paths",
"controlnet": "ControlNet Paths"
}
},
"directoryPicker": {
"title": "Browse Folders",
"selectFolder": "Select This Folder",
"goUp": "Up",
"pathPlaceholder": "Enter path...",
"go": "Go",
"emptyFolder": "No subfolders",
"loadError": "Failed to load directory"
},
"pathValidation": {
"valid": "Path is valid",
"pathNotFound": "Path does not exist",
"notADirectory": "Not a directory",
"notReadable": "Path is not readable",
"notWritable": "Path is not writable"
},
"priorityTags": {
"title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "Valid template"
}
},
"filenameTemplates": {
"title": "Filename Templates",
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "Available placeholders:",
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "Apply to Library Now",
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
"validation": {
"restoreOriginal": "Valid (empty template restores original filenames)",
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
"validTemplate": "Valid template"
}
},
"exampleImages": {
"downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images",
@@ -722,11 +792,41 @@
"downloadComplete": "Download completed successfully",
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
"enableOpenmodeldbApi": "Enable OpenModelDB as metadata provider",
"enableOpenmodeldbApiHelp": "When on, upscaler metadata is also looked up in the OpenModelDB catalogue (openmodeldb.info) when CivitAI has no record. The catalogue is cached locally and refreshed daily.",
"providerOrder": "Metadata provider fallback order",
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "Sidecar Storage Mode",
"modeHelp": "Choose where .metadata.json sidecars and preview images are stored: next to each model file, or in a single centralized directory that mirrors your library structure. .civitai.info files always stay next to the model file.",
"modeOptions": {
"alongside": "Alongside model files (default)",
"centralized": "Centralized storage"
},
"path": "Centralized Storage Path",
"pathHelp": "Root directory for centralized sidecar storage. Leave empty to use the default location (<settings dir>/sidecars).",
"pathPlaceholder": "Empty = <settings dir>/sidecars",
"management": "Sidecar Migration",
"managementHelp": "Move existing .metadata.json sidecars and preview images between alongside and centralized storage, matching the currently selected mode. Changing the mode does not move existing files automatically.",
"migrateButton": "Migrate Sidecars Now",
"migratingButton": "Migrating...",
"migrating": "Migrating sidecars...",
"migrateFailed": "Sidecar migration failed: {message}",
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
"confirmToAlongside": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the \"Migrate Sidecars Now\" button.",
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?",
"effectivePathLabel": "Effective storage location:",
"openFolderButton": "Open Folder",
"repoWarning": "The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
"openLocationSuccess": "Opened sidecar storage folder",
"openLocationCopied": "Sidecar storage path copied to clipboard: {path}",
"openLocationClipboardFallback": "Copy the sidecar storage path manually: {path}",
"openLocationFailed": "Failed to open the sidecar storage folder"
},
"proxySettings": {
"enableProxy": "Enable App-level Proxy",
"enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
@@ -873,6 +973,14 @@
"complete": "Auto-organize complete",
"error": "Error: {error}"
},
"filenameTemplateProgress": {
"initializing": "Initializing filename template apply...",
"starting": "Applying filename template to {type}...",
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "Filename template apply complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich Metadata with AI"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
},
"actions": {
"openFileLocation": "Open File Location",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "No other-model folders found",
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
"descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add your model folders under Settings → Model Paths, then restart LoRA Manager.",
"hintStandalone": "Only enabled model types are scanned; enable the types you need under Library → Folder Settings.",
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
"openSettings": "Open Settings"
"openSettings": "Open Settings",
"openModelPaths": "Configure Model Folders",
"openSettingsFolder": "Open Settings Folder"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
"notEmptyTitle": "Folder is not empty",
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
"notEmptyMessageCount": "This folder still contains {count} model file(s). Delete or move them first — deleting a folder never cascades over model files.",
"notEmptyMessageExcluded": "This folder still contains {count} model file(s), {excluded} of them excluded from the library. Un-exclude them in Manage Excluded Models and delete them first — deleting a folder never cascades over model files.",
"busyTitle": "A deletion is still pending",
"checking": "Checking the folder contents...",
"confirm": "Delete folder"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "Folder restored",
"failed": "Failed to delete folder: {message}",
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
"notEmptyWithCount": "This folder still contains {count} model file(s). Refresh the sidebar and try again.",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page",
"noRoot": "No model root is configured"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "Select a root directory",
"selectModelRoot": "Select Model Root:",
"selectTypeRoot": "Select {type} Root:",
"routingOverride": {
"label": "Destination type:",
"tooltip": "Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Target Folder Path:",
"browseFolders": "Browse Folders:",
"createNewFolder": "Create new folder",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "Current file:",
"downloading": "Downloading: {name}",
"metadata": "Metadata: {name}",
"indexingFile": "Reading model file...",
"fetchingSourceMetadata": "Fetching metadata from {source}...",
"fetchingMetadata": "Fetching metadata...",
"transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --",
@@ -1554,6 +1679,33 @@
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
"action": "Check All"
},
"filenameTemplateConfirm": {
"titleApply": "Apply filename template to library?",
"titleRevert": "Restore original filenames?",
"revertButton": "Restore Original Filenames"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "Move sidecars to centralized storage?",
"titleToAlongside": "Move sidecars back next to model files?",
"confirmButton": "Migrate Now",
"titleRelocateRoot": "Move sidecars to the new storage directory?",
"destination": "Destination: {path}"
},
"sidecarMigrationResult": {
"title": "Sidecar Migration Summary",
"completedSuccessfully": "Migration completed successfully",
"completedWithErrors": "Completed with {count} error(s)",
"statMoved": "Moved Files",
"statModels": "Models",
"statSkipped": "Skipped",
"statConflicts": "Conflicts Resolved",
"statErrors": "Errors",
"failedItems": "Failed Items ({count})",
"columnModel": "Model",
"columnError": "Error",
"successMessage": "Moved {moved} files for {models} models",
"location": "Storage location: {path}"
},
"bulkAddTags": {
"title": "Add Tags to Multiple Models",
"description": "Add tags to",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI",
"copyHash": "Copy hash",
"copyCivitaiId": "Copy Civitai ID",
"civitaiIdCopied": "Civitai ID copied to clipboard",
"deleteModelWithShortcut": "Delete model (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "Base Model",
"size": "Size",
"hashes": "Hashes",
"civitaiModelId": "Model ID",
"civitaiVersionId": "Version ID",
"unknown": "Unknown",
"usageTips": "Usage Tips",
"additionalNotes": "Additional Notes",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}",
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "Applying filename template failed: {error}",
"noModelsSelected": "No models selected"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
"filenameTemplatesUpdated": "Filename templates updated",
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
"recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "Duplicate Filename Conflicts"
},
"sidecar_mirror_orphans": {
"title": "Centralized Sidecars"
},
"ui_version": {
"title": "UI Version"
}
@@ -2694,6 +2858,11 @@
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
"enable": "Enable Other Models",
"openSettings": "Open Settings"
},
"pager": {
"previous": "Previous message",
"next": "Next message",
"position": "Message {current} of {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "Cancelar",
"confirm": "Confirmar",
"reorder": {
"dragHandle": "Arrastra para reordenar (Alt + ↑/↓)",
"ariaLabel": "Reordenar {item}, posición {position} de {total}",
"announcement": "Movido a la posición {position} de {total}"
"dragHandle": "Arrastra para reordenar"
},
"actions": {
"save": "Guardar",
@@ -253,7 +251,6 @@
"modelname": "Nombre del modelo",
"tags": "Etiquetas",
"creator": "Creador",
"hash": "Hash",
"title": "Título de la receta",
"loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "Configurado",
"civitaiApiKeyNotConfigured": "No configurado",
"civitaiApiKeySet": "Configurar",
"huggingfaceApiKey": "Token de acceso de Hugging Face",
"huggingfaceApiKeyPlaceholder": "Introduce tu token de acceso de Hugging Face",
"huggingfaceApiKeyHelp": "Necesario para descargar de repositorios de Hugging Face restringidos (gated) o privados. Crea un token de solo lectura en huggingface.co/settings/tokens y acepta primero los términos del repositorio en su página.",
"huggingfaceApiKeyConfigured": "Configurado",
"huggingfaceApiKeyNotConfigured": "No configurado",
"huggingfaceApiKeySet": "Configurar",
"civitaiHost": {
"label": "Host de CivitAI",
"help": "Elige qué sitio de CivitAI se abre al usar los enlaces de \"View on CivitAI\".",
@@ -349,6 +352,14 @@
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
},
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
"unknownBaseModelRouting": {
"label": "Enrutamiento de modelos base desconocidos",
"help": "Decide a dónde van las descargas de Checkpoint cuando CivitAI informa de un modelo base que no es ni un Checkpoint conocido (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modelo de difusión conocido. Las nuevas arquitecturas de difusión aparecen con frecuencia, por lo que enrutarlas a modelos de difusión suele ser correcto.",
"options": {
"diffusionModel": "Modelos de difusión",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "Preferencia de host de CivitAI disponible",
"content": "CivitAI ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
@@ -379,12 +390,15 @@
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
"sidecarStorage": "Almacenamiento de archivos sidecar",
"proxySettings": "Configuración de proxy"
},
"nav": {
"general": "General",
"interface": "Interfaz",
"library": "Biblioteca"
"library": "Biblioteca",
"organization": "Organización",
"modelPaths": "Rutas de modelos"
},
"search": {
"placeholder": "Buscar ajustes...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
}
},
"modelPaths": {
"title": "Rutas de la biblioteca de modelos",
"description": "Carpetas raíz que LoRA Manager escanea en busca de tus modelos. Son las ubicaciones de modelos principales leídas de settings.json en modo independiente.",
"restartRequired": "Requiere reiniciar para que surta efecto",
"coreTypes": "Tipos de modelos principales",
"otherTypes": "Otros tipos de modelos",
"otherTypesDisabledHint": "No hay habilitado ningún otro tipo de modelo. Activa los tipos que necesites arriba para configurar sus carpetas.",
"saveSuccessRestart": "Rutas de la biblioteca de modelos actualizadas. Se requiere reinicio para aplicar los cambios.",
"pendingRestartNotice": "Cambios de rutas guardados. Reinicia LoRA Manager para que surtan efecto.",
"pendingRestartBannerTitle": "Se requiere reinicio para aplicar los cambios de rutas",
"pendingRestartBannerMessage": "Se actualizaron las rutas de la biblioteca de modelos. Reinicia el servidor de LoRA Manager para escanear las nuevas carpetas.",
"folderKeys": {
"loras": "Rutas de LoRA",
"checkpoints": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embeddings": "Rutas de Embedding",
"vae": "Rutas de VAE",
"upscale_models": "Rutas de Upscaler",
"text_encoders": "Rutas de Text Encoder",
"clip": "Rutas de CLIP (heredadas)",
"clip_vision": "Rutas de CLIP Vision",
"controlnet": "Rutas de ControlNet"
}
},
"directoryPicker": {
"title": "Explorar carpetas",
"selectFolder": "Seleccionar esta carpeta",
"goUp": "Subir",
"pathPlaceholder": "Introducir ruta...",
"go": "Ir",
"emptyFolder": "No hay subcarpetas",
"loadError": "Error al cargar el directorio"
},
"pathValidation": {
"valid": "La ruta es válida",
"pathNotFound": "La ruta no existe",
"notADirectory": "No es un directorio",
"notReadable": "La ruta no es legible",
"notWritable": "La ruta no es escribible"
},
"priorityTags": {
"title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "Plantilla válida"
}
},
"filenameTemplates": {
"title": "Plantillas de nombres de archivo",
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
"availablePlaceholders": "Marcadores de posición disponibles:",
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
"applyButton": "Aplicar a la biblioteca ahora",
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
"validation": {
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
"validTemplate": "Plantilla válida"
}
},
"exampleImages": {
"downloadLocation": "Ubicación de descarga",
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
@@ -722,11 +792,41 @@
"downloadComplete": "Descarga completada exitosamente",
"enableCivarchiveApi": "Habilitar CivArchive API como proveedor de metadatos",
"enableCivarchiveApiHelp": "Al activarlo, la API de CivArchive se usa como fuente alternativa de metadatos de modelos (p. ej. para modelos eliminados de CivitAI). Desactívelo para evitar por completo los límites de velocidad de CivArchive.",
"enableOpenmodeldbApi": "Habilitar OpenModelDB como proveedor de metadatos",
"enableOpenmodeldbApiHelp": "Al activarlo, los metadatos de Upscaler también se consultan en el catálogo de OpenModelDB (openmodeldb.info) cuando CivitAI no tiene ningún registro. El catálogo se almacena en caché localmente y se actualiza a diario.",
"providerOrder": "Orden de proveedores de metadatos de respaldo",
"providerOrderHelp": "La API de CivitAI siempre se intenta primero. Elija el orden de los demás proveedores al buscar metadatos.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "Modo de almacenamiento de archivos sidecar",
"modeHelp": "Elige dónde se guardan los archivos sidecar .metadata.json y las imágenes de vista previa: junto a cada archivo de modelo, o en un único directorio centralizado que refleja la estructura de tu biblioteca. Los archivos .civitai.info siempre permanecen junto al archivo de modelo.",
"modeOptions": {
"alongside": "Junto a los archivos de modelo (predeterminado)",
"centralized": "Almacenamiento centralizado"
},
"path": "Ruta del almacenamiento centralizado",
"pathHelp": "Carpeta raíz del almacenamiento centralizado de archivos sidecar. Déjalo vacío para usar la ubicación predeterminada (<settings dir>/sidecars).",
"pathPlaceholder": "Vacío = <settings dir>/sidecars",
"management": "Migración de archivos sidecar",
"managementHelp": "Mueve los archivos sidecar .metadata.json y las imágenes de vista previa existentes entre el almacenamiento junto a los modelos y el almacenamiento centralizado, según el modo seleccionado. Cambiar el modo no mueve los archivos existentes automáticamente.",
"migrateButton": "Migrar archivos sidecar ahora",
"migratingButton": "Migrando...",
"migrating": "Migrando archivos sidecar...",
"migrateFailed": "Error al migrar los archivos sidecar: {message}",
"migrationDeferred": "Los archivos sidecar existentes no se movieron. Puedes migrarlos más tarde desde Configuración → Biblioteca → Almacenamiento de archivos sidecar.",
"confirmToCentralized": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Moverlos ahora al directorio de almacenamiento centralizado? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
"confirmToAlongside": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Devolverlos ahora junto a sus archivos de modelo? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?",
"effectivePathLabel": "Ubicación de almacenamiento efectiva:",
"openFolderButton": "Abrir carpeta",
"repoWarning": "La ubicación de almacenamiento efectiva está dentro de la carpeta de instalación de LoRA Manager. Reinstalar el plugin o una actualización limpia puede eliminarla — define una ruta de almacenamiento explícita fuera de la carpeta de instalación.",
"openLocationSuccess": "Carpeta de almacenamiento de archivos sidecar abierta",
"openLocationCopied": "Ruta de almacenamiento de archivos sidecar copiada al portapapeles: {path}",
"openLocationClipboardFallback": "Copia manualmente la ruta de almacenamiento de archivos sidecar: {path}",
"openLocationFailed": "No se pudo abrir la carpeta de almacenamiento de archivos sidecar"
},
"proxySettings": {
"enableProxy": "Habilitar proxy a nivel de aplicación",
"enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
@@ -873,6 +973,14 @@
"complete": "Auto-organización completada",
"error": "Error: {error}"
},
"filenameTemplateProgress": {
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
"complete": "Aplicación de plantilla de nombres de archivo completada",
"error": "Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos con IA"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
},
"actions": {
"openFileLocation": "Abrir ubicación del archivo",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "No se encontraron carpetas de otros modelos",
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
"descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade tus carpetas de modelos en Configuración → Rutas de modelos y reinicia LoRA Manager.",
"hintStandalone": "Solo se escanean los tipos de modelos habilitados; activa los tipos que necesites en Biblioteca → Raíces predeterminadas.",
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
"openSettings": "Abrir configuración"
"openSettings": "Abrir configuración",
"openModelPaths": "Configurar carpetas de modelos",
"openSettingsFolder": "Abrir carpeta de ajustes"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
"notEmptyTitle": "La carpeta no está vacía",
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"notEmptyMessageCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"notEmptyMessageExcluded": "Esta carpeta aún contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca. Anula su exclusión en «Gestionar modelos excluidos» y elimínalos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"busyTitle": "Todavía hay una eliminación pendiente",
"checking": "Comprobando el contenido de la carpeta...",
"confirm": "Eliminar carpeta"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "Carpeta restaurada",
"failed": "Error al eliminar la carpeta: {message}",
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
"notEmptyWithCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "La eliminación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "Selecciona un directorio raíz",
"selectModelRoot": "Seleccionar raíz del modelo:",
"selectTypeRoot": "Seleccionar raíz de {type}:",
"routingOverride": {
"label": "Tipo de destino:",
"tooltip": "Detectado automáticamente a partir de los metadatos del modelo. Cámbialo si la detección fue incorrecta; cambiar desactiva «Usar ruta predeterminada» para esta descarga."
},
"targetFolderPath": "Ruta de carpeta de destino:",
"browseFolders": "Explorar carpetas:",
"createNewFolder": "Crear nueva carpeta",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "Archivo actual:",
"downloading": "Descargando: {name}",
"metadata": "Metadatos: {name}",
"indexingFile": "Leyendo el archivo de modelo...",
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
"fetchingMetadata": "Obteniendo metadatos...",
"transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --",
@@ -1554,6 +1679,33 @@
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
"action": "Comprobar todo"
},
"filenameTemplateConfirm": {
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
"titleRevert": "¿Restaurar los nombres de archivo originales?",
"revertButton": "Restaurar nombres de archivo originales"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "¿Mover los archivos sidecar al almacenamiento centralizado?",
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
"confirmButton": "Migrar ahora",
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
"destination": "Destino: {path}"
},
"sidecarMigrationResult": {
"title": "Resumen de la migración de archivos sidecar",
"completedSuccessfully": "Migración completada correctamente",
"completedWithErrors": "Completado con {count} error(es)",
"statMoved": "Archivos movidos",
"statModels": "Modelos",
"statSkipped": "Omitidos",
"statConflicts": "Conflictos resueltos",
"statErrors": "Errores",
"failedItems": "Elementos fallidos ({count})",
"columnModel": "Modelo",
"columnError": "Error",
"successMessage": "Se movieron {moved} archivos para {models} modelos",
"location": "Ubicación de almacenamiento: {path}"
},
"bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI",
"copyHash": "Copiar hash",
"copyCivitaiId": "Copiar ID de Civitai",
"civitaiIdCopied": "ID de Civitai copiado al portapapeles",
"deleteModelWithShortcut": "Eliminar modelo (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "Modelo base",
"size": "Tamaño",
"hashes": "Hashes",
"civitaiModelId": "ID del modelo",
"civitaiVersionId": "ID de versión",
"unknown": "Desconocido",
"usageTips": "Consejos de uso",
"additionalNotes": "Notas adicionales",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}",
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
"noModelsSelected": "No hay modelos seleccionados"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "Conflictos de nombres de archivo duplicados"
},
"sidecar_mirror_orphans": {
"title": "Archivos sidecar centralizados"
},
"ui_version": {
"title": "Versión de la interfaz"
}
@@ -2694,6 +2858,11 @@
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
"enable": "Activar otros modelos",
"openSettings": "Abrir configuración"
},
"pager": {
"previous": "Notificación anterior",
"next": "Notificación siguiente",
"position": "Notificación {current} de {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "Annuler",
"confirm": "Confirmer",
"reorder": {
"dragHandle": "Glisser pour réordonner (Alt + ↑/↓)",
"ariaLabel": "Réordonner {item}, position {position} sur {total}",
"announcement": "Déplacé en position {position} sur {total}"
"dragHandle": "Glisser pour réordonner"
},
"actions": {
"save": "Enregistrer",
@@ -253,7 +251,6 @@
"modelname": "Nom du modèle",
"tags": "Tags",
"creator": "Créateur",
"hash": "Hash",
"title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "Configuré",
"civitaiApiKeyNotConfigured": "Non configuré",
"civitaiApiKeySet": "Configurer",
"huggingfaceApiKey": "Jeton d'accès Hugging Face",
"huggingfaceApiKeyPlaceholder": "Entrez votre jeton d'accès Hugging Face",
"huggingfaceApiKeyHelp": "Nécessaire pour télécharger depuis des dépôts Hugging Face restreints (gated) ou privés. Créez un jeton en lecture seule sur huggingface.co/settings/tokens, puis acceptez d'abord les conditions du dépôt sur sa page.",
"huggingfaceApiKeyConfigured": "Configuré",
"huggingfaceApiKeyNotConfigured": "Non configuré",
"huggingfaceApiKeySet": "Configurer",
"civitaiHost": {
"label": "Hôte CivitAI",
"help": "Choisissez quel site CivitAI s'ouvre lorsque vous utilisez les liens « View on CivitAI ».",
@@ -349,6 +352,14 @@
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
},
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
"unknownBaseModelRouting": {
"label": "Routage des modèles de base inconnus",
"help": "Détermine où vont les téléchargements de Checkpoint lorsque CivitAI signale un modèle de base qui n’est ni un Checkpoint connu (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modèle de diffusion connu. De nouvelles architectures de diffusion apparaissent fréquemment, donc les router vers les modèles de diffusion est généralement correct.",
"options": {
"diffusionModel": "Modèles de diffusion",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "Préférence d’hôte CivitAI disponible",
"content": "CivitAI utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
@@ -379,12 +390,15 @@
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
"sidecarStorage": "Stockage des fichiers sidecar",
"proxySettings": "Paramètres du proxy"
},
"nav": {
"general": "Général",
"interface": "Interface",
"library": "Bibliothèque"
"library": "Bibliothèque",
"organization": "Organisation",
"modelPaths": "Chemins de modèles"
},
"search": {
"placeholder": "Rechercher dans les paramètres...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
}
},
"modelPaths": {
"title": "Chemins de la bibliothèque de modèles",
"description": "Dossiers racine que LoRA Manager analyse pour trouver vos modèles. Ce sont les emplacements de modèles principaux lus depuis settings.json en mode autonome.",
"restartRequired": "Un redémarrage est requis pour appliquer les changements",
"coreTypes": "Types de modèles principaux",
"otherTypes": "Autres types de modèles",
"otherTypesDisabledHint": "Aucun autre type de modèle n’est activé. Activez les types dont vous avez besoin ci-dessus pour configurer leurs dossiers.",
"saveSuccessRestart": "Chemins de la bibliothèque de modèles mis à jour. Redémarrage requis pour appliquer les changements.",
"pendingRestartNotice": "Changements de chemins enregistrés. Redémarrez LoRA Manager pour qu’ils prennent effet.",
"pendingRestartBannerTitle": "Redémarrage requis pour appliquer les changements de chemins",
"pendingRestartBannerMessage": "Les chemins de la bibliothèque de modèles ont été mis à jour. Redémarrez le serveur LoRA Manager pour analyser les nouveaux dossiers.",
"folderKeys": {
"loras": "Chemins LoRA",
"checkpoints": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embeddings": "Chemins Embedding",
"vae": "Chemins VAE",
"upscale_models": "Chemins Upscaler",
"text_encoders": "Chemins Text Encoder",
"clip": "Chemins CLIP (hérité)",
"clip_vision": "Chemins CLIP Vision",
"controlnet": "Chemins ControlNet"
}
},
"directoryPicker": {
"title": "Parcourir les dossiers",
"selectFolder": "Sélectionner ce dossier",
"goUp": "Remonter",
"pathPlaceholder": "Saisir un chemin...",
"go": "Aller",
"emptyFolder": "Aucun sous-dossier",
"loadError": "Échec du chargement du dossier"
},
"pathValidation": {
"valid": "Le chemin est valide",
"pathNotFound": "Le chemin n’existe pas",
"notADirectory": "N’est pas un dossier",
"notReadable": "Le chemin n’est pas lisible",
"notWritable": "Le chemin n’est pas accessible en écriture"
},
"priorityTags": {
"title": "Tags prioritaires",
"description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "Modèle valide"
}
},
"filenameTemplates": {
"title": "Modèles de nom de fichier",
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
"availablePlaceholders": "Espaces réservés disponibles :",
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
"applyButton": "Appliquer à la bibliothèque maintenant",
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
"validation": {
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
"validTemplate": "Modèle valide"
}
},
"exampleImages": {
"downloadLocation": "Emplacement de téléchargement",
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
@@ -722,11 +792,41 @@
"downloadComplete": "Téléchargement terminé avec succès",
"enableCivarchiveApi": "Activer l'API CivArchive comme fournisseur de métadonnées",
"enableCivarchiveApiHelp": "Lorsqu'elle est activée, l'API CivArchive est utilisée comme source de secours pour les métadonnées des modèles (par ex. pour les modèles supprimés de CivitAI). Désactivez pour éviter entièrement les limites de débit de CivArchive.",
"enableOpenmodeldbApi": "Activer OpenModelDB comme fournisseur de métadonnées",
"enableOpenmodeldbApiHelp": "Lorsqu’elle est activée, les métadonnées des Upscaler sont également recherchées dans le catalogue OpenModelDB (openmodeldb.info) lorsque CivitAI n’a aucune fiche. Le catalogue est mis en cache localement et actualisé quotidiennement.",
"providerOrder": "Ordre de secours des fournisseurs de métadonnées",
"providerOrderHelp": "L'API CivitAI est toujours essayée en premier. Choisissez l'ordre des autres fournisseurs lors de la recherche de métadonnées.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "Mode de stockage des fichiers sidecar",
"modeHelp": "Choisissez où sont stockés les fichiers sidecar .metadata.json et les images d’aperçu : à côté de chaque fichier de modèle, ou dans un seul dossier centralisé qui reflète la structure de votre bibliothèque. Les fichiers .civitai.info restent toujours à côté du fichier de modèle.",
"modeOptions": {
"alongside": "À côté des fichiers de modèle (par défaut)",
"centralized": "Stockage centralisé"
},
"path": "Chemin du stockage centralisé",
"pathHelp": "Dossier racine du stockage centralisé des fichiers sidecar. Laissez vide pour utiliser l’emplacement par défaut (<settings dir>/sidecars).",
"pathPlaceholder": "Vide = <settings dir>/sidecars",
"management": "Migration des fichiers sidecar",
"managementHelp": "Déplacez les fichiers sidecar .metadata.json et les images d’aperçu existants entre le stockage à côté des modèles et le stockage centralisé, selon le mode sélectionné. Changer de mode ne déplace pas les fichiers existants automatiquement.",
"migrateButton": "Migrer les fichiers sidecar maintenant",
"migratingButton": "Migration en cours...",
"migrating": "Migration des fichiers sidecar en cours...",
"migrateFailed": "Échec de la migration des fichiers sidecar : {message}",
"migrationDeferred": "Les fichiers sidecar existants n’ont pas été déplacés. Vous pouvez les migrer plus tard depuis Paramètres → Bibliothèque → Stockage des fichiers sidecar.",
"confirmToCentralized": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les déplacer maintenant dans le dossier de stockage centralisé ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
"confirmToAlongside": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les remettre maintenant à côté de leurs fichiers de modèle ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?",
"effectivePathLabel": "Emplacement de stockage effectif :",
"openFolderButton": "Ouvrir le dossier",
"repoWarning": "L’emplacement de stockage effectif se trouve dans le dossier d’installation de LoRA Manager. Une réinstallation de l’extension ou une mise à jour propre peut le supprimer — définissez un chemin de stockage explicite en dehors du dossier d’installation.",
"openLocationSuccess": "Dossier de stockage des fichiers sidecar ouvert",
"openLocationCopied": "Chemin de stockage des fichiers sidecar copié dans le presse-papiers : {path}",
"openLocationClipboardFallback": "Copiez manuellement le chemin de stockage des fichiers sidecar : {path}",
"openLocationFailed": "Échec de l’ouverture du dossier de stockage des fichiers sidecar"
},
"proxySettings": {
"enableProxy": "Activer le proxy au niveau de l'application",
"enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
@@ -873,6 +973,14 @@
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
},
"filenameTemplateProgress": {
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
"starting": "Application du modèle de nom de fichier pour {type}...",
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
"complete": "Application du modèle de nom de fichier terminée",
"error": "Erreur : {error}"
},
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
},
"actions": {
"openFileLocation": "Ouvrir l’emplacement du fichier",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "Aucun dossier d’autres modèles trouvé",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier d’autres modèles n’a été trouvé. Ajoutez vos dossiers de modèles dans Paramètres → Chemins de modèles, puis redémarrez LoRA Manager.",
"hintStandalone": "Seuls les types de modèles activés sont analysés ; activez les types dont vous avez besoin dans Bibliothèque → Racines par défaut.",
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
"openSettings": "Ouvrir les paramètres"
"openSettings": "Ouvrir les paramètres",
"openModelPaths": "Configurer les dossiers de modèles",
"openSettingsFolder": "Ouvrir le dossier des paramètres"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
"notEmptyTitle": "Le dossier n’est pas vide",
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"notEmptyMessageCount": "Ce dossier contient encore {count} fichier(s) de modèle. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"notEmptyMessageExcluded": "Ce dossier contient encore {count} fichier(s) de modèle, dont {excluded} sont exclus de la bibliothèque. Annulez leur exclusion dans « Gérer les modèles exclus » et supprimez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
"busyTitle": "Une suppression est encore en attente",
"checking": "Vérification du contenu du dossier...",
"confirm": "Supprimer le dossier"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "Dossier restauré",
"failed": "Échec de la suppression du dossier : {message}",
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
"notEmptyWithCount": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle n’est configurée"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "Sélectionner un répertoire racine",
"selectModelRoot": "Sélectionner la racine du modèle :",
"selectTypeRoot": "Sélectionner la racine {type} :",
"routingOverride": {
"label": "Type de destination :",
"tooltip": "Détecté automatiquement à partir des métadonnées du modèle. Changez si la détection est incorrecte ; changer désactive « Utiliser le chemin par défaut » pour ce téléchargement."
},
"targetFolderPath": "Chemin du dossier cible :",
"browseFolders": "Parcourir les dossiers :",
"createNewFolder": "Créer un nouveau dossier",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}",
"metadata": "Métadonnées : {name}",
"indexingFile": "Lecture du fichier de modèle...",
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
"fetchingMetadata": "Récupération des métadonnées...",
"transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --",
@@ -1554,6 +1679,33 @@
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
"action": "Tout vérifier"
},
"filenameTemplateConfirm": {
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
"titleRevert": "Restaurer les noms de fichier d'origine ?",
"revertButton": "Restaurer les noms de fichier d'origine"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "Déplacer les fichiers sidecar vers le stockage centralisé ?",
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
"confirmButton": "Migrer maintenant",
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
"destination": "Destination : {path}"
},
"sidecarMigrationResult": {
"title": "Résumé de la migration des fichiers sidecar",
"completedSuccessfully": "Migration terminée avec succès",
"completedWithErrors": "Terminé avec {count} erreur(s)",
"statMoved": "Fichiers déplacés",
"statModels": "Modèles",
"statSkipped": "Ignorés",
"statConflicts": "Conflits résolus",
"statErrors": "Erreurs",
"failedItems": "Éléments en échec ({count})",
"columnModel": "Modèle",
"columnError": "Erreur",
"successMessage": "{moved} fichiers déplacés pour {models} modèles",
"location": "Emplacement de stockage : {path}"
},
"bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI",
"copyHash": "Copier le hash",
"copyCivitaiId": "Copier l’ID Civitai",
"civitaiIdCopied": "ID Civitai copié dans le presse-papiers",
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "Modèle de base",
"size": "Taille",
"hashes": "Hashes",
"civitaiModelId": "ID du modèle",
"civitaiVersionId": "ID de version",
"unknown": "Inconnu",
"usageTips": "Conseils d'utilisation",
"additionalNotes": "Notes supplémentaires",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
"noModelsSelected": "Aucun modèle sélectionné"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "Conflits de noms de fichiers en double"
},
"sidecar_mirror_orphans": {
"title": "Fichiers sidecar centralisés"
},
"ui_version": {
"title": "Version de l'interface"
}
@@ -2694,6 +2858,11 @@
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
"enable": "Activer les autres modèles",
"openSettings": "Ouvrir les paramètres"
},
"pager": {
"previous": "Message précédent",
"next": "Message suivant",
"position": "Message {current} sur {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "ביטול",
"confirm": "אישור",
"reorder": {
"dragHandle": "גרור כדי לשנות סדר (Alt + ↑/↓)",
"ariaLabel": "סדר מחדש את {item}, מיקום {position} מתוך {total}",
"announcement": "הועבר למיקום {position} מתוך {total}"
"dragHandle": "גרור כדי לשנות סדר"
},
"actions": {
"save": "שמירה",
@@ -253,7 +251,6 @@
"modelname": "שם מודל",
"tags": "תגיות",
"creator": "יוצר",
"hash": "hash",
"title": "כותרת מתכון",
"loraName": "שם קובץ LoRA",
"loraModel": "שם מודל LoRA",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "מוגדר",
"civitaiApiKeyNotConfigured": "לא מוגדר",
"civitaiApiKeySet": "הגדר",
"huggingfaceApiKey": "אסימון גישה של Hugging Face",
"huggingfaceApiKeyPlaceholder": "הזן את אסימון הגישה שלך מ-Hugging Face",
"huggingfaceApiKeyHelp": "נדרש להורדה ממאגרי Hugging Face מוגבלים (gated) או פרטיים. צור אסימון לקריאה-בלבד בכתובת huggingface.co/settings/tokens ואשר תחילה את תנאי המאגר בעמוד שלו.",
"huggingfaceApiKeyConfigured": "מוגדר",
"huggingfaceApiKeyNotConfigured": "לא מוגדר",
"huggingfaceApiKeySet": "הגדר",
"civitaiHost": {
"label": "מארח CivitAI",
"help": "בחר איזה אתר של CivitAI ייפתח בעת שימוש בקישורי \"View on CivitAI\".",
@@ -349,6 +352,14 @@
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
},
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
"unknownBaseModelRouting": {
"label": "ניתוב מודל בסיס לא מוכר",
"help": "קובע לאן מועברות הורדות של Checkpoint כאשר CivitAI מדווח על מודל בסיס שהוא לא Checkpoint מוכר (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ולא מודל דיפוזיה מוכר. ארכיטקטורות דיפוזיה חדשות מופיעות לעיתים קרובות, ולכן ניתוב שלהן למודלי דיפוזיה הוא בדרך כלל הנכון.",
"options": {
"diffusionModel": "מודלי דיפוזיה",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "העדפת מארח CivitAI זמינה",
"content": "CivitAI משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
@@ -379,12 +390,15 @@
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
"sidecarStorage": "אחסון קובצי לוואי",
"proxySettings": "הגדרות פרוקסי"
},
"nav": {
"general": "כללי",
"interface": "ממשק",
"library": "ספרייה"
"library": "ספרייה",
"organization": "ארגון",
"modelPaths": "נתיבי מודלים"
},
"search": {
"placeholder": "חיפוש בהגדרות...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
}
},
"modelPaths": {
"title": "נתיבי ספריית המודלים",
"description": "תיקיות שורש ש-LoRA Manager סורק לאיתור המודלים שלך. אלו מיקומי המודלים הראשיים הנקראים מ-settings.json במצב עצמאי.",
"restartRequired": "נדרש אתחול כדי שהשינוי ייכנס לתוקף",
"coreTypes": "סוגי מודלים מרכזיים",
"otherTypes": "סוגי מודלים אחרים",
"otherTypesDisabledHint": "לא מופעלים סוגי מודלים אחרים. הפעל למעלה את הסוגים הדרושים לך כדי להגדיר את התיקיות שלהם.",
"saveSuccessRestart": "נתיבי ספריית המודלים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"pendingRestartNotice": "שינויי הנתיבים נשמרו. הפעל מחדש את LoRA Manager כדי שייכנסו לתוקף.",
"pendingRestartBannerTitle": "נדרשת הפעלה מחדש כדי להחיל את שינויי הנתיבים",
"pendingRestartBannerMessage": "נתיבי ספריית המודלים עודכנו. הפעל מחדש את שרת LoRA Manager כדי לסרוק את התיקיות החדשות.",
"folderKeys": {
"loras": "נתיבי LoRA",
"checkpoints": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embeddings": "נתיבי Embedding",
"vae": "נתיבי VAE",
"upscale_models": "נתיבי Upscaler",
"text_encoders": "נתיבי Text Encoder",
"clip": "נתיבי CLIP (ישן)",
"clip_vision": "נתיבי CLIP Vision",
"controlnet": "נתיבי ControlNet"
}
},
"directoryPicker": {
"title": "עיון בתיקיות",
"selectFolder": "בחר תיקייה זו",
"goUp": "למעלה",
"pathPlaceholder": "הזן נתיב...",
"go": "עבור",
"emptyFolder": "אין תתי-תיקיות",
"loadError": "טעינת התיקייה נכשלה"
},
"pathValidation": {
"valid": "הנתיב תקין",
"pathNotFound": "הנתיב לא קיים",
"notADirectory": "לא תיקייה",
"notReadable": "הנתיב לא ניתן לקריאה",
"notWritable": "הנתיב לא ניתן לכתיבה"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "תבנית תקינה"
}
},
"filenameTemplates": {
"title": "תבניות שמות קבצים",
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
"availablePlaceholders": "מצייני מקום זמינים:",
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
"applyButton": "החל על הספרייה כעת",
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
"validation": {
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
"validTemplate": "תבנית תקינה"
}
},
"exampleImages": {
"downloadLocation": "מיקום הורדה",
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
@@ -722,11 +792,41 @@
"downloadComplete": "ההורדה הושלמה בהצלחה",
"enableCivarchiveApi": "הפעל את CivArchive API כספק מטא-נתונים",
"enableCivarchiveApiHelp": "כאשר מופעל, CivArchive API משמש כמקור גיבוי למטא-נתונים של מודלים (למשל עבור מודלים שנמחקו מ-CivitAI). כבה כדי להימנע לחלוטין ממגבלות הקצב של CivArchive.",
"enableOpenmodeldbApi": "הפעל את OpenModelDB כספק מטא-נתונים",
"enableOpenmodeldbApiHelp": "כאשר מופעל, מטא-נתונים של Upscaler מחופשים גם בקטלוג OpenModelDB (openmodeldb.info) כאשר ל-CivitAI אין רשומה. הקטלוג נשמר במטמון מקומי ומתרענן מדי יום.",
"providerOrder": "סדר ספקי מטא-נתונים לגיבוי",
"providerOrderHelp": "CivitAI API תמיד מנוסה ראשון. בחר את סדר הספקים הנותרים בעת חיפוש מטא-נתונים.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "מצב אחסון קובצי לוואי",
"modeHelp": "בחר היכן יאוחסנו קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה: לצד כל קובץ מודל, או בתיקייה מרכזית אחת שמשקפת את מבנה הספרייה שלך. קובצי .civitai.info נשארים תמיד לצד קובץ המודל.",
"modeOptions": {
"alongside": "לצד קובצי המודלים (ברירת מחדל)",
"centralized": "אחסון מרכזי"
},
"path": "נתיב האחסון המרכזי",
"pathHelp": "תיקיית השורש של אחסון קובצי הלוואי המרכזי. השאר ריק כדי להשתמש במיקום ברירת המחדל <settings dir>/sidecars.",
"pathPlaceholder": "ריק = <settings dir>/sidecars",
"management": "העברת קובצי לוואי",
"managementHelp": "העבר קובצי לוואי .metadata.json ותמונות תצוגה מקדימה קיימים בין אחסון לצד המודלים לאחסון המרכזי, בהתאם למצב שנבחר. שינוי המצב אינו מעביר קבצים קיימים באופן אוטומטי.",
"migrateButton": "העבר קובצי לוואי כעת",
"migratingButton": "מעביר...",
"migrating": "מעביר קובצי לוואי...",
"migrateFailed": "העברת קובצי הלוואי נכשלה: {message}",
"migrationDeferred": "קובצי הלוואי הקיימים לא הועברו. ניתן להעביר אותם מאוחר יותר דרך הגדרות > ספרייה > אחסון קובצי לוואי.",
"confirmToCentralized": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להעביר אותם כעת לתיקיית האחסון המרכזי? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
"confirmToAlongside": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להחזיר אותם כעת לצד קובצי המודל שלהם? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?",
"effectivePathLabel": "מיקום האחסון בפועל:",
"openFolderButton": "פתח תיקייה",
"repoWarning": "מיקום האחסון בפועל נמצא בתוך תיקיית ההתקנה של LoRA Manager. התקנה מחדש של התוסף או עדכון נקי עלולים למחוק אותו — הגדר נתיב אחסון מפורש מחוץ לתיקיית ההתקנה.",
"openLocationSuccess": "תיקיית אחסון קובצי הלוואי נפתחה",
"openLocationCopied": "נתיב אחסון קובצי הלוואי הועתק ללוח: {path}",
"openLocationClipboardFallback": "העתק את נתיב אחסון קובצי הלוואי ידנית: {path}",
"openLocationFailed": "פתיחת תיקיית אחסון קובצי הלוואי נכשלה"
},
"proxySettings": {
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
"enableProxyHelp": "אפשר הגדרות פרוקסי מותאמות אישית עבור יישום זה, במקום הגדרות הפרוקסי של המערכת",
@@ -873,6 +973,14 @@
"complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}"
},
"filenameTemplateProgress": {
"initializing": "מאתחל החלת תבנית שם קובץ...",
"starting": "מחיל תבנית שם קובץ על {type}...",
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"complete": "החלת תבנית שם הקובץ הושלמה",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
},
"actions": {
"openFileLocation": "פתח מיקום קובץ",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "לא נמצאו תיקיות של מודלים אחרים",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
"hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות"
"openSettings": "פתח הגדרות",
"openModelPaths": "הגדר תיקיות מודלים",
"openSettingsFolder": "פתח תיקיית הגדרות"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"busyTitle": "מחיקה עדיין ממתינה",
"checking": "בודק את תוכן התיקייה...",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "בחר ספריית שורש",
"selectModelRoot": "בחר שורש מודל:",
"selectTypeRoot": "בחר שורש {type}:",
"routingOverride": {
"label": "סוג יעד:",
"tooltip": "זוהה אוטומטית ממטא-נתוני המודל. החלף אם הזיהוי שגוי; החלפה מכבה את «השתמש בנתיב ברירת מחדל» עבור הורדה זו."
},
"targetFolderPath": "נתיב תיקיית יעד:",
"browseFolders": "דפדף בתיקיות:",
"createNewFolder": "צור תיקייה חדשה",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}",
"metadata": "מטא-נתונים: {name}",
"indexingFile": "קורא קובץ מודל...",
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
"fetchingMetadata": "מביא מטא-נתונים...",
"transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --",
@@ -1554,6 +1679,33 @@
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
"action": "בדוק הכל"
},
"filenameTemplateConfirm": {
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
"titleRevert": "לשחזר שמות קבצים מקוריים?",
"revertButton": "שחזר שמות קבצים מקוריים"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "להעביר את קובצי הלוואי לאחסון המרכזי?",
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
"confirmButton": "העבר כעת",
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
"destination": "יעד: {path}"
},
"sidecarMigrationResult": {
"title": "סיכום העברת קובצי לוואי",
"completedSuccessfully": "ההעברה הושלמה בהצלחה",
"completedWithErrors": "הושלם עם {count} שגיאות",
"statMoved": "קבצים שהועברו",
"statModels": "מודלים",
"statSkipped": "דולגו",
"statConflicts": "קונפליקטים שנפתרו",
"statErrors": "שגיאות",
"failedItems": "פריטים שנכשלו ({count})",
"columnModel": "מודל",
"columnError": "שגיאה",
"successMessage": "הועברו {moved} קבצים עבור {models} מודלים",
"location": "מיקום אחסון: {path}"
},
"bulkAddTags": {
"title": "הוסף תגיות למספר מודלים",
"description": "הוסף תגיות ל-",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI",
"copyHash": "העתק hash",
"copyCivitaiId": "העתק מזהה Civitai",
"civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה",
"deleteModelWithShortcut": "מחק מודל (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "מודל בסיס",
"size": "גודל",
"hashes": "hashes",
"civitaiModelId": "מזהה מודל",
"civitaiVersionId": "מזהה גרסה",
"unknown": "לא ידוע",
"usageTips": "טיפים לשימוש",
"additionalNotes": "הערות נוספות",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
"noModelsSelected": "לא נבחרו מודלים"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "התנגשויות שמות קבצים כפולים"
},
"sidecar_mirror_orphans": {
"title": "קבצי לוואי מרוכזים"
},
"ui_version": {
"title": "גרסת הממשק"
}
@@ -2694,6 +2858,11 @@
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות"
},
"pager": {
"previous": "הודעה קודמת",
"next": "הודעה הבאה",
"position": "הודעה {current} מתוך {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "キャンセル",
"confirm": "確認",
"reorder": {
"dragHandle": "ドラッグして並べ替え(Alt + ↑/↓)",
"ariaLabel": "{item} を並べ替え、{total} 件中 {position} 番目",
"announcement": "{total} 件中 {position} 番目に移動しました"
"dragHandle": "ドラッグして並べ替え"
},
"actions": {
"save": "保存",
@@ -253,7 +251,6 @@
"modelname": "モデル名",
"tags": "タグ",
"creator": "作成者",
"hash": "ハッシュ",
"title": "レシピタイトル",
"loraName": "LoRAファイル名",
"loraModel": "LoRAモデル名",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "設定済み",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"huggingfaceApiKey": "Hugging Face アクセストークン",
"huggingfaceApiKeyPlaceholder": "Hugging Face アクセストークンを入力してください",
"huggingfaceApiKeyHelp": "ゲート付きまたはプライベートな Hugging Face リポジトリからダウンロードする際に必要です。huggingface.co/settings/tokens で読み取り専用トークンを作成し、先にリポジトリのページで利用条件に同意してください。",
"huggingfaceApiKeyConfigured": "設定済み",
"huggingfaceApiKeyNotConfigured": "未設定",
"huggingfaceApiKeySet": "設定",
"civitaiHost": {
"label": "CivitAI ホスト",
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
@@ -349,6 +352,14 @@
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
},
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
"unknownBaseModelRouting": {
"label": "不明なベースモデルの振り分け",
"help": "CivitAIが報告するベースモデルが既知のCheckpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI)でも既知のDiffusion Modelでもない場合に、Checkpointのダウンロードの保存先を決定します。新しい拡散アーキテクチャは頻繁に登場するため、通常はDiffusion Modelへの振り分けが正しい選択です。",
"options": {
"diffusionModel": "Diffusion Model",
"checkpoint": "Checkpoint"
}
},
"civitaiHostBanner": {
"title": "CivitAI ホスト設定を利用できます",
"content": "CivitAI は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
@@ -379,12 +390,15 @@
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
"sidecarStorage": "サイドカーファイルの保存",
"proxySettings": "プロキシ設定"
},
"nav": {
"general": "一般",
"interface": "インターフェース",
"library": "ライブラリ"
"library": "ライブラリ",
"organization": "整理",
"modelPaths": "モデルパス"
},
"search": {
"placeholder": "設定を検索...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
}
},
"modelPaths": {
"title": "モデルライブラリパス",
"description": "LoRA Managerがモデルをスキャンするルートフォルダーです。スタンドアロンモードでは settings.json から読み込まれる主要なモデルの場所になります。",
"restartRequired": "変更を有効にするには再起動が必要です",
"coreTypes": "コアモデルタイプ",
"otherTypes": "その他のモデルタイプ",
"otherTypesDisabledHint": "その他のモデルタイプが有効になっていません。フォルダーを設定するには、上で必要なタイプをオンにしてください。",
"saveSuccessRestart": "モデルライブラリパスを更新しました。変更を適用するには再起動が必要です。",
"pendingRestartNotice": "パスの変更を保存しました。変更を有効にするにはLoRA Managerを再起動してください。",
"pendingRestartBannerTitle": "パスの変更を適用するには再起動が必要です",
"pendingRestartBannerMessage": "モデルライブラリパスが更新されました。新しいフォルダーをスキャンするにはLoRA Managerサーバーを再起動してください。",
"folderKeys": {
"loras": "LoRAパス",
"checkpoints": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embeddings": "Embeddingパス",
"vae": "VAEパス",
"upscale_models": "Upscalerパス",
"text_encoders": "Text Encoderパス",
"clip": "CLIPパス(レガシー)",
"clip_vision": "CLIP Visionパス",
"controlnet": "ControlNetパス"
}
},
"directoryPicker": {
"title": "フォルダを参照",
"selectFolder": "このフォルダを選択",
"goUp": "上へ",
"pathPlaceholder": "パスを入力...",
"go": "移動",
"emptyFolder": "サブフォルダがありません",
"loadError": "ディレクトリの読み込みに失敗しました"
},
"pathValidation": {
"valid": "パスは有効です",
"pathNotFound": "パスが存在しません",
"notADirectory": "ディレクトリではありません",
"notReadable": "パスは読み取れません",
"notWritable": "パスは書き込めません"
},
"priorityTags": {
"title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "有効なテンプレート"
}
},
"filenameTemplates": {
"title": "ファイル名テンプレート",
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
"availablePlaceholders": "利用可能なプレースホルダー:",
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name})",
"applyButton": "ライブラリに今すぐ適用",
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
"validation": {
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
"validTemplate": "有効なテンプレート"
}
},
"exampleImages": {
"downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
@@ -722,11 +792,41 @@
"downloadComplete": "ダウンロードが正常に完了しました",
"enableCivarchiveApi": "CivArchive API をメタデータプロバイダーとして有効化",
"enableCivarchiveApiHelp": "有効にすると、CivArchive API がモデルメタデータの代替ソースとして使用されます(例:CivitAI から削除されたモデルの場合)。オフにすると、CivArchive のレート制限を完全に回避できます。",
"enableOpenmodeldbApi": "OpenModelDB をメタデータプロバイダーとして有効化",
"enableOpenmodeldbApiHelp": "有効にすると、CivitAI に記録がない場合に、OpenModelDB カタログ(openmodeldb.info)でも Upscaler のメタデータを検索します。カタログはローカルにキャッシュされ、毎日更新されます。",
"providerOrder": "メタデータプロバイダーのフォールバック順序",
"providerOrderHelp": "CivitAI API が常に最初に試行されます。メタデータ検索時の残りのプロバイダーの順序を選択してください。",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "サイドカーファイルの保存モード",
"modeHelp": ".metadata.json サイドカーファイルとプレビュー画像の保存先を選択します。各モデルファイルの隣に保存するか、ライブラリ構造をミラーリングした単一の集中ディレクトリに保存します。.civitai.info ファイルは常にモデルファイルの隣に置かれます。",
"modeOptions": {
"alongside": "モデルファイルの隣(デフォルト)",
"centralized": "集中保存"
},
"path": "集中保存先パス",
"pathHelp": "サイドカーファイルを集中保存するルートディレクトリ。空欄の場合は既定の場所(<settings dir>/sidecars)を使用します。",
"pathPlaceholder": "空欄 = <settings dir>/sidecars",
"management": "サイドカーファイルの移動",
"managementHelp": "既存の .metadata.json サイドカーファイルとプレビュー画像を、現在選択されているモードに合わせて「モデルファイルの隣」と「集中保存」の間で移動します。モードを変更しても既存ファイルは自動では移動しません。",
"migrateButton": "今すぐサイドカーファイルを移動",
"migratingButton": "移動しています...",
"migrating": "サイドカーファイルを移動しています...",
"migrateFailed": "サイドカーファイルの移動に失敗しました:{message}",
"migrationDeferred": "既存のサイドカーファイルは移動されませんでした。後で「設定 > ライブラリ > サイドカーファイルの保存」から移動できます。",
"confirmToCentralized": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ集中保存ディレクトリに移動しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
"confirmToAlongside": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ各モデルファイルの隣に戻しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?",
"effectivePathLabel": "実際の保存場所:",
"openFolderButton": "フォルダを開く",
"repoWarning": "実際の保存場所が LoRA Manager のインストールフォルダ内にあります。プラグインの再インストールやクリーンアップデートで削除される可能性があります。インストールフォルダの外に明示的な保存パスを設定してください。",
"openLocationSuccess": "サイドカーファイルの保存フォルダを開きました",
"openLocationCopied": "サイドカーファイルの保存パスをクリップボードにコピーしました:{path}",
"openLocationClipboardFallback": "サイドカーファイルの保存パスを手動でコピーしてください:{path}",
"openLocationFailed": "サイドカーファイルの保存フォルダを開けませんでした"
},
"proxySettings": {
"enableProxy": "アプリレベルのプロキシを有効化",
"enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
@@ -873,6 +973,14 @@
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
},
"filenameTemplateProgress": {
"initializing": "ファイル名テンプレートの適用を初期化中...",
"starting": "{type}にファイル名テンプレートを適用中...",
"processing": "処理中({processed}/{total})- {success} リネーム、{skipped} スキップ、{failures} 失敗",
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
"complete": "ファイル名テンプレートの適用が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "メタデータをAIで補完"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "その他のモデルのフォルダーが見つかりません",
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
"hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く"
"openSettings": "設定を開く",
"openModelPaths": "モデルフォルダーを設定",
"openSettingsFolder": "設定フォルダーを開く"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"busyTitle": "保留中の削除があります",
"checking": "フォルダの内容を確認しています...",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "ルートディレクトリを選択",
"selectModelRoot": "モデルルートを選択:",
"selectTypeRoot": "{type}ルートを選択:",
"routingOverride": {
"label": "保存先タイプ:",
"tooltip": "モデルのメタデータから自動検出されます。誤検出の場合は切り替えてください。切り替えると、このダウンロードでは「デフォルトパスを使用」がオフになります。"
},
"targetFolderPath": "ターゲットフォルダパス:",
"browseFolders": "フォルダを参照:",
"createNewFolder": "新しいフォルダを作成",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}",
"metadata": "メタデータ: {name}",
"indexingFile": "モデルファイルを読み込み中...",
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
"fetchingMetadata": "メタデータを取得中...",
"transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --",
@@ -1554,6 +1679,33 @@
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認"
},
"filenameTemplateConfirm": {
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
"titleRevert": "元のファイル名を復元しますか?",
"revertButton": "元のファイル名を復元"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "サイドカーファイルを集中保存に移動しますか?",
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
"confirmButton": "今すぐ移動",
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
"destination": "移動先:{path}"
},
"sidecarMigrationResult": {
"title": "サイドカーファイル移動の概要",
"completedSuccessfully": "移動が正常に完了しました",
"completedWithErrors": "{count} 件のエラーで完了しました",
"statMoved": "移動したファイル",
"statModels": "モデル",
"statSkipped": "スキップ",
"statConflicts": "解決した競合",
"statErrors": "エラー",
"failedItems": "失敗した項目({count})",
"columnModel": "モデル",
"columnError": "エラー",
"successMessage": "{models} 個のモデルの {moved} 個のファイルを移動しました",
"location": "保存場所:{path}"
},
"bulkAddTags": {
"title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信",
"copyHash": "ハッシュをコピー",
"copyCivitaiId": "Civitai IDをコピー",
"civitaiIdCopied": "Civitai IDをクリップボードにコピーしました",
"deleteModelWithShortcut": "モデルを削除(Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "ベースモデル",
"size": "サイズ",
"hashes": "ハッシュ",
"civitaiModelId": "モデル ID",
"civitaiVersionId": "バージョン ID",
"unknown": "不明",
"usageTips": "使用のヒント",
"additionalNotes": "追加メモ",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
"noModelsSelected": "モデルが選択されていません"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
"recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "ファイル名重複競合"
},
"sidecar_mirror_orphans": {
"title": "集中保存のサイドカーファイル"
},
"ui_version": {
"title": "UI バージョン"
}
@@ -2694,6 +2858,11 @@
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする",
"openSettings": "設定を開く"
},
"pager": {
"previous": "前の通知",
"next": "次の通知",
"position": "{total} 件中 {current} 件目の通知"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "취소",
"confirm": "확인",
"reorder": {
"dragHandle": "드래그하여 순서 변경 (Alt + ↑/↓)",
"ariaLabel": "{item} 순서 변경, 총 {total}개 중 {position}번째",
"announcement": "총 {total}개 중 {position}번째로 이동했습니다"
"dragHandle": "드래그하여 순서 변경"
},
"actions": {
"save": "저장",
@@ -253,7 +251,6 @@
"modelname": "모델명",
"tags": "태그",
"creator": "제작자",
"hash": "해시",
"title": "레시피 제목",
"loraName": "LoRA 파일명",
"loraModel": "LoRA 모델명",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "설정됨",
"civitaiApiKeyNotConfigured": "설정되지 않음",
"civitaiApiKeySet": "설정",
"huggingfaceApiKey": "Hugging Face 액세스 토큰",
"huggingfaceApiKeyPlaceholder": "Hugging Face 액세스 토큰을 입력하세요",
"huggingfaceApiKeyHelp": "게이트가 설정된 또는 비공개 Hugging Face 저장소에서 다운로드할 때 필요합니다. huggingface.co/settings/tokens에서 읽기 전용 토큰을 만들고, 먼저 저장소 페이지에서 이용 약관에 동의하세요.",
"huggingfaceApiKeyConfigured": "설정됨",
"huggingfaceApiKeyNotConfigured": "설정되지 않음",
"huggingfaceApiKeySet": "설정",
"civitaiHost": {
"label": "CivitAI 호스트",
"help": "\"View on CivitAI\" 링크를 사용할 때 어떤 CivitAI 사이트를 열지 선택합니다.",
@@ -349,6 +352,14 @@
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
},
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
"unknownBaseModelRouting": {
"label": "알 수 없는 베이스 모델 라우팅",
"help": "CivitAI가 보고한 베이스 모델이 알려진 Checkpoint(SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI)도 알려진 Diffusion Model도 아닐 때 Checkpoint 다운로드의 저장 위치를 결정합니다. 새로운 확산 아키텍처는 자주 등장하므로 일반적으로 Diffusion Model로 라우팅하는 것이 올바릅니다.",
"options": {
"diffusionModel": "Diffusion Model",
"checkpoint": "Checkpoint"
}
},
"civitaiHostBanner": {
"title": "CivitAI 호스트 기본 설정 사용 가능",
"content": "이제 CivitAI는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
@@ -379,12 +390,15 @@
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
"sidecarStorage": "사이드카 파일 저장",
"proxySettings": "프록시 설정"
},
"nav": {
"general": "일반",
"interface": "인터페이스",
"library": "라이브러리"
"library": "라이브러리",
"organization": "정리",
"modelPaths": "모델 경로"
},
"search": {
"placeholder": "설정 검색...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
}
},
"modelPaths": {
"title": "모델 라이브러리 경로",
"description": "LoRA Manager가 모델을 스캔하는 루트 폴더입니다. 독립 실행 모드에서는 settings.json에서 읽어오는 기본 모델 위치입니다.",
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
"coreTypes": "핵심 모델 유형",
"otherTypes": "기타 모델 유형",
"otherTypesDisabledHint": "활성화된 기타 모델 유형이 없습니다. 위에서 필요한 유형을 켜면 해당 폴더를 구성할 수 있습니다.",
"saveSuccessRestart": "모델 라이브러리 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"pendingRestartNotice": "경로 변경 사항이 저장되었습니다. 적용하려면 LoRA Manager를 재시작하세요.",
"pendingRestartBannerTitle": "경로 변경 사항을 적용하려면 재시작이 필요합니다",
"pendingRestartBannerMessage": "모델 라이브러리 경로가 업데이트되었습니다. 새 폴더를 스캔하려면 LoRA Manager 서버를 재시작하세요.",
"folderKeys": {
"loras": "LoRA 경로",
"checkpoints": "Checkpoint 경로",
"unet": "Diffusion Model 경로",
"embeddings": "Embedding 경로",
"vae": "VAE 경로",
"upscale_models": "Upscaler 경로",
"text_encoders": "Text Encoder 경로",
"clip": "CLIP 경로 (레거시)",
"clip_vision": "CLIP Vision 경로",
"controlnet": "ControlNet 경로"
}
},
"directoryPicker": {
"title": "폴더 찾아보기",
"selectFolder": "이 폴더 선택",
"goUp": "위로",
"pathPlaceholder": "경로 입력...",
"go": "이동",
"emptyFolder": "하위 폴더 없음",
"loadError": "디렉터리를 불러오지 못했습니다"
},
"pathValidation": {
"valid": "유효한 경로입니다",
"pathNotFound": "경로가 존재하지 않습니다",
"notADirectory": "디렉터리가 아닙니다",
"notReadable": "경로를 읽을 수 없습니다",
"notWritable": "경로에 쓸 수 없습니다"
},
"priorityTags": {
"title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -641,6 +695,22 @@
"validTemplate": "유효한 템플릿"
}
},
"filenameTemplates": {
"title": "파일명 템플릿",
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
"availablePlaceholders": "사용 가능한 플레이스홀더:",
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
"applyButton": "지금 라이브러리에 적용",
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
"validation": {
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
"validTemplate": "유효한 템플릿"
}
},
"exampleImages": {
"downloadLocation": "다운로드 위치",
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
@@ -722,11 +792,41 @@
"downloadComplete": "다운로드가 성공적으로 완료되었습니다",
"enableCivarchiveApi": "CivArchive API를 메타데이터 제공자로 활성화",
"enableCivarchiveApiHelp": "활성화하면 CivArchive API가 모델 메타데이터의 대체 소스로 사용됩니다 (예: CivitAI에서 삭제된 모델의 경우). 비활성화하면 CivArchive의 속도 제한을 완전히 피할 수 있습니다.",
"enableOpenmodeldbApi": "OpenModelDB를 메타데이터 제공자로 활성화",
"enableOpenmodeldbApiHelp": "활성화하면 CivitAI에 기록이 없을 때 OpenModelDB 카탈로그(openmodeldb.info)에서도 Upscaler 메타데이터를 조회합니다. 카탈로그는 로컬에 캐시되며 매일 새로 고쳐집니다.",
"providerOrder": "메타데이터 제공자 폴백 순서",
"providerOrderHelp": "CivitAI API가 항상 먼저 시도됩니다. 메타데이터 조회 시 나머지 제공자의 순서를 선택하세요.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "사이드카 파일 저장 모드",
"modeHelp": ".metadata.json 사이드카 파일과 미리보기 이미지를 저장할 위치를 선택하세요. 각 모델 파일 옆에 저장하거나, 라이브러리 구조를 미러링하는 단일 중앙 집중식 디렉터리에 저장할 수 있습니다. .civitai.info 파일은 항상 모델 파일 옆에 남습니다.",
"modeOptions": {
"alongside": "모델 파일 옆 (기본값)",
"centralized": "중앙 집중식 저장"
},
"path": "중앙 집중식 저장 경로",
"pathHelp": "사이드카 파일을 중앙 집중식으로 저장할 루트 디렉터리입니다. 비워 두면 기본 위치(<settings dir>/sidecars)를 사용합니다.",
"pathPlaceholder": "비움 = <settings dir>/sidecars",
"management": "사이드카 파일 이동",
"managementHelp": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 현재 선택한 모드에 맞게 '모델 파일 옆'과 '중앙 집중식 저장' 사이에서 이동합니다. 모드를 변경해도 기존 파일은 자동으로 이동되지 않습니다.",
"migrateButton": "지금 사이드카 파일 이동",
"migratingButton": "이동 중...",
"migrating": "사이드카 파일을 이동하는 중...",
"migrateFailed": "사이드카 파일 이동 실패: {message}",
"migrationDeferred": "기존 사이드카 파일은 이동되지 않았습니다. 나중에 설정 → 라이브러리 → 사이드카 파일 저장에서 이동할 수 있습니다.",
"confirmToCentralized": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 중앙 집중식 저장 디렉터리로 이동할까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
"confirmToAlongside": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 각 모델 파일 옆으로 되돌릴까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?",
"effectivePathLabel": "실제 저장 위치:",
"openFolderButton": "폴더 열기",
"repoWarning": "실제 저장 위치가 LoRA Manager 설치 폴더 안에 있습니다. 플러그인을 재설치하거나 클린 업데이트하면 삭제될 수 있으니 설치 폴더 밖에 명시적인 저장 경로를 설정하세요.",
"openLocationSuccess": "사이드카 파일 저장 폴더를 열었습니다",
"openLocationCopied": "사이드카 파일 저장 경로가 클립보드에 복사되었습니다: {path}",
"openLocationClipboardFallback": "사이드카 파일 저장 경로를 직접 복사하세요: {path}",
"openLocationFailed": "사이드카 파일 저장 폴더를 열지 못했습니다"
},
"proxySettings": {
"enableProxy": "앱 수준 프록시 활성화",
"enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
@@ -873,6 +973,14 @@
"complete": "자동 정리 완료",
"error": "오류: {error}"
},
"filenameTemplateProgress": {
"initializing": "파일명 템플릿 적용 초기화 중...",
"starting": "{type}에 파일명 템플릿 적용 중...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"complete": "파일명 템플릿 적용 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "AI로 메타데이터 보강"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
},
"actions": {
"openFileLocation": "파일 위치 열기",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "기타 모델 폴더를 찾을 수 없습니다",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기"
"openSettings": "설정 열기",
"openModelPaths": "모델 폴더 구성",
"openSettingsFolder": "설정 폴더 열기"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"busyTitle": "대기 중인 삭제 작업이 있습니다",
"checking": "폴더 내용을 확인하는 중...",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "루트 디렉토리를 선택하세요",
"selectModelRoot": "모델 루트 선택:",
"selectTypeRoot": "{type} 루트 선택:",
"routingOverride": {
"label": "대상 유형:",
"tooltip": "모델 메타데이터에서 자동으로 감지됩니다. 잘못 감지된 경우 전환하세요. 전환하면 이 다운로드에서 '기본 경로 사용'이 꺼집니다."
},
"targetFolderPath": "대상 폴더 경로:",
"browseFolders": "폴더 탐색:",
"createNewFolder": "새 폴더 만들기",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}",
"metadata": "메타데이터: {name}",
"indexingFile": "모델 파일 읽는 중...",
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
"fetchingMetadata": "메타데이터 가져오는 중...",
"transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --",
@@ -1554,6 +1679,33 @@
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
"action": "전체 확인"
},
"filenameTemplateConfirm": {
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
"titleRevert": "원본 파일명을 복원하시겠습니까?",
"revertButton": "원본 파일명 복원"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "사이드카 파일을 중앙 집중식 저장으로 이동할까요?",
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
"confirmButton": "지금 이동",
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
"destination": "대상 위치: {path}"
},
"sidecarMigrationResult": {
"title": "사이드카 파일 이동 요약",
"completedSuccessfully": "이동이 성공적으로 완료되었습니다",
"completedWithErrors": "{count}개의 오류와 함께 완료되었습니다",
"statMoved": "이동된 파일",
"statModels": "모델",
"statSkipped": "건너뜀",
"statConflicts": "해결된 충돌",
"statErrors": "오류",
"failedItems": "실패한 항목 ({count})",
"columnModel": "모델",
"columnError": "오류",
"successMessage": "{models}개 모델의 파일 {moved}개를 이동했습니다",
"location": "저장 위치: {path}"
},
"bulkAddTags": {
"title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기",
"copyHash": "해시 복사",
"copyCivitaiId": "Civitai ID 복사",
"civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다",
"deleteModelWithShortcut": "모델 삭제(Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "베이스 모델",
"size": "크기",
"hashes": "해시",
"civitaiModelId": "모델 ID",
"civitaiVersionId": "버전 ID",
"unknown": "알 수 없음",
"usageTips": "사용 팁",
"additionalNotes": "추가 메모",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}",
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
"noModelsSelected": "선택된 모델이 없습니다"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "파일명 중복 충돌"
},
"sidecar_mirror_orphans": {
"title": "중앙 집중식 사이드카 파일"
},
"ui_version": {
"title": "UI 버전"
}
@@ -2694,6 +2858,11 @@
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화",
"openSettings": "설정 열기"
},
"pager": {
"previous": "이전 알림",
"next": "다음 알림",
"position": "전체 {total}개 중 {current}번째 알림"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "Отмена",
"confirm": "Подтвердить",
"reorder": {
"dragHandle": "Перетащите, чтобы изменить порядок (Alt + ↑/↓)",
"ariaLabel": "Изменить порядок {item}, позиция {position} из {total}",
"announcement": "Перемещено на позицию {position} из {total}"
"dragHandle": "Перетащите, чтобы изменить порядок"
},
"actions": {
"save": "Сохранить",
@@ -253,7 +251,6 @@
"modelname": "Название модели",
"tags": "Теги",
"creator": "Автор",
"hash": "Хэш",
"title": "Название рецепта",
"loraName": "Имя файла LoRA",
"loraModel": "Название модели LoRA",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "Настроен",
"civitaiApiKeyNotConfigured": "Не настроен",
"civitaiApiKeySet": "Настроить",
"huggingfaceApiKey": "Токен доступа Hugging Face",
"huggingfaceApiKeyPlaceholder": "Введите ваш токен доступа Hugging Face",
"huggingfaceApiKeyHelp": "Требуется для загрузки из закрытых (gated) или приватных репозиториев Hugging Face. Создайте токен только для чтения на huggingface.co/settings/tokens и сначала примите условия репозитория на его странице.",
"huggingfaceApiKeyConfigured": "Настроен",
"huggingfaceApiKeyNotConfigured": "Не настроен",
"huggingfaceApiKeySet": "Настроить",
"civitaiHost": {
"label": "Хост CivitAI",
"help": "Выберите, какой сайт CivitAI будет открываться при использовании ссылок «View on CivitAI».",
@@ -349,6 +352,14 @@
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
},
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
"unknownBaseModelRouting": {
"label": "Маршрутизация неизвестных базовых моделей",
"help": "Определяет, куда отправляются загрузки Checkpoint, когда CivitAI сообщает базовую модель, которая не является ни известным Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI), ни известной диффузионной моделью. Новые диффузионные архитектуры появляются часто, поэтому маршрутизация их в диффузионные модели обычно правильная.",
"options": {
"diffusionModel": "Диффузионные модели",
"checkpoint": "Checkpoints"
}
},
"civitaiHostBanner": {
"title": "Доступна настройка хоста CivitAI",
"content": "Теперь CivitAI использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
@@ -379,12 +390,15 @@
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
"sidecarStorage": "Хранилище sidecar-файлов",
"proxySettings": "Настройки прокси"
},
"nav": {
"general": "Общее",
"interface": "Интерфейс",
"library": "Библиотека"
"library": "Библиотека",
"organization": "Организация",
"modelPaths": "Пути к моделям"
},
"search": {
"placeholder": "Поиск в настройках...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
}
},
"modelPaths": {
"title": "Пути библиотеки моделей",
"description": "Корневые папки, которые LoRA Manager сканирует в поисках ваших моделей. В автономном режиме это основные расположения моделей, считываемые из settings.json.",
"restartRequired": "Требуется перезапуск, чтобы изменения вступили в силу",
"coreTypes": "Основные типы моделей",
"otherTypes": "Другие типы моделей",
"otherTypesDisabledHint": "Другие типы моделей не включены. Включите нужные типы выше, чтобы настроить их папки.",
"saveSuccessRestart": "Пути библиотеки моделей обновлены. Требуется перезапуск для применения изменений.",
"pendingRestartNotice": "Изменения путей сохранены. Перезапустите LoRA Manager, чтобы они вступили в силу.",
"pendingRestartBannerTitle": "Требуется перезапуск для применения изменений путей",
"pendingRestartBannerMessage": "Пути библиотеки моделей обновлены. Перезапустите сервер LoRA Manager, чтобы просканировать новые папки.",
"folderKeys": {
"loras": "Пути LoRA",
"checkpoints": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embeddings": "Пути Embedding",
"vae": "Пути VAE",
"upscale_models": "Пути Upscaler",
"text_encoders": "Пути Text Encoder",
"clip": "Пути CLIP (устаревшие)",
"clip_vision": "Пути CLIP Vision",
"controlnet": "Пути ControlNet"
}
},
"directoryPicker": {
"title": "Обзор папок",
"selectFolder": "Выбрать эту папку",
"goUp": "Вверх",
"pathPlaceholder": "Введите путь...",
"go": "Перейти",
"emptyFolder": "Нет подпапок",
"loadError": "Не удалось загрузить каталог"
},
"pathValidation": {
"valid": "Путь действителен",
"pathNotFound": "Путь не существует",
"notADirectory": "Не является каталогом",
"notReadable": "Путь недоступен для чтения",
"notWritable": "Путь недоступен для записи"
},
"priorityTags": {
"title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -641,6 +695,22 @@
"validTemplate": "Действительный шаблон"
}
},
"filenameTemplates": {
"title": "Шаблоны имён файлов",
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
"availablePlaceholders": "Доступные заполнители:",
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
"applyButton": "Применить к библиотеке сейчас",
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
"validation": {
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
"validTemplate": "Действительный шаблон"
}
},
"exampleImages": {
"downloadLocation": "Место загрузки",
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
@@ -722,11 +792,41 @@
"downloadComplete": "Загрузка успешно завершена",
"enableCivarchiveApi": "Включить CivArchive API как источник метаданных",
"enableCivarchiveApiHelp": "При включении CivArchive API используется как резервный источник метаданных моделей (например, для моделей, удалённых с CivitAI). Отключите, чтобы полностью избежать ограничений скорости CivArchive.",
"enableOpenmodeldbApi": "Включить OpenModelDB как источник метаданных",
"enableOpenmodeldbApiHelp": "При включении метаданные Upscaler также ищутся в каталоге OpenModelDB (openmodeldb.info), если у CivitAI нет записи. Каталог кэшируется локально и обновляется ежедневно.",
"providerOrder": "Порядок резервных источников метаданных",
"providerOrderHelp": "CivitAI API всегда проверяется первым. Выберите порядок остальных источников при поиске метаданных.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "Режим хранения sidecar-файлов",
"modeHelp": "Выберите, где хранить sidecar-файлы .metadata.json и изображения превью: рядом с каждым файлом модели или в одном централизованном каталоге, повторяющем структуру вашей библиотеки. Файлы .civitai.info всегда остаются рядом с файлом модели.",
"modeOptions": {
"alongside": "Рядом с файлами моделей (по умолчанию)",
"centralized": "Централизованное хранилище"
},
"path": "Путь централизованного хранилища",
"pathHelp": "Корневой каталог централизованного хранилища sidecar-файлов. Оставьте пустым, чтобы использовать расположение по умолчанию (<settings dir>/sidecars).",
"pathPlaceholder": "Пусто = <settings dir>/sidecars",
"management": "Перенос sidecar-файлов",
"managementHelp": "Переносите существующие sidecar-файлы .metadata.json и изображения превью между хранением рядом с моделями и централизованным хранилищем в соответствии с выбранным режимом. Смена режима не переносит существующие файлы автоматически.",
"migrateButton": "Перенести sidecar-файлы сейчас",
"migratingButton": "Перенос...",
"migrating": "Перенос sidecar-файлов...",
"migrateFailed": "Не удалось перенести sidecar-файлы: {message}",
"migrationDeferred": "Существующие sidecar-файлы не были перенесены. Вы можете перенести их позже в разделе «Настройки → Библиотека → Хранилище sidecar-файлов».",
"confirmToCentralized": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Перенести их сейчас в централизованное хранилище? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
"confirmToAlongside": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Вернуть их сейчас рядом с их файлами моделей? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?",
"effectivePathLabel": "Фактическое расположение хранилища:",
"openFolderButton": "Открыть папку",
"repoWarning": "Фактическое расположение хранилища находится внутри папки установки LoRA Manager. Переустановка плагина или чистое обновление может удалить его — задайте явный путь хранения вне папки установки.",
"openLocationSuccess": "Папка хранилища sidecar-файлов открыта",
"openLocationCopied": "Путь к хранилищу sidecar-файлов скопирован в буфер обмена: {path}",
"openLocationClipboardFallback": "Скопируйте путь к хранилищу sidecar-файлов вручную: {path}",
"openLocationFailed": "Не удалось открыть папку хранилища sidecar-файлов"
},
"proxySettings": {
"enableProxy": "Включить прокси на уровне приложения",
"enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
@@ -873,6 +973,14 @@
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
},
"filenameTemplateProgress": {
"initializing": "Инициализация применения шаблона имён файлов...",
"starting": "Применение шаблона имён файлов к {type}...",
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
"complete": "Применение шаблона имён файлов завершено",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
},
"actions": {
"openFileLocation": "Открыть расположение файла",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "Папки других моделей не найдены",
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки"
"openSettings": "Открыть настройки",
"openModelPaths": "Настроить папки моделей",
"openSettingsFolder": "Открыть папку настроек"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"busyTitle": "Удаление всё ещё отложено",
"checking": "Проверка содержимого папки...",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "Выберите корневую папку",
"selectModelRoot": "Выберите корень моделей:",
"selectTypeRoot": "Выберите корень {type}:",
"routingOverride": {
"label": "Тип назначения:",
"tooltip": "Определяется автоматически из метаданных модели. Переключите, если определение неверно; переключение отключает «Использовать путь по умолчанию» для этой загрузки."
},
"targetFolderPath": "Путь к целевой папке:",
"browseFolders": "Обзор папок:",
"createNewFolder": "Создать новую папку",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}",
"metadata": "Метаданные: {name}",
"indexingFile": "Чтение файла модели...",
"fetchingSourceMetadata": "Получение метаданных из {source}...",
"fetchingMetadata": "Получение метаданных...",
"transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --",
@@ -1554,6 +1679,33 @@
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
"action": "Проверить всё"
},
"filenameTemplateConfirm": {
"titleApply": "Применить шаблон имён файлов к библиотеке?",
"titleRevert": "Восстановить исходные имена файлов?",
"revertButton": "Восстановить исходные имена файлов"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "Перенести sidecar-файлы в централизованное хранилище?",
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
"confirmButton": "Перенести сейчас",
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
"destination": "Назначение: {path}"
},
"sidecarMigrationResult": {
"title": "Сводка переноса sidecar-файлов",
"completedSuccessfully": "Перенос успешно завершён",
"completedWithErrors": "Завершено с ошибками ({count})",
"statMoved": "Перенесено файлов",
"statModels": "Модели",
"statSkipped": "Пропущено",
"statConflicts": "Разрешено конфликтов",
"statErrors": "Ошибки",
"failedItems": "Элементы с ошибками ({count})",
"columnModel": "Модель",
"columnError": "Ошибка",
"successMessage": "Перенесено {moved} файлов для {models} моделей",
"location": "Расположение хранилища: {path}"
},
"bulkAddTags": {
"title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI",
"copyHash": "Копировать хеш",
"copyCivitaiId": "Копировать ID Civitai",
"civitaiIdCopied": "ID Civitai скопирован в буфер обмена",
"deleteModelWithShortcut": "Удалить модель (Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "Базовая модель",
"size": "Размер",
"hashes": "Хэши",
"civitaiModelId": "ID модели",
"civitaiVersionId": "ID версии",
"unknown": "Неизвестно",
"usageTips": "Советы по использованию",
"additionalNotes": "Дополнительные заметки",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
"noModelsSelected": "Модели не выбраны"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "Конфликты дублирующихся имён файлов"
},
"sidecar_mirror_orphans": {
"title": "Централизованные sidecar-файлы"
},
"ui_version": {
"title": "Версия интерфейса"
}
@@ -2694,6 +2858,11 @@
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели",
"openSettings": "Открыть настройки"
},
"pager": {
"previous": "Предыдущее уведомление",
"next": "Следующее уведомление",
"position": "Уведомление {current} из {total}"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "取消",
"confirm": "确认",
"reorder": {
"dragHandle": "拖拽以调整顺序(Alt + ↑/↓)",
"ariaLabel": "调整 {item} 的顺序,第 {position} 个,共 {total} 个",
"announcement": "已移动到第 {position} 个,共 {total} 个"
"dragHandle": "拖拽以调整顺序"
},
"actions": {
"save": "保存",
@@ -253,7 +251,6 @@
"modelname": "模型名称",
"tags": "标签",
"creator": "创作者",
"hash": "哈希",
"title": "配方标题",
"loraName": "LoRA 文件名",
"loraModel": "LoRA 模型名称",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "已配置",
"civitaiApiKeyNotConfigured": "未配置",
"civitaiApiKeySet": "设置",
"huggingfaceApiKey": "Hugging Face 访问令牌",
"huggingfaceApiKeyPlaceholder": "请输入你的 Hugging Face 访问令牌",
"huggingfaceApiKeyHelp": "从受限(gated)或私有 Hugging Face 仓库下载时需要。请在 huggingface.co/settings/tokens 创建只读令牌,并先在该仓库页面同意其条款。",
"huggingfaceApiKeyConfigured": "已配置",
"huggingfaceApiKeyNotConfigured": "未配置",
"huggingfaceApiKeySet": "设置",
"civitaiHost": {
"label": "CivitAI 站点",
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
@@ -349,6 +352,14 @@
"placeholder": "留空则使用 PATH 中的 aria2c"
},
"aria2HelpLink": "了解如何配置 aria2 下载后端",
"unknownBaseModelRouting": {
"label": "未知基础模型路由",
"help": "当 CivitAI 报告的基础模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的扩散模型时,决定 Checkpoint 下载的保存位置。新的扩散架构频繁出现,因此将其路由到扩散模型通常是正确的。",
"options": {
"diffusionModel": "扩散模型",
"checkpoint": "Checkpoint"
}
},
"civitaiHostBanner": {
"title": "已提供 CivitAI 站点偏好设置",
"content": "CivitAI 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
@@ -379,12 +390,15 @@
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
"sidecarStorage": "附属文件存储",
"proxySettings": "代理设置"
},
"nav": {
"general": "通用",
"interface": "界面",
"library": "库"
"library": "库",
"organization": "整理",
"modelPaths": "模型路径"
},
"search": {
"placeholder": "搜索设置...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
}
},
"modelPaths": {
"title": "模型库路径",
"description": "LoRA Manager 扫描模型所用的根文件夹。独立模式下,这些是从 settings.json 读取的主要模型位置。",
"restartRequired": "需要重启才能生效",
"coreTypes": "核心模型类型",
"otherTypes": "其他模型类型",
"otherTypesDisabledHint": "未启用任何其他模型类型。请在上方启用你需要的类型,然后为其配置文件夹。",
"saveSuccessRestart": "模型库路径已更新,需要重启才能生效。",
"pendingRestartNotice": "路径更改已保存。重启 LoRA Manager 后生效。",
"pendingRestartBannerTitle": "需要重启以应用路径更改",
"pendingRestartBannerMessage": "模型库路径已更新。请重启 LoRA Manager 服务器以扫描新文件夹。",
"folderKeys": {
"loras": "LoRA 路径",
"checkpoints": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embeddings": "Embedding 路径",
"vae": "VAE 路径",
"upscale_models": "Upscaler 路径",
"text_encoders": "Text Encoder 路径",
"clip": "CLIP 路径(旧版)",
"clip_vision": "CLIP Vision 路径",
"controlnet": "ControlNet 路径"
}
},
"directoryPicker": {
"title": "浏览文件夹",
"selectFolder": "选择此文件夹",
"goUp": "上级目录",
"pathPlaceholder": "输入路径...",
"go": "跳转",
"emptyFolder": "没有子文件夹",
"loadError": "目录加载失败"
},
"pathValidation": {
"valid": "路径有效",
"pathNotFound": "路径不存在",
"notADirectory": "不是一个目录",
"notReadable": "路径不可读",
"notWritable": "路径不可写"
},
"priorityTags": {
"title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "有效模板"
}
},
"filenameTemplates": {
"title": "文件名模板",
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
"availablePlaceholders": "可用占位符:",
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name})",
"applyButton": "立即应用到库",
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
"validation": {
"restoreOriginal": "有效(空模板将恢复原始文件名)",
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *)",
"invalidPlaceholder": "无效占位符:{placeholder}",
"validTemplate": "有效模板"
}
},
"exampleImages": {
"downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
@@ -722,11 +792,41 @@
"downloadComplete": "下载成功完成",
"enableCivarchiveApi": "启用 CivArchive API 作为元数据提供者",
"enableCivarchiveApiHelp": "开启后,CivArchive API 将作为模型元数据的备用来源(例如用于已从 CivitAI 删除的模型)。关闭可完全避免 CivArchive 的速率限制。",
"enableOpenmodeldbApi": "启用 OpenModelDB 作为元数据提供者",
"enableOpenmodeldbApiHelp": "开启后,当 CivitAI 没有记录时,也会在 OpenModelDB 目录(openmodeldb.info)中查询 Upscaler 元数据。目录会缓存在本地并每天刷新。",
"providerOrder": "元数据提供者回退顺序",
"providerOrderHelp": "CivitAI API 始终优先尝试。选择查找元数据时其余提供者的顺序。",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "附属文件存储模式",
"modeHelp": "选择 .metadata.json 附属文件和预览图片的存放位置:与每个模型文件放在一起,或集中存放在一个镜像模型库结构的目录中。.civitai.info 文件始终与模型文件放在一起。",
"modeOptions": {
"alongside": "与模型文件放在一起(默认)",
"centralized": "集中存储"
},
"path": "集中存储路径",
"pathHelp": "集中存储附属文件的根目录。留空则使用默认位置(<settings dir>/sidecars)。",
"pathPlaceholder": "留空 = <settings dir>/sidecars",
"management": "附属文件迁移",
"managementHelp": "在“与模型文件放在一起”和“集中存储”之间迁移现有的 .metadata.json 附属文件和预览图片,以匹配当前选择的模式。更改模式不会自动迁移现有文件。",
"migrateButton": "立即迁移附属文件",
"migratingButton": "迁移中...",
"migrating": "正在迁移附属文件...",
"migrateFailed": "附属文件迁移失败:{message}",
"migrationDeferred": "现有附属文件未迁移。你可以稍后在“设置 → 库 → 附属文件存储”中迁移它们。",
"confirmToCentralized": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移入集中存储目录吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
"confirmToAlongside": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移回各自的模型文件旁边吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?",
"effectivePathLabel": "实际存储位置:",
"openFolderButton": "打开文件夹",
"repoWarning": "实际存储位置位于 LoRA Manager 安装目录内。重新安装插件或干净更新可能会将其删除——请在安装目录之外设置一个明确的存储路径。",
"openLocationSuccess": "已打开附属文件存储文件夹",
"openLocationCopied": "附属文件存储路径已复制到剪贴板:{path}",
"openLocationClipboardFallback": "请手动复制附属文件存储路径:{path}",
"openLocationFailed": "打开附属文件存储文件夹失败"
},
"proxySettings": {
"enableProxy": "启用应用级代理",
"enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
@@ -873,6 +973,14 @@
"complete": "自动整理已完成",
"error": "错误:{error}"
},
"filenameTemplateProgress": {
"initializing": "正在初始化应用文件名模板...",
"starting": "正在为 {type} 应用文件名模板...",
"processing": "处理中({processed}/{total})- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "文件名模板应用完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI 元数据增强"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "打开文件位置",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "未找到其他模型文件夹",
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
"hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置"
"openSettings": "打开设置",
"openModelPaths": "配置模型文件夹",
"openSettingsFolder": "打开设置文件夹"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"busyTitle": "仍有删除操作待处理",
"checking": "正在检查文件夹内容...",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "选择根目录",
"selectModelRoot": "选择模型根目录:",
"selectTypeRoot": "选择 {type} 根目录:",
"routingOverride": {
"label": "目标类型:",
"tooltip": "根据模型元数据自动检测。如果检测结果有误,请切换;切换会为本次下载关闭“使用默认路径”。"
},
"targetFolderPath": "目标文件夹路径:",
"browseFolders": "浏览文件夹:",
"createNewFolder": "新建文件夹",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "当前文件:",
"downloading": "下载中:{name}",
"metadata": "元数据:{name}",
"indexingFile": "正在读取模型文件...",
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
"fetchingMetadata": "正在获取元数据...",
"transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--",
@@ -1554,6 +1679,33 @@
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部"
},
"filenameTemplateConfirm": {
"titleApply": "将文件名模板应用到库?",
"titleRevert": "恢复原始文件名?",
"revertButton": "恢复原始文件名"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "要将附属文件移到集中存储吗?",
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
"confirmButton": "立即迁移",
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
"destination": "目标位置:{path}"
},
"sidecarMigrationResult": {
"title": "附属文件迁移摘要",
"completedSuccessfully": "迁移已成功完成",
"completedWithErrors": "完成,但有 {count} 个错误",
"statMoved": "已迁移文件",
"statModels": "模型",
"statSkipped": "已跳过",
"statConflicts": "已解决冲突",
"statErrors": "错误",
"failedItems": "失败项({count})",
"columnModel": "模型",
"columnError": "错误",
"successMessage": "已为 {models} 个模型迁移 {moved} 个文件",
"location": "存储位置:{path}"
},
"bulkAddTags": {
"title": "批量添加标签",
"description": "为多个模型添加标签",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI",
"copyHash": "复制哈希值",
"copyCivitaiId": "复制 Civitai ID",
"civitaiIdCopied": "Civitai ID 已复制到剪贴板",
"deleteModelWithShortcut": "删除模型(Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "基础模型",
"size": "大小",
"hashes": "哈希值",
"civitaiModelId": "模型 ID",
"civitaiVersionId": "版本 ID",
"unknown": "未知",
"usageTips": "使用提示",
"additionalNotes": "附加备注",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}",
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
"filenameTemplateFailed": "应用文件名模板失败:{error}",
"noModelsSelected": "未选中模型"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"filenameTemplatesUpdated": "文件名模板已更新",
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
"recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "文件名重复冲突"
},
"sidecar_mirror_orphans": {
"title": "集中存储的附属文件"
},
"ui_version": {
"title": "UI 版本"
}
@@ -2694,6 +2858,11 @@
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型",
"openSettings": "打开设置"
},
"pager": {
"previous": "上一条通知",
"next": "下一条通知",
"position": "第 {current} 条通知,共 {total} 条"
}
}
}
+178 -9
View File
@@ -3,9 +3,7 @@
"cancel": "取消",
"confirm": "確認",
"reorder": {
"dragHandle": "拖曳以調整順序(Alt + ↑/↓)",
"ariaLabel": "調整 {item} 的順序,第 {position} 個,共 {total} 個",
"announcement": "已移動到第 {position} 個,共 {total} 個"
"dragHandle": "拖曳以調整順序"
},
"actions": {
"save": "儲存",
@@ -253,7 +251,6 @@
"modelname": "模型名稱",
"tags": "標籤",
"creator": "創作者",
"hash": "雜湊",
"title": "配方標題",
"loraName": "LoRA 檔案名稱",
"loraModel": "LoRA 模型名稱",
@@ -327,6 +324,12 @@
"civitaiApiKeyConfigured": "已設定",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"huggingfaceApiKey": "Hugging Face 存取權杖",
"huggingfaceApiKeyPlaceholder": "請輸入您的 Hugging Face 存取權杖",
"huggingfaceApiKeyHelp": "從受限(gated)或私有 Hugging Face 倉庫下載時需要。請在 huggingface.co/settings/tokens 建立唯讀權杖,並先在該倉庫頁面同意其條款。",
"huggingfaceApiKeyConfigured": "已設定",
"huggingfaceApiKeyNotConfigured": "未設定",
"huggingfaceApiKeySet": "設定",
"civitaiHost": {
"label": "CivitAI 站點",
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
@@ -349,6 +352,14 @@
"placeholder": "留空則使用 PATH 中的 aria2c"
},
"aria2HelpLink": "了解如何設定 aria2 下載後端",
"unknownBaseModelRouting": {
"label": "未知基礎模型路由",
"help": "當 CivitAI 回報的基礎模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的擴散模型時,決定 Checkpoint 下載的儲存位置。新的擴散架構頻繁出現,因此將其路由到擴散模型通常是正確的。",
"options": {
"diffusionModel": "擴散模型",
"checkpoint": "Checkpoint"
}
},
"civitaiHostBanner": {
"title": "已提供 CivitAI 站點偏好設定",
"content": "CivitAI 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。您可以在設定中變更預設開啟的站點。",
@@ -379,12 +390,15 @@
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
"sidecarStorage": "附屬檔案儲存",
"proxySettings": "代理設定"
},
"nav": {
"general": "通用",
"interface": "介面",
"library": "模型庫"
"library": "模型庫",
"organization": "整理",
"modelPaths": "模型路徑"
},
"search": {
"placeholder": "搜尋設定...",
@@ -585,6 +599,46 @@
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
}
},
"modelPaths": {
"title": "模型庫路徑",
"description": "LoRA Manager 掃描您模型的根目錄資料夾。這些是獨立模式下從 settings.json 讀取的主要模型位置。",
"restartRequired": "需要重新啟動才能生效",
"coreTypes": "核心模型類型",
"otherTypes": "其他模型類型",
"otherTypesDisabledHint": "尚未啟用任何其他模型類型。請在上方開啟您需要的類型,以設定其資料夾。",
"saveSuccessRestart": "模型庫路徑已更新,需要重新啟動才能生效。",
"pendingRestartNotice": "路徑變更已儲存。請重新啟動 LoRA Manager 以使其生效。",
"pendingRestartBannerTitle": "需要重新啟動才能套用路徑變更",
"pendingRestartBannerMessage": "模型庫路徑已更新。請重新啟動 LoRA Manager 伺服器以掃描新的資料夾。",
"folderKeys": {
"loras": "LoRA 路徑",
"checkpoints": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embeddings": "Embedding 路徑",
"vae": "VAE 路徑",
"upscale_models": "Upscaler 路徑",
"text_encoders": "Text Encoder 路徑",
"clip": "CLIP 路徑(舊版)",
"clip_vision": "CLIP Vision 路徑",
"controlnet": "ControlNet 路徑"
}
},
"directoryPicker": {
"title": "瀏覽資料夾",
"selectFolder": "選擇此資料夾",
"goUp": "上一層",
"pathPlaceholder": "輸入路徑...",
"go": "前往",
"emptyFolder": "沒有子資料夾",
"loadError": "目錄載入失敗"
},
"pathValidation": {
"valid": "路徑有效",
"pathNotFound": "路徑不存在",
"notADirectory": "不是目錄",
"notReadable": "路徑無法讀取",
"notWritable": "路徑無法寫入"
},
"priorityTags": {
"title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -641,6 +695,22 @@
"validTemplate": "範本有效"
}
},
"filenameTemplates": {
"title": "檔案名稱範本",
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
"availablePlaceholders": "可用佔位符:",
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name})",
"applyButton": "立即套用至模型庫",
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
"validation": {
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *)",
"invalidPlaceholder": "無效佔位符:{placeholder}",
"validTemplate": "範本有效"
}
},
"exampleImages": {
"downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
@@ -722,11 +792,41 @@
"downloadComplete": "下載成功完成",
"enableCivarchiveApi": "啟用 CivArchive API 作為中繼資料提供者",
"enableCivarchiveApiHelp": "開啟後,CivArchive API 將作為模型中繼資料的備用來源(例如用於已從 CivitAI 刪除的模型)。關閉可完全避免 CivArchive 的速率限制。",
"enableOpenmodeldbApi": "啟用 OpenModelDB 作為中繼資料提供者",
"enableOpenmodeldbApiHelp": "開啟後,當 CivitAI 沒有記錄時,也會在 OpenModelDB 目錄(openmodeldb.info)中查詢 Upscaler 中繼資料。目錄會快取在本機並每日更新。",
"providerOrder": "中繼資料提供者回退順序",
"providerOrderHelp": "CivitAI API 始終優先嘗試。選擇查詢中繼資料時其餘提供者的順序。",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
},
"sidecarStorage": {
"mode": "附屬檔案儲存模式",
"modeHelp": "選擇 .metadata.json 附屬檔案與預覽圖片的存放位置:與每個模型檔案放在一起,或集中在一個對應模型庫結構的目錄中。.civitai.info 檔案一律與模型檔案放在一起。",
"modeOptions": {
"alongside": "與模型檔案放在一起(預設)",
"centralized": "集中儲存"
},
"path": "集中儲存路徑",
"pathHelp": "集中儲存附屬檔案的根目錄。留空則使用預設位置(<settings dir>/sidecars)。",
"pathPlaceholder": "留空 = <settings dir>/sidecars",
"management": "附屬檔案遷移",
"managementHelp": "在「與模型檔案放在一起」與「集中儲存」之間遷移現有的 .metadata.json 附屬檔案與預覽圖片,以符合目前選擇的模式。變更模式不會自動遷移現有檔案。",
"migrateButton": "立即遷移附屬檔案",
"migratingButton": "遷移中...",
"migrating": "正在遷移附屬檔案...",
"migrateFailed": "附屬檔案遷移失敗:{message}",
"migrationDeferred": "現有附屬檔案未遷移。您稍後可以在「設定 > 模型庫 > 附屬檔案儲存」中遷移它們。",
"confirmToCentralized": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移入集中儲存目錄嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
"confirmToAlongside": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移回各自的模型檔案旁邊嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?",
"effectivePathLabel": "實際儲存位置:",
"openFolderButton": "開啟資料夾",
"repoWarning": "實際儲存位置位於 LoRA Manager 安裝目錄內。重新安裝外掛或乾淨更新可能會將其刪除——請在安裝目錄之外設定明確的儲存路徑。",
"openLocationSuccess": "已開啟附屬檔案儲存資料夾",
"openLocationCopied": "附屬檔案儲存路徑已複製到剪貼簿:{path}",
"openLocationClipboardFallback": "請手動複製附屬檔案儲存路徑:{path}",
"openLocationFailed": "無法開啟附屬檔案儲存資料夾"
},
"proxySettings": {
"enableProxy": "啟用應用程式代理",
"enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
@@ -873,6 +973,14 @@
"complete": "自動整理完成",
"error": "錯誤:{error}"
},
"filenameTemplateProgress": {
"initializing": "正在初始化檔案名稱範本套用...",
"starting": "正在將檔案名稱範本套用至 {type}...",
"processing": "處理中({processed}/{total})- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
"complete": "檔案名稱範本套用完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI 中繼資料增強"
},
"contextMenu": {
@@ -920,7 +1028,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "開啟檔案位置",
@@ -1241,11 +1351,13 @@
},
"noPaths": {
"title": "找不到其他模型資料夾",
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型的資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
"hintStandalone": "僅會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定"
"openSettings": "開啟設定",
"openModelPaths": "設定模型資料夾",
"openSettingsFolder": "開啟設定資料夾"
}
},
"sidebar": {
@@ -1276,6 +1388,10 @@
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"busyTitle": "仍有刪除操作待處理",
"checking": "正在檢查資料夾內容...",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
@@ -1284,6 +1400,7 @@
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
@@ -1447,6 +1564,10 @@
"selectRootDirectory": "選擇根目錄",
"selectModelRoot": "選擇模型根目錄:",
"selectTypeRoot": "選擇 {type} 根目錄:",
"routingOverride": {
"label": "目標類型:",
"tooltip": "根據模型中繼資料自動偵測。如果偵測結果有誤,請切換;切換會為本次下載關閉「使用預設路徑」。"
},
"targetFolderPath": "目標資料夾路徑:",
"browseFolders": "瀏覽資料夾:",
"createNewFolder": "建立新資料夾",
@@ -1486,6 +1607,10 @@
"progress": {
"currentFile": "目前檔案:",
"downloading": "下載中:{name}",
"metadata": "中繼資料:{name}",
"indexingFile": "正在讀取模型檔案...",
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
"fetchingMetadata": "正在取得中繼資料...",
"transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--",
@@ -1554,6 +1679,33 @@
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查"
},
"filenameTemplateConfirm": {
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
"titleRevert": "要還原原始檔案名稱嗎?",
"revertButton": "還原原始檔案名稱"
},
"sidecarMigrationConfirm": {
"titleToCentralized": "要將附屬檔案移到集中儲存嗎?",
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
"confirmButton": "立即遷移",
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
"destination": "目標位置:{path}"
},
"sidecarMigrationResult": {
"title": "附屬檔案遷移摘要",
"completedSuccessfully": "遷移已成功完成",
"completedWithErrors": "完成,但有 {count} 個錯誤",
"statMoved": "已遷移檔案",
"statModels": "模型",
"statSkipped": "已跳過",
"statConflicts": "已解決衝突",
"statErrors": "錯誤",
"failedItems": "失敗項目({count})",
"columnModel": "模型",
"columnError": "錯誤",
"successMessage": "已為 {models} 個模型遷移 {moved} 個檔案",
"location": "儲存位置:{path}"
},
"bulkAddTags": {
"title": "新增標籤到多個模型",
"description": "新增標籤到",
@@ -1686,6 +1838,8 @@
"sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI",
"copyHash": "複製雜湊值",
"copyCivitaiId": "複製 Civitai ID",
"civitaiIdCopied": "Civitai ID 已複製到剪貼簿",
"deleteModelWithShortcut": "刪除模型(Del)"
},
"openFileLocation": {
@@ -1704,6 +1858,8 @@
"baseModel": "基礎模型",
"size": "大小",
"hashes": "雜湊值",
"civitaiModelId": "模型 ID",
"civitaiVersionId": "版本 ID",
"unknown": "未知",
"usageTips": "使用提示",
"additionalNotes": "附加備註",
@@ -2263,6 +2419,9 @@
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}",
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
"noModelsSelected": "未選擇任何模型"
},
"recipes": {
@@ -2429,6 +2588,8 @@
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"filenameTemplatesUpdated": "檔案名稱範本已更新",
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
"recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}",
@@ -2625,6 +2786,9 @@
"filename_conflicts": {
"title": "檔案名稱重複衝突"
},
"sidecar_mirror_orphans": {
"title": "集中儲存的附屬檔案"
},
"ui_version": {
"title": "UI 版本"
}
@@ -2694,6 +2858,11 @@
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enable": "啟用其他模型",
"openSettings": "開啟設定"
},
"pager": {
"previous": "上一則通知",
"next": "下一則通知",
"position": "第 {current} 則通知,共 {total} 則"
}
}
}
+20
View File
@@ -891,6 +891,17 @@ class Config:
if self.recipes_path:
preview_roots.update(self._expand_preview_root(self.recipes_path))
# Centralized sidecar storage holds preview assets outside the model
# roots; allow serving them when the mode is active.
try:
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
sidecar_root = get_sidecar_root()
except Exception: # pragma: no cover - defensive fallback
sidecar_root = ""
if sidecar_root:
preview_roots.update(self._expand_preview_root(sidecar_root))
for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link))
@@ -1494,6 +1505,15 @@ class Config:
self.other_roots = self._init_other_paths()
self._rebuild_preview_roots()
def refresh_preview_roots(self) -> None:
"""Rebuild the preview allowlist after path-affecting settings change.
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
updated so centralized preview assets become servable (or stop being
servable) without a restart.
"""
self._rebuild_preview_roots()
def get_other_models_availability(self) -> Dict[str, Any]:
"""Report the other-model folders the host can actually expose.
+5 -1
View File
@@ -172,12 +172,16 @@ async def download_preview(
"""
from ..services.downloader import get_downloader
from ..utils.exif_utils import ExifUtils
from ..utils.sidecar_paths import get_preview_dir
if not url or not url.strip():
return None
base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_dir = os.path.dirname(model_path)
preview_dir = get_preview_dir(model_path)
# Centralized mirrors may not exist yet (unlike the model's own directory
# in alongside mode).
os.makedirs(preview_dir, exist_ok=True)
output_path = os.path.join(preview_dir, base_name + ".webp")
downloader = await get_downloader()
+451
View File
@@ -0,0 +1,451 @@
"""Load an image and expose locally resolved generation settings."""
from __future__ import annotations
import hashlib
import json
import os
from typing import Any
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.exif_utils import ExifUtils
from ..utils.generation_metadata import (
GenerationMetadata,
MetadataError,
extract_generation_metadata,
finite_number,
split_lora_tags,
)
from ..utils.utils import _format_model_name_for_comfyui
from .checkpoint_loader import CheckpointLoaderLM
DEFAULTS = {
"positive": "", "negative": "", "seed": 0, "steps": 20, "cfg": 7.0,
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
}
# An SDXL-sized starter preset inspired by ComfyUI's bottle example. These
# values are explicitly synthetic, never presented as recovered metadata.
EMPTY_IMAGE_DEFAULTS = {
**DEFAULTS,
"positive": "beautiful scenery inside a glass bottle, purple galaxy, intricate miniature landscape, highly detailed",
"negative": "text, watermark",
"width": 1024,
"height": 1024,
}
ALLOWED_OVERRIDES = set(DEFAULTS) | {"model_name", "checkpoint_name", "unet_name", "width", "height", "loras"}
def parse_overrides(text: str) -> dict[str, Any]:
try:
value = json.loads(text or "{}")
except ValueError as exc:
raise MetadataError(f"Invalid overrides_json: {exc}") from exc
if not isinstance(value, dict):
raise MetadataError("overrides_json must be an object")
unknown = set(value) - ALLOWED_OVERRIDES
if unknown:
raise MetadataError(f"Unknown override keys: {', '.join(sorted(unknown))}")
model_keys = [key for key in ("model_name", "checkpoint_name", "unet_name") if key in value]
if len(model_keys) > 1:
raise MetadataError("Specify only one model_name override (checkpoint_name/unet_name are legacy aliases)")
if model_keys:
key = model_keys[0]
name = value.pop(key)
if not isinstance(name, str) or not name.strip():
raise MetadataError("model_name override must be nonempty text")
value["model_name"] = name.strip()
return value
_MODEL_FILE_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf")
def _model_stem(name: str) -> str:
"""Remove a known file extension, retaining dots in model/version names."""
for extension in _MODEL_FILE_EXTENSIONS:
if name.lower().endswith(extension):
return name[:-len(extension)]
return name
def resolve_resource(name: str, resources: list[dict[str, Any]], roots: list[str]) -> dict[str, Any]:
"""Match paths, filenames, then exact catalog aliases; never fuzzy-match."""
if not isinstance(name, str) or not name.strip():
raise MetadataError("Missing model name")
normalized = name.strip().replace("\\", "/")
levels: list[list[dict[str, Any]]] = [[], [], [], []]
for item in resources:
file_path = item.get("file_path")
if not file_path:
continue
path = file_path.replace("\\", "/")
relative = _format_model_name_for_comfyui(file_path, roots).replace("\\", "/")
exact = normalized in (path, relative, _model_stem(path), _model_stem(relative))
basename = normalized.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]
stem = _model_stem(normalized.rsplit("/", 1)[-1]) == _model_stem(path.rsplit("/", 1)[-1])
aliases = [item.get("file_name"), item.get("model_name")]
alias = any(
isinstance(value, str) and normalized in (value.strip(), _model_stem(value.strip()))
for value in aliases
)
# Stat only plausible matches, not every file in a large library for
# each LoRA. Missing cached files must never win a match.
if not (exact or basename or stem or alias) or not os.path.isfile(file_path):
continue
if exact:
levels[0].append(item)
if basename:
levels[1].append(item)
if stem:
levels[2].append(item)
if alias:
levels[3].append(item)
for matches in levels:
unique = {os.path.abspath(item["file_path"]): item for item in matches}
if len(unique) == 1:
return next(iter(unique.values()))
if unique:
raise MetadataError(f"Ambiguous local model '{name}': {', '.join(unique)}. Specify its relative path in overrides_json.")
raise MetadataError(f"Model '{name}' could not be matched to an existing file in the local LoRA Manager catalog")
class LoadImageMetadataLM:
NAME = "Load Image Metadata (LoraManager)"
CATEGORY = "Lora Manager/loaders"
DESCRIPTION = (
"Load an image and recover prompts, LoRAs and sampling settings from its metadata. "
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
)
# model_name, sampler_name and scheduler select a value from a loader or
# sampler dropdown. They must stay untyped (Any, "*"): ComfyUI rejects a
# "COMBO" (and a "STRING") output linked into the classic list-style combo
# inputs used by Load Checkpoint, KSampler, and the LoRA Manager loaders
# (comfy_execution/validation.py refuses a non-string input type), which
# surfaced as "Return type mismatch between linked nodes" at queue time.
# "*" is the same type ComfyUI's own Primitive node uses to feed widgets.
RETURN_TYPES = (
"IMAGE", "MASK", "STRING", "STRING", "*", "LORA_STACK", "STRING",
"INT", "INT", "FLOAT", "*", "*", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
)
RETURN_NAMES = (
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
"seed", "steps", "cfg", "sampler_name", "scheduler", "width", "height", "denoise", "report", "readable_report", "missing_files",
)
FUNCTION = "load_metadata"
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
from nodes import LoadImage # pyright: ignore[reportMissingImports]
return {"required": {
"image": LoadImage.INPUT_TYPES()["required"]["image"],
"sampler_node_id": ("STRING", {"default": "", "tooltip": "Leave empty for a single sampler. Subgraphs: use the full API ID, e.g. 1481:1783 (or 1481/1783). A container or leaf ID works only when unique."}),
"missing_settings": (["use_defaults", "strict"], {"tooltip": "Extraction errors always return defaults and an ERROR report, including for saved strict settings. Unresolved files are listed in missing_files."}),
"overrides_json": ("STRING", {"default": "{}", "multiline": True, "dynamicPrompts": False, "tooltip": 'Explicit replacements, e.g. {"scheduler":"normal", "model_name":"folder/model.safetensors"}. Use "loras": [] to clear the recovered stack.'}),
"prefer_saved_image_metadata": ("BOOLEAN", {"default": True, "tooltip": "Prefer saved A1111-style generation parameters. Disable to select an active workflow sampler; muted/bypassed samplers are excluded."}),
}}
@classmethod
def VALIDATE_INPUTS(cls, image: str, **kwargs: Any) -> bool | str:
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid image file: {image}"
return True
@classmethod
def IS_CHANGED(cls, image: str, **kwargs: Any) -> str:
digest = hashlib.sha256()
with open(folder_paths.get_annotated_filepath(image), "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@staticmethod
def _source_diagnostics(path: str) -> str:
"""Describe the actual selected file without including prompt contents."""
from PIL import Image
try:
with Image.open(path) as source:
if source.format == "PNG":
source.load()
details = (
f"File: {path}\nFormat: {source.format}; "
f"size: {os.path.getsize(path)} bytes; "
f"metadata keys: {', '.join(sorted(source.info)) or '(none)'}"
)
return details
except (OSError, ValueError) as exc:
return f"File: {path}\nCould not inspect image metadata: {exc}"
@staticmethod
def _library() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
from ..services.service_registry import ServiceRegistry
async def snapshot() -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]], list[str]]:
models = await ServiceRegistry.get_checkpoint_scanner()
loras = await ServiceRegistry.get_lora_scanner()
model_cache = await models.get_cached_data()
lora_cache = await loras.get_cached_data()
return list(model_cache.raw_data), models.get_model_roots(), list(lora_cache.raw_data), loras.get_model_roots()
return CheckpointLoaderLM._run_async(snapshot)
def load_metadata(
self, image: str, sampler_node_id: str = "", missing_settings: str = "use_defaults",
overrides_json: str = "{}", prefer_saved_image_metadata: bool = True,
) -> tuple[Any, ...]:
import comfy.samplers # pyright: ignore[reportMissingImports]
from nodes import LoadImage # pyright: ignore[reportMissingImports]
overrides = parse_overrides(overrides_json)
if missing_settings not in ("strict", "use_defaults"):
raise MetadataError("Invalid missing_settings policy")
path = folder_paths.get_annotated_filepath(image)
pixels, mask = LoadImage().load_image(image)
fields = {}
no_metadata = False
try:
fields = ExifUtils._load_structured_metadata(path)
no_metadata = not any(fields.values())
if no_metadata:
extracted = GenerationMetadata(
values=dict(EMPTY_IMAGE_DEFAULTS),
notes=[
"ERROR: No generation metadata found. Using the SDXL bottle starter preset; these settings were not extracted from the image.",
self._source_diagnostics(path),
],
)
else:
extracted = extract_generation_metadata(fields, sampler_node_id, prefer_saved_image_metadata)
except (ValueError, TypeError, KeyError, OSError, RecursionError) as exc:
error = f"ERROR: Metadata extraction failed: {exc}"
extracted = GenerationMetadata(issues={"source": str(exc)})
# An unsupported API graph need not make valid saved generation
# parameters unusable. Do not execute or infer custom graph nodes.
if (fields.get("prompt") or fields.get("workflow")) and (fields.get("parameters") or fields.get("comment")):
try:
extracted = extract_generation_metadata({
"parameters": fields.get("parameters"), "comment": fields.get("comment"),
})
extracted.notes.append(error + "; recovered saved generation parameters instead.")
if sampler_node_id.strip():
extracted.notes.append("ERROR: Global saved parameters cannot verify the requested sampler stage; they are an image-level fallback.")
except (ValueError, TypeError, KeyError, RecursionError) as fallback_exc:
extracted.notes.append(f"ERROR: Parameter fallback failed: {fallback_exc}")
if "source" in extracted.issues:
extracted.notes.extend([error, self._source_diagnostics(path)])
source_resources = {"checkpoint_name": extracted.values.get("checkpoint_name"), "unet_name": extracted.values.get("unet_name"), "loras": list(extracted.loras), "resource_hints": extracted.resource_hints}
values = extracted.values
notes = extracted.notes
for key, value in overrides.items():
values[key] = value
extracted.issues.pop(key, None)
notes.append(f"Explicit override: {key}.")
if "model_name" in overrides:
extracted.issues.pop("model", None)
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
if "loras" in overrides:
extracted.loras = self._override_loras(overrides["loras"])
notes.extend(f"ERROR: {key}: {message}" for key, message in extracted.issues.items())
# Discard incomplete graph results instead of outputting half a LoRA
# chain or a prompt known to differ from its conditioning.
for key in extracted.issues:
if key not in overrides:
values.pop(key, None)
if "loras" in extracted.issues and "loras" not in overrides:
extracted.loras = []
if "model" in extracted.issues and "model_name" not in overrides:
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
# Extraction without a recognized latent source (e.g. img2img) leaves
# width/height unset; the source image dimensions are the best
# estimate then. The synthetic starter preset keeps its fixed size.
image_fallback = not no_metadata and "source" not in extracted.issues
try:
image_height, image_width = int(pixels.shape[1]), int(pixels.shape[2])
except (AttributeError, IndexError, TypeError, ValueError):
image_fallback = False
for key, default in EMPTY_IMAGE_DEFAULTS.items():
if key in values:
continue
if image_fallback and key in ("width", "height"):
values[key] = image_width if key == "width" else image_height
notes.append(f"WARNING Missing {key}; using source image dimension {values[key]}.")
else:
values[key] = default
notes.append(f"ERROR: Missing {key}; using default {default!r}.")
# Validate independently so one invalid value cannot erase the other
# successfully extracted settings. Invalid explicit overrides still
# identify a user configuration error rather than an extraction error.
for key in EMPTY_IMAGE_DEFAULTS:
trial = {**EMPTY_IMAGE_DEFAULTS, key: values[key]}
try:
self._validate_values(trial, comfy.samplers.KSampler.SAMPLERS, comfy.samplers.KSampler.SCHEDULERS, True, [])
values[key] = trial[key]
except (ValueError, TypeError, OverflowError) as exc:
if key in overrides:
raise MetadataError(f"Invalid override {key}: {exc}") from exc
values[key] = EMPTY_IMAGE_DEFAULTS[key]
notes.append(f"ERROR: Invalid {key}: {exc}; using default {values[key]!r}.")
# Only A1111 directives represent LoRA application. In ComfyUI graphs,
# literal tags in encoder text are not executed by CLIPTextEncode.
for key in ("positive", "negative"):
try:
clean, tags = split_lora_tags(values[key])
except (ValueError, TypeError) as exc:
if key in overrides:
raise MetadataError(f"Invalid override {key}: {exc}") from exc
values[key] = EMPTY_IMAGE_DEFAULTS[key]
notes.append(f"ERROR: Invalid LoRA directive in {key}: {exc}; using starter prompt.")
continue
if tags:
if notes and notes[0] == "A1111/Forge parameters.":
if "loras" not in overrides:
extracted.loras.extend(tags)
values[key] = clean
else:
notes.append(f"Literal LoRA tags retained in {key}; the embedded ComfyUI graph determines the stack.")
try:
models, roots, loras, lora_roots = self._library()
except Exception as exc:
models, roots, loras, lora_roots = [], [], [], []
notes.append(f"ERROR: Local library lookup failed: {exc}. Extracted names remain in source_resources.")
if (no_metadata or "source" in extracted.issues) and "model_name" not in overrides:
base_candidates = [
item for item in models
if item.get("sub_type") == "checkpoint"
and os.path.basename(item.get("file_path", "")).lower() == "sd_xl_base_1.0.safetensors"
and os.path.isfile(item["file_path"])
]
if len(base_candidates) == 1:
values["model_name"] = _format_model_name_for_comfyui(base_candidates[0]["file_path"], roots)
notes.append("Starter checkpoint: indexed sd_xl_base_1.0.safetensors.")
else:
notes.append("Select an SDXL checkpoint manually, or set model_name in overrides_json. No unambiguous SDXL base checkpoint was found.")
missing_entries = []
name = values.get("model_name") or values.get("checkpoint_name") or values.get("unet_name")
values.pop("checkpoint_name", None)
values.pop("unet_name", None)
values["model_name"] = ""
values["model_type"] = ""
if name:
try:
# A1111's generic Model label can refer to either category.
# Search both together so duplicate names remain ambiguous.
available_models = [item for item in models if item.get("sub_type") in ("checkpoint", "diffusion_model")]
item = resolve_resource(name, available_models, roots)
values["model_name"] = _format_model_name_for_comfyui(item["file_path"], roots)
values["model_type"] = item["sub_type"]
notes.append(f"Resolved model_name: {values['model_name']} ({values['model_type']}).")
except MetadataError as exc:
missing_entries.append(f"Model: {name} — {exc}")
notes.append(f"WARNING {exc}; model_name is empty.")
if not values["model_name"]:
notes.append("WARNING No model resolved. Select a model manually on your loader.")
stack = []
for name, model_strength, clip_strength in extracted.loras:
try:
item = resolve_resource(name, loras, lora_roots)
stack.append((os.path.abspath(item["file_path"]), model_strength, clip_strength))
except MetadataError as exc:
missing_entries.append(f"LoRA: {name} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g} — {exc}")
notes.append(f"WARNING Skipped LoRA: {exc}.")
notes.append(f"Resolved {len(stack)} LoRA entries; preserve stack order and avoid adding them again in the loader widget.")
notes.append("Metadata settings do not restore VAE, text encoders, ControlNet, regional conditioning or the original latent pipeline.")
lora_stack_text = "\n".join(
f"{path} | model weight: {model_strength:g} | CLIP weight: {clip_strength:g}"
for path, model_strength, clip_strength in stack
)
missing_files = "\n".join(missing_entries)
report = "\n".join(notes) + "\n\n" + json.dumps({**values, "loras": stack, "lora_stack_text": lora_stack_text, "source_resources": source_resources, "missing_files": missing_files}, ensure_ascii=False, indent=2)
readable_report = self._readable_report(image, values, extracted.loras, stack, source_resources, notes)
return (pixels, mask, values["positive"], values["negative"], values["model_name"],
stack, lora_stack_text, values["seed"], values["steps"],
values["cfg"], values["sampler_name"], values["scheduler"], values["width"],
values["height"], values["denoise"], report, readable_report, missing_files)
@staticmethod
def _readable_report(
image: str, values: dict[str, Any], requested_loras: list[tuple[str, float, float]],
stack: list[tuple[str, float, float]], source: dict[str, Any], notes: list[str],
) -> str:
errors = [note for note in notes if note.startswith("ERROR")]
lines = ["🖼️ IMAGE GENERATION SETTINGS", f"Image: {image}"]
if errors:
lines.extend(["", "❌ ERROR — RECOVERED SETTINGS / DEFAULTS", *errors])
else:
lines.append("✅ Metadata extracted")
lines.extend(["", "📦 MODEL"])
for key, label in (("checkpoint_name", "Checkpoint"), ("unet_name", "UNet")):
if source.get(key):
lines.append(f"{label} recorded in image: {source[key]}")
if values["model_name"]:
lines.append(f"Model resolved locally: {values['model_name']} ({values['model_type']})")
else:
lines.append("No local model resolved.")
lines.extend([
"", "⚙️ SAMPLING", f"Seed: {values['seed']}", f"Steps: {values['steps']}",
f"CFG: {values['cfg']:g}", f"Sampler: {values['sampler_name']}",
f"Scheduler: {values['scheduler']}", f"Size: {values['width']} × {values['height']}",
f"Denoise: {values['denoise']:g}", "", "🧩 LORAS",
])
if requested_loras:
for name, model_strength, clip_strength in requested_loras:
lines.append(f"- {name} (model: {model_strength:g}, CLIP: {clip_strength:g})")
else:
lines.append("No LoRA entries extracted or selected.")
for hint in source.get("resource_hints", []):
if hint.get("name") not in {entry[0] for entry in requested_loras}:
lines.append(f"- Recorded resource: {hint['name']} (strength unresolved)")
lines.append(f"Resolved locally: {len(stack)} of {len(requested_loras)} requested entries.")
lines.extend(["", "➕ POSITIVE PROMPT", values["positive"] or "(empty)",
"", "➖ NEGATIVE PROMPT", values["negative"] or "(empty)",
"", "📋 NOTES AND WARNINGS"])
lines.extend(f"{'❌' if note.startswith('ERROR') else '⚠️' if note.startswith('WARNING') else 'ℹ️'} {note}" for note in notes)
return "\n".join(lines)
@staticmethod
def _override_loras(value: Any) -> list[tuple[str, float, float]]:
if not isinstance(value, list):
raise MetadataError("loras override must be a list of [name, model_strength, clip_strength]")
entries = []
for entry in value:
if not isinstance(entry, list) or len(entry) != 3 or not isinstance(entry[0], str):
raise MetadataError("Each LoRA override must be [name, model_strength, clip_strength]")
entries.append((entry[0], finite_number(entry[1]), finite_number(entry[2])))
return entries
@staticmethod
def _validate_values(values: dict[str, Any], samplers: list[str], schedulers: list[str], strict: bool, notes: list[str]) -> None:
for key in ("positive", "negative"):
if not isinstance(values[key], str):
raise MetadataError(f"{key} must be text")
for key, low, high in (("seed", 0, 2**64 - 1), ("steps", 1, 10000), ("width", 1, 16384), ("height", 1, 16384)):
raw = values[key]
try:
number = int(raw)
if isinstance(raw, bool) or (isinstance(raw, float) and raw != number) or not low <= number <= high:
raise ValueError()
except (ValueError, TypeError, OverflowError) as exc:
raise MetadataError(f"{key} must be an integer between {low} and {high}") from exc
values[key] = number
for key, low, high in (("cfg", 0, 100), ("denoise", 0, 1)):
try:
number = finite_number(values[key])
if not low <= number <= high:
raise ValueError()
except (ValueError, TypeError) as exc:
raise MetadataError(f"{key} must be a finite number between {low} and {high}") from exc
values[key] = number
for key, choices in (("sampler_name", samplers), ("scheduler", schedulers)):
if values[key] not in choices:
if strict:
raise MetadataError(f"Unsupported {key}: {values[key]!r}; set an explicit override")
fallback = DEFAULTS[key]
if fallback not in choices:
raise MetadataError(f"Default {key} {fallback!r} is unavailable in this ComfyUI installation")
notes.append(f"WARNING Replaced unsupported {key} {values[key]!r} with {fallback!r}.")
values[key] = fallback
+4 -1
View File
@@ -1,5 +1,6 @@
import importlib
import logging
import os
import comfy.sd # pyright: ignore[reportMissingImports]
import comfy.utils # pyright: ignore[reportMissingImports]
@@ -37,7 +38,9 @@ def _collect_stack_entries(lora_stack):
for lora_path, model_strength, clip_strength in lora_stack:
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
absolute_lora_path, trigger_words = get_lora_info_absolute(
lora_path if os.path.isabs(lora_path) else lora_name
)
entries.append({
"name": lora_name,
"absolute_path": absolute_lora_path,
+15 -1
View File
@@ -7,6 +7,7 @@ from ..services.wildcard_service import (
contains_dynamic_syntax,
get_wildcard_service,
is_trigger_words_input,
linked_text_requires_rerun,
)
@@ -85,6 +86,10 @@ class PromptLM:
),
},
"optional": optional_inputs,
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("CONDITIONING", "STRING")
@@ -100,10 +105,16 @@ class PromptLM:
text: str,
clip: Any | None = None,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
**kwargs: Any,
):
del clip, kwargs
if contains_dynamic_syntax(text) and seed is None:
if seed is not None:
return False
if contains_dynamic_syntax(text):
return float("NaN")
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
return float("NaN")
return False
@@ -112,8 +123,11 @@ class PromptLM:
text: str,
clip: Any,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
**kwargs: Any,
):
del prompt, unique_id
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
trigger_words = []
+29 -4
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
from ..services.wildcard_service import (
contains_dynamic_syntax,
get_wildcard_service,
linked_text_requires_rerun,
)
class TextLM:
@@ -34,6 +38,10 @@ class TextLM:
},
),
},
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("STRING",)
@@ -42,10 +50,27 @@ class TextLM:
FUNCTION = "process"
@classmethod
def IS_CHANGED(cls, text: str, seed: int | None = None):
if contains_dynamic_syntax(text) and seed is None:
def IS_CHANGED(
cls,
text: str,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
):
if seed is not None:
return False
if contains_dynamic_syntax(text):
return float("NaN")
if text is None and linked_text_requires_rerun(prompt, unique_id, "text"):
return float("NaN")
return False
def process(self, text: str, seed: int | None = None):
def process(
self,
text: str,
seed: int | None = None,
prompt: dict | None = None,
unique_id: str | None = None,
):
del prompt, unique_id
return (get_wildcard_service().expand_text(text, seed=seed),)
+18
View File
@@ -24,9 +24,11 @@ from ..services.use_cases import (
AutoOrganizeUseCase,
BulkMetadataRefreshUseCase,
DownloadModelUseCase,
FilenameTemplateUseCase,
)
from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler,
ModelCivitaiHandler,
ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet,
ModelListingHandler,
ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager,
logger=logger,
)
filename_template_use_case = FilenameTemplateUseCase(
scanner=service.scanner,
lifecycle_service=self._ensure_lifecycle_service(),
lock_provider=self._ws_manager,
model_type=service.model_type,
)
filename_template = ModelFilenameTemplateHandler(
use_case=filename_template_use_case,
progress_callback=self.filename_template_progress_callback,
logger=logger,
)
updates = ModelUpdateHandler(
service=service,
update_service=update_service,
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai,
move=move,
auto_organize=auto_organize,
filename_template=filename_template,
updates=updates,
)
@@ -55,6 +55,11 @@ class DownloadRoutingHandler:
status=400,
)
# CivitAI ModelType.UNet downloads go through the checkpoint branch,
# same as in the download manager.
if model_type.lower() == "unet":
model_type = "checkpoint"
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
from ...services.settings_manager import get_settings_manager
@@ -96,10 +101,15 @@ class DownloadRoutingHandler:
}
)
from ...services.settings_manager import get_settings_manager
is_diffusion = is_diffusion_model_download(
model_type,
file_types=(str(t) for t in file_types),
base_model=base_model,
unknown_base_model_default=get_settings_manager().get(
"unknown_base_model_routing", "diffusion_model"
),
)
return web.json_response(
{
+354 -12
View File
@@ -45,6 +45,8 @@ from ...services.llm_service import (
get_provider_model_ids,
)
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from ...services.websocket_progress_callback import WebSocketBroadcastCallback
from ...utils.models import BaseModelMetadata
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
@@ -54,18 +56,28 @@ from ...utils.constants import (
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
folder_path_schema,
)
from .model_source_handlers import ModelSourceHandler
from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
)
from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.sidecar_paths import (
describe_sidecar_root,
get_configured_sidecar_root,
get_metadata_path,
get_preview_dir,
get_storage_mode,
get_unmatched_sidecar_components,
)
from ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet
@@ -421,6 +433,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
return None
def _has_gui_display() -> bool:
"""Check whether a GUI session is reachable for xdg-open."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers."""
@@ -791,6 +808,7 @@ class DoctorHandler:
await self._check_civitai_api_key(),
await self._check_cache_health(),
await self._check_filename_conflicts(),
self._check_sidecar_mirror_orphans(),
self._check_ui_version(client_version, app_version),
]
@@ -936,15 +954,24 @@ class DoctorHandler:
os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_base_no_ext + suffix
new_sidecar = new_base_no_ext + suffix
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
old_metadata_path = get_metadata_path(path)
new_metadata_path = get_metadata_path(new_path)
if os.path.exists(old_metadata_path):
os.rename(old_metadata_path, new_metadata_path)
old_sidecar = old_base_no_ext + ".civitai.info"
new_sidecar = new_base_no_ext + ".civitai.info"
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext
new_preview = new_base_no_ext + preview_ext
old_preview = os.path.join(
get_preview_dir(path), base_name + preview_ext
)
new_preview = os.path.join(
get_preview_dir(new_path),
candidate_base + preview_ext,
)
if os.path.exists(old_preview):
os.rename(old_preview, new_preview)
@@ -956,7 +983,10 @@ class DoctorHandler:
old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
entry["preview_url"] = os.path.join(
get_preview_dir(new_path),
candidate_base + preview_ext,
).replace(os.sep, "/")
await scanner.update_single_model_cache(
path, new_path, entry
)
@@ -1015,6 +1045,71 @@ class DoctorHandler:
logger.error("Error exporting doctor bundle: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _check_sidecar_mirror_orphans(self) -> dict[str, Any]:
"""Flag centralized sidecars stranded by a moved/removed model root.
Centralized sidecars live under a per-root mirror directory. A root
that was moved, renamed, or dropped from the configuration leaves its
mirror behind; without this check the loss is silent, because the
scanner simply rebuilds default metadata at the new location.
"""
actions = [{"id": "open-settings", "label": "Open Settings"}]
try:
mode = get_storage_mode()
except Exception as exc: # pragma: no cover - defensive fallback
logger.debug("Doctor: sidecar mode lookup failed: %s", exc)
mode = "alongside"
if mode != "centralized":
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "ok",
"summary": "Sidecar metadata is stored alongside the models.",
"details": [],
"actions": actions,
}
try:
orphans = get_unmatched_sidecar_components()
except Exception as exc: # pragma: no cover - defensive fallback
logger.warning("Doctor: sidecar orphan check failed: %s", exc)
orphans = []
if not orphans:
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "ok",
"summary": "Every mirrored sidecar directory is linked to a model root.",
"details": [f"Root: {describe_sidecar_root().get('root', '')}"],
"actions": actions,
}
details = [
"Metadata (favorites, notes, tags, usage tips) for these models is on disk but is not being read.",
"This usually means a model root was moved, renamed, or removed. Restore the original root path in Settings; the mirror is re-linked automatically.",
]
details.extend(
f"{item['component']} — last known root: {item['last_path'] or 'unknown'}"
for item in orphans[:5]
)
if len(orphans) > 5:
details.append(f"… and {len(orphans) - 5} more")
return {
"id": "sidecar_mirror_orphans",
"title": "Centralized Sidecars",
"status": "warning",
"summary": (
f"{len(orphans)} sidecar "
f"director{'y' if len(orphans) == 1 else 'ies'} could not be "
"linked to a configured model root."
),
"details": details,
"actions": actions,
}
async def _check_civitai_api_key(self) -> dict[str, Any]:
api_key = (self._settings.get("civitai_api_key", "") or "").strip()
if not api_key:
@@ -1499,6 +1594,7 @@ class SettingsHandler:
# Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (*_set).
"civitai_api_key",
"huggingface_api_key",
"llm_api_key",
}
)
@@ -1557,6 +1653,8 @@ class SettingsHandler:
# Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key)
raw_hf_key = self._settings.get("huggingface_api_key")
response_data["huggingface_api_key_set"] = bool(raw_hf_key)
raw_llm_key = self._settings.get("llm_api_key")
response_data["llm_api_key_set"] = bool(raw_llm_key)
# Derived capability flag (not persisted): whether the host exposes
@@ -1575,9 +1673,46 @@ class SettingsHandler:
availability_error,
)
response_data["other_models_paths_available"] = None
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
response_data["standalone_mode"] = standalone_mode
if standalone_mode:
# Standalone reads its model roots exclusively from
# settings.json, so the Model Paths settings UI needs the
# current values plus the editable-key schema. In plugin mode
# the paths come from the ComfyUI host and stay hidden.
folder_paths = self._settings.get("folder_paths") or {}
# A fresh install is seeded from settings.json.example, whose
# folder_paths are documentation placeholders — hide them so
# the UI starts with empty editors instead of fake paths.
get_placeholders = getattr(
self._settings, "get_template_folder_path_placeholders", None
)
placeholders = get_placeholders() if get_placeholders else set()
if placeholders:
folder_paths = {
key: [p for p in paths if p not in placeholders]
if isinstance(paths, list)
else paths
for key, paths in folder_paths.items()
}
response_data["folder_paths"] = folder_paths
response_data["folder_path_schema"] = folder_path_schema()
settings_file = getattr(self._settings, "settings_file", None)
if settings_file:
response_data["settings_file"] = settings_file
# Resolved centralized sidecar root (mode-independent): lets the
# settings UI show where sidecars actually live, including when the
# path setting is empty and the default kicks in. inside_repo flags
# the portable-mode hazard (root inside the plugin folder).
try:
sidecar_info = describe_sidecar_root()
response_data["sidecar_storage_root"] = sidecar_info["root"]
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
except Exception as sidecar_error: # pragma: no cover - defensive
logger.debug(
"Could not resolve sidecar storage info: %s", sidecar_error
)
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if messages_getter else []
return web.json_response(
@@ -1672,6 +1807,7 @@ class SettingsHandler:
if key in (
"enable_metadata_archive_db",
"enable_civarchive_api",
"enable_openmodeldb_api",
"metadata_provider_order",
):
await self._metadata_provider_updater()
@@ -2759,12 +2895,40 @@ class ModelLibraryHandler:
normalized_type, scanner = await self._get_scanner_for_type(model_type)
if not normalized_type:
# The lookup cannot be served as a fully interactive list. Two
# cases share this branch: a CivitAI type with no scanner at all
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
# and an Other-model type while the opt-in master switch is off.
# Answer 200 with the CivitAI list marked read-only plus a
# machine-readable reason, so clients can still show the
# versions and explain why the actions are missing. Legacy
# clients keep working: they only read `success`/`versions`.
reason = (
"other_models_disabled"
if self._normalize_model_type(model_type) == "other"
else "model_type_unsupported"
)
return web.json_response(
{
"success": False,
"error": f'Model type "{model_type}" is not supported',
},
status=400,
"success": True,
"modelId": model_id,
"modelName": model_name,
"modelType": model_type,
"supported": False,
"reason": reason,
"versions": [
{
"id": version.get("id"),
"name": version.get("name", ""),
"thumbnailUrl": version.get("images")[0]["url"]
if version.get("images")
else None,
"inLibrary": False,
"hasBeenDownloaded": False,
}
for version in versions
],
}
)
if not scanner:
@@ -2806,6 +2970,7 @@ class ModelLibraryHandler:
"modelId": model_id,
"modelName": model_name,
"modelType": model_type,
"supported": True,
"versions": enriched_versions,
}
)
@@ -3282,6 +3447,18 @@ class FileSystemHandler:
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": path,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", path])
return web.json_response(
@@ -3393,6 +3570,18 @@ class FileSystemHandler:
subprocess.Popen(["open", "-R", settings_file])
else:
folder = os.path.dirname(settings_file)
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": settings_file,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", folder])
return web.json_response(
@@ -3426,6 +3615,94 @@ class FileSystemHandler:
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_sidecar_location(self, request: web.Request) -> web.Response:
"""Open the centralized sidecar storage root in the file manager."""
try:
root = get_configured_sidecar_root()
if not root:
return web.json_response(
{"success": False, "error": "Sidecar storage root is not resolvable"},
status=404,
)
# Create on demand so the button also works before the first
# migration/download has materialized the directory.
os.makedirs(root, exist_ok=True)
return await self._open_path(root)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory for the settings-UI directory picker."""
try:
data = await request.json()
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to browse directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def validate_path(self, request: web.Request) -> web.Response:
"""Validate a filesystem path for the settings UI.
A well-formed request always returns HTTP 200; invalid paths are
reported via ``error_code`` in the payload. HTTP 400 is reserved for
malformed requests (missing path, invalid JSON).
"""
try:
data = await request.json()
raw_path = data.get("path")
expect = data.get("expect", "directory")
if not raw_path or not isinstance(raw_path, str):
return web.json_response(
{"success": False, "error": "Missing path parameter"}, status=400
)
# Business path convention: abspath only, never realpath.
path = os.path.abspath(os.path.expanduser(raw_path))
exists = os.path.exists(path)
is_directory = os.path.isdir(path) if exists else False
readable = bool(exists and os.access(path, os.R_OK))
writable = bool(exists and os.access(path, os.W_OK))
error_code = None
if not exists:
error_code = "path_not_found"
elif expect == "directory" and not is_directory:
error_code = "not_a_directory"
elif expect == "file" and not os.path.isfile(path):
error_code = "not_a_file"
elif not readable:
error_code = "not_readable"
elif not writable:
error_code = "not_writable"
return web.json_response(
{
"success": True,
"path": path,
"exists": exists,
"is_directory": is_directory,
"readable": readable,
"writable": writable,
"error_code": error_code,
}
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to validate path: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex."""
@@ -3978,6 +4255,64 @@ class NodeRegistryHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
class SidecarMigrationHandler:
"""Migrate sidecar metadata and previews between storage layouts."""
_VALID_DIRECTIONS = ("to_centralized", "to_alongside", "relocate_root")
def __init__(
self,
*,
use_case_factory: Callable[[], SidecarMigrationUseCase] = SidecarMigrationUseCase,
progress_callback_factory: Callable[[], Any] = WebSocketBroadcastCallback,
) -> None:
self._use_case_factory = use_case_factory
self._progress_callback_factory = progress_callback_factory
async def migrate_sidecars(self, request: web.Request) -> web.Response:
"""Run a sidecar migration; accepts POST JSON or GET query params."""
try:
if request.method == "GET":
params: Mapping[str, Any] = request.query
else:
try:
params = await request.json()
except Exception: # empty/invalid body: fall back to query
params = request.query
direction = str(params.get("direction") or "").strip()
if direction not in self._VALID_DIRECTIONS:
return web.json_response(
{
"success": False,
"error": "direction must be 'to_centralized', 'to_alongside' or 'relocate_root'",
},
status=400,
)
force = params.get("force") in (True, 1, "true", "1")
old_root = str(params.get("old_root") or "").strip()
if direction == "relocate_root" and not old_root:
return web.json_response(
{"success": False, "error": "old_root is required for relocate_root"},
status=400,
)
use_case = self._use_case_factory()
progress_cb = self._progress_callback_factory()
result = await use_case.execute_with_error_handling(
direction=direction,
progress_cb=progress_cb,
force=force,
old_root=old_root,
)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
logger.error("Sidecar migration failed: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
@@ -4004,6 +4339,7 @@ class MiscHandlerSet:
model_source_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
sidecar_migration: Any = None,
) -> None:
self.health = health
self.settings = settings
@@ -4025,6 +4361,7 @@ class MiscHandlerSet:
self.model_source_handler = model_source_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
self.sidecar_migration = sidecar_migration
def to_route_mapping(
self,
@@ -4070,6 +4407,9 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location,
"open_sidecar_location": self.filesystem.open_sidecar_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters,
@@ -4089,6 +4429,8 @@ class MiscHandlerSet:
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Download routing handler
"get_download_routing": self.download_routing.get_download_routing,
# Sidecar migration handler
"migrate_sidecars": self.sidecar_migration.migrate_sidecars,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
+85 -5
View File
@@ -37,15 +37,21 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError,
DownloadModelUseCase,
DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter,
)
from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.websocket_progress_callback import (
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager
from ...utils.sidecar_paths import get_metadata_path
from ...utils.url_utils import relative_root_prefix
LICENSE_FIELDS = (
"allowNoCredit",
@@ -200,6 +206,7 @@ class ModelPageView:
"version": self._get_app_version(),
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
"provider_models_json": "{}",
"rel_prefix": relative_root_prefix(request.path),
}
if not is_initializing:
@@ -380,7 +387,6 @@ class ModelListingHandler:
== "true",
"tags": request.query.get("search_tags", "false").lower() == "true",
"creator": request.query.get("search_creator", "false").lower() == "true",
"hash": request.query.get("search_hash", "false").lower() == "true",
"recursive": request.query.get("recursive", "true").lower() == "true",
}
@@ -670,7 +676,7 @@ class ModelManagementHandler:
status=400,
)
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
local_metadata = await self._metadata_sync.load_local_metadata(
metadata_path
)
@@ -1754,7 +1760,8 @@ class ModelDownloadHandler:
payload = await request.json()
result = await self._download_use_case.execute(payload)
if not result.get("success", False):
return web.json_response(result, status=500)
status = 429 if result.get("reason") == "rate_limited" else 500
return web.json_response(result, status=status)
return web.json_response(result)
except DownloadModelValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -1812,7 +1819,8 @@ class ModelDownloadHandler:
mock_request = type("MockRequest", (), {"json": lambda self=None: future})()
result = await self._download_use_case.execute(data)
if not result.get("success", False):
return web.json_response(result, status=500)
status = 429 if result.get("reason") == "rate_limited" else 500
return web.json_response(result, status=status)
return web.json_response(result)
except DownloadModelValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
@@ -1910,6 +1918,11 @@ class ModelDownloadHandler:
response_payload["status"] = status
if "message" in progress_data:
response_payload["message"] = progress_data["message"]
# Post-transfer stage (indexing / source metadata); polling
# consumers need it to tell "working" from "stuck".
for field in ("stage", "platform"):
if field in progress_data:
response_payload[field] = progress_data[field]
elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"]
@@ -2687,6 +2700,71 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelFilenameTemplateHandler:
"""Apply the configured filename template to existing library models."""
def __init__(
self,
*,
use_case: FilenameTemplateUseCase,
progress_callback: WebSocketFilenameTemplateProgressCallback,
logger: logging.Logger,
) -> None:
self._use_case = use_case
self._progress_callback = progress_callback
self._logger = logger
async def apply_filename_template(self, request: web.Request) -> web.Response:
try:
file_paths = None
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
except Exception: # pragma: no cover - permissive path
pass
else:
# GET variant (browser extension is GET-only): comma-separated
# file_paths query parameter.
raw_file_paths = request.query.get("file_paths")
if raw_file_paths:
file_paths = [
path.strip()
for path in raw_file_paths.split(",")
if path.strip()
]
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
)
_broadcast_models_changed()
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:
return web.json_response(
{
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
},
status=409,
)
except Exception as exc:
self._logger.error(
"Error in apply_filename_template: %s", exc, exc_info=True
)
try:
await self._progress_callback.on_progress(
{
"type": "filename_template_progress",
"status": "error",
"error": str(exc),
}
)
except Exception: # pragma: no cover - defensive reporting
pass
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler:
"""Handle update tracking requests."""
@@ -3454,6 +3532,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler
move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler
def to_route_mapping(
@@ -3518,6 +3597,7 @@ class ModelHandlerSet:
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"apply_filename_template": self.filename_template.apply_filename_template,
"get_model_notes": self.query.get_model_notes,
"get_model_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url,
+127 -45
View File
@@ -30,7 +30,7 @@ from ...services.model_sources import (
SourceRef,
detect_source,
get_download_source,
is_valid_source_id,
hydrate_from_source,
list_sources,
normalize_metadata_source,
)
@@ -38,7 +38,12 @@ from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.metadata_manager import MetadataManager
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ...utils.models import (
LoraMetadata,
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
)
logger = logging.getLogger(__name__)
@@ -77,6 +82,11 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
if os.path.normpath(p).replace(os.sep, "/") == norm:
return EmbeddingMetadata, "get_embedding_scanner"
# Other-model roots (VAE, text encoders, upscalers, ...)
for p in config.other_roots or []:
if os.path.normpath(p).replace(os.sep, "/") == norm:
return OtherModelMetadata, "get_other_scanner"
# Fallback — should not happen in normal use
logger.warning(
"Could not determine model type for root '%s'; defaulting to LoRA",
@@ -85,25 +95,77 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _report_phase(
download_id: str | None, stage: str, platform: str = ""
) -> None:
"""Tell the progress UI which post-transfer stage is running.
A download's byte counter stops the moment the last byte lands, but the
backend still has to index the file and read the model site's API. Without
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
several seconds. *stage* is machine-readable — the UI localises it — and
*platform* lets it name the site the metadata comes from.
"""
if not download_id:
return
try:
await ws_manager.broadcast_download_progress(
download_id,
{
"status": "metadata",
"stage": stage,
"platform": platform,
"progress": 100,
},
)
except Exception as exc: # pragma: no cover - progress must never be fatal
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
async def _save_source_metadata(
dest_path: str, ref: SourceRef, model_root: str
dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the
SHA256 hash, extracts safetensors header metadata (base_model), and
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
``EmbeddingMetadata``) object. We then overlay the external-source fields
and register the model in the in-memory scanner cache so it appears
immediately without a full filesystem walk.
The metadata is created through the owning scanner rather than
``MetadataManager.create_default_metadata()``, because that is the only
factory that knows when hashing must be deferred: ``CheckpointScanner`` and
``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
``sha256`` for their multi-GB files, and the generic helper would read a
10 GB checkpoint end to end *inside the download request*. Scanners for the
small types delegate straight back to it, so nothing changes for them.
The external-source fields are then overlaid and the model is registered in
the in-memory scanner cache so it appears immediately without a full
filesystem walk.
Finally the site's own published metadata is applied (see
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
ModelScope or Hugging Face download lands with the same populated model
card a CivitAI download produces instead of a bare filename and hash.
Both post-transfer stages are reported through *download_id* when the UI is
watching one, because neither advances the byte counter.
"""
try:
model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
scanner = None
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
# 1. Create proper metadata (reads safetensors headers; hashes only for
# the model types whose scanner does not defer it)
await _report_phase(download_id, "indexing", ref.platform)
create_metadata = getattr(scanner, "_create_default_metadata", None)
if create_metadata is not None:
metadata = await create_metadata(dest_path)
else:
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
@@ -120,8 +182,8 @@ async def _save_source_metadata(
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
logger.info(
"Saved %s metadata (source=%s) for %s",
ref.platform, ref.url, dest_path,
"Saved %s metadata (source=%s, hash_status=%s) for %s",
ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
)
# 4. Determine relative folder path for cache
@@ -132,13 +194,16 @@ async def _save_source_metadata(
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
if scanner is not None:
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
if scanner is not None:
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
# 6. Top up from the site's public API. Runs last so the scanner-cache
# refresh it performs lands on the entry created above. It never
# raises and never fails the download.
await _report_phase(download_id, "source", ref.platform)
await hydrate_from_source(dest_path, ref=ref)
except Exception as exc:
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
@@ -157,12 +222,14 @@ def _find_matching_root(dest_dir: str) -> str | None:
config.extra_unet_roots or [],
config.embeddings_roots or [],
config.extra_embeddings_roots or [],
config.other_roots or [],
):
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
# Find the longest matching prefix
# Find the longest matching prefix. The boundary check prevents a root like
# `/models/vae` from swallowing a sibling directory like `/models/vae-old`.
match: str | None = None
for root in all_roots:
if norm.startswith(root):
if norm == root or norm.startswith(root + "/"):
if match is None or len(root) > len(match):
match = root
return match
@@ -209,9 +276,7 @@ class ModelSourceHandler:
"supports_enrichment": source.supports_enrichment,
"supports_download": source.supports_download,
"default_revision": source.default_revision,
"example_url": source.canonical_url(
"user/repo" if source.platform != "tensorart" else "827823520299086029"
),
"example_url": source.canonical_url(source.example_source_id),
}
for source in list_sources()
])
@@ -258,9 +323,7 @@ class ModelSourceHandler:
"error": (
"Unsupported model URL. Supported formats: "
+ ", ".join(
f"{s.label} ({s.canonical_url('user/repo')})"
if s.platform != "tensorart"
else f"{s.label} (https://tensor.art/models/<id>)"
f"{s.label} ({s.canonical_url(s.example_source_id)})"
for s in list_sources()
)
),
@@ -356,9 +419,9 @@ class ModelSourceHandler:
source = get_download_source(platform)
if source is None:
return _unsupported_platform_error(platform)
if not is_valid_source_id(repo):
if not source.is_valid_source_id(repo):
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
{"error": "Missing or invalid 'repo' parameter"},
status=400,
)
@@ -424,10 +487,11 @@ class ModelSourceHandler:
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# `owner/name` only; the components become path segments below.
if not is_valid_source_id(repo):
# The id becomes a path segment below; each site defines what a safe
# id looks like (`owner/name` for repository sites, a flat token for
# OpenModelDB).
if not source.is_valid_source_id(repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
owner, repo_name = repo.split("/", 1)
# Validate filename — must not contain path traversal
if ".." in filename:
@@ -453,7 +517,7 @@ class ModelSourceHandler:
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
if use_default_paths:
target_dir = os.path.join(base_dir, source.default_subdir, owner, repo_name)
target_dir = os.path.join(base_dir, *source.default_subdir_parts(repo))
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
@@ -466,22 +530,30 @@ class ModelSourceHandler:
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base)
# Built per request: sites that redirect to a CDN hand out a
# time-limited token in the redirect, so the URL must never be cached.
try:
resolve_url = await source.resolve_download_url(repo, filename, revision)
except ModelSourceError as exc:
return web.json_response({"error": str(exc)}, status=exc.status)
ref = SourceRef(
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
# The sidecar may predate the source metadata being fetched, or may
# have been deleted, so top it up instead of skipping past it.
# Hydration no-ops when there is no sidecar to update.
await _report_phase(download_id, "source", source.platform)
await hydrate_from_source(dest_path, ref=ref)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Built per request: sites that redirect to a CDN hand out a
# time-limited token in the redirect, so the URL must never be cached.
resolve_url = source.file_download_url(repo, filename, revision)
ref = SourceRef(
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
)
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
@@ -519,6 +591,10 @@ class ModelSourceHandler:
get_settings_manager().get("download_backend", "default")
)
# Site-specific credentials (e.g. a Hugging Face access token for
# gated/private repositories); empty for anonymous downloads.
auth_headers = source.auth_headers()
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"{source.platform}_{repo}_{filename}"
@@ -528,9 +604,12 @@ class ModelSourceHandler:
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
headers=auth_headers or None,
)
if ok:
await _save_source_metadata(dest_path, ref, model_root)
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
@@ -555,9 +634,12 @@ class ModelSourceHandler:
use_auth=False,
allow_resume=True,
progress_callback=progress_callback,
custom_headers=auth_headers or None,
)
if success:
await _save_source_metadata(dest_path, ref, model_root)
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
+159 -198
View File
@@ -9,7 +9,6 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web
@@ -34,8 +33,10 @@ from ...utils.civitai_utils import (
rewrite_preview_url,
)
from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...utils.url_utils import relative_root_prefix
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -215,6 +216,7 @@ class RecipePageView:
settings=self._settings,
request=request,
t=self._server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
except Exception as cache_error: # pragma: no cover - logging path
self._logger.error("Error loading recipe cache data: %s", cache_error)
@@ -223,6 +225,7 @@ class RecipePageView:
settings=self._settings,
request=request,
t=self._server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
return web.Response(text=rendered, content_type="text/html")
except Exception as exc: # pragma: no cover - logging path
@@ -1281,6 +1284,21 @@ class RecipeManagementHandler:
_original_image_url,
) = await self._download_remote_media(image_url)
# CivitAI's optimized rendition is re-encoded and metadata-free, so an
# embedded ComfyUI workflow only exists in the original. Fetch it
# lazily: unlike the URL import path (which needs the original for
# metadata parsing anyway), this path would download it purely for the
# workflow, so it is skipped unless the API reports one.
original_workflow = None
if _original_image_url and self._meta_indicates_comfy_workflow(
civitai_meta_raw
):
_raw_original, original_workflow = await self._fetch_original_media(
_original_image_url
)
if original_workflow:
metadata["workflow"] = original_workflow
# Build a version-cached map of local model hashes to cache items so
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
# exist on disk. Built once and shared by every parse pass below.
@@ -1803,6 +1821,10 @@ class RecipeManagementHandler:
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
# Opt-in workflow embedding. The widget historically POSTs with no
# body at all, so a missing/empty body is not an error.
workflow = await self._read_optional_json_field(request, "workflow")
analysis = await self._analysis_service.analyze_widget_metadata(
recipe_scanner=recipe_scanner
)
@@ -1815,6 +1837,7 @@ class RecipeManagementHandler:
recipe_scanner=recipe_scanner,
metadata=metadata,
image_bytes=image_bytes,
workflow=workflow,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
@@ -1879,6 +1902,24 @@ class RecipeManagementHandler:
return []
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
async def _read_optional_json_field(
self, request: web.Request, field: str
) -> Any:
"""Read one field from an optional JSON request body.
Some callers (notably the widget's long-standing "Save Recipe" action)
POST with no body at all, and a stale cached extension may still do so
after a body is introduced. A missing, empty or malformed body is
therefore treated as "no value" rather than a request error.
"""
if not request.can_read_body:
return None
try:
data = await request.json()
except Exception:
return None
return data.get(field) if isinstance(data, dict) else None
async def _count_recipe_loras(
self, recipe_scanner: Any, recipe_id: Optional[str]
) -> Optional[int]:
@@ -2087,6 +2128,90 @@ class RecipeManagementHandler:
except FileNotFoundError:
pass
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
``ExifUtils.extract_image_metadata`` stops at the generation
parameters, so the UI-format workflow has to be read through the
structured metadata reader. Failures map to ``None``.
"""
if not image_path or not os.path.exists(image_path):
return None
try:
metadata = ExifUtils._load_structured_metadata(image_path)
except Exception as exc:
self._logger.debug(
"Failed to read embedded workflow from %s: %s", image_path, exc
)
return None
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
return workflow if isinstance(workflow, str) and workflow else None
@staticmethod
def _meta_indicates_comfy_workflow(civitai_meta_raw: Any) -> bool:
"""Whether CivitAI reports an embedded ComfyUI workflow for an image.
``meta.comfy`` is the payload CivitAI captured from the original image,
so its presence is the signal that fetching the original is worth the
bandwidth when the caller does not already need it for metadata
parsing.
"""
if not isinstance(civitai_meta_raw, dict):
return False
inner = civitai_meta_raw.get("meta")
if isinstance(inner, dict) and inner.get("comfy"):
return True
return bool(civitai_meta_raw.get("comfy"))
async def _fetch_original_media(
self, original_image_url: Optional[str]
) -> tuple[Optional[str], Optional[str]]:
"""Download the original rendition and read its embedded media.
CivitAI's optimized renditions are re-encoded and carry no metadata, so
the original is the only source for embedded generation metadata and
for the UI-format ComfyUI workflow (the raw extractor's fallback chain
ends at ``workflow`` only when no prompt is present).
Returns ``(raw_metadata, workflow)``; either element is ``None`` when
unavailable. Failures never raise — imports keep working with the
optimized rendition when the original cannot be fetched.
"""
if not original_image_url:
return None, None
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
temp_path = temp_file.name
try:
downloader = await self._downloader_factory()
success, _result = await downloader.download_file(
original_image_url, temp_path, use_auth=False
)
if not success:
self._logger.warning(
"Failed to download original rendition: %s", original_image_url
)
return None, None
raw_metadata = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_path
)
workflow = await asyncio.to_thread(
self._read_embedded_workflow, temp_path
)
return raw_metadata, workflow
except Exception as exc:
self._logger.warning(
"Failed to read original rendition %s: %s", original_image_url, exc
)
return None, None
finally:
try:
if os.path.exists(temp_path):
os.unlink(temp_path)
except OSError:
pass
def _safe_int(self, value: Any) -> int:
try:
return int(value)
@@ -2292,6 +2417,7 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata: %s", exc
)
original_workflow: Optional[str] = None
if not parsed_embedded and original_image_url:
self._logger.debug(
"Optimized image has no embedded metadata, "
@@ -2299,48 +2425,32 @@ class RecipeManagementHandler:
original_image_url,
)
try:
downloader = await self._downloader_factory()
with tempfile.NamedTemporaryFile(
suffix=".png", delete=False
) as tmp:
orig_tmp_path = tmp.name
try:
success, _ = await downloader.download_file(
original_image_url, orig_tmp_path, use_auth=False
)
if success:
raw_orig = await asyncio.to_thread(
ExifUtils.extract_image_metadata, orig_tmp_path
raw_orig, original_workflow = await self._fetch_original_media(
original_image_url
)
diagnostics["exif_present"] = bool(raw_orig) or bool(
diagnostics.get("exif_present")
)
if raw_orig:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_orig
)
diagnostics["exif_present"] = bool(raw_orig)
if raw_orig:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_orig
)
)
if parser:
diagnostics["exif_parser"] = parser.__class__.__name__
if isinstance(parser, CivitaiApiMetadataParser):
parsed_embedded = await parser.parse_metadata(
raw_orig,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
if parser:
diagnostics["exif_parser"] = parser.__class__.__name__
if isinstance(parser, CivitaiApiMetadataParser):
parsed_embedded = await parser.parse_metadata(
raw_orig,
recipe_scanner=recipe_scanner,
local_cache=local_cache,
)
else:
parsed_embedded = await parser.parse_metadata(
raw_orig, recipe_scanner=recipe_scanner
)
if (
parsed_embedded
and "gen_params" in parsed_embedded
):
embedded_gen_params = parsed_embedded[
"gen_params"
]
finally:
if os.path.exists(orig_tmp_path):
os.unlink(orig_tmp_path)
else:
parsed_embedded = await parser.parse_metadata(
raw_orig, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
except Exception as exc:
self._logger.warning(
"Failed to extract metadata from original image: %s", exc
@@ -2388,6 +2498,8 @@ class RecipeManagementHandler:
"gen_params": embedded_gen_params or {},
"source_path": image_url,
}
if original_workflow:
metadata["workflow"] = original_workflow
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
@@ -3124,11 +3236,10 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
# Virtual path token for the Windows drive list. Kept as a class
# attribute for backwards compatibility; the canonical definition lives
# in py/utils/directory_browser.py.
WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
def __init__(
self,
@@ -3301,131 +3412,8 @@ class BatchImportHandler:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
return self._windows_drives_response()
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = (
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
)
else:
parent_path = str(path.parent)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
@@ -3434,30 +3422,3 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _windows_drives_response(self) -> web.Response:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [
{"name": drive, "path": drive, "is_parent": False} for drive in drives
]
return web.json_response(
{
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
)
+12
View File
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
@@ -111,6 +113,16 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/download/routing", "get_download_routing"
),
# Sidecar storage layout migration (GET supported for the extension)
RouteDefinition(
"POST", "/api/lm/sidecars/migrate", "migrate_sidecars"
),
RouteDefinition(
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
),
RouteDefinition(
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
),
RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source"
),
+3
View File
@@ -32,6 +32,7 @@ from .handlers.misc_handlers import (
NodeRegistry,
NodeRegistryHandler,
SettingsHandler,
SidecarMigrationHandler,
SupportersHandler,
TrainedWordsHandler,
UsageStatsHandler,
@@ -142,6 +143,7 @@ class MiscRoutes:
model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
sidecar_migration = SidecarMigrationHandler()
return self._handler_set_factory(
health=health,
@@ -164,6 +166,7 @@ class MiscRoutes:
model_source_handler=model_source_handler,
agent_handler=agent_handler,
download_routing=download_routing,
sidecar_migration=sidecar_migration,
)
+6
View File
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
+6 -1
View File
@@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes):
# resolved to no existing folder. Render an actionable empty state
# instead of an apparently broken empty grid.
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
return {
context = {
"other_disabled": False,
"other_no_paths": not bool(config.other_roots),
"standalone_mode": standalone_mode,
}
if standalone_mode:
# The empty state points at the Model Paths settings section and
# shows the settings.json path as a fallback reference.
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
return context
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
+2
View File
@@ -13,6 +13,7 @@ from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.model_query import normalize_sub_type, resolve_sub_type
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.url_utils import relative_root_prefix
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
@@ -106,6 +107,7 @@ class StatsRoutes:
settings=settings_manager,
request=request,
t=server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
return web.Response(
+14 -1
View File
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
# otherwise delete them because they are untracked and, in released tags,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# regardless of whether it is ignored.
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
# ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
# are listed too because a portable install can predate the cache/ move.
_PRESERVE_DIRS = (
'settings.json',
'civitai',
'wildcards',
'backups',
'stats',
'logs',
'cache',
'model_cache',
'recipe_cache',
)
def _clean_excludes() -> List[str]:
+3 -18
View File
@@ -33,8 +33,8 @@ from ..model_sources import (
resolve_source_ref,
source_label,
)
from ..model_sources.hydration import load_model_card, resolve_site_base_model
from ..websocket_manager import ws_manager
from .base_model_resolver import resolve_base_model
from .post_processor import PostProcessor
from .skill_registry import SkillRegistry
from .skills.enrich_hf_metadata.readme_processor import (
@@ -466,12 +466,7 @@ class AgentService:
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
cache_key = f"{ref.platform}:{ref.source_id}"
readme = cache.readmes.get(cache_key) if cache is not None else None
if readme is None:
readme = await source.fetch_model_card(ref.source_id)
if cache is not None and readme:
cache.readmes[cache_key] = readme
readme = await load_model_card(source, ref.source_id, cache)
# Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images). The
@@ -507,17 +502,7 @@ class AgentService:
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
from ...metadata_ops import list_base_models
hints = [*source_context.base_model_aliases, source_context.base_model]
if not any(hints):
return ""
try:
known_names = await list_base_models()
except Exception as exc:
logger.debug("Failed to list base models for site resolution: %s", exc)
return ""
return resolve_base_model(hints, known_names)
return await resolve_site_base_model(source_context)
async def _build_prompt_context(
self,
+67 -29
View File
@@ -48,6 +48,7 @@ class PostProcessor:
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor.
@@ -63,13 +64,18 @@ class PostProcessor:
hints resolve to, used when the LLM did not supply one (which is the
normal case when the LLM was skipped).
*metadata_source* records who produced the metadata. The AI skill
keeps its historical value; the deterministic download-time hydration
passes its own so the two remain distinguishable. ``llm_enriched_at``
is only stamped when *llm_output* actually carries a provider answer.
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
``preview_downloaded`` (bool), and ``errors`` (list).
"""
if skill_name == "enrich_hf_metadata":
return await self._process_enrich_hf_metadata(
model_path, llm_output, metadata, readme_content, source_context,
resolved_base_model,
resolved_base_model, metadata_source,
)
return {
"success": False,
@@ -89,6 +95,7 @@ class PostProcessor:
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
from ...metadata_ops import (
apply_metadata_updates,
@@ -135,6 +142,17 @@ class PostProcessor:
if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base
# model_name — the site's own display name, so a source download never
# shows up under its local filename. Written only while the name is
# still the untouched file stem: once a user renames a model that
# choice is theirs to keep.
site_name = ((source_context.model_name if source_context else "") or "").strip()
if is_source_model and site_name:
current_name = (metadata.get("model_name") or "").strip()
file_stem = (metadata.get("file_name") or "").strip()
if not current_name or current_name == file_stem:
updates["model_name"] = site_name
# trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True
@@ -142,14 +160,9 @@ class PostProcessor:
cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {}
current_triggers = current_civitai.get("trainedWords") or []
current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_source_model):
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
self._merge_civitai(updates, metadata, trainedWords=cleaned)
# modelDescription — the author's own summary (when the site keeps one
# outside the README, e.g. ModelScope's ``Description``) followed by the
@@ -175,12 +188,25 @@ class PostProcessor:
if not short_desc:
short_desc = site_description
if short_desc and is_source_model:
current_civitai = metadata.get("civitai") or {}
desc_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
desc_civitai.update(updates["civitai"])
desc_civitai["description"] = short_desc
updates["civitai"] = desc_civitai
self._merge_civitai(updates, metadata, description=short_desc)
# The version label completes the card the way a CivitAI download does:
# the UI renders `civitai.name` as the version chip. It is per file,
# so a collection repository shows that checkpoint's own label.
site_version = (
(source_context.version_name if source_context else "") or ""
).strip()
if is_source_model and site_version:
self._merge_civitai(updates, metadata, name=site_version)
# Site-native identity ids (ModelScope's published model/version ids).
# They are what version grouping keys off, so they must reach the
# sidecar even when nothing else about the card changed.
if is_source_model and source_context is not None:
if source_context.source_model_id:
updates["source_model_id"] = source_context.source_model_id
if source_context.source_version_id:
updates["source_version_id"] = source_context.source_version_id
# gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body)
@@ -244,12 +270,7 @@ class PostProcessor:
all_images = _dedupe_images(site_images + readme_images)
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
self._merge_civitai(updates, metadata, images=all_images)
# tags — the site's curated tags are authoritative content vocabulary, so
# they are kept alongside whatever the LLM proposed (the LLM is skipped
@@ -269,9 +290,12 @@ class PostProcessor:
if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged
# metadata_source & llm_enriched_at (always set)
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# metadata_source is recorded for provenance; llm_enriched_at only means
# something when a provider actually answered, so the deterministic
# download-time hydration does not claim an enrichment that never ran.
updates["metadata_source"] = metadata_source
if llm_output:
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# LLM confidence, stored for the enrichment evaluation harness. The key
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
@@ -292,12 +316,7 @@ class PostProcessor:
if instance_prompt:
site_triggers = [instance_prompt]
if site_triggers:
current_civitai = metadata.get("civitai") or {}
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = site_triggers
updates["civitai"] = trig_civitai
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned
@@ -371,6 +390,25 @@ class PostProcessor:
"", "unknown",
)
@staticmethod
def _merge_civitai(
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
) -> None:
"""Layer *fields* onto the ``civitai`` block being assembled.
Description, version label, trigger words and gallery images all live
in the same dict and are contributed by separate branches, so each one
starts from what is already on disk and then applies whatever an
earlier branch queued in *updates*.
"""
merged = dict(metadata.get("civitai") or {})
queued = updates.get("civitai")
if isinstance(queued, dict):
merged.update(queued)
merged.update(fields)
updates["civitai"] = merged
@staticmethod
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten."""
+15 -7
View File
@@ -81,6 +81,14 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.red/api/download/",
)
#: Hosts whose authenticated downloads redirect to a signed CDN URL. aria2
#: forwards custom headers to redirect targets, so for these hosts the
#: redirect is resolved first and the signed URL is handed to aria2 without
#: the credentials.
AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES = CIVITAI_DOWNLOAD_URL_PREFIXES + (
"https://huggingface.co/",
)
def _is_no_uri_available_error(message: str) -> bool:
"""Return True for aria2's "No URI available" transfer failure.
@@ -308,12 +316,12 @@ class Aria2Downloader:
resolved_url = url
request_headers = headers
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES):
if headers and url.startswith(AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES):
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
if resolved_url != url:
request_headers = None
logger.debug(
"Resolved Civitai download %s to signed URL for aria2",
"Resolved authenticated download %s to signed URL for aria2",
download_id,
)
@@ -341,7 +349,7 @@ class Aria2Downloader:
]
logger.debug(
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
"Submitting aria2 download %s -> %s (auth=%s, signed_url=%s)",
download_id,
save_path,
bool(request_headers),
@@ -732,7 +740,7 @@ class Aria2Downloader:
if location:
return location
raise Aria2Error(
"Authenticated Civitai redirect did not include a Location header"
"Authenticated redirect did not include a Location header"
)
if response.status == 200:
@@ -740,12 +748,12 @@ class Aria2Downloader:
body = await response.text()
raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
f"Failed to resolve authenticated redirect: status={response.status} body={body[:300]}"
)
except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc):
logger.error(
"SSL certificate verification failed during Civitai redirect "
"SSL certificate verification failed during authenticated redirect "
"resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
@@ -753,7 +761,7 @@ class Aria2Downloader:
url,
)
raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}"
f"Failed to resolve authenticated redirect: {exc}"
) from exc
async def _ensure_process(self) -> None:
+3 -1
View File
@@ -27,6 +27,8 @@ import os
import threading
from typing import TYPE_CHECKING, Optional
from ..utils.sidecar_paths import get_metadata_path
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
from .model_scanner import ModelScanner
@@ -41,7 +43,7 @@ def _resolve_autov3(file_path: str) -> str:
safetensors header hash. Returns ``''`` when neither is available.
"""
try:
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = get_metadata_path(file_path)
if os.path.exists(metadata_path):
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
+6 -10
View File
@@ -740,18 +740,14 @@ class BaseModelService(ABC):
return annotated
@staticmethod
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
key = BaseModelService._extract_source_group_key(item)
return key if key and key.startswith("hf:") else None
@staticmethod
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Return the external-source group key for *item*, or None.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (``ms:`` / ``ta:``).
Only sources with a site-native model identity yield a key:
ModelScope groups by its published-model id (``ms:{id}``), TensorArt
by its numeric model id (``ta:{id}``); Hugging Face models never
group (see :meth:`ModelSource.group_key`).
"""
return source_group_key(item)
@@ -761,8 +757,8 @@ class BaseModelService(ABC):
Preference order:
1. CivitAI ``modelId`` (int)
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
``ms:{owner}/{repo}``, ``ta:{model_id}`` (str)
2. External model source identity, e.g. ``ms:{model_id}``,
``ta:{model_id}`` (str)
3. ``None`` (no known grouping source)
"""
mid = BaseModelService._extract_model_id(item)
+5
View File
@@ -645,6 +645,11 @@ class BatchImportService:
if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"]
# A workflow recovered from the source's original rendition
# travels as metadata and is embedded into the stored image.
if payload.get("workflow"):
metadata["workflow"] = payload["workflow"]
nsfw = payload.get("preview_nsfw_level")
if isinstance(nsfw, int) and nsfw > 0:
metadata["preview_nsfw_level"] = nsfw
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_preview_dir, is_centralized
from ..config import config
from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex
@@ -61,10 +62,9 @@ class CheckpointScanner(ModelScanner):
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
# AutoV3 reads only the safetensors header, so it is cheap even for
# large checkpoints; record the checked state at creation time ("" =
@@ -322,6 +322,11 @@ class CheckpointScanner(ModelScanner):
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for checkpoint metadata files with pending hash status."""
# Centralized mode stores sidecars in the mirror tree, not next to the
# models; walk the mirror instead of the model folders.
if is_centralized():
return self._find_pending_models_in_sidecar_mirror()
pending_models = []
for root_path in self.get_model_roots():
+2
View File
@@ -69,6 +69,8 @@ class CheckpointService(BaseModelService):
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""),
}
+272 -15
View File
@@ -25,6 +25,8 @@ from ..utils.models import (
)
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
@@ -33,18 +35,21 @@ from ..utils.constants import (
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
from .service_registry import ServiceRegistry
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import RateLimitError
from .errors import DownloadRateLimitError, RateLimitError
from .rate_limit_coordinator import RateLimitCoordinator
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first
import tempfile
@@ -56,6 +61,15 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.red/api/download/",
)
# Hosts a model download may hit (metadata + file transfer). The pre-flight
# cooldown gate consults the RateLimitCoordinator for these before a download
# occupies a concurrency slot.
DOWNLOAD_PREFLIGHT_HOSTS = ("civitai.com", "civitai.red", "civarchive.com")
# Fallback retry_after when neither the vendor nor the coordinator can supply
# a number (matches the Retry-After parsing default in downloader.py).
DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS = 60
# File types that are never the intended download target even when CivitAI
# marks them primary — configs/archives/workflows are auxiliary artifacts.
@@ -199,11 +213,30 @@ class DownloadManager:
)
except Aria2Error as exc:
logger.error("aria2 download failed for %s: %s", download_url, exc)
# Best-effort 429 detection: aria2 reports HTTP status errors
# via its error message (e.g. "status=429") without exposing
# the vendor's Retry-After. Surface the structured rate-limit
# error so the queue contract behaves the same as the python
# backend; the coordinator backoff supplies the wait time.
message = str(exc)
if "429" in message or "rate limit" in message.lower():
host = self._url_host(download_url)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(host, None)
raise DownloadRateLimitError(
f"Download rate limited (429): {message}",
retry_after=None,
host=host,
) from exc
return False, str(exc)
download_kwargs: Dict[str, Any] = {
"progress_callback": progress_callback,
"use_auth": use_auth,
# The model download queue contract requires structured 429
# propagation (reason="rate_limited"), not a plain error string.
"raise_on_rate_limit": True,
}
if pause_control is not None:
@@ -212,6 +245,88 @@ class DownloadManager:
downloader = await get_downloader()
return await downloader.download_file(download_url, save_path, **download_kwargs)
@staticmethod
def _url_host(url: str) -> str:
"""Extract the normalized hostname from a URL (fallback: ``unknown``)."""
hostname = urlparse(url).hostname
return hostname.lower() if hostname else "unknown"
async def _preflight_rate_limit_error(self) -> Optional[DownloadRateLimitError]:
"""Fail fast when a download target host is in a rate-limit cooldown.
Consults the RateLimitCoordinator's per-host cooldown state for the
hosts a model download may hit. Runs BEFORE the concurrency semaphore
is acquired so queued items never occupy a slot during a 429 episode.
Deliberately non-blocking: the caller is expected to pace itself (the
companion extension auto-pauses on the structured 429 response).
"""
coordinator = await RateLimitCoordinator.get_instance()
worst_host: Optional[str] = None
worst_remaining = 0.0
for host in DOWNLOAD_PREFLIGHT_HOSTS:
remaining = coordinator.remaining_seconds(host)
if remaining > worst_remaining:
worst_host = host
worst_remaining = remaining
if worst_host is None or worst_remaining <= 0:
return None
retry_after = max(1, int(worst_remaining + 0.5))
return DownloadRateLimitError(
f"Download rate limited: '{worst_host}' is in cooldown, "
f"retry after {retry_after}s",
retry_after=worst_remaining,
host=worst_host,
)
async def _handle_rate_limited_download(
self,
task_id: str,
exc: RateLimitError,
) -> Dict[str, Any]:
"""Build the structured rate-limit result for a failed download.
The queue row goes back to ``queued`` (NOT history) so a later retry
simply starts the download again — this is what lets the companion
extension auto-pause the queue during a 429 episode and resume it
after ``retry_after`` seconds.
"""
retry_after = exc.retry_after
host = getattr(exc, "host", None) or exc.provider
if retry_after is None or retry_after <= 0:
# The coordinator clamps/backoffs via register_rate_limit, so its
# remaining cooldown supplies the number when the vendor didn't.
coordinator = await RateLimitCoordinator.get_instance()
remaining = coordinator.remaining_seconds(host)
if remaining > 0:
retry_after = remaining
if retry_after is None or retry_after <= 0:
retry_after = float(DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS)
retry_after_seconds = max(1, int(retry_after + 0.5))
message = str(exc) or (
f"Download rate limited, retry after {retry_after_seconds}s"
)
if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = "queued"
self._active_downloads[task_id]["error"] = message
self._active_downloads[task_id]["bytes_per_second"] = 0.0
try:
queue_service = await DownloadQueueService.get_instance()
await queue_service.update_status(task_id, "queued", error=message)
except Exception:
logger.warning(
"Failed to re-queue rate-limited download %s", task_id, exc_info=True
)
return {
"success": False,
"reason": "rate_limited",
"retry_after": retry_after_seconds,
"error": message,
}
async def _get_lora_scanner(self):
"""Get the lora scanner from registry"""
return await ServiceRegistry.get_lora_scanner()
@@ -554,6 +669,15 @@ class DownloadManager:
original_callback, snapshot, progress_value
)
# Pre-flight cooldown gate: fail fast (without holding a semaphore
# slot) when a target host is still cooling down from an earlier 429.
preflight_error = await self._preflight_rate_limit_error()
if preflight_error is not None:
logger.info(
"Download %s skipped: %s", task_id, preflight_error
)
return await self._handle_rate_limited_download(task_id, preflight_error)
# Acquire semaphore to limit concurrent downloads
try:
async with self._download_semaphore:
@@ -658,6 +782,12 @@ class DownloadManager:
logger.info(f"Download cancelled for task {task_id}")
raise
except RateLimitError as e:
# 429 (real vendor response or cooldown gate): re-queue
# instead of completing as failed so a later retry just
# starts the download again.
logger.info(f"Download rate limited for task {task_id}: {e}")
return await self._handle_rate_limited_download(task_id, e)
except Exception as e:
# Handle other errors
logger.error(
@@ -828,7 +958,7 @@ class DownloadManager:
)
for file_path in target_files:
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
deleted = await self._delete_file_with_retries(metadata_path)
if not deleted and os.path.exists(metadata_path):
logger.error(f"Error deleting metadata file: {metadata_path}")
@@ -1519,7 +1649,11 @@ class DownloadManager:
return {"success": False, "error": "Failed to fetch model metadata"}
model_type_from_info = version_info.get("model", {}).get("type", "").lower()
if model_type_from_info == "checkpoint":
# CivitAI ModelType has no model-level diffusion variant: DiT
# models are uploaded as "Checkpoint" or "UNet". Both go through
# the checkpoint branch so the standard diffusion routing chain
# (file type -> baseModel lists -> unknown default) applies.
if model_type_from_info in ("checkpoint", "unet"):
model_type = "checkpoint"
elif model_type_from_info in VALID_LORA_TYPES:
model_type = "lora"
@@ -1666,6 +1800,9 @@ class DownloadManager:
model_type,
file_types=(f.get("type", "") for f in version_info.get("files", [])),
base_model=base_model_value,
unknown_base_model_default=get_settings_manager().get(
"unknown_base_model_routing", "diffusion_model"
),
)
# Existence check after the metadata fetch (#1058):
@@ -2104,6 +2241,10 @@ class DownloadManager:
return result
except RateLimitError:
# Structured 429 propagation must reach _download_with_semaphore
# unmodified so the queue row is re-queued instead of failed.
raise
except Exception as e:
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
# Check if this might be an early access error
@@ -2326,16 +2467,26 @@ class DownloadManager:
if not first_tag:
first_tag = "no tags" # Default if no tags available
# Tags come straight from CivitAI, so sanitize the value before it
# becomes a path segment and cap its length (#1119).
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
# Format the template with available data
formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace(
"{model_name}", sanitize_folder_name(model_info.get("name", ""))
"{model_name}",
sanitize_folder_name(
model_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
)
formatted_path = formatted_path.replace(
"{version_name}", sanitize_folder_name(version_info.get("name", ""))
"{version_name}",
sanitize_folder_name(
version_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
)
if model_type == "embedding":
@@ -2434,7 +2585,7 @@ class DownloadManager:
return {"success": False, "error": save_path}
part_path = save_path + ".part"
metadata_path = os.path.splitext(save_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(save_path)
pause_control = self._pause_events.get(download_id) if download_id else None
@@ -2451,6 +2602,10 @@ class DownloadManager:
# Download preview image if available
images = version_info.get("images", [])
if images:
# Centralized preview mirrors may not exist yet (unlike the
# model's own directory in alongside mode).
os.makedirs(get_preview_dir(save_path), exist_ok=True)
if progress_callback:
await progress_callback(
1
@@ -2490,7 +2645,10 @@ class DownloadManager:
if media_type == "video":
preview_ext = _extension_from_url(preview_url, ".mp4")
preview_path = os.path.splitext(save_path)[0] + preview_ext
preview_path = os.path.join(
get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
)
rewritten_url, rewritten = rewrite_preview_url(
preview_url, media_type="video"
)
@@ -2517,7 +2675,10 @@ class DownloadManager:
)
if rewritten and rewritten_url:
preview_ext = _extension_from_url(preview_url, ".png")
preview_path = os.path.splitext(save_path)[0] + preview_ext
preview_path = os.path.join(
get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
)
success, _ = await downloader.download_file(
rewritten_url, preview_path, use_auth=False
)
@@ -2544,8 +2705,9 @@ class DownloadManager:
temp_file_handle.write(
content if isinstance(content, bytes) else content.encode("utf-8")
)
preview_path = (
os.path.splitext(save_path)[0] + ".webp"
preview_path = os.path.join(
get_preview_dir(save_path),
os.path.splitext(os.path.basename(save_path))[0] + ".webp",
)
optimized_data, _ = ExifUtils.optimize_image(
@@ -2746,6 +2908,7 @@ class DownloadManager:
else None
)
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index]
@@ -2774,9 +2937,7 @@ class DownloadManager:
entry = cast(Any, adjusted_entry)
metadata_entries[index] = entry
metadata_file_path = (
os.path.splitext(entry.file_path)[0] + ".metadata.json"
)
metadata_file_path = get_metadata_path(entry.file_path)
metadata_files_for_cleanup.append(metadata_file_path)
await MetadataManager.save_metadata(entry.file_path, entry)
@@ -2788,6 +2949,15 @@ class DownloadManager:
if scanner is not None:
await scanner.add_model_to_cache(metadata_dict, relative_path)
downloaded_metadata.append(metadata_dict)
await self._apply_download_filename_template(
scanner=scanner,
model_type=model_type,
downloaded_metadata=downloaded_metadata,
download_id=download_id,
)
if transfer_backend == "aria2" and download_id:
await self._aria2_state_store.remove(download_id)
@@ -2797,6 +2967,10 @@ class DownloadManager:
return {"success": True}
except RateLimitError:
# Structured 429 propagation must reach _download_with_semaphore
# unmodified so the queue row is re-queued instead of failed.
raise
except Exception as e:
logger.error(f"Error in _execute_download: {e}", exc_info=True)
cleanup_targets = {
@@ -2827,6 +3001,83 @@ class DownloadManager:
return {"success": False, "error": str(e)}
async def _apply_download_filename_template(
self,
*,
scanner,
model_type: str,
downloaded_metadata: List[Dict[str, Any]],
download_id: Optional[str],
) -> None:
"""Rename freshly downloaded models according to the filename template.
Best-effort post-download step: any failure (including name conflicts)
is logged and skipped so a successful download is never turned into a
failure by a rename problem.
"""
try:
if scanner is None or not downloaded_metadata:
return
template = get_settings_manager().get_download_filename_template(
model_type
)
if not template:
return
lifecycle_service = ModelLifecycleService(
scanner=scanner,
metadata_manager=MetadataManager,
metadata_loader=load_local_metadata,
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
)
for metadata_dict in downloaded_metadata:
file_path = metadata_dict.get("file_path")
if not isinstance(file_path, str) or not file_path:
continue
new_stem = calculate_filename_for_model(metadata_dict, model_type)
if not new_stem:
continue
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
continue
try:
result = await lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
except ValueError as exc:
logger.warning(
"Keeping original filename for %s: %s", file_path, exc
)
continue
new_file_path = result.get("new_file_path")
if download_id and isinstance(new_file_path, str):
info = self._active_downloads.get(download_id)
if info is None:
continue
if info.get("file_path") == file_path:
info["file_path"] = new_file_path
extracted = info.get("extracted_paths")
if isinstance(extracted, list):
info["extracted_paths"] = [
new_file_path if path == file_path else path
for path in extracted
]
except Exception as exc: # Rename phase must never fail the download
logger.warning(
"Filename template rename failed for %s download: %s",
model_type,
exc,
exc_info=True,
)
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type in ("checkpoint", "other"):
return {
@@ -2949,7 +3200,11 @@ class DownloadManager:
extension = os.path.splitext(preview_path)[1] or ".webp"
targets = [
os.path.splitext(entry.file_path)[0] + extension for entry in entries
os.path.join(
get_preview_dir(entry.file_path),
os.path.splitext(os.path.basename(entry.file_path))[0] + extension,
)
for entry in entries
]
if not targets:
@@ -2957,10 +3212,12 @@ class DownloadManager:
first_target = targets[0]
if preview_path != first_target:
os.makedirs(os.path.dirname(first_target), exist_ok=True)
os.replace(preview_path, first_target)
source_path = first_target
for target in targets[1:]:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copyfile(source_path, target)
return targets
+22 -2
View File
@@ -13,6 +13,7 @@ import logging
from typing import Iterable, Optional
from ..utils.constants import (
CHECKPOINT_BASE_MODELS,
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
DIFFUSION_MODEL_BASE_MODELS,
@@ -24,17 +25,27 @@ logger = logging.getLogger(__name__)
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
# Allowed values for the "unknown_base_model_routing" setting / the
# unknown_base_model_default parameter below.
ROUTING_DIFFUSION_MODEL = "diffusion_model"
ROUTING_CHECKPOINT = "checkpoint"
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
unknown_base_model_default: str = ROUTING_DIFFUSION_MODEL,
) -> bool:
"""Return True when a download should be routed to the unet roots.
Only applies to downloads initiated from the checkpoint library.
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
direct signal from CivitAI), (2) baseModel is a known diffusion model.
direct signal from CivitAI), (2) baseModel is a known diffusion model,
(3) baseModel is a known full checkpoint -> not diffusion, (4) unknown
or empty baseModel -> the ``unknown_base_model_default`` setting, which
defaults to diffusion because the set of true checkpoint families is
closed while new DiT base models appear all the time.
"""
if model_type != "checkpoint":
return False
@@ -54,7 +65,16 @@ def is_diffusion_model_download(
)
return True
return False
if base_model in CHECKPOINT_BASE_MODELS:
return False
is_diffusion = unknown_base_model_default != ROUTING_CHECKPOINT
logger.info(
"baseModel '%s' is unknown, routing to %s folder (unknown_base_model_routing)",
base_model,
"unet" if is_diffusion else "checkpoint",
)
return is_diffusion
def resolve_other_download_sub_type(
+33 -1
View File
@@ -31,7 +31,7 @@ from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
ConnectivityGuard,
)
from .errors import RateLimitError
from .errors import DownloadRateLimitError, RateLimitError
from .rate_limit_coordinator import RateLimitCoordinator
logger = logging.getLogger(__name__)
@@ -434,6 +434,7 @@ class Downloader:
custom_headers: Optional[Dict[str, str]] = None,
allow_resume: bool = True,
pause_event: Optional[DownloadStreamControl] = None,
raise_on_rate_limit: bool = False,
) -> Tuple[bool, str]:
"""
Download a file with resumable downloads and retry mechanism
@@ -446,6 +447,11 @@ class Downloader:
custom_headers: Additional headers to include in request
allow_resume: Whether to support resumable downloads
pause_event: Optional stream control used to pause/resume and request reconnects
raise_on_rate_limit: When True, a 429 response raises
``DownloadRateLimitError`` instead of returning a plain error
string, so callers (the model download manager) can build the
structured rate-limit result required by the download queue
contract. Defaults to the legacy tuple behavior.
Returns:
Tuple[bool, str]: (success, save_path or error message)
@@ -610,6 +616,12 @@ class Downloader:
logger.warning(
f"Rate limited (429) for {url}, retry_after={retry_after}"
)
if raise_on_rate_limit:
raise DownloadRateLimitError(
f"Download rate limited (429), retry after {retry_after}s",
retry_after=retry_after,
host=self._guard_destination(url),
)
return False, f"Download rate limited (429), retry after {retry_after}s"
else:
logger.error(
@@ -902,6 +914,11 @@ class Downloader:
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
)
except DownloadRateLimitError:
# 429s are never retried in-band; the structured error must
# reach the caller (download manager) unmodified.
raise
except Exception as e:
logger.error(f"Unexpected download error: {e}")
return False, str(e)
@@ -931,6 +948,7 @@ class Downloader:
use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False,
raise_on_rate_limit: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
"""
Download a file to memory (for small files like preview images)
@@ -940,6 +958,10 @@ class Downloader:
use_auth: Whether to include authentication headers
custom_headers: Additional headers to include in request
return_headers: Whether to return response headers along with content
raise_on_rate_limit: When True, a 429 response raises
``DownloadRateLimitError`` instead of returning a plain error
string (see ``download_file``). Defaults to the legacy tuple
behavior.
Returns:
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
@@ -1002,11 +1024,21 @@ class Downloader:
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
url, retry_after,
)
if raise_on_rate_limit:
raise DownloadRateLimitError(
f"Rate limited (429), retry after {retry_after}s",
retry_after=retry_after,
host=destination,
)
return False, f"Rate limited (429), retry after {retry_after}s", None
else:
error_msg = f"Download failed with status {response.status}"
return False, error_msg, None
except DownloadRateLimitError:
# Structured rate-limit errors must reach the caller unmodified.
raise
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
+2
View File
@@ -69,6 +69,8 @@ class EmbeddingService(BaseModelService):
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""),
}
+19
View File
@@ -20,6 +20,25 @@ class RateLimitError(RuntimeError):
self.provider = provider
class DownloadRateLimitError(RateLimitError):
"""Raised when a file download is rejected with HTTP 429.
Carries the vendor's ``Retry-After`` hint (when present) and the target
host so the download manager can build the structured rate-limit result
the download queue contract expects.
"""
def __init__(
self,
message: str,
*,
retry_after: Optional[float] = None,
host: Optional[str] = None,
) -> None:
super().__init__(message, retry_after=retry_after)
self.host = host
class ResourceNotFoundError(RuntimeError):
"""Raised when a remote resource is permanently missing."""
+13 -5
View File
@@ -81,6 +81,8 @@ class LoraService(BaseModelService):
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""),
}
@@ -714,12 +716,18 @@ class LoraService(BaseModelService):
),
)
# Return minimal data needed for cycling
return [
{
# Return minimal data needed for cycling. usage_tips is only included
# when non-empty so widget consumers (recommended strength range cues)
# can build their lookup without inflating the payload.
result = []
for lora in available_loras:
entry = {
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""),
}
for lora in available_loras
]
usage_tips = lora.get("usage_tips")
if usage_tips:
entry["usage_tips"] = usage_tips
result.append(entry)
return result
+29 -3
View File
@@ -10,6 +10,7 @@ from .model_metadata_provider import (
SQLiteModelMetadataProvider,
CivitaiModelMetadataProvider,
CivArchiveModelMetadataProvider,
OpenModelDBModelMetadataProvider,
FallbackMetadataProvider,
RateLimitRetryingProvider,
)
@@ -22,12 +23,18 @@ logger = logging.getLogger(__name__)
_PROVIDER_DISPLAY_NAMES = {
"civitai_api": "CivitAI",
"civarchive_api": "CivArchive",
"openmodeldb_api": "OpenModelDB",
"sqlite": "Archive DB",
}
# Preset fallback chains. civitai_api is always first (richest metadata).
# openmodeldb_api sits right after it: its lookups are local index hits over a
# cached bulk dump (no rate-limit budget spent), and it covers upscalers that
# CivArchive only has when they once existed on CivitAI. Providers that are not
# registered (disabled/unavailable) are skipped, so presets degrade gracefully.
_PRESET_PROVIDER_ORDERS = {
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
"civitai_archive_sqlite": ["civitai_api", "openmodeldb_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "openmodeldb_api", "sqlite", "civarchive_api"],
}
async def initialize_metadata_providers():
@@ -42,6 +49,7 @@ async def initialize_metadata_providers():
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
providers = []
@@ -92,6 +100,22 @@ async def initialize_metadata_providers():
else:
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
# Register the OpenModelDB provider when enabled. It only covers upscaler
# models (hash-matched against its catalogue dump), so it complements
# rather than replaces the CivitAI-family providers; disabling it avoids
# the one-time bulk dump download entirely.
if enable_openmodeldb_api:
try:
openmodeldb_client = await ServiceRegistry.get_openmodeldb_client()
openmodeldb_provider = OpenModelDBModelMetadataProvider(openmodeldb_client)
provider_manager.register_provider('openmodeldb_api', openmodeldb_provider)
providers.append(('openmodeldb_api', openmodeldb_provider))
logger.debug("OpenModelDB metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize OpenModelDB metadata provider: {e}")
else:
logger.debug("OpenModelDB metadata provider disabled by setting 'enable_openmodeldb_api'")
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
# civitai_api is always first (better metadata); the remaining providers
# are arranged by the configured preset. Providers that are not
@@ -135,6 +159,7 @@ async def update_metadata_providers():
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
# Reinitialize all providers with new settings
@@ -153,9 +178,10 @@ async def update_metadata_providers():
)
logger.info(
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
"Updated metadata providers: archive_db=%s, civarchive_api=%s, openmodeldb_api=%s, chain=%s",
enable_archive_db,
enable_civarchive_api,
enable_openmodeldb_api,
chain,
)
return provider_manager
+93 -16
View File
@@ -12,13 +12,51 @@ from ..services.settings_manager import SettingsManager
from ..utils.civitai_utils import resolve_license_payload
from ..utils.model_utils import determine_base_model
from ..utils.models import autov3_from_civitai_files
from ..utils.sidecar_paths import get_metadata_path
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError
from .model_sources import has_external_source
from .model_metadata_provider import _LOCAL_PROVIDER_LABELS
from .model_sources import get_source_platform, has_external_source
logger = logging.getLogger(__name__)
# Providers restricted to specific model sub_types, keyed by their
# registration label. Providers not listed apply to every model type.
# OpenModelDB only indexes upscalers, so it is never consulted for other model
# types — that keeps its one-time bulk-catalogue download from being paid by
# users who manage no upscalers at all.
_PROVIDER_SUB_TYPE_RESTRICTIONS: Dict[str, frozenset] = {
"openmodeldb_api": frozenset({"upscaler"}),
}
#: External-source platforms that have their own hash-lookup metadata
#: provider. A model downloaded from one of these is refreshed against the
#: source's own catalogue first (upgrading the download-time card to the full
#: payload) before CivitAI is consulted at all.
_EXTERNAL_SOURCE_METADATA_PROVIDERS: Dict[str, str] = {
"openmodeldb": "openmodeldb_api",
}
def _restricted_providers_for_sub_type(sub_type: Optional[str]) -> list:
"""Return the restricted provider labels that apply to ``sub_type``."""
return [
name
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
if sub_type in allowed
]
def _inapplicable_providers_for_sub_type(sub_type: Optional[str]) -> set:
"""Return the restricted provider labels that do NOT apply to ``sub_type``."""
return {
name
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
if sub_type not in allowed
}
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
"""Concatenate two word lists, dropping duplicates without reordering.
@@ -216,7 +254,7 @@ class MetadataSyncService:
logger.error(error)
return False, error
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
enable_archive = self._settings.get("enable_metadata_archive_db", False)
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
@@ -225,6 +263,18 @@ class MetadataSyncService:
sqlite_attempted = False
if model_data.get("civitai_deleted") is True:
# Sub_type-restricted providers (e.g. OpenModelDB for
# upscalers) stay reachable for deleted models: their
# catalogues grow independently of CivitAI, so a model deleted
# from CivitAI may still gain metadata there later.
for restricted_name in _restricted_providers_for_sub_type(
model_data.get("sub_type")
):
try:
provider_attempts.append((restricted_name, await self._get_provider(restricted_name)))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve %s provider: %s", restricted_name, exc)
if previous_source in (None, "civarchive"):
try:
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
@@ -249,19 +299,44 @@ class MetadataSyncService:
is_hf_source = has_external_source(model_data)
if is_hf_source:
# External-source model (Hugging Face / ModelScope /
# TensorArt): only check CivitAI API directly.
# CivArchive is almost guaranteed to have no record, and
# hitting it wastes rate-limit budget.
# TensorArt / OpenModelDB): a source with its own
# hash-lookup provider (OpenModelDB) is consulted first,
# then CivitAI API directly. CivArchive is almost
# guaranteed to have no record, and hitting it wastes
# rate-limit budget.
# Use a distinct provider name ("civitai_api" not None) so
# downstream code does NOT interpret a "Model not found"
# response as civitai_api_not_found — which would mark the
# model civitai_deleted=True when it was never on CivitAI.
try:
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve civitai_api provider: %s", exc)
source_provider = _EXTERNAL_SOURCE_METADATA_PROVIDERS.get(
get_source_platform(model_data)
)
provider_names = (
[source_provider, "civitai_api"]
if source_provider
else ["civitai_api"]
)
for provider_name in provider_names:
try:
provider_attempts.append(
(provider_name, await self._get_provider(provider_name))
)
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug(
"Unable to resolve %s provider: %s", provider_name, exc
)
if not provider_attempts:
provider_attempts.append((None, await self._get_default_provider()))
default_provider = await self._get_default_provider()
# Drop sub_type-restricted providers that cannot apply to
# this model (e.g. OpenModelDB only indexes upscalers), so
# their cold-start cost is never paid pointlessly.
inapplicable = _inapplicable_providers_for_sub_type(
model_data.get("sub_type")
)
excluding = getattr(default_provider, "excluding", None)
if inapplicable and callable(excluding):
default_provider = excluding(inapplicable)
provider_attempts.append((None, default_provider))
civitai_metadata: Optional[Dict[str, Any]] = None
metadata_provider: Optional[MetadataProviderProtocol] = None
@@ -272,10 +347,11 @@ class MetadataSyncService:
skip_network_providers = False
for provider_name, provider in provider_attempts:
if skip_network_providers and provider_name != "sqlite":
if skip_network_providers and provider_name not in _LOCAL_PROVIDER_LABELS:
# A network provider was already rate-limited; failing
# over to another network provider just spreads the flood
# (#1085). The local sqlite archive stays as last resort.
# (#1085). Local lookups (sqlite archive, the cached
# OpenModelDB index) stay available as a last resort.
continue
try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
@@ -385,6 +461,7 @@ class MetadataSyncService:
readable_source = {
"civitai_api": "CivitAI API",
"civarchive": "CivArchive API",
"openmodeldb": "OpenModelDB",
"archive_db": "Archive Database",
}.get(source, source)
@@ -485,7 +562,7 @@ class MetadataSyncService:
+ (f" with version: {model_version_id}" if model_version_id else "")
)
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
await self.update_model_metadata(
metadata_path,
metadata,
@@ -505,7 +582,7 @@ class MetadataSyncService:
) -> Dict[str, Any]:
"""Apply metadata updates and persist to disk and cache."""
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
metadata = await metadata_loader(metadata_path)
for key, value in updates.items():
@@ -554,7 +631,7 @@ class MetadataSyncService:
}
expected_hash: Optional[str] = None
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json"
first_metadata_path = get_metadata_path(file_paths[0])
first_metadata = await metadata_loader(first_metadata_path)
if first_metadata and "sha256" in first_metadata:
expected_hash = first_metadata["sha256"].lower()
@@ -565,7 +642,7 @@ class MetadataSyncService:
try:
actual_hash = await hash_calculator(path)
metadata_path = os.path.splitext(path)[0] + ".metadata.json"
metadata_path = get_metadata_path(path)
metadata = await metadata_loader(metadata_path)
stored_hash = metadata.get("sha256", "").lower()
+92 -7
View File
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
@@ -15,6 +16,16 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__)
def _normalize_match_path(path: Any) -> str:
"""Normalize a path for set membership tests.
Business paths only — symlinks are never resolved here, matching how the
scanner stores ``excluded_models``. The forward-slash form keeps Windows
comparisons working with the scanner's normalized entries.
"""
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
class ProgressCallback(ABC):
"""Abstract callback interface for progress reporting"""
@@ -43,10 +54,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary"""
if self.operation_type == 'filename_template':
message = (
f'Filename template applied: {self.success_count} renamed, '
f'{self.skipped_count} skipped, {self.failure_count} failed '
f'out of {self.total} total'
)
else:
message = (
f'Auto-organize {self.operation_type} completed: '
f'{self.success_count} moved, {self.skipped_count} skipped, '
f'{self.failure_count} failed out of {self.total} total'
)
result: Dict[str, Any] = {
'success': self.status != 'error',
'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
'message': message,
'summary': {
'total': self.total,
'success': self.success_count,
@@ -555,8 +578,9 @@ class ModelMoveService:
Returns:
Dictionary with the success flag plus a removal manifest
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
``total_bytes``/``restorable``) on success.
(``model_count``/``excluded_model_count``/``file_count``/
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
on success.
"""
try:
if not folder_path or not str(folder_path).strip():
@@ -596,13 +620,34 @@ class ModelMoveService:
}
if manifest["model_count"] > 0:
model_count = manifest["model_count"]
excluded_count = manifest["excluded_model_count"]
# Excluded models are hidden from the library lists but are
# still real weight files, so they block the cascade just like
# any other model. Naming them is what makes the refusal
# actionable: the folder looks empty in the sidebar precisely
# because everything in it is excluded.
if excluded_count == model_count:
error = (
f"Folder still contains {model_count} model file(s), "
"all excluded from the library; un-exclude or delete "
"them first"
)
elif excluded_count:
error = (
f"Folder still contains {model_count} model file(s), "
f"{excluded_count} of them excluded from the library; "
"delete or move them first"
)
else:
error = (
f"Folder still contains {model_count} model "
"file(s); delete or move them first"
)
return {
"success": False,
"code": "not_empty",
"error": (
f"Folder still contains {manifest['model_count']} model "
"file(s); delete or move them first"
),
"error": error,
"manifest": manifest,
}
@@ -619,6 +664,21 @@ class ModelMoveService:
shutil.rmtree(absolute_path)
# Centralized mode: prune the folder's mirror subtree when it no
# longer holds any sidecar files (per-model deletes already
# removed their sidecars, so only empty directories are expected;
# a non-empty mirror keeps its orphan sidecars).
if is_centralized():
mirror_dir = resolve_centralized_dir_for_dir(absolute_path)
if mirror_dir and os.path.isdir(mirror_dir):
for root, _dirs, files in os.walk(mirror_dir, topdown=False):
if files:
continue
try:
os.rmdir(root)
except OSError: # pragma: no cover - best-effort cleanup
pass
await self._forget_folder(relative_folder)
return {
@@ -655,13 +715,20 @@ class ModelMoveService:
Symbolic links are never followed (``os.walk`` default) and are counted
separately — ``shutil.rmtree`` unlinks them without touching their
targets.
``excluded_model_count`` splits the subset of ``model_count`` that the
library hides behind the ``exclude`` flag: those files still block the
delete, yet they are invisible to the model lists (and therefore to the
folder tree, which derives "empty" from them).
"""
model_count = 0
excluded_model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
excluded_paths = self._excluded_model_paths()
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
@@ -680,6 +747,8 @@ class ModelMoveService:
continue
if self._is_model_file(name):
model_count += 1
if _normalize_match_path(full_path) in excluded_paths:
excluded_model_count += 1
else:
file_count += 1
try:
@@ -689,6 +758,7 @@ class ModelMoveService:
return {
"model_count": model_count,
"excluded_model_count": excluded_model_count,
"file_count": file_count,
"dir_count": dir_count,
"symlink_count": symlink_count,
@@ -704,6 +774,21 @@ class ModelMoveService:
),
}
def _excluded_model_paths(self) -> Set[str]:
"""Absolute paths of the models the library hides behind ``exclude``.
Best-effort: scanner stand-ins that do not expose the accessor simply
report no excluded models.
"""
get_excluded = getattr(self.scanner, "get_excluded_models", None)
if not callable(get_excluded):
return set()
try:
paths = get_excluded() or []
except Exception: # pragma: no cover - defensive
return set()
return {_normalize_match_path(path) for path in paths if path}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
if not relative_folder:
+172 -36
View File
@@ -2,14 +2,18 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
from ..services.service_registry import ServiceRegistry
from ..services.pending_delete_service import get_pending_delete_service
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
logger = logging.getLogger(__name__)
@@ -17,19 +21,45 @@ if TYPE_CHECKING:
from ..services.model_update_service import ModelUpdateService
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
"""Load a metadata sidecar JSON, returning an empty dict when missing.
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
(download manager, use cases) that do not hold a sync-service instance.
"""
if not os.path.exists(metadata_path):
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
return {}
return payload if isinstance(payload, dict) else {}
async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]:
"""Delete the primary model artefacts within ``target_dir``."""
"""Delete the primary model artefacts within ``target_dir``.
Sidecars and previews are taken from the model's sidecar directory — the
model's own directory in alongside mode, the centralized mirror otherwise.
"""
main_extension = ".safetensors" if main_extension is None else main_extension
main_file = f"{file_name}{main_extension}" if main_extension else file_name
patterns = [main_file, f"{file_name}.metadata.json"]
model_path = os.path.join(target_dir, main_file)
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}")
deleted: List[str] = []
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
main_path = model_path.replace(os.sep, "/")
if os.path.exists(main_path):
os.remove(main_path)
@@ -37,8 +67,8 @@ async def delete_model_artifacts(
else:
logger.warning("Model file not found: %s", main_file)
for pattern in patterns[1:]:
path = os.path.join(target_dir, pattern)
for pattern in patterns:
path = os.path.join(sidecar_dir, pattern)
if os.path.exists(path):
try:
os.remove(path)
@@ -79,6 +109,36 @@ def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "pat
)
class BulkRenameContext:
"""Per-session state threaded through ``rename_model`` calls of a bulk rename.
Holds the lazily built recipe hash index so a bulk rename loop pays the
O(recipes) index build at most once (on the first recipe-touching rename)
instead of rescanning every recipe per renamed file. Also tracks whether
any recipe was re-pointed so the session finalizes recipe maintenance only
when needed.
"""
def __init__(self, recipe_scanner: Any) -> None:
self._recipe_scanner = recipe_scanner
self._recipe_hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None
self.recipes_touched = False
@property
def recipe_scanner(self) -> Any:
return self._recipe_scanner
async def get_recipe_hash_index(self) -> Optional[Dict[str, List[Dict[str, Any]]]]:
"""Return the lora-hash → recipes index, building it on first use."""
if self._recipe_scanner is None:
return None
if self._recipe_hash_index is None:
self._recipe_hash_index = (
await self._recipe_scanner.build_lora_hash_index()
)
return self._recipe_hash_index
class ModelLifecycleService:
"""Co-ordinate destructive and mutating model operations."""
@@ -239,7 +299,7 @@ class ModelLifecycleService:
_require_path_in_library_roots(file_path, self._scanner, label="File path")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
metadata = await self._metadata_loader(metadata_path)
metadata["exclude"] = True
@@ -294,7 +354,7 @@ class ModelLifecycleService:
if not os.path.exists(file_path):
raise ValueError("Model file does not exist")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(file_path)
metadata_payload = await self._metadata_loader(metadata_path)
metadata_payload["exclude"] = False
@@ -337,10 +397,45 @@ class ModelLifecycleService:
return await self._scanner.bulk_delete_models(file_paths)
@asynccontextmanager
async def bulk_rename_session(self) -> AsyncIterator[BulkRenameContext]:
"""Context for bulk rename loops (filename-template "Apply to Library").
While active, the per-file ``update_single_model_cache`` resort/persist
chain and the per-file recipe folder-metadata refresh/resort are
deferred; both run exactly once when the outermost session exits — see
``ModelScanner.defer_cache_persist`` and
``RecipeScanner.finalize_bulk_filename_updates``. The finalize steps run
even on cancellation or mid-loop errors, because files are already
renamed on disk and the caches must not be left diverging.
Yields a :class:`BulkRenameContext` to pass as ``bulk_context`` into
each ``rename_model`` call of the loop.
"""
recipe_scanner = await self._recipe_scanner_factory()
context = BulkRenameContext(recipe_scanner)
async with self._scanner.defer_cache_persist():
try:
yield context
finally:
if recipe_scanner is not None and context.recipes_touched:
try:
await recipe_scanner.finalize_bulk_filename_updates()
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error finalizing bulk recipe updates: %s", exc
)
async def rename_model(
self, *, file_path: str, new_file_name: str
self, *, file_path: str, new_file_name: str, bulk_context: Optional[BulkRenameContext] = None
) -> Dict[str, object]:
"""Rename a model and its companion artefacts."""
"""Rename a model and its companion artefacts.
When ``bulk_context`` is given (bulk rename loop), the recipe
re-pointing uses the session's prebuilt hash index and defers recipe
maintenance to the session finalize; the scanner cache persist is
likewise deferred by the surrounding ``bulk_rename_session``.
"""
if not file_path or not new_file_name:
raise ValueError("File path and new file name are required")
@@ -363,21 +458,26 @@ class ModelLifecycleService:
if os.path.exists(new_file_path):
raise ValueError("A file with this name already exists")
patterns = [
f"{old_file_name}{old_extension}",
f"{old_file_name}.metadata.json",
f"{old_file_name}.metadata.json.bak",
metadata_filename = os.path.basename(get_metadata_path(file_path))
# Sidecars/previews live in the sidecar dir (the model's own dir in
# alongside mode, the centralized mirror otherwise); the model file
# itself always stays in target_dir.
sidecar_dir = get_sidecar_dir(file_path)
patterns: List[tuple[str, str]] = [
(target_dir, f"{old_file_name}{old_extension}"),
(sidecar_dir, metadata_filename),
(sidecar_dir, f"{metadata_filename}.bak"),
]
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
existing_files: List[tuple[str, str]] = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
for pattern_dir, pattern in patterns:
path = os.path.join(pattern_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
metadata_path = get_metadata_path(file_path)
metadata: Optional[Dict[str, object]] = None
hash_value: Optional[str] = None
@@ -386,31 +486,25 @@ class ModelLifecycleService:
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
hash_value = raw_hash if isinstance(raw_hash, str) else None
renamed_files: List[str] = []
new_metadata_path: Optional[str] = None
new_preview: Optional[str] = None
for old_path, pattern in existing_files:
ext = self._get_multipart_ext(pattern)
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
os.sep, "/"
)
os.rename(old_path, new_path)
renamed_files.append(new_path)
if ext == ".metadata.json":
new_metadata_path = new_path
renamed_files, new_metadata_path = await asyncio.to_thread(
self._rename_companion_files, existing_files, new_file_name
)
if metadata and new_metadata_path:
metadata["file_name"] = new_file_name
metadata["file_path"] = new_file_path
# Preserve the pre-rename stem so the original download filename
# stays recoverable after template-driven renames.
metadata.setdefault("original_file_name", old_file_name)
if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"])
ext = self._get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
os.sep, "/"
)
new_preview = os.path.join(
get_preview_dir(new_file_path), f"{new_file_name}{ext}"
).replace(os.sep, "/")
metadata["preview_url"] = new_preview
await self._metadata_manager.save_metadata(new_file_path, metadata)
@@ -421,12 +515,26 @@ class ModelLifecycleService:
)
if hash_value and getattr(self._scanner, "model_type", "") == "lora":
recipe_scanner = await self._recipe_scanner_factory()
if bulk_context is not None:
recipe_scanner = bulk_context.recipe_scanner
hash_index = await bulk_context.get_recipe_hash_index()
defer_maintenance = True
else:
recipe_scanner = await self._recipe_scanner_factory()
hash_index = None
defer_maintenance = False
if recipe_scanner:
try:
await recipe_scanner.update_lora_filename_by_hash(
hash_value, new_file_name
file_count, cache_count = (
await recipe_scanner.update_lora_filename_by_hash(
hash_value,
new_file_name,
hash_index=hash_index,
defer_maintenance=defer_maintenance,
)
)
if bulk_context is not None and (file_count or cache_count):
bulk_context.recipes_touched = True
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error updating recipe references for %s: %s",
@@ -442,6 +550,34 @@ class ModelLifecycleService:
"reload_required": False,
}
def _rename_companion_files(
self,
existing_files: List[tuple[str, str]],
new_file_name: str,
) -> tuple[List[str], Optional[str]]:
"""Rename all companion files, off the event loop thread.
Runs the blocking ``os.rename`` sequence for one model in a worker
thread so a single file's HDD I/O does not stall the event loop.
Never parallelized across files: one model's renames stay sequential
and the helper holds no locks.
"""
renamed_files: List[str] = []
new_metadata_path: Optional[str] = None
for old_path, pattern in existing_files:
ext = self._get_multipart_ext(pattern)
new_path = os.path.join(
os.path.dirname(old_path), f"{new_file_name}{ext}"
).replace(os.sep, "/")
os.rename(old_path, new_path)
renamed_files.append(new_path)
if ext == ".metadata.json":
new_metadata_path = new_path
return renamed_files, new_metadata_path
@staticmethod
def _get_multipart_ext(filename: str) -> str:
"""Return the extension for files with compound suffixes."""
+60 -1
View File
@@ -112,7 +112,10 @@ class _RateLimitRetryHelper:
# Labels of providers that are free to consult even while a network provider
# is rate-limited (local lookups, no vendor cost).
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"})
# "openmodeldb_api" qualifies because its lookups hit a local index built from
# a cached bulk dump; the underlying site is a static host (GitHub Pages), so
# even a cold cache refresh is a single cheap GET against a different vendor.
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite", "openmodeldb_api"})
class ModelMetadataProvider(ABC):
@@ -480,6 +483,36 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
except json.JSONDecodeError:
return None
class OpenModelDBModelMetadataProvider(ModelMetadataProvider):
"""Provider that serves upscaler metadata from the OpenModelDB catalogue.
Only hash lookups are supported: OpenModelDB has no per-model or version
API, so the remaining provider surface intentionally returns None and lets
the fallback chain continue to the next provider.
"""
def __init__(self, openmodeldb_client):
self.client = openmodeldb_client
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
return await self.client.get_model_by_hash(model_hash)
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Not supported: OpenModelDB models have no version history API."""
return None
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Not supported: OpenModelDB models have no version history API."""
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Not supported: OpenModelDB models have no version history API."""
return None, "Model not found"
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Not supported by the OpenModelDB provider."""
return None
class FallbackMetadataProvider(ModelMetadataProvider):
"""Try providers in order, return first successful result.
@@ -750,6 +783,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
def _iter_providers(self):
return zip(self.providers, self._provider_labels)
def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider":
"""Return a copy of this chain without the providers named in *labels*.
Used by the metadata sync service to skip providers that cannot apply
to a given model (e.g. OpenModelDB only indexes upscalers), so their
cold-start cost (a bulk dump download) is never paid pointlessly.
"""
kept = [
(label, provider)
for provider, label in self._iter_providers()
if label not in labels
]
if len(kept) == len(self.providers):
return self
if not kept:
# Never produce an empty chain; the caller still needs a provider
# that can at least report "Model not found".
return self
return FallbackMetadataProvider(
kept,
rate_limit_retry_limit=self._rate_limit_retry_limit,
rate_limit_base_delay=self._rate_limit_base_delay,
rate_limit_max_delay=self._rate_limit_max_delay,
rate_limit_jitter_ratio=self._rate_limit_jitter_ratio,
)
async def _call_with_rate_limit(self, label: str, func, *args, **kwargs):
return await self._rate_limit_helper.run(label, func, *args, **kwargs)
+20 -8
View File
@@ -432,7 +432,6 @@ class SearchStrategy:
"tags": False,
"recursive": True,
"creator": False,
"hash": False,
}
def __init__(
@@ -495,13 +494,14 @@ class SearchStrategy:
results.append(item)
continue
# Hash search is always exact (never fuzzy): match the full
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
if options.get("hash", False):
hash_query = search_lower.strip()
if hash_query and self._matches_hash(item, hash_query):
results.append(item)
continue
# Hash/id search is always exact (never fuzzy) and always on: it
# matches the full sha256, its autov2 prefix (first 10 chars),
# the autov3 hash, or the Civitai model/version ids. Exact-match
# semantics mean it adds no noise to ordinary keyword searches.
hash_query = search_lower.strip()
if hash_query and self._matches_hash(item, hash_query):
results.append(item)
continue
return results
@@ -515,6 +515,18 @@ class SearchStrategy:
autov3 = item.get("autov3")
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
return True
civitai = item.get("civitai")
if isinstance(civitai, dict):
# A model card corresponds to one Civitai version: `modelId` is
# the model id (may match several cards when the library holds
# multiple versions), `id` is the version id (unique per card).
for key in ("modelId", "model_id", "id"):
value = civitai.get(key)
if value is None:
continue
value_str = str(value).strip()
if value_str and value_str != "0" and hash_query == value_str:
return True
return False
def _matches(
+381 -48
View File
@@ -4,6 +4,7 @@ import logging
import asyncio
import time
import shutil
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
@@ -11,6 +12,13 @@ from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
from ..config import config
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256, calculate_autov3
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import (
get_metadata_path,
get_preview_dir,
get_sidecar_dir,
is_centralized,
resolve_centralized_dir_for_dir,
)
from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
@@ -154,6 +162,14 @@ class ModelScanner:
self._persistent_cache = get_persistent_cache()
self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
self._move_locks: Dict[str, asyncio.Lock] = {} # Per-source-file move locks
# Bulk-operation deferral: while _defer_persist_depth > 0,
# update_single_model_cache() skips the per-call resort/persist and
# only marks _deferred_persist_pending; the exit of the outermost
# defer_cache_persist() context finalizes once (see
# _finalize_deferred_cache_persist).
self._defer_persist_depth = 0
self._deferred_persist_pending = False
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
# Guard against concurrent all-folders backfill walks (cold fallback
# for persisted snapshots that predate folder recording).
@@ -399,10 +415,14 @@ class ModelScanner:
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
# External model source (Hugging Face / ModelScope / TensorArt).
# `source_url` + `source_platform` are canonical; `hf_url` stays in
# sync as a legacy alias (normalised below).
# sync as a legacy alias (normalised below). `source_model_id` /
# `source_version_id` are the site-native identity ids version
# grouping keys off (ModelScope; empty elsewhere).
'source_platform': get_value('source_platform', '') or '',
'source_url': get_value('source_url', '') or '',
'hf_url': get_value('hf_url', '') or '',
'source_model_id': get_value('source_model_id', '') or '',
'source_version_id': get_value('source_version_id', '') or '',
}
normalize_metadata_source(entry)
@@ -761,11 +781,11 @@ class ModelScanner:
except Exception as exc:
logger.warning("AutoV3 backfill failed: %s", exc)
async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
async def _save_persistent_cache(self, scan_result: CacheBuildResult, *, force: bool = False) -> None:
if not scan_result or not getattr(self, '_persistent_cache', None):
return
if self.is_cancelled():
if self.is_cancelled() and not force:
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
@@ -824,7 +844,7 @@ class ModelScanner:
bucket.append(path)
return snapshot
async def _persist_current_cache(self) -> None:
async def _persist_current_cache(self, *, force: bool = False) -> None:
if self._cache is None or not getattr(self, '_persistent_cache', None):
return
@@ -839,7 +859,7 @@ class ModelScanner:
else None
),
)
await self._save_persistent_cache(snapshot)
await self._save_persistent_cache(snapshot, force=force)
await self._sync_download_history(snapshot.raw_data, source='scan')
def _count_model_files(self) -> int:
"""Count all model files with supported extensions in all roots
@@ -1609,6 +1629,25 @@ class ModelScanner:
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
# Centralized sidecar mode: sidecars/previews live in the mirror tree,
# not under the renamed model directory, so the mirror subtree must
# move too and mirror-prefixed preview URLs need their own rekey.
old_mirror_dir: Optional[str] = None
new_mirror_dir: Optional[str] = None
if is_centralized():
old_mirror_dir = resolve_centralized_dir_for_dir(str(previous_path))
new_mirror_dir = resolve_centralized_dir_for_dir(str(new_path))
old_mirror_prefix = (
f"{old_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
if old_mirror_dir
else ""
)
new_mirror_prefix = (
f"{new_mirror_dir.replace(chr(92), '/').rstrip('/')}/"
if new_mirror_dir
else ""
)
cache = self._cache
if cache is None:
return False
@@ -1666,8 +1705,24 @@ class ModelScanner:
item["preview_url"] = self._rekey_path(
item["preview_url"], old_abs_prefix, new_abs_prefix
)
if old_mirror_prefix:
item["preview_url"] = self._rekey_path(
item["preview_url"], old_mirror_prefix, new_mirror_prefix
)
touched.append(item)
if old_mirror_dir and new_mirror_dir and os.path.isdir(old_mirror_dir):
try:
os.makedirs(os.path.dirname(new_mirror_dir), exist_ok=True)
shutil.move(old_mirror_dir, new_mirror_dir)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Failed to move centralized sidecar mirror %s -> %s: %s",
old_mirror_dir,
new_mirror_dir,
exc,
)
if touched:
changed = True
await self._rewrite_sidecar_paths(touched)
@@ -1696,7 +1751,9 @@ class ModelScanner:
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
"""Point each model's metadata sidecar at its new location.
Sidecars travel with the renamed directory, so only the recorded
In alongside mode sidecars travel with the renamed directory; in
centralized mode the mirror subtree has already been moved by the
caller (:meth:`rename_known_folder`). Either way only the recorded
``file_path``/``preview_url`` inside them need rewriting. Failures are
logged and skipped — a stale sidecar is repaired by the next metadata
refresh, and must not abort the rename.
@@ -1705,7 +1762,7 @@ class ModelScanner:
file_path = item.get("file_path")
if not file_path:
continue
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = get_metadata_path(file_path)
if not os.path.exists(metadata_path):
continue
try:
@@ -1715,6 +1772,94 @@ class ModelScanner:
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
)
def _find_pending_models_in_sidecar_mirror(self) -> List[Dict[str, Any]]:
"""Mirror-tree counterpart of the alongside pending-hash filesystem scan.
Centralized mode stores ``.metadata.json`` sidecars in the mirror
tree, so walking the model folders finds nothing. Each mirror base is
resolved from a configured model root; a sidecar's recorded
``file_path`` locates its model, with a stem-based probe under the
mapped model root as fallback (mirror path components are sanitized,
so reverse mapping is best-effort). Orphan sidecars whose model file
no longer exists are skipped, matching the alongside scan.
"""
pending_models: List[Dict[str, Any]] = []
for root_path in self.get_model_roots():
mirror_base = resolve_centralized_dir_for_dir(root_path)
if not mirror_base or not os.path.isdir(mirror_base):
continue
for dirpath, dirnames, filenames in os.walk(mirror_base):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
for filename in filenames:
if not filename.endswith(".metadata.json"):
continue
metadata_path = os.path.join(dirpath, filename)
try:
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if hash is pending
hash_status = data.get("hash_status", "completed")
sha256 = data.get("sha256", "")
if hash_status != "completed" or not sha256:
# Find corresponding model file: prefer the
# sidecar's recorded path, then probe by stem
# under the mapped model root.
model_path = None
recorded_path = data.get("file_path")
if (
isinstance(recorded_path, str)
and recorded_path
and os.path.exists(recorded_path)
):
model_path = recorded_path
else:
model_name = filename.replace(".metadata.json", "")
rel_dir = os.path.relpath(dirpath, mirror_base)
candidate_dir = (
root_path
if rel_dir == os.curdir
else os.path.join(root_path, rel_dir)
)
for ext in self.file_extensions:
potential_path = os.path.join(
candidate_dir, model_name + ext
)
if os.path.exists(potential_path):
model_path = potential_path
break
if model_path:
pending_models.append(
{
"file_path": model_path.replace(os.sep, "/"),
"hash_status": hash_status,
"sha256": sha256,
**{
k: v
for k, v in data.items()
if k
not in [
"file_path",
"hash_status",
"sha256",
]
},
}
)
except (json.JSONDecodeError, Exception) as e:
logger.debug(
f"Error reading metadata file {metadata_path}: {e}"
)
continue
return pending_models
def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running:
@@ -1885,7 +2030,7 @@ class ModelScanner:
file_info['name'] = os.path.basename(file_path)
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
metadata.preview_url = find_preview_file(local_stem, get_preview_dir(file_path))
await MetadataManager.save_metadata(file_path, metadata)
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
except Exception as e:
@@ -2280,10 +2425,23 @@ class ModelScanner:
Returns:
Optional[str]: New file path if successful, None if failed
"""
try:
source_path = source_path.replace(os.sep, '/')
target_path = target_path.replace(os.sep, '/')
source_path = source_path.replace(os.sep, '/')
target_path = target_path.replace(os.sep, '/')
# Serialize moves per source file: concurrent requests for the same
# model (auto-organize racing a manual move, duplicate clicks) must
# not interleave, or the second mover hits a missing source file.
lock_key = os.path.normcase(os.path.abspath(os.path.normpath(source_path)))
lock = self._move_locks.setdefault(lock_key, asyncio.Lock())
try:
async with lock:
return await self._move_model_locked(source_path, target_path)
finally:
if not lock.locked():
self._move_locks.pop(lock_key, None)
async def _move_model_locked(self, source_path: str, target_path: str) -> Optional[Dict[str, Any]]:
try:
file_ext = os.path.splitext(source_path)[1]
if not file_ext or file_ext.lower() not in self.file_extensions:
@@ -2314,39 +2472,61 @@ class ModelScanner:
if final_filename != f"{base_name}{file_ext}":
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
real_source = os.path.realpath(source_path)
real_target = os.path.realpath(target_file)
# Business paths (abspath, symlinks NOT resolved) per project
# convention: file mutations must operate on the paths as they
# appear under the configured model roots.
move_source = os.path.abspath(source_path)
move_target = os.path.abspath(target_file)
shutil.move(real_source, real_target)
if not os.path.exists(move_source):
# The source is gone — typically a previous move already
# succeeded but the cache/metadata were left pointing at the
# old path. Repair that state instead of failing.
natural_target = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
return await self._reconcile_already_moved(source_path, [target_file, natural_target])
shutil.move(move_source, move_target)
# Move all associated files with the same base name
source_metadata = None
moved_metadata_path = None
# Find all files with the same base name in the source directory
files_to_move = []
try:
for file in os.listdir(source_dir):
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
source_file_path = os.path.join(source_dir, file)
# Generate new filename with the same base name as the model file
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
new_associated_filename = f"{final_base_name}{file_suffix}"
target_associated_path = os.path.join(target_path, new_associated_filename)
# Associated files (sidecar metadata, previews) sit next to the
# model in alongside mode and in the mirror tree in centralized
# mode; collect from every directory that holds them.
source_sidecar_dir = get_sidecar_dir(source_path)
target_sidecar_dir = get_sidecar_dir(target_file)
associated_dirs = [(source_dir, target_path)]
if os.path.normpath(source_sidecar_dir) != os.path.normpath(source_dir):
associated_dirs.append((source_sidecar_dir, target_sidecar_dir))
# Store metadata file path for special handling
if file == f"{base_name}.metadata.json":
source_metadata = source_file_path
moved_metadata_path = target_associated_path
else:
files_to_move.append((source_file_path, target_associated_path))
except Exception as e:
logger.error(f"Error listing files in {source_dir}: {e}")
# Find all files with the same base name in the source directories
files_to_move = []
metadata_filename = os.path.basename(get_metadata_path(source_path))
for assoc_source_dir, assoc_target_dir in associated_dirs:
try:
for file in os.listdir(assoc_source_dir):
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
source_file_path = os.path.join(assoc_source_dir, file)
# Generate new filename with the same base name as the model file
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
new_associated_filename = f"{final_base_name}{file_suffix}"
target_associated_path = os.path.join(assoc_target_dir, new_associated_filename)
# Store metadata file path for special handling
if file == metadata_filename:
source_metadata = source_file_path
moved_metadata_path = target_associated_path
else:
files_to_move.append((source_file_path, target_associated_path))
except Exception as e:
logger.error(f"Error listing files in {assoc_source_dir}: {e}")
# Move all associated files
metadata = None
for source_file, target_file_path in files_to_move:
try:
os.makedirs(os.path.dirname(target_file_path), exist_ok=True)
shutil.move(source_file, target_file_path)
except Exception as e:
logger.error(f"Error moving associated file {source_file}: {e}")
@@ -2354,6 +2534,7 @@ class ModelScanner:
# Handle metadata file specially to update paths
if source_metadata and moved_metadata_path and os.path.exists(source_metadata):
try:
os.makedirs(os.path.dirname(moved_metadata_path), exist_ok=True)
shutil.move(source_metadata, moved_metadata_path)
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
except Exception as e:
@@ -2384,6 +2565,69 @@ class ModelScanner:
logger.error(f"Error moving model: {e}", exc_info=True)
return None
async def _reconcile_already_moved(self, source_path: str, target_candidates: List[str]) -> Optional[Dict[str, Any]]:
"""Repair state when a move's source file is already gone.
A previous move may have relocated the file while the cache/metadata
still point at the old path (crash mid-move, concurrent request, or
external tools). If the model is found at its new location, update
the cache and metadata to match reality instead of failing.
"""
candidates: List[str] = []
source_hash = self.get_hash_by_path(source_path)
if source_hash:
indexed_path = self.get_path_by_hash(source_hash)
if indexed_path:
candidates.append(indexed_path)
candidates.extend(target_candidates)
for candidate in candidates:
if not candidate or os.path.normpath(candidate) == os.path.normpath(source_path):
continue
if not os.path.exists(os.path.abspath(candidate)):
continue
new_path = candidate.replace(os.sep, '/')
logger.info(
f"Move source {source_path} no longer exists; the model is already "
f"at {new_path}. Reconciling cache and metadata."
)
cache = await self.get_cached_data()
existing_at_target = next((item for item in cache.raw_data if item['file_path'] == new_path), None)
if existing_at_target is not None:
# Cache already tracks the moved file (a previous move updated
# it); just drop the stale source entry without appending a
# duplicate.
await self.update_single_model_cache(source_path, new_path, None)
return {"new_path": new_path, "cache_entry": existing_at_target}
metadata = None
metadata_path = get_metadata_path(new_path)
if os.path.exists(metadata_path):
metadata = await self._update_metadata_paths(metadata_path, new_path)
if metadata is None:
# No sidecar at the new location — reuse the stale cache entry
# so the model card keeps its data under the corrected path.
existing_item = next((item for item in cache.raw_data if item['file_path'] == source_path), None)
if existing_item:
metadata = dict(existing_item)
metadata['file_path'] = new_path
metadata['file_name'] = os.path.splitext(os.path.basename(new_path))[0]
update_result = await self.update_single_model_cache(source_path, new_path, metadata, recalculate_type=True)
return {
"new_path": new_path,
"cache_entry": update_result if isinstance(update_result, dict) else None,
}
logger.error(
f"Cannot move model: source file not found: {source_path} "
f"(already moved or deleted outside LoRA Manager?)"
)
return None
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Optional[Dict[str, Any]]:
"""Update file paths in metadata file"""
try:
@@ -2395,7 +2639,7 @@ class ModelScanner:
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
if 'preview_url' in metadata and metadata['preview_url']:
preview_dir = os.path.dirname(model_path)
preview_dir = get_preview_dir(model_path)
# Update preview filename to match the new base name
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
preview_ext = get_preview_extension(metadata['preview_url'])
@@ -2410,11 +2654,85 @@ class ModelScanner:
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
return None
@asynccontextmanager
async def defer_cache_persist(self):
"""Defer heavyweight cache maintenance for a bulk operation.
While at least one ``defer_cache_persist`` context is active,
:meth:`update_single_model_cache` performs only the in-memory entry
swap plus incremental index updates — it skips the full version-index
rebuild, the natsort resort, and the whole-table SQLite persist plus
download-history sync that normally run per call. When the outermost
context exits, the pending maintenance runs **once** (resort, persist,
download-history sync).
The final persist is forced: it runs even when the scanner's
cancellation flag is set or the wrapped block raised, because callers
use this around operations that already mutated files on disk and the
cache must not be left diverging from reality.
Intended for bulk rename/move loops (e.g. the filename-template "Apply
to Library" flow). Single-shot callers keep the immediate per-call
behavior by not entering this context.
"""
self._defer_persist_depth = getattr(self, "_defer_persist_depth", 0) + 1
try:
yield
finally:
self._defer_persist_depth -= 1
if self._defer_persist_depth == 0:
await self._finalize_deferred_cache_persist()
@property
def _cache_persist_deferred(self) -> bool:
"""True while cache resort/persist is deferred to a bulk finalize."""
return getattr(self, "_defer_persist_depth", 0) > 0
async def _finalize_deferred_cache_persist(self) -> None:
"""Run the resort + persist deferred by ``defer_cache_persist``.
Best-effort: failures are logged, never raised, so an error here
cannot mask the outcome of the bulk operation itself (including
cancellation).
"""
if not getattr(self, "_deferred_persist_pending", False):
return
self._deferred_persist_pending = False
if self._cache is None:
return
try:
# resort() rebuilds the version index and folder list, so the
# per-call rebuilds skipped during deferral are covered here.
await self._cache.resort()
await self._persist_current_cache(force=True)
self.bump_cache_version()
except Exception:
logger.error(
"%s Scanner: failed to finalize deferred cache persist",
self.model_type.capitalize(),
exc_info=True,
)
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Optional[Dict[str, Any]], recalculate_type: bool = False) -> Union[bool, Dict[str, Any]]:
"""Update cache after a model has been moved or modified"""
"""Update cache after a model has been moved or modified.
Performs the full maintenance chain (version-index rebuild, resort,
whole-table persist, download-history sync) unless the scanner is
inside a :meth:`defer_cache_persist` context, in which case only
the in-memory entry swap and incremental index updates run and the
heavy chain executes once at context exit.
"""
deferred = self._cache_persist_deferred
cache = await self.get_cached_data()
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
existing_index: Optional[int] = None
existing_item = None
for idx, item in enumerate(cache.raw_data):
if item['file_path'] == original_path:
existing_item = item
existing_index = idx
break
if existing_item:
cache.remove_from_version_index(existing_item)
@@ -2427,10 +2745,17 @@ class ModelScanner:
self._hash_index.remove_by_path(original_path)
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != original_path
]
if deferred:
# In-place swap avoids the O(n) list rebuild per renamed file;
# indexes were already updated incrementally above/below, and the
# folder recompute happens in the single finalize resort().
if existing_index is not None:
cache.raw_data.pop(existing_index)
else:
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != original_path
]
cache_modified = bool(existing_item) or bool(metadata)
cache_entry: Optional[Dict[str, Any]] = None
@@ -2471,8 +2796,11 @@ class ModelScanner:
cache_entry.get('autov3') or None,
)
all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
if not deferred:
# O(n) over raw_data; the finalize resort() recomputes the
# folder list once, so bulk callers skip it per file.
all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# The move target may live in directories the last scan never saw;
# record the destination folder (and its parents) in the known
@@ -2487,13 +2815,18 @@ class ModelScanner:
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
cache.rebuild_version_index()
if deferred:
if cache_modified:
self._deferred_persist_pending = True
self.bump_cache_version()
else:
cache.rebuild_version_index()
await cache.resort()
await cache.resort()
if cache_modified:
await self._persist_current_cache()
self.bump_cache_version()
if cache_modified:
await self._persist_current_cache()
self.bump_cache_version()
if metadata and cache_entry is not None:
return cache_entry
@@ -2755,7 +3088,7 @@ class ModelScanner:
# Sidecar write-back: JSON null encodes the checked-unavailable
# state. Skip silently when the sidecar does not exist.
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = get_metadata_path(file_path)
if os.path.exists(metadata_path):
with open(metadata_path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
@@ -2821,7 +3154,7 @@ class ModelScanner:
if not file_path:
return None
dir_path = os.path.dirname(file_path)
dir_path = get_preview_dir(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_path = find_preview_file(base_name, dir_path)
if preview_path:
+13 -2
View File
@@ -1,4 +1,4 @@
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
"""External model-source providers (Hugging Face, ModelScope, TensorArt, OpenModelDB).
This package is the single abstraction over "a site that hosts models and
a model card". See :mod:`py.services.model_sources.base` for the provider
@@ -24,7 +24,13 @@ from .base import (
is_valid_source_id,
)
from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource
from .hydration import (
hydrate_from_source,
load_model_card,
resolve_site_base_model,
)
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .openmodeldb import OpenModelDBSource
from .registry import (
LEGACY_HF_URL_FIELD,
SOURCE_PLATFORM_FIELD,
@@ -52,7 +58,9 @@ __all__ = [
"ModelSourceCache",
"ModelSourceError",
"HuggingFaceSource",
"ModelScopeIntlSource",
"ModelScopeSource",
"OpenModelDBSource",
"SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD",
"SourceRef",
@@ -68,9 +76,12 @@ __all__ = [
"get_source",
"get_source_platform",
"has_external_source",
"hydrate_from_source",
"is_valid_source_id",
"list_sources",
"load_model_card",
"normalize_metadata_source",
"resolve_site_base_model",
"resolve_source_ref",
"source_group_key",
"source_label",
+118 -8
View File
@@ -25,7 +25,7 @@ import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Optional
from typing import Any, Dict, Iterable, Mapping, Optional
import aiohttp
@@ -45,7 +45,9 @@ USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
GROUP_PREFIXES: dict[str, str] = {
"huggingface": "hf",
"modelscope": "ms",
"modelscope-ai": "msai",
"tensorart": "ta",
"openmodeldb": "omdb",
}
@@ -77,6 +79,30 @@ class ModelCardContext:
description: str = ""
"""Author-written summary shown on the model page, outside the README."""
model_name: str = ""
"""Site-published display name for the repository.
Sites publish this next to the repository id (ModelScope's ``Name``).
It is what a CivitAI download would store as the model's name, so the
card never has to fall back to the local filename.
"""
model_name_localized: str = ""
"""Site-published localized name (ModelScope's ``ChineseName``)."""
version_name: str = ""
"""Site-published label for the requested file's version.
Resolved per file, like :attr:`example_images`: a repository publishes
one label per checkpoint (ModelScope's ``modelVersion.showName``).
"""
license: str = ""
"""License the site records for the repository."""
model_type: str = ""
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
base_model: str = ""
"""Base model as reported by the site (possibly a site-local id)."""
@@ -98,17 +124,38 @@ class ModelCardContext:
trigger_words: list[str] = field(default_factory=list)
"""Trigger words the site records for the requested model file."""
source_model_id: str = ""
"""Site-native id of the *published model* the requested file belongs to.
Sites whose repository is not a model identity publish a separate,
stable id per model (ModelScope's ``modelVersion.modelId`` — identical
across every version of one published model, different between the
models of a collection repository). It is the version-grouping key,
persisted on the sidecar as ``source_model_id``.
"""
source_version_id: str = ""
"""Site-native id of the published version the requested file belongs to
(ModelScope's ``modelVersion.id``), persisted as ``source_version_id``."""
def is_empty(self) -> bool:
"""Return ``True`` when the site contributed nothing extra."""
return not any(
(
self.description,
self.model_name,
self.model_name_localized,
self.version_name,
self.license,
self.model_type,
self.base_model,
self.base_model_aliases,
self.official_tags,
self.example_images,
self.trigger_words,
self.source_model_id,
self.source_version_id,
)
)
@@ -163,7 +210,9 @@ def is_valid_source_id(source_id: str) -> bool:
)
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
async def fetch_text(
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
) -> str:
"""Fetch *url* and return its body as text, or ``""`` on any failure.
Network problems are expected (offline installs, rate limits, dead
@@ -172,8 +221,11 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
"""
try:
request_headers = {"User-Agent": USER_AGENT}
if headers:
request_headers.update(headers)
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
@@ -186,7 +238,7 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
async def fetch_json(
url: str, *, timeout: int = HTTP_TIMEOUT
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
) -> tuple[int, Any]:
"""Fetch *url* and return ``(status, parsed_body)``.
@@ -197,8 +249,11 @@ async def fetch_json(
"""
try:
request_headers = {"User-Agent": USER_AGENT}
if headers:
request_headers.update(headers)
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
@@ -234,6 +289,11 @@ class ModelSource:
#: Sub-directory the "use default paths" template places downloads in.
default_subdir: str = ""
#: Source id used to build the example URL shown in UI copy and error
#: messages. ``owner/name`` suits repository sites; sites with a
#: different identity shape override it with a real example.
example_source_id: str = "user/repo"
#: Lenient pattern used to recognise URLs already stored in metadata.
#: Captures the site-specific source id in group ``id``.
url_pattern: re.Pattern[str] | None = None
@@ -286,16 +346,45 @@ class ModelSource:
raise NotImplementedError
def is_valid_source_id(self, source_id: str) -> bool:
"""Return ``True`` when *source_id* is a safe id on this site.
Defaults to the ``owner/name`` repository rule; sites whose ids are
not repositories (OpenModelDB's flat model ids) override it.
"""
return is_valid_source_id(source_id)
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
"""Path segments appended to the model root by "use default paths".
Defaults to ``<default_subdir>/<owner>/<repo>`` so downloads from
repository sites stay namespaced by author. Sites without an
owner/repo split override it.
"""
owner, repo_name = source_id.split("/", 1)
return (self.default_subdir, owner, repo_name)
def asset_base_url(self, source_id: str, revision: str = "") -> str:
"""Base URL used to resolve repository-relative asset paths."""
return ""
def group_key(self, source_id: str) -> str:
"""Return the version-group key for *source_id*."""
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Return the version-group key for the model described by *item*.
The default groups by source id (``{prefix}:{owner}/{repo}``), which
is only correct when the source id already identifies a single
published model. Sources whose repository hosts many unrelated
models override this: they either derive the key from a site-native
model identity recorded in *item* (ModelScope's ``source_model_id``)
or return ``None`` when the platform has no reliable model identity
at all (Hugging Face), leaving the model ungrouped.
"""
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{source_id}"
return f"{prefix}:{ref.source_id}"
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch the raw model card (README) markdown for *source_id*."""
@@ -351,6 +440,15 @@ class ModelSource:
return []
def auth_headers(self) -> Dict[str, str]:
"""Extra request headers this site needs for API and file downloads.
Empty by default; sites with gated/private content (Hugging Face)
override it to attach the user's access token when one is configured.
"""
return {}
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
@@ -360,6 +458,18 @@ class ModelSource:
f"{self.label or self.platform} does not support downloads", status=400
)
async def resolve_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Resolve the download URL for one file, allowing async lookups.
Defaults to the synchronous :meth:`file_download_url`; sites whose
download URL is not derivable from the id alone (OpenModelDB stores
the URL inside its catalogue entry) override this to look it up.
"""
return self.file_download_url(source_id, filename, revision)
def resolve_revision(self, revision: str = "") -> str:
"""Return *revision*, falling back to this site's default branch."""
+49 -2
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
import logging
import re
from typing import Any, Mapping, Optional
from .base import (
ModelSource,
ModelSourceError,
SourceRef,
fetch_json,
fetch_text,
filter_weight_files,
@@ -27,6 +29,18 @@ _STRICT_URL_PATTERN = re.compile(
)
def _hf_token() -> str:
"""Return the configured Hugging Face access token, or ``""``."""
try:
from ..settings_manager import get_settings_manager
token = get_settings_manager().get("huggingface_api_key", "")
except Exception: # pragma: no cover - settings must never break downloads
return ""
return token.strip() if isinstance(token, str) else ""
class HuggingFaceSource(ModelSource):
"""Hugging Face Hub (``huggingface.co``)."""
@@ -42,15 +56,33 @@ class HuggingFaceSource(ModelSource):
def canonical_url(self, source_id: str) -> str:
return f"https://huggingface.co/{source_id}"
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Hugging Face models never auto-group.
A repository is not a model identity — collection repos host many
unrelated models — and the Hub exposes no site-native published-model
id, so there is no reliable key to group by.
"""
return None
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
def auth_headers(self) -> dict[str, str]:
"""Bearer header for gated/private repositories, when a token is set."""
token = _hf_token()
return {"Authorization": f"Bearer {token}"} if token else {}
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
headers = self.auth_headers()
for branch in ("main", "master"):
text = await fetch_text(
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
f"https://huggingface.co/{source_id}/raw/{branch}/README.md",
headers=headers,
)
if text:
return text
@@ -67,11 +99,26 @@ class HuggingFaceSource(ModelSource):
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
f"https://huggingface.co/api/models/{source_id}/tree/{revision}",
headers=self.auth_headers(),
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
if status in (401, 403):
if _hf_token():
raise ModelSourceError(
f"Access to '{source_id}' was denied (HTTP {status}). For a gated "
"repository you must accept its terms on the Hugging Face page, "
"and the configured token needs read permission for it.",
status=403,
)
raise ModelSourceError(
f"'{source_id}' requires a Hugging Face access token (gated or "
"private repository). Configure one in Settings → Hugging Face "
"Access Token, and accept the repository's terms on its page.",
status=401,
)
if status != 200 or not isinstance(payload, list):
raise ModelSourceError(
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
+235
View File
@@ -0,0 +1,235 @@
"""Deterministic metadata hydration for freshly downloaded source models.
A CivitAI download writes a fully-populated metadata sidecar as part of the
download itself: the name, the description, the tags, the trigger words and
the example images all arrive with the file. A download from an external
model source (ModelScope, Hugging Face) has the same information behind a
public API, but historically landed as a bare filename plus a source URL that
the user had to enrich by hand ("Enrich Metadata with AI").
This module closes that gap without involving an LLM. It fetches the linked
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
the AI skill uses, and writes the result. Everything it applies is data the
site published, so it is safe to run automatically on every download and to
treat as a fallback for the gaps the LLM would otherwise fill.
Nothing here may break a download: every failure is logged and normalised to
"the site had nothing to contribute".
"""
from __future__ import annotations
import logging
import os
import time
from typing import TYPE_CHECKING, Optional
from .base import ModelCardContext, ModelSourceCache
from .registry import get_source, resolve_source_ref
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSource, SourceRef
logger = logging.getLogger(__name__)
#: How long a fetched repository payload stays usable. A download batch walks
#: a repository's files one HTTP request at a time, and the README plus the
#: detail payload describe the *repository*, not the file, so re-fetching them
#: per file would be pure waste. They expire so an edited model card is still
#: picked up by the next batch.
SHARED_CACHE_TTL = 300.0
#: Upper bound on memoised repositories; a long-running server must not grow
#: without limit.
SHARED_CACHE_MAX_ENTRIES = 32
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
"""Return a short-lived per-repository memo for download-time hydration."""
now = time.monotonic()
key = f"{platform}:{source_id}"
entry = _shared_caches.get(key)
if entry is not None and entry[0] > now:
return entry[1]
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
_shared_caches.pop(expired, None)
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
_shared_caches.pop(oldest, None)
cache = ModelSourceCache()
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
return cache
def reset_shared_caches() -> None:
"""Drop every memoised repository — used by tests."""
_shared_caches.clear()
async def load_model_card(
source: "ModelSource",
source_id: str,
cache: Optional[ModelSourceCache] = None,
) -> str:
"""Return *source_id*'s README, reusing *cache* when one is supplied.
Only successful reads are memoised, leaving a transient failure to be
retried for the next file of the same repository.
"""
key = f"{source.platform}:{source_id}"
if cache is not None:
cached = cache.readmes.get(key)
if cached is not None:
return cached
readme = await source.fetch_model_card(source_id)
if cache is not None and readme:
cache.readmes[key] = readme
return readme or ""
async def resolve_site_base_model(context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.
Sites name base models in their own vocabulary (ModelScope publishes both
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
strict and only ever returns a name the canonical vocabulary already
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
wrong value.
"""
hints = [*context.base_model_aliases, context.base_model]
if not any(hints):
return ""
# Imported lazily: pulling in the agent package at module scope would make
# the model-source package import itself while it is still initialising.
try:
from ...metadata_ops import list_base_models
from ..agent.base_model_resolver import resolve_base_model
known_names = await list_base_models()
except Exception as exc:
logger.warning("Could not resolve a site base model: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def hydrate_from_source(
file_path: str,
*,
ref: "SourceRef",
cache: Optional[ModelSourceCache] = None,
) -> list[str]:
"""Apply the linked site's published metadata to a downloaded model.
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
it produces the same populated model card a CivitAI download produces,
without an LLM and without user action.
Args:
file_path: The just-downloaded model file, whose sidecar already
carries the SHA256 used to match the right file in a collection
repository.
ref: The source the file came from.
cache: Optional per-call memo; defaults to a short-lived shared one so
a batch over one repository fetches its card only once.
Returns:
The names of the metadata fields that changed. Never raises — a site
that is down, or an API that changed shape, must not fail a download.
"""
try:
source = get_source(ref.platform)
if source is None or not source.supports_enrichment:
return []
from ...metadata_ops import read_metadata
metadata = await read_metadata(file_path)
if not metadata:
logger.debug("No metadata to hydrate for %s", file_path)
return []
# Only a model that is actually linked to this repository may be
# updated. The download path writes those fields just before calling
# us; a file that merely shares a name with the requested one must not
# be given another model's card.
linked = resolve_source_ref(metadata)
if linked is None or (linked.platform, linked.source_id) != (
ref.platform,
ref.source_id,
):
logger.debug(
"Not hydrating %s: linked to %s, not %s",
file_path, linked.url if linked else "no model source", ref.url,
)
return []
memo = cache if cache is not None else shared_source_cache(
ref.platform, ref.source_id
)
readme = await load_model_card(source, ref.source_id, memo)
context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(file_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=memo,
)
if context.is_empty() and not readme:
logger.debug(
"No published metadata for %s on %s", ref.source_id, ref.platform
)
return []
resolved_base_model = await resolve_site_base_model(context)
from ..agent.post_processor import PostProcessor
result = await PostProcessor().process(
skill_name="enrich_hf_metadata",
model_path=file_path,
llm_output={},
metadata=metadata,
readme_content=readme,
source_context=context,
resolved_base_model=resolved_base_model,
metadata_source=f"source:{ref.platform}",
)
if not result.get("success", True):
logger.debug(
"Hydration reported failure for %s: %s",
file_path, result.get("errors"),
)
return []
updated = list(result.get("updated_fields") or [])
logger.info(
"Hydrated %s from %s (%s): %s",
file_path, source.label or ref.platform, ref.source_id,
", ".join(updated) or "nothing to change",
)
return updated
except Exception as exc: # pragma: no cover - defensive by design
logger.warning("Source hydration failed for %s: %s", file_path, exc)
return []
__all__ = [
"SHARED_CACHE_MAX_ENTRIES",
"SHARED_CACHE_TTL",
"hydrate_from_source",
"load_model_card",
"reset_shared_caches",
"resolve_site_base_model",
"shared_source_cache",
]
+216 -38
View File
@@ -1,4 +1,4 @@
"""ModelScope (魔搭社区) model source.
"""ModelScope (魔搭社区) model sources.
ModelScope exposes the same "model card as README.md" convention as
Hugging Face, including a YAML frontmatter block that often carries
@@ -10,11 +10,13 @@ none of which requires an API key for public models:
the same content through the API, used as a fallback when the resolve
URL is unavailable.
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
model page. It carries the author's summary (``Description``), the
site-curated tags (``OfficialTags``), and, per published version, the
model filenames (``MuseInfo.versions[].stats.fileList``) together with
that file's example images (``coverImages``) and trigger words. See
:meth:`ModelScopeSource.fetch_model_card_context`.
model page. It carries the repository's display name (``Name`` /
``ChineseName``), the author's summary (``Description``), the license, the
AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
per published version, the model filenames
(``MuseInfo.versions[].stats.fileList``) together with that version's label
(``modelVersion.showName``), example images (``coverImages``) and trigger
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
listing backing the download picker. It reports real sizes for LFS
files (not the pointer size), so no extra HEAD request is needed.
@@ -28,6 +30,12 @@ valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository.
Two deployments are served by this module. ``modelscope.cn`` (with
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
catalogues*, not mirrors, so they are registered as distinct sources:
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
class builds is derived from its ``base_url``.
"""
from __future__ import annotations
@@ -36,12 +44,15 @@ import json
import logging
import os
import re
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional
from .base import (
GROUP_PREFIXES,
ModelCardContext,
ModelSource,
ModelSourceError,
SourceRef,
clean_source_url,
fetch_json,
fetch_text,
filter_weight_files,
@@ -52,18 +63,28 @@ if TYPE_CHECKING: # pragma: no cover - typing only
logger = logging.getLogger(__name__)
_URL_PATTERN = re.compile(
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
)
#: ModelScope runs two independent catalogues. ``modelscope.com`` is a
#: redirect alias of the mainland site, but ``modelscope.ai`` is the
#: *international* deployment with its own repository catalogue — a repository
#: published on one is routinely absent from the other (``referall13/EM1``
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
#: host therefore decides which site, API and CDN a model belongs to, and the
#: two deployments are registered as separate sources rather than folded into
#: one id.
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
#: Trailing view segments the site appends to a model URL; accepted verbatim
#: when the user pastes a browser tab URL.
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
rf"/?{_VIEW_SEGMENTS}/?$"
)
def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
"""Build the lenient and strict model-URL patterns for *hosts*."""
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
#: for repos imported from Hugging Face.
@@ -71,7 +92,12 @@ _REVISIONS = ("master", "main")
class ModelScopeSource(ModelSource):
"""ModelScope (``modelscope.cn``)."""
"""ModelScope's mainland site (``modelscope.cn``).
``modelscope.com`` is accepted as an alias of it. The international
deployment is :class:`ModelScopeIntlSource`; everything below is written in
terms of ``base_url`` so both share one implementation.
"""
platform = "modelscope"
label = "ModelScope"
@@ -79,15 +105,34 @@ class ModelScopeSource(ModelSource):
supports_download = True
default_revision = "master"
default_subdir = "modelscope"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
#: Origin every outgoing URL is built from.
base_url = "https://modelscope.cn"
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
def canonical_url(self, source_id: str) -> str:
return f"https://modelscope.cn/models/{source_id}"
return f"{self.base_url}/models/{source_id}"
def group_key(self, ref: SourceRef, item: Mapping[str, Any]) -> Optional[str]:
"""Group by ModelScope's published-model id, never by repository.
A collection repository hosts many unrelated published models, so
the repo id is not a version-group identity. Only models whose
metadata carries the site-native ``source_model_id`` (recorded at
enrichment time from ``MuseInfo.versions[].modelVersion.modelId``)
group together; unenriched models stay standalone.
"""
model_id = clean_source_url(item.get("source_model_id"))
if not model_id:
return None
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{model_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return (
f"https://modelscope.cn/models/{source_id}/resolve/"
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}"
)
@@ -96,7 +141,7 @@ class ModelScopeSource(ModelSource):
for revision in _REVISIONS:
text = await fetch_text(
f"https://modelscope.cn/models/{source_id}/resolve/{revision}/README.md"
f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
)
if text:
return text
@@ -105,7 +150,7 @@ class ModelScopeSource(ModelSource):
# environments where the CDN resolve host is blocked.
for revision in _REVISIONS:
text = await fetch_text(
"https://modelscope.cn/api/v1/models/"
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
)
if text:
@@ -158,7 +203,7 @@ class ModelScopeSource(ModelSource):
return cache.provider[cache_key]
status, payload = await fetch_json(
f"https://modelscope.cn/api/v1/models/{source_id}"
f"{self.base_url}/api/v1/models/{source_id}"
)
if status != 200 or not isinstance(payload, dict):
logger.debug(
@@ -185,7 +230,7 @@ class ModelScopeSource(ModelSource):
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
"https://modelscope.cn/api/v1/models/"
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo/files?Revision={revision}"
)
@@ -208,18 +253,37 @@ class ModelScopeSource(ModelSource):
self, source_id: str, filename: str, revision: str = ""
) -> str:
return (
f"https://modelscope.cn/models/{source_id}/resolve/"
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}"
)
def page_url_for_file(self, source_id: str, filename: str) -> str:
return (
f"https://modelscope.cn/models/{source_id}/file/view/"
f"{self.base_url}/models/{source_id}/file/view/"
f"{self.default_revision}/{filename}"
)
__all__ = ["ModelScopeSource"]
class ModelScopeIntlSource(ModelScopeSource):
"""ModelScope's international site (``modelscope.ai``).
A separate catalogue rather than a mirror, so it is registered under its
own platform id: the two deployments must not share a version group, a
"use default paths" directory, or a stored ``source_url``. The detail API,
the file listing, the resolve URLs and the CDN redirect all behave exactly
like the mainland site, which is why every URL here is derived from
:attr:`base_url` instead of being duplicated.
"""
platform = "modelscope-ai"
label = "ModelScope (International)"
default_subdir = "modelscope-ai"
base_url = "https://www.modelscope.ai"
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
# ---------------------------------------------------------------------------
@@ -229,6 +293,33 @@ __all__ = ["ModelScopeSource"]
#: Trigger-word values that mean "the author left this blank".
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
#: Repository tags that only restate what the model *is* (its library, task or
#: framework) rather than what it depicts. ModelScope mixes both into the
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
_GENERIC_TAGS = frozenset(
{
"any-to-any",
"checkpoint",
"controlnet",
"diffusers",
"embedding",
"image-text-to-text",
"image-to-image",
"image-to-video",
"lora",
"lycoris",
"onnx",
"pytorch",
"safetensors",
"tensorflow",
"text-to-image",
"text-to-speech",
"text-to-video",
"textual-inversion",
"vae",
}
)
def _clean_text(value: Any) -> str:
"""Return a stripped string for *value*, or ``""`` for anything else."""
@@ -259,9 +350,13 @@ def _build_card_context(
context = ModelCardContext(
description=_clean_text(data.get("Description")),
model_name=_clean_text(data.get("Name")),
model_name_localized=_clean_text(data.get("ChineseName")),
license=_clean_text(data.get("License")),
model_type=_clean_text(data.get("AigcType")),
base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data.get("OfficialTags")),
official_tags=_official_tags(data),
)
versions = _matching_versions(
@@ -271,11 +366,40 @@ def _build_card_context(
sha256=sha256,
)
if versions:
context.version_name = _version_label(versions)
context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions)
context.source_model_id, context.source_version_id = _version_identity(
versions
)
return context
def _version_identity(versions: list[dict[str, Any]]) -> tuple[str, str]:
"""Return the site-native ``(model id, version id)`` of the first match.
``modelVersion.modelId`` is identical across every version of one
published model and differs between the models of a collection
repository, which makes it the version-grouping identity;
``modelVersion.id`` identifies the version itself. Both are ints in
the payload and are stored as strings.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
model_id = model_version.get("modelId")
version_id = model_version.get("id")
if model_id is None and version_id is None:
continue
return (
str(model_id) if model_id is not None else "",
str(version_id) if version_id is not None else "",
)
return "", ""
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
"""Return the site's own names for the base model.
@@ -303,26 +427,63 @@ def _base_model_aliases(data: dict[str, Any]) -> list[str]:
return aliases
def _official_tags(value: Any) -> list[str]:
"""Extract the site-curated tag values from ``OfficialTags``.
def _official_tags(data: dict[str, Any]) -> list[str]:
"""Return the content tags the site publishes for the repository.
ModelScope's entries are dicts carrying an English ``Tag`` plus a
``ChineseName``; the English value is the curated content vocabulary, so
that is the one surfaced here.
``OfficialTags`` is ModelScope's curated content vocabulary and is
preferred whenever it is populated. Plenty of AIGC repositories leave it
empty and carry only the plain ``Tags`` list, which mixes content tags with
framework and task categories; those categories are dropped so a card is
not handed "lora" and "text-to-image" as if they described the model.
"""
curated = _dedupe(_tag_values(data.get("OfficialTags")))
if curated:
return curated
generic = set(_GENERIC_TAGS)
for value in (
data.get("AigcType"),
data.get("Libraries"),
data.get("Frameworks"),
):
for item in value if isinstance(value, list) else [value]:
text = _clean_text(item).lower()
if text:
generic.add(text)
return _dedupe(
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
)
def _tag_values(value: Any) -> list[str]:
"""Return the tag strings from either shape ModelScope publishes.
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
carrying an English value; the plain ``Tags`` list is already strings.
"""
tags: list[str] = []
if not isinstance(value, list):
return tags
return []
tags: list[str] = []
for entry in value:
if not isinstance(entry, dict):
continue
tag = _clean_text(entry.get("Tag"))
if tag and tag not in tags:
tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
if tag:
tags.append(tag)
return tags
def _dedupe(values: Iterable[str]) -> list[str]:
"""Drop empties and repeats, keeping the first spelling seen."""
unique: list[str] = []
for value in values:
if value and value not in unique:
unique.append(value)
return unique
def _version_files(version: dict[str, Any]) -> list[str]:
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
@@ -359,6 +520,23 @@ def _version_show_name(version: dict[str, Any]) -> str:
return _clean_text(model_version.get("showName")).lower()
def _version_label(versions: list[dict[str, Any]]) -> str:
"""Return the first published version label, preserving its spelling.
Unlike :func:`_version_show_name` this is for display, so the label is
not lowercased.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
label = _clean_text(model_version.get("showName"))
if label:
return label
return ""
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
"""Return ``basename -> sha256`` for every published weight file.
+210
View File
@@ -0,0 +1,210 @@
"""OpenModelDB model source (upscaler catalogue).
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
models. Unlike the repository-based sources (Hugging Face, ModelScope) a
model id here is a flat token (``4x-UltraSharp``) that *is* the published
model identity: there is no owner/repo split, no revision, and no README.
Everything the source needs — the resource download URLs, sizes, sha256
hashes, tags and example images — comes from the site's bulk JSON dumps via
:class:`~py.services.openmodeldb_client.OpenModelDBClient`, which caches the
catalogue on disk, so every method below is a local lookup once warmed.
Only PyTorch resources (``.pth`` / ``.safetensors``) are listed for download:
``.onnx`` is not a loadable weight format for the supported model types (see
:data:`py.utils.constants.MODEL_FILE_EXTENSIONS`). Resources can carry
mirror URLs; only the primary URL is ever used (see
:meth:`OpenModelDBClient.primary_url`).
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any, Optional
from urllib.parse import urlparse
from .base import (
ModelCardContext,
ModelSource,
ModelSourceCache,
ModelSourceError,
filter_weight_files,
)
from ..openmodeldb_client import OPENMODELDB_SITE_BASE, OpenModelDBClient
logger = logging.getLogger(__name__)
#: Model ids are flat tokens (``4x-UltraSharp``), usable as a path segment.
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
_URL_PATTERN = re.compile(
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)"
)
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)/?$"
)
#: Resource platforms whose files ComfyUI can load.
_DOWNLOADABLE_PLATFORMS = frozenset({"pytorch"})
class OpenModelDBSource(ModelSource):
"""OpenModelDB (``openmodeldb.info``)."""
platform = "openmodeldb"
label = "OpenModelDB"
supports_enrichment = True
supports_download = True
default_revision = ""
default_subdir = "openmodeldb"
example_source_id = "4x-UltraSharp"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"{OPENMODELDB_SITE_BASE}/models/{source_id}"
def is_valid_source_id(self, source_id: str) -> bool:
"""OpenModelDB ids are flat tokens, not ``owner/name`` repositories."""
return bool(isinstance(source_id, str) and _SOURCE_ID.match(source_id))
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
"""Flat catalogue: there is no owner/repo split to mirror on disk."""
return (self.default_subdir,)
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Build the card extras from the cached catalogue entry.
OpenModelDB has no README; the catalogue entry itself carries the
description, license, tags and example images, so the context is the
whole card. The catalogue is bulk-loaded and disk-cached, so no
per-run memo is needed.
"""
try:
client = await OpenModelDBClient.get_instance()
found = await client.get_model_entry(source_id)
except Exception as exc: # never break enrichment on a lookup fault
logger.debug("OpenModelDB context lookup failed for %s: %s", source_id, exc)
return ModelCardContext()
if found is None:
return ModelCardContext()
entry = found[1]
scale = entry.get("scale")
arch_name = client._resolve_architecture_name(entry)
# e.g. "ESRGAN 4x" — closest thing upscalers have to a base model,
# recorded as a hint rather than a canonical base-model name.
base_hint = (
f"{arch_name} {scale}x".strip()
if arch_name and isinstance(scale, (int, float))
else arch_name
)
description = entry.get("description")
return ModelCardContext(
description=description if isinstance(description, str) else "",
model_name=entry.get("name") or source_id,
license=entry.get("license") or "",
model_type="Upscaler",
base_model_aliases=[base_hint] if base_hint else [],
official_tags=client._resolve_tags(entry),
example_images=client.example_image_urls(entry),
source_model_id=source_id,
)
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict[str, Any]]:
"""List the entry's directly downloadable PyTorch resources.
Resources whose only mirrors are HTML-gateway hosts (mediafire,
mega.nz, drive.google.com) are skipped: they serve a web page, not
the file bytes. When every resource is mirror-only this raises a
manual-download hint instead of returning an empty list, which the
download dialog would otherwise misreport as "no model files".
"""
client = await OpenModelDBClient.get_instance()
entry = await self._require_entry(client, source_id)
saw_mirror_only = False
entries = []
for resource in entry.get("resources") or []:
if not isinstance(resource, dict):
continue
if str(resource.get("platform") or "").lower() not in _DOWNLOADABLE_PLATFORMS:
continue
url = client.direct_url(resource)
if not url:
saw_mirror_only = True
continue
size = resource.get("size")
entries.append(
(
client.resource_filename(source_id, resource),
size if isinstance(size, (int, float)) else 0,
)
)
if not entries and saw_mirror_only:
raise ModelSourceError(
f"None of this model's mirrors support direct download; "
f"download it manually from {self.canonical_url(source_id)}",
status=400,
)
return filter_weight_files(entries)
async def resolve_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Resolve the direct download URL of one resource by filename."""
client = await OpenModelDBClient.get_instance()
entry = await self._require_entry(client, source_id)
resource = client.find_resource_by_filename(
source_id, entry, os.path.basename(filename)
)
if resource is None:
raise ModelSourceError(
f"'{filename}' is not a downloadable resource of '{source_id}'",
status=404,
)
url = client.direct_url(resource)
if not url:
host = urlparse(client.primary_url(resource)).netloc or "this mirror"
raise ModelSourceError(
f"This mirror ({host}) requires manual download from "
f"{self.canonical_url(source_id)}",
status=400,
)
return url
async def _require_entry(
self, client: OpenModelDBClient, source_id: str
) -> dict[str, Any]:
"""Return the catalogue entry, raising a mapped error otherwise."""
if not await client.catalogue_ready():
raise ModelSourceError("OpenModelDB catalogue unavailable", status=502)
found = await client.get_model_entry(source_id)
if found is None:
raise ModelSourceError(
f"Model '{source_id}' not found on OpenModelDB", status=404
)
return found[1]
__all__ = ["OpenModelDBSource"]
+14 -4
View File
@@ -13,16 +13,21 @@ from typing import Any, Dict, Mapping, Optional
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .openmodeldb import OpenModelDBSource
from .tensorart import TensorArtSource
logger = logging.getLogger(__name__)
#: Order matters only for disambiguation; the URL patterns are disjoint.
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
_SOURCES: tuple[ModelSource, ...] = (
HuggingFaceSource(),
ModelScopeSource(),
ModelScopeIntlSource(),
TensorArtSource(),
OpenModelDBSource(),
)
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
@@ -193,8 +198,13 @@ def get_source_platform(item: Mapping[str, Any]) -> str:
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
"""Return the version-group key for *item*, or ``None``.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
Only sources with a site-native model identity yield a key: TensorArt
groups by its numeric model id (``ta:<id>``) and ModelScope by the
published-model id recorded at enrichment time (``ms:<id>`` /
``msai:<id>``). Hugging Face yields no key at all — a repository is
not a model identity — and unenriched ModelScope models stay
standalone rather than collapsing a whole collection repository into
one group.
"""
ref = resolve_source_ref(item)
@@ -203,7 +213,7 @@ def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
source = get_source(ref.platform)
if source is None:
return None
return source.group_key(ref.source_id)
return source.group_key(ref, item)
__all__ = [
+1
View File
@@ -42,6 +42,7 @@ class TensorArtSource(ModelSource):
label = "TensorArt"
supports_enrichment = False
supports_download = False
example_source_id = "827823520299086029"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
+762
View File
@@ -0,0 +1,762 @@
"""Client for the OpenModelDB bulk JSON API.
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
models. It exposes no per-model or by-hash endpoint — only bulk JSON dumps
(``/api/v1/models.json`` and friends), so this client downloads the dumps
once, caches them on disk with a TTL, honors ETag/Last-Modified on refresh,
and builds an in-memory SHA256 -> model index for read-only metadata lookups.
Every catalogue resource carries a ``sha256`` and a byte ``size``, which is
what makes hash-based matching against local files possible. Lookups degrade
gracefully: when the catalogue cannot be fetched (offline, upstream failure)
the stale disk cache is used, and if there is no cache at all the lookup
reports "not found" so the metadata fallback chain simply moves on.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from .downloader import get_downloader
from .errors import RateLimitError
from ..utils.cache_paths import get_cache_base_dir
from ..utils.constants import MODEL_FILE_EXTENSIONS
logger = logging.getLogger(__name__)
OPENMODELDB_API_BASE = "https://openmodeldb.info/api/v1"
OPENMODELDB_SITE_BASE = "https://openmodeldb.info"
#: Value emitted as ``source`` in the synthesized version dict; the metadata
#: sync service persists it as the model's ``metadata_source``.
METADATA_SOURCE_VALUE = "openmodeldb"
#: Hosts whose URLs serve an HTML interstitial page instead of the raw file
#: bytes. Downloading from them would silently save a web page as ``.pth``
#: (the download flow does not verify the sha256 afterwards), so the model
#: source layer rejects them with a manual-download hint. Kept deliberately
#: small and explicit.
HTML_GATEWAY_HOSTS = frozenset({"mediafire.com", "mega.nz", "drive.google.com"})
def is_html_gateway_url(url: str) -> bool:
"""Return ``True`` when *url* points at a known HTML-gateway host."""
if not isinstance(url, str) or not url:
return False
try:
host = urlparse(url).netloc.lower()
except ValueError:
return False
return any(host == g or host.endswith(f".{g}") for g in HTML_GATEWAY_HOSTS)
def _is_ephemeral_viewer_url(url: str) -> bool:
"""Return ``True`` for imgdiff.net session URLs.
Paired comparisons are hosted as ephemeral imgdiff viewer sessions
(``/api/image.php?id=...``) that expire shortly after the site build;
they 404 when used as an ``<img>`` source and must never be emitted as a
displayable image URL.
"""
return isinstance(url, str) and "imgdiff.net/api/" in url
#: Bulk dumps consumed by the client. Only ``models`` is strictly required;
#: the rest resolve ids to human-readable names and degrade to raw ids.
_DUMP_NAMES = ("models", "users", "tags", "architectures")
#: How long a fetched catalogue is considered fresh before a revalidation
#: request is made. The site only changes when it is rebuilt (hours to days),
#: so a daily TTL avoids re-downloading the ~1.4MB models dump on every
#: lookup while still picking up new models reasonably fast.
CACHE_TTL_SECONDS = 24 * 60 * 60
_META_FILENAME = "_meta.json"
class OpenModelDBClient:
"""Hash-lookup client over a locally cached OpenModelDB catalogue dump."""
_instance: Optional["OpenModelDBClient"] = None
_instance_lock = asyncio.Lock()
@classmethod
async def get_instance(cls) -> "OpenModelDBClient":
"""Get the singleton instance of OpenModelDBClient."""
async with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
# Register this client as a metadata provider (mirrors the
# CivitAI/CivArchive client bootstrap).
from .model_metadata_provider import (
ModelMetadataProviderManager,
OpenModelDBModelMetadataProvider,
)
provider_manager = await ModelMetadataProviderManager.get_instance()
provider_manager.register_provider(
"openmodeldb",
OpenModelDBModelMetadataProvider(cls._instance),
False,
)
return cls._instance
def __init__(
self,
cache_dir: Optional[str] = None,
ttl_seconds: float = CACHE_TTL_SECONDS,
) -> None:
# Guard re-initialization for the singleton pattern.
if hasattr(self, "_initialized"):
return
self._initialized = True
self._cache_dir_override = cache_dir
self._ttl_seconds = ttl_seconds
self._models: Dict[str, Dict[str, Any]] = {}
self._users: Dict[str, Dict[str, Any]] = {}
self._tags: Dict[str, Dict[str, Any]] = {}
self._architectures: Dict[str, Dict[str, Any]] = {}
# sha256 (lowercase) -> (model_id, model entry, matching resource)
self._index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
self._loaded_at: float = 0.0
self._load_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def get_model_by_hash(
self, model_hash: str
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Find an upscaler model by SHA256 hash.
Returns a CivitAI-shaped version dict (same contract as the other
metadata providers) or ``(None, reason)``.
"""
if not model_hash or not isinstance(model_hash, str):
return None, "Model not found"
try:
loaded = await self._ensure_loaded()
except RateLimitError:
raise
except Exception as exc:
logger.error("OpenModelDB lookup failed for %s: %s", model_hash[:10], exc)
return None, str(exc)
if not loaded:
return None, "OpenModelDB catalogue unavailable"
hit = self._index.get(model_hash.lower())
if hit is None:
return None, "Model not found"
model_id, model_entry, resource = hit
return self._to_civitai_version(model_id, model_entry, resource), None
async def catalogue_ready(self) -> bool:
"""Return ``True`` when the catalogue is loaded (or loadable)."""
try:
return await self._ensure_loaded()
except RateLimitError:
raise
except Exception as exc:
logger.error("OpenModelDB catalogue load failed: %s", exc)
return False
async def get_model_entry(
self, model_id: str
) -> Optional[Tuple[str, Dict[str, Any]]]:
"""Return ``(model_id, catalogue entry)`` for *model_id*, or ``None``.
Loads the catalogue on first use; an unavailable catalogue and an
unknown id both yield ``None`` (callers that need to distinguish the
two can check :meth:`catalogue_ready` first).
"""
if not model_id or not isinstance(model_id, str):
return None
if not await self.catalogue_ready():
return None
entry = self._models.get(model_id)
if not isinstance(entry, dict):
return None
return model_id, entry
def find_resource_by_filename(
self, model_id: str, entry: Dict[str, Any], filename: str
) -> Optional[Dict[str, Any]]:
"""Match a resource by its derived filename (see :meth:`resource_filename`)."""
target = (filename or "").strip().lower()
if not target:
return None
for resource in entry.get("resources") or []:
if not isinstance(resource, dict):
continue
if self.resource_filename(model_id, resource).lower() == target:
return resource
return None
@staticmethod
def resource_filename(model_id: str, resource: Dict[str, Any]) -> str:
"""Derive the local filename for a catalogue resource.
The download URL's basename is not authoritative — mirrors like
mediafire put the real filename mid-path
(``/file/<key>/90s_Sonic_2x.pth/file``) and folder links (mega.nz)
have no filename at all. Strategy: the first URL path segment whose
extension is a known model format, else ``{model_id}.{type}`` (the
catalogue's ``type`` field is authoritative).
"""
urls = resource.get("urls")
for url in urls if isinstance(urls, list) else []:
if not isinstance(url, str):
continue
path = url.split("?", 1)[0].split("#", 1)[0]
for segment in path.split("/"):
if os.path.splitext(segment)[1].lower() in MODEL_FILE_EXTENSIONS:
return segment
resource_type = str(resource.get("type") or "").lower()
extension = (
resource_type if resource_type in {"pth", "safetensors", "onnx"} else "bin"
)
return f"{model_id}.{extension}"
@staticmethod
def primary_url(resource: Dict[str, Any]) -> str:
"""Return the resource's primary download URL.
Only the first URL is used: additional entries are mirrors that may
need site-specific handling (e.g. mega.nz) and are never tried
automatically.
"""
urls = resource.get("urls")
if isinstance(urls, list):
for url in urls:
if isinstance(url, str) and url.startswith("http"):
return url
return ""
@staticmethod
def direct_url(resource: Dict[str, Any]) -> str:
"""Return the first URL that serves raw bytes, or ``""``.
HTML-gateway hosts (mediafire, mega.nz, drive.google.com — see
:data:`HTML_GATEWAY_HOSTS`) serve an interstitial page instead of the
file, so they are skipped here and reported to the user instead.
"""
urls = resource.get("urls")
if isinstance(urls, list):
for url in urls:
if (
isinstance(url, str)
and url.startswith("http")
and not is_html_gateway_url(url)
):
return url
return ""
@staticmethod
def _absolutize(url: str) -> str:
"""Turn a site-relative path (``/thumbs/...``) into an absolute URL."""
if isinstance(url, str) and url.startswith("/"):
return f"{OPENMODELDB_SITE_BASE}{url}"
return url
def _paired_display_url(self, image: Dict[str, Any]) -> str:
"""Return the displayable URL for a paired comparison image.
Prefers the site-hosted thumbnail: the ``LR``/``SR`` originals are
frequently ephemeral imgdiff session URLs that 404 outside the
viewer. Falls back to the SR (then LR) original only when it is not
one of those session URLs.
"""
thumbnail = image.get("thumbnail")
if isinstance(thumbnail, str) and thumbnail:
return self._absolutize(thumbnail)
for key in ("SR", "LR"):
original = image.get(key)
if (
isinstance(original, str)
and original
and not _is_ephemeral_viewer_url(original)
):
return original
return ""
def _model_preview_url(self, entry: Dict[str, Any]) -> str:
"""Return the model-level thumbnail URL, mirroring the site's own
``getPreviewImage`` precedence (paired → SR, standalone → url)."""
thumbnail = entry.get("thumbnail")
if not isinstance(thumbnail, dict):
return ""
if thumbnail.get("type") == "paired":
url = thumbnail.get("SR") or thumbnail.get("LR")
else:
url = thumbnail.get("url")
if isinstance(url, str) and url:
return self._absolutize(url)
return ""
def example_image_urls(self, entry: Dict[str, Any]) -> List[str]:
"""Return displayable example-image URLs, model thumbnail first.
Never contains ephemeral imgdiff session URLs; standalone images keep
their direct URLs (regular image hosts are hotlinkable).
"""
urls: List[str] = []
lead = self._model_preview_url(entry)
if lead:
urls.append(lead)
for image in entry.get("images") or []:
if not isinstance(image, dict):
continue
if image.get("type") == "paired":
url = self._paired_display_url(image)
else:
url = image.get("url")
if isinstance(url, str) and url and url not in urls:
urls.append(url)
return urls
# ------------------------------------------------------------------
# Catalogue loading
# ------------------------------------------------------------------
async def _ensure_loaded(self) -> bool:
"""Ensure the in-memory index is built, refreshing stale caches."""
async with self._load_lock:
if self._index and (time.monotonic() - self._loaded_at) < self._ttl_seconds:
return True
meta = self._read_meta()
fetched_at = float(meta.get("fetched_at") or 0.0)
disk_fresh = (
fetched_at > 0
and (time.time() - fetched_at) < self._ttl_seconds
and all(os.path.exists(self._dump_path(name)) for name in _DUMP_NAMES)
)
if disk_fresh:
if self._load_from_disk():
return True
# Corrupt disk cache: fall through to a network refresh.
if await self._refresh_from_network(meta):
return True
# Network failed or was blocked: fall back to whatever is on disk,
# however stale — old metadata beats none.
if fetched_at > 0 and self._load_from_disk():
logger.info("Using stale OpenModelDB cache (network refresh failed)")
return True
return False
def _load_from_disk(self) -> bool:
"""Load all dumps from the disk cache and rebuild the index."""
payloads: Dict[str, Dict[str, Any]] = {}
for name in _DUMP_NAMES:
path = self._dump_path(name)
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
if name == "models":
return False
data = {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
if name == "models":
return False
data = {}
payloads[name] = data if isinstance(data, dict) else {}
if not payloads["models"]:
return False
self._install_payloads(payloads)
return True
async def _refresh_from_network(self, meta: Dict[str, Any]) -> bool:
"""Revalidate cached dumps against the site and rebuild the index.
Honors ETag/Last-Modified via a HEAD probe: an unchanged dump keeps
its cached body, so a TTL expiry without upstream changes costs one
tiny request per dump instead of a full download.
"""
payloads: Dict[str, Dict[str, Any]] = {}
etags: Dict[str, str] = dict(meta.get("etags") or {})
last_modified: Dict[str, str] = dict(meta.get("last_modified") or {})
for name in _DUMP_NAMES:
payload, etag, modified = await self._fetch_dump(
name,
known_etag=etags.get(name) or "",
known_last_modified=last_modified.get(name) or "",
)
if payload is None:
if name == "models":
return False
payload = {}
payloads[name] = payload
if etag:
etags[name] = etag
if modified:
last_modified[name] = modified
self._install_payloads(payloads)
self._write_cache(payloads, etags, last_modified)
return True
async def _fetch_dump(
self,
name: str,
*,
known_etag: str,
known_last_modified: str,
) -> Tuple[Optional[Dict[str, Any]], str, str]:
"""Fetch one dump, returning ``(body, etag, last_modified)``.
``body`` is ``None`` when the fetch failed and there is no usable
cached copy. When the HEAD probe shows the resource unchanged, the
cached body is returned without a full download.
"""
url = f"{OPENMODELDB_API_BASE}/{name}.json"
disk_path = self._dump_path(name)
have_cached = os.path.exists(disk_path)
downloader = await get_downloader()
head_etag = ""
head_modified = ""
try:
head_ok, head_headers = await downloader.get_response_headers(url)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("OpenModelDB HEAD probe failed for %s: %s", url, exc)
head_ok, head_headers = False, {}
if head_ok and isinstance(head_headers, dict):
# aiohttp headers are case-insensitive; plain dicts in tests are not.
head_etag = str(head_headers.get("ETag") or head_headers.get("etag") or "")
head_modified = str(
head_headers.get("Last-Modified") or head_headers.get("last-modified") or ""
)
if (
have_cached
and known_etag
and head_etag
and head_etag == known_etag
):
cached = self._read_dump_file(disk_path)
if cached is not None:
logger.debug("OpenModelDB %s unchanged (etag match); using cache", name)
return cached, known_etag, known_last_modified or head_modified
success, payload = await downloader.make_request("GET", url, use_auth=False)
if isinstance(payload, RateLimitError):
raise payload
if not success or not isinstance(payload, dict):
logger.warning(
"OpenModelDB %s fetch failed: %s",
name,
payload if isinstance(payload, str) else "unexpected payload",
)
if have_cached:
cached = self._read_dump_file(disk_path)
if cached is not None:
return cached, known_etag, known_last_modified
return None, known_etag, known_last_modified
return payload, head_etag or known_etag, head_modified or known_last_modified
# ------------------------------------------------------------------
# Index and transformation
# ------------------------------------------------------------------
def _install_payloads(self, payloads: Dict[str, Dict[str, Any]]) -> None:
"""Install dump payloads and rebuild the sha256 index."""
self._models = payloads.get("models") or {}
self._users = payloads.get("users") or {}
self._tags = payloads.get("tags") or {}
self._architectures = payloads.get("architectures") or {}
self._index = self._build_index(self._models)
self._loaded_at = time.monotonic()
logger.debug(
"OpenModelDB catalogue loaded: %d models, %d indexed hashes",
len(self._models),
len(self._index),
)
@staticmethod
def _build_index(
models: Dict[str, Dict[str, Any]]
) -> Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]]:
"""Build the sha256 -> (model_id, model entry, resource) index."""
index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
for model_id, entry in models.items():
if not isinstance(entry, dict):
continue
resources = entry.get("resources")
if not isinstance(resources, list):
continue
for resource in resources:
if not isinstance(resource, dict):
continue
sha256 = resource.get("sha256")
if not isinstance(sha256, str) or not sha256:
continue
# First writer wins: duplicate hashes across catalogue entries
# are ambiguous and cannot be disambiguated locally.
index.setdefault(sha256.lower(), (model_id, entry, resource))
return index
def _resolve_authors(self, entry: Dict[str, Any]) -> Tuple[str, List[str]]:
"""Resolve the author field to a display name plus the raw user ids."""
raw = entry.get("author")
author_ids = raw if isinstance(raw, list) else [raw]
ids = [str(a) for a in author_ids if isinstance(a, str) and a]
names: List[str] = []
for author_id in ids:
user = self._users.get(author_id)
name = user.get("name") if isinstance(user, dict) else None
names.append(name if isinstance(name, str) and name else author_id)
return ", ".join(names), ids
def _resolve_tags(self, entry: Dict[str, Any]) -> List[str]:
"""Resolve tag ids to their display names."""
raw_tags = entry.get("tags")
if not isinstance(raw_tags, list):
return []
resolved: List[str] = []
for tag_id in raw_tags:
if not isinstance(tag_id, str) or not tag_id:
continue
tag = self._tags.get(tag_id)
name = tag.get("name") if isinstance(tag, dict) else None
resolved.append(name if isinstance(name, str) and name else tag_id)
return resolved
def _resolve_architecture_name(self, entry: Dict[str, Any]) -> str:
"""Resolve the architecture id to its display name."""
arch_id = entry.get("architecture")
if not isinstance(arch_id, str) or not arch_id:
return ""
arch = self._architectures.get(arch_id)
if isinstance(arch, dict):
name = arch.get("name")
if isinstance(name, str) and name:
return name
return arch_id
@staticmethod
def _resource_format(resource: Dict[str, Any]) -> str:
"""Map an OpenModelDB resource type to a CivitAI file metadata format."""
resource_type = str(resource.get("type") or "").lower()
if resource_type == "safetensors":
return "SafeTensor"
if resource_type in ("pth", "pt", "ckpt"):
return "PickleTensor"
return "Other"
def _to_civitai_version(
self,
model_id: str,
entry: Dict[str, Any],
matched_resource: Dict[str, Any],
) -> Dict[str, Any]:
"""Map an OpenModelDB catalogue entry to a CivitAI-shaped version dict.
Follows the same contract as the CivArchive/SQLite providers so the
metadata sync service can merge it unchanged. Numeric ``id``/``modelId``
are deliberately omitted: OpenModelDB ids are strings, and consumers
treat a missing ``modelId`` as "not a CivitAI model" (no CivitAI page
link, no update checks).
"""
author_display, author_ids = self._resolve_authors(entry)
tags = self._resolve_tags(entry)
architecture_id = entry.get("architecture")
architecture_name = self._resolve_architecture_name(entry)
description = entry.get("description")
license_name = entry.get("license")
page_url = f"{OPENMODELDB_SITE_BASE}/models/{model_id}"
files: List[Dict[str, Any]] = []
resources = entry.get("resources")
for resource in resources if isinstance(resources, list) else []:
if not isinstance(resource, dict):
continue
# The displayable download URL prefers a direct-bytes mirror when
# one exists; the filename is derived (never the raw URL basename,
# which mediafire-style mirrors leave as "file").
download_url = self.direct_url(resource) or self.primary_url(resource)
sha256 = resource.get("sha256")
size_bytes = resource.get("size")
files.append(
{
"name": self.resource_filename(model_id, resource),
"type": "Model",
"sizeKB": (size_bytes / 1024.0)
if isinstance(size_bytes, (int, float))
else 0,
"downloadUrl": download_url,
"primary": resource is matched_resource,
"hashes": {"SHA256": str(sha256).upper()} if sha256 else {},
"metadata": {"format": self._resource_format(resource)},
}
)
images: List[Dict[str, Any]] = []
# The model-level thumbnail is the site's own preview pick and larger
# than the per-image small thumbs; the card preview derives from
# images[0], so it leads the list.
lead = self._model_preview_url(entry)
if lead:
images.append({"url": lead, "nsfwLevel": 1, "type": "image"})
raw_images = entry.get("images")
for image in raw_images if isinstance(raw_images, list) else []:
if not isinstance(image, dict):
continue
paired = image.get("type") == "paired"
# Paired entries show the upscaled (SR) result as the preview.
url = self._paired_display_url(image) if paired else image.get("url")
if not isinstance(url, str) or not url:
continue
if any(existing["url"] == url for existing in images):
continue
mapped: Dict[str, Any] = {"url": url, "nsfwLevel": 1, "type": "image"}
thumbnail = image.get("thumbnail")
if isinstance(thumbnail, str) and thumbnail:
thumbnail_url = self._absolutize(thumbnail)
if thumbnail_url != url:
mapped["thumbnailUrl"] = thumbnail_url
meta: Dict[str, Any] = {}
if paired:
comparison = image.get("SR") or image.get("LR")
if (
isinstance(comparison, str)
and comparison
and comparison != url
and _is_ephemeral_viewer_url(comparison)
):
# Ephemeral imgdiff viewer session, kept for reference
# only — it 404s outside the session and is never
# displayable.
meta["comparisonUrl"] = comparison
caption = image.get("caption")
if isinstance(caption, str) and caption:
meta["caption"] = caption
if meta:
mapped["meta"] = meta
images.append(mapped)
return {
"name": entry.get("name") or model_id,
# Upscalers are not tied to a diffusion base model.
"baseModel": "Other",
"description": description or "",
"publishedAt": entry.get("date"),
"trainedWords": [],
"model": {
"name": entry.get("name") or model_id,
"type": "Upscaler",
"nsfw": False,
"description": description,
"tags": tags,
"license": license_name or "",
},
"creator": {"username": author_display, "image": None},
"files": files,
"images": images,
"source": METADATA_SOURCE_VALUE,
# OpenModelDB-native provenance, kept inside the persisted payload
# so the UI can link to the model page in a later phase.
"openmodeldb": {
"id": model_id,
"url": page_url,
"authors": author_ids,
"architecture": architecture_id or "",
"architectureName": architecture_name,
"scale": entry.get("scale"),
"inputChannels": entry.get("inputChannels"),
"outputChannels": entry.get("outputChannels"),
"size": entry.get("size") or [],
"license": license_name or "",
"date": entry.get("date"),
},
}
# ------------------------------------------------------------------
# Disk cache
# ------------------------------------------------------------------
def _cache_dir(self) -> str:
base = self._cache_dir_override or os.path.join(
get_cache_base_dir(), "openmodeldb"
)
os.makedirs(base, exist_ok=True)
return base
def _dump_path(self, name: str) -> str:
return os.path.join(self._cache_dir(), f"{name}.json")
def _meta_path(self) -> str:
return os.path.join(self._cache_dir(), _META_FILENAME)
def _read_meta(self) -> Dict[str, Any]:
try:
with open(self._meta_path(), "r", encoding="utf-8") as handle:
meta = json.load(handle)
return meta if isinstance(meta, dict) else {}
except FileNotFoundError:
return {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read OpenModelDB cache meta: %s", exc)
return {}
def _read_dump_file(self, path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
return None
def _write_cache(
self,
payloads: Dict[str, Dict[str, Any]],
etags: Dict[str, str],
last_modified: Dict[str, str],
) -> None:
for name, payload in payloads.items():
path = self._dump_path(name)
try:
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
except OSError as exc:
logger.warning("Failed to write OpenModelDB cache %s: %s", path, exc)
meta = {
"fetched_at": time.time(),
"etags": etags,
"last_modified": last_modified,
}
try:
with open(self._meta_path(), "w", encoding="utf-8") as handle:
json.dump(meta, handle, indent=2)
except OSError as exc:
logger.warning("Failed to write OpenModelDB cache meta: %s", exc)
+2
View File
@@ -69,6 +69,8 @@ class OtherModelService(BaseModelService):
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"source_model_id": model_data.get("source_model_id", ""),
"source_version_id": model_data.get("source_version_id", ""),
"hf_url": model_data.get("hf_url", ""),
}
+7 -2
View File
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from ..utils.models import OtherModelMetadata
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_preview_dir, is_centralized
from ..config import config
from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex
@@ -72,10 +73,9 @@ class OtherScanner(ModelScanner):
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
# AutoV3 reads only the safetensors header, so it is cheap even for
# large files; record the checked state at creation time ("" =
@@ -333,6 +333,11 @@ class OtherScanner(ModelScanner):
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for other-model metadata files with pending hash status."""
# Centralized mode stores sidecars in the mirror tree, not next to the
# models; walk the mirror instead of the model folders.
if is_centralized():
return self._find_pending_models_in_sidecar_mirror()
pending_models = []
for root_path in self.get_model_roots():
+25 -9
View File
@@ -38,6 +38,7 @@ from typing import (
)
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.sidecar_paths import get_metadata_path, get_sidecar_dir
from ..utils import settings_paths
logger = logging.getLogger(__name__)
@@ -669,13 +670,22 @@ class PendingDeleteService:
"""Enumerate existing artifacts exactly like delete_model_artifacts."""
main_extension = ".safetensors" if main_extension is None else main_extension
main_file = f"{file_name}{main_extension}" if main_extension else file_name
patterns = [main_file, f"{file_name}.metadata.json"]
model_path = os.path.join(target_dir, main_file)
artifacts: List[str] = []
main_path = os.path.abspath(model_path)
if os.path.exists(main_path):
artifacts.append(main_path)
# Sidecars/previews live in the sidecar dir (the model's own dir in
# alongside mode, the centralized mirror otherwise).
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}")
artifacts: List[str] = []
for pattern in patterns:
path = os.path.abspath(os.path.join(target_dir, pattern))
path = os.path.abspath(os.path.join(sidecar_dir, pattern))
if os.path.exists(path):
artifacts.append(path)
return artifacts
@@ -694,7 +704,9 @@ class PendingDeleteService:
"""
for original_path in artifacts:
staged_path = os.path.join(batch_dir, os.path.basename(original_path))
os.rename(original_path, staged_path)
# EXDEV-tolerant: centralized sidecars may live on a different
# filesystem than the staging batch dir under the model root.
self._restore_file(original_path, staged_path)
staged_pairs.append(
{
"staged": os.path.abspath(staged_path),
@@ -736,13 +748,14 @@ class PendingDeleteService:
return staged_pairs
def _restore_file(self, staged_path: str, original_path: str) -> None:
"""Restore a staged file to its original path, tolerating EXDEV.
"""Move a file between staging and library paths, tolerating EXDEV.
``os.rename`` is atomic and preferred (model staging and most recipe
restores are same-volume). Recipe staging copies into the settings-dir
staging parent, which may live on a DIFFERENT filesystem than the
recipes dir; rename then raises EXDEV. Fall back to ``shutil.copy2`` +
``os.remove`` so the bytes are restored and the staged copy removed.
staging parent, and centralized sidecars live under the configured
sidecar root; both may live on a DIFFERENT filesystem than the target
dir, so rename can raise EXDEV. Fall back to ``shutil.copy2`` +
``os.remove`` so the bytes are moved and the source copy removed.
"""
try:
os.rename(staged_path, original_path)
@@ -764,7 +777,10 @@ class PendingDeleteService:
if not os.path.exists(staged_path):
continue
try:
os.rename(staged_path, original_path)
# EXDEV-tolerant: centralized sidecars may have been copied
# across filesystems into staging, so plain os.rename would
# fail here and strand the only copy.
self._restore_file(staged_path, original_path)
except OSError as exc: # pragma: no cover - best-effort rollback
logger.warning(
"Failed to roll back staged file %s -> %s: %s",
+260 -246
View File
@@ -6,7 +6,9 @@ import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
from .model_sources import normalize_metadata_source
logger = logging.getLogger(__name__)
@@ -66,6 +68,8 @@ class PersistentModelCache:
"source_platform",
"source_url",
"hf_url",
"source_model_id",
"source_version_id",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {}
@@ -212,6 +216,8 @@ class PersistentModelCache:
"source_platform": row["source_platform"] or "",
"source_url": row["source_url"] or "",
"hf_url": row["hf_url"] or "",
"source_model_id": row["source_model_id"] or "",
"source_version_id": row["source_version_id"] or "",
}
# Legacy rows only carry `hf_url`; derive the canonical pair so
# every consumer sees the same shape.
@@ -257,267 +263,271 @@ class PersistentModelCache:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Cross-process serialization: another LoRA Manager instance may
# share this settings directory, and the read-merge-write below
# spans several statements.
with exclusive_lock(self._db_path):
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
model_map: Dict[str, Tuple[Any, ...]] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path
}
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
model_map: Dict[str, Tuple[Any, ...]] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path
}
existing_models = conn.execute(
"SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_model_map: Dict[str, sqlite3.Row] = {
row["file_path"]: row for row in existing_models
}
to_remove_models = [
(model_type, path)
for path in existing_model_map.keys()
if path not in model_map
]
if to_remove_models:
conn.executemany(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
insert_rows: List[Tuple[Any, ...]] = []
update_rows: List[Tuple[Any, ...]] = []
for file_path, row in model_map.items():
existing = existing_model_map.get(file_path)
if existing is None:
insert_rows.append(row)
continue
existing_values = tuple(
existing[column] for column in self._MODEL_COLUMNS[1:]
)
current_values = row[1:]
if existing_values != current_values:
update_rows.append(row[2:] + (model_type, file_path))
if insert_rows:
conn.executemany(self._insert_model_sql(), insert_rows)
if update_rows:
set_clause = ", ".join(
f"{column} = ?"
for column in self._MODEL_UPDATE_COLUMNS
)
update_sql = (
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
)
conn.executemany(update_sql, update_rows)
existing_tags_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
existing_tags: Dict[str, set[str]] = {}
for row in existing_tags_rows:
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
new_tags: Dict[str, set[str]] = {}
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
tags = set(item.get("tags") or [])
if tags:
new_tags[file_path] = tags
tag_inserts: List[Tuple[str, str, str]] = []
tag_deletes: List[Tuple[str, str, str]] = []
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
for path in all_tag_paths:
existing_set = existing_tags.get(path, set())
new_set = new_tags.get(path, set())
to_add = new_set - existing_set
to_remove = existing_set - new_set
for tag in to_add:
tag_inserts.append((model_type, path, tag))
for tag in to_remove:
tag_deletes.append((model_type, path, tag))
if tag_deletes:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
tag_deletes,
)
if tag_inserts:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_inserts,
)
existing_hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_hash_map: Dict[str, set[str]] = {}
for row in existing_hash_rows:
sha_value = (row["sha256"] or "").lower()
if not sha_value:
continue
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
new_hash_map: Dict[str, set[str]] = {}
for sha_value, paths in hash_index.items():
normalized_sha = (sha_value or "").lower()
if not normalized_sha:
continue
bucket = new_hash_map.setdefault(normalized_sha, set())
for path in paths:
if path:
bucket.add(path)
hash_inserts: List[Tuple[str, str, str]] = []
hash_deletes: List[Tuple[str, str, str]] = []
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for sha_value in all_shas:
existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths:
hash_deletes.append((model_type, sha_value, path))
for path in new_paths - existing_paths:
hash_inserts.append((model_type, sha_value, path))
if hash_deletes:
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
hash_deletes,
)
if hash_inserts:
conn.executemany(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_inserts,
)
if autov3_hash_index is not None:
existing_autov3_rows = conn.execute(
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
existing_models = conn.execute(
"SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_autov3_map: Dict[str, set[str]] = {}
for row in existing_autov3_rows:
autov3_value = (row["autov3"] or "").lower()
if not autov3_value:
continue
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
existing_model_map: Dict[str, sqlite3.Row] = {
row["file_path"]: row for row in existing_models
}
new_autov3_map: Dict[str, set[str]] = {}
for autov3_value, paths in autov3_hash_index.items():
normalized_autov3 = (autov3_value or "").lower()
if not normalized_autov3:
to_remove_models = [
(model_type, path)
for path in existing_model_map.keys()
if path not in model_map
]
if to_remove_models:
conn.executemany(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
insert_rows: List[Tuple[Any, ...]] = []
update_rows: List[Tuple[Any, ...]] = []
for file_path, row in model_map.items():
existing = existing_model_map.get(file_path)
if existing is None:
insert_rows.append(row)
continue
bucket = new_autov3_map.setdefault(normalized_autov3, set())
existing_values = tuple(
existing[column] for column in self._MODEL_COLUMNS[1:]
)
current_values = row[1:]
if existing_values != current_values:
update_rows.append(row[2:] + (model_type, file_path))
if insert_rows:
conn.executemany(self._insert_model_sql(), insert_rows)
if update_rows:
set_clause = ", ".join(
f"{column} = ?"
for column in self._MODEL_UPDATE_COLUMNS
)
update_sql = (
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
)
conn.executemany(update_sql, update_rows)
existing_tags_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
existing_tags: Dict[str, set[str]] = {}
for row in existing_tags_rows:
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
new_tags: Dict[str, set[str]] = {}
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
tags = set(item.get("tags") or [])
if tags:
new_tags[file_path] = tags
tag_inserts: List[Tuple[str, str, str]] = []
tag_deletes: List[Tuple[str, str, str]] = []
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
for path in all_tag_paths:
existing_set = existing_tags.get(path, set())
new_set = new_tags.get(path, set())
to_add = new_set - existing_set
to_remove = existing_set - new_set
for tag in to_add:
tag_inserts.append((model_type, path, tag))
for tag in to_remove:
tag_deletes.append((model_type, path, tag))
if tag_deletes:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
tag_deletes,
)
if tag_inserts:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_inserts,
)
existing_hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_hash_map: Dict[str, set[str]] = {}
for row in existing_hash_rows:
sha_value = (row["sha256"] or "").lower()
if not sha_value:
continue
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
new_hash_map: Dict[str, set[str]] = {}
for sha_value, paths in hash_index.items():
normalized_sha = (sha_value or "").lower()
if not normalized_sha:
continue
bucket = new_hash_map.setdefault(normalized_sha, set())
for path in paths:
if path:
bucket.add(path)
autov3_inserts: List[Tuple[str, str, str]] = []
autov3_deletes: List[Tuple[str, str, str]] = []
hash_inserts: List[Tuple[str, str, str]] = []
hash_deletes: List[Tuple[str, str, str]] = []
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
for autov3_value in all_autov3:
existing_paths = existing_autov3_map.get(autov3_value, set())
new_paths = new_autov3_map.get(autov3_value, set())
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for sha_value in all_shas:
existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths:
autov3_deletes.append((model_type, autov3_value, path))
hash_deletes.append((model_type, sha_value, path))
for path in new_paths - existing_paths:
autov3_inserts.append((model_type, autov3_value, path))
hash_inserts.append((model_type, sha_value, path))
if autov3_deletes:
if hash_deletes:
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
autov3_deletes,
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
hash_deletes,
)
if autov3_inserts:
if hash_inserts:
conn.executemany(
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
autov3_inserts,
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_inserts,
)
existing_excluded_rows = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
new_excluded = {path for path in excluded_models if path}
if autov3_hash_index is not None:
existing_autov3_rows = conn.execute(
"SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_autov3_map: Dict[str, set[str]] = {}
for row in existing_autov3_rows:
autov3_value = (row["autov3"] or "").lower()
if not autov3_value:
continue
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
excluded_deletes = [
(model_type, path)
for path in existing_excluded - new_excluded
]
excluded_inserts = [
(model_type, path)
for path in new_excluded - existing_excluded
]
new_autov3_map: Dict[str, set[str]] = {}
for autov3_value, paths in autov3_hash_index.items():
normalized_autov3 = (autov3_value or "").lower()
if not normalized_autov3:
continue
bucket = new_autov3_map.setdefault(normalized_autov3, set())
for path in paths:
if path:
bucket.add(path)
if excluded_deletes:
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
excluded_deletes,
)
if excluded_inserts:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_inserts,
)
autov3_inserts: List[Tuple[str, str, str]] = []
autov3_deletes: List[Tuple[str, str, str]] = []
if all_folders is not None:
conn.execute(
"DELETE FROM folders WHERE model_type = ?",
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
for autov3_value in all_autov3:
existing_paths = existing_autov3_map.get(autov3_value, set())
new_paths = new_autov3_map.get(autov3_value, set())
for path in existing_paths - new_paths:
autov3_deletes.append((model_type, autov3_value, path))
for path in new_paths - existing_paths:
autov3_inserts.append((model_type, autov3_value, path))
if autov3_deletes:
conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?",
autov3_deletes,
)
if autov3_inserts:
conn.executemany(
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)",
autov3_inserts,
)
existing_excluded_rows = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
)
folder_inserts = [
(model_type, path) for path in all_folders if path
]
if folder_inserts:
conn.executemany(
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
folder_inserts,
)
# Mark the snapshot as having folder data even when the
# library has no subfolders, so an empty list is not
# mistaken for "never recorded" on load.
conn.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(f"folders_recorded:{model_type}", "1"),
)
).fetchall()
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
new_excluded = {path for path in excluded_models if path}
conn.commit()
finally:
conn.close()
excluded_deletes = [
(model_type, path)
for path in existing_excluded - new_excluded
]
excluded_inserts = [
(model_type, path)
for path in new_excluded - existing_excluded
]
if excluded_deletes:
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
excluded_deletes,
)
if excluded_inserts:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_inserts,
)
if all_folders is not None:
conn.execute(
"DELETE FROM folders WHERE model_type = ?",
(model_type,),
)
folder_inserts = [
(model_type, path) for path in all_folders if path
]
if folder_inserts:
conn.executemany(
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
folder_inserts,
)
# Mark the snapshot as having folder data even when the
# library has no subfolders, so an empty list is not
# mistaken for "never recorded" on load.
conn.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(f"folders_recorded:{model_type}", "1"),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
@@ -573,6 +583,8 @@ class PersistentModelCache:
source_platform TEXT DEFAULT '',
source_url TEXT DEFAULT '',
hf_url TEXT DEFAULT '',
source_model_id TEXT DEFAULT '',
source_version_id TEXT DEFAULT '',
PRIMARY KEY (model_type, file_path)
);
@@ -642,6 +654,8 @@ class PersistentModelCache:
"source_platform": "TEXT DEFAULT ''",
"source_url": "TEXT DEFAULT ''",
"hf_url": "TEXT DEFAULT ''",
"source_model_id": "TEXT DEFAULT ''",
"source_version_id": "TEXT DEFAULT ''",
"autov3": "TEXT",
}
@@ -650,16 +664,14 @@ class PersistentModelCache:
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
detect_types=sqlite3.PARSE_DECLTYPES,
row_factory=sqlite3.Row,
)
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
@@ -731,6 +743,8 @@ class PersistentModelCache:
item.get("source_platform") or "",
item.get("source_url") or "",
item.get("hf_url") or "",
item.get("source_model_id") or "",
item.get("source_version_id") or "",
)
def _insert_model_sql(self) -> str:
+79 -46
View File
@@ -19,7 +19,9 @@ import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
logger = logging.getLogger(__name__)
@@ -170,65 +172,98 @@ class PersistentRecipeCache:
recipes: List[Dict[str, Any]],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
skip_if_empty: bool = False,
) -> bool:
"""Save all recipes to SQLite cache.
Args:
recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
skip_if_empty: When True, refuse to replace a non-empty cache with an
empty one. This is the storage-level backstop against a scan that
silently loses every recipe (unavailable drive / mis-resolved
recipes directory): overwriting both deletes the user's data and
destroys their only record of it. Intentional full clears (manual
rebuild) must pass ``skip_if_empty=False``.
Returns:
``True`` when the write happened, ``False`` when it was skipped.
"""
if not self.is_enabled():
return
return False
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
return False
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Cross-process serialization: another LoRA Manager instance may
# share this settings directory, and a full-table replace is a
# read-modify-write that SQLite alone cannot make atomic.
with exclusive_lock(self._db_path):
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Clear existing data
conn.execute("DELETE FROM recipes")
if skip_if_empty and not recipes:
existing = conn.execute(
"SELECT COUNT(*) FROM recipes"
).fetchone()
if existing and existing[0]:
conn.rollback()
logger.warning(
"Refusing to persist an empty recipe cache: the "
"stored cache still holds %d recipe(s). The scan "
"found nothing, which usually means the recipes "
"path was unavailable or resolved elsewhere; "
"keeping the stored cache so the data stays "
"recoverable.",
existing[0],
)
return False
# Prepare and insert all rows
recipe_rows = []
for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
# Clear existing data
conn.execute("DELETE FROM recipes")
json_path = ""
if json_paths:
json_path = json_paths.get(recipe_id, "")
# Prepare and insert all rows
recipe_rows = []
for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
row = self._prepare_recipe_row(recipe, json_path)
recipe_rows.append(row)
json_path = ""
if json_paths:
json_path = json_paths.get(recipe_id, "")
if recipe_rows:
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
columns = ", ".join(self._RECIPE_COLUMNS)
conn.executemany(
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
recipe_rows,
row = self._prepare_recipe_row(recipe, json_path)
recipe_rows.append(row)
if recipe_rows:
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
columns = ", ".join(self._RECIPE_COLUMNS)
conn.executemany(
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})",
recipe_rows,
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally:
conn.close()
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
return True
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist recipe cache: %s", exc)
return False
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
"""Return stored file stats for all cached recipes.
@@ -486,16 +521,14 @@ class PersistentRecipeCache:
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
detect_types=sqlite3.PARSE_DECLTYPES,
row_factory=sqlite3.Row,
)
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
"""Convert a recipe dict to a row tuple for SQLite insertion."""
+9 -2
View File
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -63,6 +64,9 @@ class PreviewAssetService:
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
preview_dir = os.path.dirname(metadata_path)
# Centralized mirrors may not exist yet (unlike the model's own
# directory in alongside mode).
os.makedirs(preview_dir, exist_ok=True)
is_video = first_preview.get("type") == "video"
preview_url = first_preview.get("url")
@@ -159,7 +163,10 @@ class PreviewAssetService:
"""Replace an existing preview asset for a model."""
base_name = os.path.splitext(os.path.basename(model_path))[0]
folder = os.path.dirname(model_path)
folder = get_preview_dir(model_path)
# Centralized mirrors may not exist yet (unlike the model's own
# directory in alongside mode).
os.makedirs(folder, exist_ok=True)
extension, optimized_data = await self._convert_preview(
preview_data, content_type, original_filename
@@ -179,7 +186,7 @@ class PreviewAssetService:
with open(preview_path, "wb") as handle:
handle.write(optimized_data)
metadata_path = os.path.splitext(model_path)[0] + ".metadata.json"
metadata_path = get_metadata_path(model_path)
metadata = await metadata_loader(metadata_path)
metadata["preview_url"] = preview_path
metadata["preview_nsfw_level"] = nsfw_level
+8 -10
View File
@@ -16,6 +16,7 @@ import threading
import time
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__)
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection."""
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
row_factory=sqlite3.Row,
)
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
"""Remove a recipe entry. Caller must hold the lock."""
+188 -24
View File
@@ -116,6 +116,12 @@ class RecipeScanner:
self._persistent_cache: Optional[PersistentRecipeCache] = None
self._civitai_client: Any = None # Lazily initialized from registry
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
# True when the last scan refused to prune the stored cache because
# every recorded recipe file was missing (see
# :meth:`_initialize_recipe_cache_sync`). Keeps dependent background
# work (FTS index) aligned with the stored rows instead of the
# intentionally out-of-sync in-memory view.
self._prune_skipped: bool = False
if lora_scanner:
self._lora_scanner = lora_scanner
if checkpoint_scanner:
@@ -1651,8 +1657,12 @@ class RecipeScanner:
'pageType': 'recipes',
})
self._schedule_post_scan_enrichment()
# Schedule FTS index build in background (non-blocking)
self._schedule_fts_index_build()
# Schedule FTS index build in background (non-blocking). When the
# prune was skipped the in-memory cache is intentionally out of sync
# with the stored rows, so leave the existing index alone instead of
# rebuilding it from the empty view.
if not self._prune_skipped:
self._schedule_fts_index_build()
except Exception as e:
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
# Ensure the cache is never None so the page stops showing the
@@ -1723,6 +1733,7 @@ class RecipeScanner:
"""
loop = None
scan_start_time: Optional[float] = None
self._prune_skipped = False
try:
# Ensure cache exists to avoid None reference errors
if self._cache is None:
@@ -1749,14 +1760,38 @@ class RecipeScanner:
logger.warning(f"Recipes directory not found: {recipes_dir}")
return self._cache
# Record which directory the scan actually used. When the Recipes
# Storage Path is empty this falls back to the first LoRA root, and
# a support reader needs that path to tell a real wipe apart from a
# scan that looked somewhere else (see the prune guard below).
logger.info(f"Recipe scan directory: {recipes_dir}")
# Try to load from persistent cache first
persisted = self._persistent_cache.load_cache()
if persisted:
recipes, changed, json_paths = self._reconcile_recipe_cache(
persisted, recipes_dir
)
(
recipes,
changed,
json_paths,
skipped_prune_reason,
) = self._reconcile_recipe_cache(persisted, recipes_dir)
self._json_path_map = json_paths
if skipped_prune_reason:
# Every persisted recipe file vanished at once. That is not a
# reliable deletion signal: a drive that did not mount, a
# recipes_path that silently fell back to another root, or a
# shared cache touched by a second instance all look exactly
# like this. Keep the stored cache and skip the prune, so the
# only copy of the user's recipes is not destroyed.
logger.warning(
f"Recipe cache prune skipped: {skipped_prune_reason}. "
f"Keeping {len(persisted.raw_data)} stored recipe(s); this "
"session reports no recipes until the files are found again."
)
self._prune_skipped = True
return self._cache
if not changed:
# Fast path: use cached data directly
logger.info(
@@ -1770,7 +1805,10 @@ class RecipeScanner:
if self._backfill_source_path_if_needed(recipes, json_paths):
self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
)
else:
# Use persisted map, or rebuild if empty (e.g. first startup
@@ -1798,7 +1836,10 @@ class RecipeScanner:
self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
)
return self._cache
@@ -1825,7 +1866,10 @@ class RecipeScanner:
# Persist for next startup
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
)
if report_progress:
@@ -1862,7 +1906,7 @@ class RecipeScanner:
self,
persisted: PersistedRecipeData,
recipes_dir: str,
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str]]:
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str], Optional[str]]:
"""Reconcile persisted cache with current filesystem state.
Args:
@@ -1870,7 +1914,11 @@ class RecipeScanner:
recipes_dir: Path to the recipes directory.
Returns:
Tuple of (recipes list, changed flag, json_paths dict).
Tuple of (recipes list, changed flag, json_paths dict,
skipped_prune_reason). The last element is ``None`` on a normal
reconcile. When it is a string, the scan saw every persisted recipe
file disappear at once; the caller must then keep the persisted
cache instead of overwriting it. The reason text is user-facing.
"""
recipes: List[Dict[str, Any]] = []
json_paths: Dict[str, str] = {}
@@ -1951,12 +1999,67 @@ class RecipeScanner:
time.sleep(0)
# Check for deleted files
for json_path in persisted.file_stats.keys():
if json_path not in current_files:
changed = True
logger.debug("Recipe file deleted: %s", json_path)
orphaned_stats = [
json_path
for json_path in persisted.file_stats.keys()
if json_path not in current_files
]
if orphaned_stats:
changed = True
# This single line plus the resolved scan directory logged by the
# caller are the evidence a support reader gets for a recipes path
# that moved; the per-file lines stay at debug to avoid flooding.
if len(orphaned_stats) > 10:
logger.info(
f"Recipe reconcile: {len(orphaned_stats)} of "
f"{len(persisted.file_stats)} cached recipe file(s) are not in "
f"{recipes_dir} (first: {orphaned_stats[0]}, "
f"last: {orphaned_stats[-1]})"
)
else:
for json_path in orphaned_stats:
logger.debug("Recipe file deleted: %s", json_path)
return recipes, changed, json_paths
skipped_prune_reason: Optional[str] = None
if not current_files and persisted.file_stats:
metadata_is_coherent = self._persisted_metadata_is_coherent(persisted)
if metadata_is_coherent:
skipped_prune_reason = (
f"every recipe file recorded in the cache "
f"({len(persisted.file_stats)}) is missing from {recipes_dir}"
)
else:
# The stored row set and its recorded file stats disagree, so
# this cache is stale rather than a faithful record of recipes
# that have just gone missing. Pruning it is safe.
logger.info(
f"Recipe reconcile: stored cache is inconsistent "
f"({len(persisted.raw_data)} row(s) vs "
f"{len(persisted.file_stats)} file record(s)); falling back "
"to a normal prune."
)
return recipes, changed, json_paths, skipped_prune_reason
@staticmethod
def _persisted_metadata_is_coherent(persisted: PersistedRecipeData) -> bool:
"""Return True when the stored rows and their file stats describe one set.
The prune guard treats "no recipe files found" as a signal that the
directory moved out from under us, which is only meaningful when the
stored cache is a faithful record of recipes that exist on disk. A cache
whose row set and file-stat set have diverged (left behind by an older
reconcile) carries recipes that were already orphaned, so it is not
evidence of a fresh disappearance.
"""
stats_ids = {
os.path.basename(json_path)[: -len(".recipe.json")]
for json_path in persisted.file_stats
if os.path.basename(json_path).lower().endswith(".recipe.json")
}
rows_ids = {str(recipe.get("id", "")) for recipe in persisted.raw_data}
rows_ids.discard("")
return bool(rows_ids) and rows_ids == stats_ids
# Metadata key recording that the one-shot source_path backfill has run.
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
@@ -2626,6 +2729,10 @@ class RecipeScanner:
try:
# Invalidate persistent cache so the sync path does a
# full directory scan instead of reconciling stale data.
# This is the deliberate escape hatch from the
# all-missing prune guard: an explicit user rebuild is
# allowed to clear the stored cache, while an implicit
# startup scan is not.
if self._persistent_cache:
self._persistent_cache.save_cache([], {})
self._json_path_map = {}
@@ -2656,7 +2763,8 @@ class RecipeScanner:
# Schedule non-blocking background work
self._schedule_post_scan_enrichment()
self._schedule_fts_index_build()
if not self._prune_skipped:
self._schedule_fts_index_build()
return cast(RecipeCache, self._cache)
@@ -4472,14 +4580,64 @@ class RecipeScanner:
return syntax_parts
async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
"""Build a one-shot lowercase-LoRA-hash → recipes index.
Scans the recipe cache exactly once (O(recipes × loras)) and returns
a mapping of lowercase lora ``hash`` to the list of recipe dicts
containing it. Bulk rename loops pass this index to
:meth:`update_lora_filename_by_hash` so per-file lookups are O(1)
instead of rescanning every recipe for each renamed LoRA.
"""
cache = await self.get_cached_data()
index: Dict[str, List[Dict[str, Any]]] = {}
if not cache or not cache.raw_data:
return index
for recipe in cache.raw_data:
loras = recipe.get("loras", [])
if not isinstance(loras, list):
continue
for lora in loras:
if not isinstance(lora, dict):
continue
hash_value = (lora.get("hash") or "").lower()
if hash_value:
index.setdefault(hash_value, []).append(recipe)
return index
async def finalize_bulk_filename_updates(self) -> None:
"""Run once after a bulk rename session that deferred maintenance.
Refreshes folder metadata and schedules a single re-sort. Filename-only
renames never change recipe folders, so the deferred refresh is
redundant but cheap; skipping it per file is what makes bulk renames
O(1)-per-file.
"""
if self._cache is None:
return
self._schedule_resort()
async def update_lora_filename_by_hash(
self, hash_value: str, new_file_name: str
self,
hash_value: str,
new_file_name: str,
*,
hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
defer_maintenance: bool = False,
) -> Tuple[int, int]:
"""Update file_name in all recipes that contain a LoRA with the specified hash.
Args:
hash_value: The SHA256 hash value of the LoRA
new_file_name: The new file_name to set
hash_index: Optional prebuilt index from
:meth:`build_lora_hash_index`. When given, the O(recipes)
cache scan (and its folder-metadata walk) is skipped and the
affected recipes are looked up directly — the bulk rename path.
defer_maintenance: When True, skip the folder-metadata refresh and
resort scheduling. The caller MUST run
:meth:`finalize_bulk_filename_updates` exactly once afterwards.
Returns:
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
@@ -4490,17 +4648,21 @@ class RecipeScanner:
# Always use lowercase hash for consistency
hash_value = hash_value.lower()
# Get cache
cache = await self.get_cached_data()
if not cache or not cache.raw_data:
return 0, 0
if hash_index is not None:
candidate_recipes = hash_index.get(hash_value, [])
else:
# Get cache
cache = await self.get_cached_data()
if not cache or not cache.raw_data:
return 0, 0
candidate_recipes = cache.raw_data
file_updated_count = 0
cache_updated_count = 0
# Find recipes that need updating from the cache
# Find recipes that need updating
recipes_to_update = []
for recipe in cache.raw_data:
for recipe in candidate_recipes:
loras = recipe.get("loras", [])
if not isinstance(loras, list):
continue
@@ -4546,7 +4708,9 @@ class RecipeScanner:
# We don't necessarily need to resort because LoRA file_name isn't a sort key,
# but we might want to schedule a resort if we're paranoid or if searching relies on sorted state.
# Given it's a rename of a dependency, search results might change if searching by LoRA name.
self._schedule_resort()
# Bulk callers defer this to a single finalize_bulk_filename_updates() call.
if not defer_maintenance:
self._schedule_resort()
return file_updated_count, cache_updated_count
+38
View File
@@ -117,6 +117,10 @@ class RecipeAnalysisService:
image_info: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
# Workflow recovered from the image. CivitAI's optimized renditions are
# re-encoded and carry no metadata, so for those the workflow only
# exists in the original rendition, fetched below for EXIF extraction.
recovered_workflow: Optional[str] = None
# Diagnostics collected during analysis; surfaced in the payload so
# callers can persist an import_info block explaining empty LoRA lists.
diagnostics: dict[str, Any] = {"channel": "url"}
@@ -238,6 +242,9 @@ class RecipeAnalysisService:
exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path
)
recovered_workflow = await asyncio.to_thread(
self._read_embedded_workflow, temp_path
)
# Fallback: try the original (non-optimized) image for EXIF data
if not exif_metadata and civitai_image_id and image_info:
@@ -255,6 +262,16 @@ class RecipeAnalysisService:
self._exif_utils.extract_image_metadata,
orig_temp_path,
)
# The original is also the only place a ComfyUI
# workflow survives; carry it so the save step can
# embed it even though the stored preview stays the
# small, metadata-free optimized rendition.
recovered_workflow = (
await asyncio.to_thread(
self._read_embedded_workflow, orig_temp_path
)
or recovered_workflow
)
finally:
self._safe_cleanup(orig_temp_path)
@@ -358,6 +375,8 @@ class RecipeAnalysisService:
diagnostics["is_video"] = is_video
result.payload["diagnostics"] = diagnostics
if recovered_workflow:
result.payload["workflow"] = recovered_workflow
return result
finally:
if temp_path:
@@ -545,6 +564,25 @@ class RecipeAnalysisService:
if not success:
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
The raw metadata string extractor stops at the generation parameters
(``prompt``/``parameters``), so the UI-format workflow has to be read
through the structured metadata reader. Failures map to ``None``.
"""
if not image_path or not os.path.exists(image_path):
return None
try:
metadata = self._exif_utils._load_structured_metadata(image_path)
except Exception as exc:
self._logger.debug(
"Failed to read embedded workflow from %s: %s", image_path, exc
)
return None
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
return workflow if isinstance(workflow, str) and workflow else None
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
payload: dict[str, Any] = {
"error": "No metadata found in this image",
+55 -13
View File
@@ -18,6 +18,7 @@ from ...utils.base_model import (
RELATION_INCOMPATIBLE,
base_model_relation,
)
from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES
from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError
@@ -73,6 +74,11 @@ class RecipePersistenceService:
byte-level EXIF update that leaves the pixels untouched). Used
by local re-import, where the source is the recipe's own
already-optimized preview image.
``metadata`` may carry a ``workflow`` entry (JSON string, dict or
list) recovered from the source's original rendition; it is embedded
into the stored image so the recipe reports ``has_workflow`` and can
send the workflow back to ComfyUI.
"""
missing_fields = []
@@ -87,6 +93,13 @@ class RecipePersistenceService:
assert metadata is not None
# A workflow recovered from a higher-fidelity source (CivitAI's
# original rendition — its optimized preview is re-encoded and carries
# no metadata) travels as data instead of as image bytes. It is
# embedded below so ``has_workflow`` and the "send workflow to ComfyUI"
# action work for imports whose preview pixels are metadata-free.
workflow = metadata.get("workflow")
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
recipes_dir = target_dir or recipe_scanner.recipes_dir
os.makedirs(recipes_dir, exist_ok=True)
@@ -108,6 +121,7 @@ class RecipePersistenceService:
format="webp",
quality=85,
preserve_metadata=True,
workflow=workflow,
)
image_filename = f"{recipe_id}{extension}"
@@ -116,6 +130,12 @@ class RecipePersistenceService:
with open(normalized_image_path, "wb") as file_obj:
file_obj.write(optimized_image)
# The optimization branch above embeds the workflow while re-encoding;
# the verbatim (skip_optimize) branch still needs it added, and this is
# also the safety net when re-encoding dropped it.
if workflow and not is_video:
self._exif_utils.embed_workflow(normalized_image_path, workflow)
current_time = time.time()
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
@@ -855,8 +875,15 @@ class RecipePersistenceService:
recipe_scanner,
metadata: dict[str, Any],
image_bytes: bytes,
workflow: Any = None,
) -> PersistenceResult:
"""Save a recipe constructed from widget metadata."""
"""Save a recipe constructed from widget metadata.
``workflow`` is the caller's ComfyUI graph (UI or API format) to embed
in the stored preview. Embedding is opt-in because the graph is by far
the largest metadata field and its widget values may contain sensitive
data; an oversized graph is dropped rather than inflating the preview.
"""
if not metadata:
raise RecipeValidationError("No generation metadata found")
@@ -865,12 +892,25 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
workflow_json = self._exif_utils.normalise_workflow(workflow)
workflow_skipped: Optional[str] = None
if workflow_json and len(workflow_json.encode("utf-8")) > MAX_WORKFLOW_EMBED_BYTES:
self._logger.warning(
"Widget workflow is %d bytes (limit %d); saving recipe without it",
len(workflow_json),
MAX_WORKFLOW_EMBED_BYTES,
)
workflow_json = None
workflow_skipped = "too_large"
optimized_image, extension = self._exif_utils.optimize_image(
image_data=image_bytes,
target_width=self._card_preview_width,
format="webp",
quality=85,
preserve_metadata=True,
workflow=workflow_json,
)
image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename)
@@ -924,9 +964,9 @@ class RecipePersistenceService:
if key not in ["checkpoint", "loras"]
},
"loras_stack": lora_stack,
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
# embedded metadata chunks, so a workflow can never be present.
"has_workflow": False,
# Set by detection below: the workflow is embedded during
# re-encoding only when the caller opted in and it fit the cap.
"has_workflow": self._detect_has_workflow(image_path),
# Widget saves read LoRAs straight from the current workflow; an
# empty list means the workflow used no LoRAs.
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
@@ -942,15 +982,17 @@ class RecipePersistenceService:
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
await recipe_scanner.add_recipe(recipe_data)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"image_path": image_path,
"json_path": json_path,
"recipe_name": recipe_name,
}
)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"image_path": image_path,
"json_path": json_path,
"recipe_name": recipe_name,
"has_workflow": recipe_data["has_workflow"],
}
if workflow_skipped:
payload["workflow_skipped"] = workflow_skipped
return PersistenceResult(payload)
# Helper methods ---------------------------------------------------
+21
View File
@@ -252,6 +252,27 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}")
return client
@classmethod
async def get_openmodeldb_client(cls):
"""Get or create OpenModelDB client instance"""
service_name = "openmodeldb_client"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .openmodeldb_client import OpenModelDBClient
client = await OpenModelDBClient.get_instance()
cls._services[service_name] = client
logger.debug(f"Created and registered {service_name}")
return client
@classmethod
async def get_download_manager(cls):
"""Get or create Download manager instance"""
+163 -10
View File
@@ -19,6 +19,7 @@ from typing import (
Mapping,
Optional,
Sequence,
Set,
Tuple,
)
@@ -37,6 +38,7 @@ from ..utils.constants import (
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
APP_NAME,
_portable_env_override,
ensure_settings_file,
get_legacy_settings_path,
get_settings_dir_override,
@@ -45,6 +47,8 @@ from ..utils.settings_paths import (
from ..utils.tag_priorities import (
PriorityTagEntry,
collect_canonical_tags,
is_civitai_meta_tag,
is_usable_path_tag,
parse_priority_tag_string,
resolve_priority_tag,
)
@@ -63,6 +67,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
"huggingface_api_key": "",
"civitai_host": "civitai.com",
"download_backend": "python",
"aria2c_path": "",
@@ -74,6 +79,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"dismissed_banners": [],
"enable_metadata_archive_db": False,
"enable_civarchive_api": True,
# OpenModelDB supplies read-only metadata for upscaler models (the "other"
# page's upscaler sub_type) via hash matching against its bulk catalogue.
"enable_openmodeldb_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
"rate_limit_gate_enabled": True,
"rate_limit_max_wait_seconds": 300,
@@ -94,8 +102,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
"recipes_path": "",
"sidecar_storage_mode": "alongside",
"sidecar_storage_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
"download_filename_templates": {},
"folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "",
@@ -122,6 +133,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [],
# Routing target for checkpoint downloads whose baseModel is neither a
# known diffusion model nor a known full checkpoint (CHECKPOINT_BASE_MODELS).
"unknown_base_model_routing": "diffusion_model",
"backup_auto_enabled": True,
"backup_retention_count": 5,
"use_new_license_icons": True,
@@ -172,13 +186,23 @@ class SettingsManager:
self._check_environment_variables()
self._collect_configuration_warnings()
if (
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
and not is_settings_dir_pinned()
):
portable_override = _portable_env_override()
if portable_override is True and not is_settings_dir_pinned():
if not self.settings.get("use_portable_settings"):
self.settings["use_portable_settings"] = True
self._save_settings()
elif portable_override is False and self.settings.get(
"use_portable_settings"
):
# Explicit opt-out from a persisted portable mode: clear the flag so
# later runs go back to the shared settings directory instead of
# requiring a manual edit of settings.json.
logger.info(
"Clearing the persisted portable-mode flag because %s=0",
"LORA_MANAGER_PORTABLE",
)
self.settings["use_portable_settings"] = False
self._save_settings()
if self._needs_initial_save:
self._save_settings()
@@ -297,6 +321,29 @@ class SettingsManager:
return payload == template
def get_template_folder_path_placeholders(self) -> Set[str]:
"""Placeholder folder_paths values shipped in settings.json.example.
A fresh standalone install is seeded from the template, so its
documentation-only placeholder paths end up in the live settings
file. The Model Paths settings UI hides them; the first real save
overwrites them via ``set("folder_paths")``.
"""
template = self._read_template_payload()
if not template:
return set()
folder_paths = template.get("folder_paths")
if not isinstance(folder_paths, Mapping):
return set()
placeholders: Set[str] = set()
for value in folder_paths.values():
paths = value if isinstance(value, list) else [value]
placeholders.update(p for p in paths if isinstance(p, str) and p)
return placeholders
def _merge_template_with_defaults(
self, defaults: Dict[str, Any], template: Mapping[str, Any]
) -> Dict[str, Any]:
@@ -1086,6 +1133,15 @@ class SettingsManager:
self.settings["civitai_api_key"] = env_api_key
self._save_settings()
# Hugging Face accepts either of its conventional variable names
env_hf_token = os.environ.get("HF_TOKEN") or os.environ.get(
"HUGGING_FACE_HUB_TOKEN"
)
if env_hf_token:
logger.info("Found HF_TOKEN environment variable")
self.settings["huggingface_api_key"] = env_hf_token
self._save_settings()
# LLM provider overrides
llm_env_map = {
"LLM_API_KEY": "llm_api_key",
@@ -1208,19 +1264,27 @@ class SettingsManager:
if self._bootstrap_reason == "missing":
message = (
"LoRA Manager created a default settings.json because no configuration was found. "
"Edit settings.json to add your model directories so library scanning can run."
"Open Settings → Model Paths to add your model directories so library scanning can run."
)
else:
message = (
"LoRA Manager could not locate any configured model directories. "
"Edit settings.json to add your model folders so library scanning can run."
"Open Settings → Model Paths to add your model folders so library scanning can run."
)
self._add_startup_message(
code="missing-model-paths",
title="Model folders need setup",
message=message,
severity="warning",
actions=self._default_settings_actions(),
actions=[
{
"action": "open-model-paths-settings",
"label": "Configure model folders",
"type": "primary",
"icon": "fas fa-cog",
},
*self._default_settings_actions(),
],
dismissible=False,
)
@@ -1233,6 +1297,7 @@ class SettingsManager:
defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults["base_model_path_mappings"] = {}
defaults["download_path_templates"] = {}
defaults["download_filename_templates"] = {}
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault("folder_paths", {})
defaults.setdefault("extra_folder_paths", {})
@@ -1524,9 +1589,15 @@ class SettingsManager:
if resolved:
return resolved
# Fall back to the first tag that is usable as a folder name. The raw
# tag list can contain keyword dumps that would become unusable folders
# and break path length limits, and Civitai mixes in structural labels
# like "base model" that mean nothing as a folder, so skip both (#1119).
for tag in tags:
if isinstance(tag, str) and tag:
return tag
if is_civitai_meta_tag(tag):
continue
if is_usable_path_tag(tag):
return tag.strip()
return ""
def get_priority_tag_suggestions(self) -> Dict[str, List[str]]:
@@ -1553,9 +1624,40 @@ class SettingsManager:
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
@staticmethod
def _normalize_sidecar_storage_mode(value: Any) -> str:
"""Return a valid sidecar storage mode, falling back to ``alongside``."""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("alongside", "centralized"):
return normalized
return "alongside"
@staticmethod
def _normalize_unknown_base_model_routing(value: Any) -> str:
"""Return a valid unknown-base-model routing target, falling back to ``diffusion_model``."""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("diffusion_model", "checkpoint"):
return normalized
return "diffusion_model"
def _refresh_sidecar_storage_config(self) -> None:
"""Rebuild dependent config state after sidecar storage settings change."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_preview_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug(
"Failed to refresh config after sidecar storage change: %s", exc
)
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
"""Resolve the effective recipes directory for the active library."""
normalized_custom = self._normalize_recipes_path_value(
self.settings.get("recipes_path", "")
if recipes_path is None
@@ -1752,6 +1854,12 @@ class SettingsManager:
target_recipes_dir = self._get_effective_recipes_dir(value)
self._validate_recipes_storage_path(target_recipes_dir)
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
elif key == "sidecar_storage_mode":
value = self._normalize_sidecar_storage_mode(value)
elif key == "unknown_base_model_routing":
value = self._normalize_unknown_base_model_routing(value)
elif key == "sidecar_storage_path":
value = self._normalize_recipes_path_value(value)
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):
@@ -1782,6 +1890,8 @@ class SettingsManager:
self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if key in ("sidecar_storage_mode", "sidecar_storage_path"):
self._refresh_sidecar_storage_config()
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if portable_switch_pending:
@@ -2381,6 +2491,49 @@ class SettingsManager:
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
)
def get_download_filename_template(self, model_type: str) -> str:
"""Get the download filename template for a specific model type.
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type. Empty string (the default for
every model type) means downloaded files keep their original
filename.
"""
templates = self.settings.get("download_filename_templates", {})
# Handle edge case where templates might be stored as JSON string
if isinstance(templates, str):
try:
parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict):
self.settings["download_filename_templates"] = parsed_templates
self._save_settings()
templates = parsed_templates
logger.info(
"Successfully parsed download_filename_templates from JSON string"
)
else:
raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
)
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
if not isinstance(templates, dict):
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
template = templates.get(model_type, "")
return template if isinstance(template, str) else ""
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
_SETTINGS_MANAGER_LOCK = Lock()
+8 -10
View File
@@ -20,6 +20,7 @@ import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from ..utils.cache_db import connect_cache_db
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__)
@@ -677,16 +678,13 @@ class TagFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection."""
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
if readonly and not os.path.exists(self._db_path):
raise FileNotFoundError(self._db_path)
return connect_cache_db(
self._db_path,
readonly=readonly,
row_factory=sqlite3.Row,
)
def _build_fts_query(self, query: str) -> str:
"""Build an FTS5 query string with prefix matching.
+2 -4
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import os
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
from ..utils.sidecar_paths import get_metadata_path
from .auto_tag_service import extract_auto_tags
@@ -24,8 +23,7 @@ class TagUpdateService:
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
) -> Tuple[List[str], List[str]]:
"""Add tags to a metadata entry and return updated tags and auto_tags."""
base, _ = os.path.splitext(file_path)
metadata_path = f"{base}.metadata.json"
metadata_path = get_metadata_path(file_path)
metadata = await metadata_loader(metadata_path)
raw_tags = metadata.get("tags", [])
+8
View File
@@ -20,6 +20,11 @@ from .example_images import (
ImportExampleImagesUseCase,
ImportExampleImagesValidationError,
)
from .filename_template_use_case import FilenameTemplateUseCase
from .sidecar_migration_use_case import (
SidecarMigrationProgressReporter,
SidecarMigrationUseCase,
)
__all__ = [
"AutoOrganizeInProgressError",
@@ -34,4 +39,7 @@ __all__ = [
"DownloadExampleImagesUseCase",
"ImportExampleImagesUseCase",
"ImportExampleImagesValidationError",
"FilenameTemplateUseCase",
"SidecarMigrationProgressReporter",
"SidecarMigrationUseCase",
]
@@ -29,6 +29,7 @@ class ImportExampleImagesUseCase:
async def execute(self, request: web.Request) -> Dict[str, Any]:
model_hash: str | None = None
model_path: str | None = None
files_to_import: List[str] = []
temp_files: List[str] = []
@@ -40,6 +41,8 @@ class ImportExampleImagesUseCase:
first_field = cast(BodyPartReader, first_field_raw) if first_field_raw is not None else None
if first_field and first_field.name == "model_hash":
model_hash = await first_field.text()
elif first_field and first_field.name == "model_path":
model_path = await first_field.text()
else:
# Support clients that send files first and hash later
if first_field is not None:
@@ -49,13 +52,22 @@ class ImportExampleImagesUseCase:
field = cast(BodyPartReader, raw_field)
if field.name == "model_hash" and not model_hash:
model_hash = await field.text()
elif field.name == "model_path" and not model_path:
model_path = await field.text()
elif field.name == "files":
await self._collect_upload_file(field, files_to_import, temp_files)
else:
data = await request.json()
model_hash = data.get("model_hash")
model_path = data.get("model_path")
files_to_import = list(data.get("file_paths", []))
# Models with a deferred hash (checkpoints, Other) send an empty
# model_hash; locate them by file path and compute the hash on
# demand, since example-image folders are keyed by hash.
if not model_hash and model_path:
model_hash = await self._processor.resolve_hash_for_file_path(model_path)
if not model_hash:
raise ImportExampleImagesValidationError("Missing model_hash parameter")
result = await self._processor.import_images(model_hash, files_to_import)
@@ -0,0 +1,250 @@
"""Filename template use case: bulk-rename library models per the configured template.
An empty template reverts previously renamed models to the original filename
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ...utils.sidecar_paths import get_metadata_path
from ...utils.utils import calculate_filename_for_model
from ..model_file_service import AutoOrganizeResult, ProgressCallback
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
from ..settings_manager import get_settings_manager
from .auto_organize_use_case import (
AutoOrganizeInProgressError,
AutoOrganizeLockProvider,
)
logger = logging.getLogger(__name__)
_PROGRESS_TYPE = "filename_template_progress"
class FilenameTemplateUseCase:
"""Apply the download filename template to existing library models.
An empty template restores the recorded original filename instead of
rendering a template. Shares the auto-organize lock (and its in-progress
error) so a bulk rename never runs concurrently with an auto-organize
operation. The whole loop runs inside a bulk rename session so cache
persist/resort and recipe maintenance happen once at the end instead of
per renamed file.
"""
def __init__(
self,
*,
scanner,
lifecycle_service: ModelLifecycleService,
lock_provider: AutoOrganizeLockProvider,
model_type: str,
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
) -> None:
self._scanner = scanner
self._lifecycle_service = lifecycle_service
self._lock_provider = lock_provider
self._model_type = model_type
self._metadata_loader = metadata_loader
async def execute(
self,
*,
file_paths: Optional[Sequence[str]] = None,
progress_callback: Optional[ProgressCallback] = None,
) -> AutoOrganizeResult:
"""Run the bulk rename guarded by the shared library-operation lock."""
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
if callable(is_running) and is_running():
raise AutoOrganizeInProgressError(
"A filename template operation is already running"
)
if self._lock_provider.is_auto_organize_running():
raise AutoOrganizeInProgressError("Auto-organize is already running")
lock = await self._lock_provider.get_auto_organize_lock()
if lock.locked():
raise AutoOrganizeInProgressError(
"Another library operation is already running"
)
async with lock:
return await self._run(
file_paths=file_paths, progress_callback=progress_callback
)
async def _run(
self,
*,
file_paths: Optional[Sequence[str]],
progress_callback: Optional[ProgressCallback],
) -> AutoOrganizeResult:
result = AutoOrganizeResult()
result.operation_type = "filename_template"
self._scanner.reset_cancellation()
try:
template = get_settings_manager().get_download_filename_template(
self._model_type
)
cache = await self._scanner.get_cached_data()
models = list(cache.raw_data)
if file_paths:
wanted = set(file_paths)
models = [
model for model in models if model.get("file_path") in wanted
]
result.total = len(models)
await self._emit_progress(progress_callback, result, "started")
async with self._lifecycle_service.bulk_rename_session() as bulk_context:
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self._scanner.is_cancelled():
logger.info(
"Filename template apply cancelled for %s", self._model_type
)
break
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
if self._scanner.is_cancelled():
break
await self._process_model(model, template, result, bulk_context)
result.processed += 1
await self._emit_progress(progress_callback, result, "processing")
# Yield between batches so the server stays responsive.
await asyncio.sleep(0.1)
if self._scanner.is_cancelled():
result.status = "cancelled"
await self._emit_progress(progress_callback, result, "cancelled")
return result
await self._emit_progress(progress_callback, result, "completed")
return result
except Exception as exc:
logger.error("Error in filename template apply: %s", exc, exc_info=True)
if progress_callback:
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": "error",
"error": str(exc),
"operation_type": result.operation_type,
}
)
raise
async def _process_model(
self,
model: Dict[str, Any],
template: str,
result: AutoOrganizeResult,
bulk_context: Any = None,
) -> None:
model_name = model.get("model_name", "Unknown")
try:
file_path = model.get("file_path")
if not file_path:
self._add_result(result, model_name, False, "No file path found")
result.failure_count += 1
return
if not template:
# Empty template = revert to the original filename recorded
# by the first rename; models without a record are skipped.
new_stem = await self._resolve_recorded_original(file_path)
else:
new_stem = calculate_filename_for_model(model, self._model_type)
if not new_stem:
result.skipped_count += 1
return
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
result.skipped_count += 1
return
await self._lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem, bulk_context=bulk_context
)
result.success_count += 1
except ValueError as exc:
# Conflicts (e.g. target name already exists) count as failures
# without aborting the batch.
self._add_result(result, model_name, False, str(exc))
result.failure_count += 1
except Exception as exc:
logger.error(
"Error applying filename template to %s: %s", model_name, exc,
exc_info=True,
)
self._add_result(result, model_name, False, f"Error: {exc}")
result.failure_count += 1
async def _resolve_recorded_original(self, file_path: str) -> str:
"""Return the original filename stem recorded at the first rename.
Reads the ``.metadata.json`` sidecar; returns an empty string when no
sidecar or no ``original_file_name`` entry exists (models never
renamed, or renamed before the recording shipped).
"""
metadata_path = get_metadata_path(file_path)
metadata = await self._metadata_loader(metadata_path)
original = metadata.get("original_file_name")
if not isinstance(original, str):
return ""
return original.strip()
async def _emit_progress(
self,
progress_callback: Optional[ProgressCallback],
result: AutoOrganizeResult,
status: str,
) -> None:
if not progress_callback:
return
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": status,
"total": result.total,
"processed": result.processed,
"success": result.success_count,
"failures": result.failure_count,
"skipped": result.skipped_count,
"operation_type": result.operation_type,
}
)
@staticmethod
def _add_result(
result: AutoOrganizeResult,
model_name: str,
success: bool,
message: str,
) -> None:
"""Add a result entry if under the limit (mirrors ModelFileService)."""
if len(result.results) < 100:
result.results.append(
{"model": model_name, "success": success, "message": message}
)
elif len(result.results) == 100:
result.results_truncated = True
result.sample_results = result.results[:50]
@@ -0,0 +1,734 @@
"""Use case migrating sidecar metadata and previews between storage layouts.
Two storage layouts exist (see :mod:`py.utils.sidecar_paths`):
- ``alongside``: ``<model_dir>/<name>.metadata.json`` and preview files live
next to the model file.
- ``centralized``: the same files live under the configured sidecar root,
mirroring each model root's directory structure under a per-root identity
component (see :func:`py.utils.sidecar_paths.root_mirror_component`).
This use case moves the ``.metadata.json`` sidecar and preview files for every
known model from one layout to the other. Model files themselves NEVER move.
Paths inside the moved sidecar (``file_path``, ``file_name``, ``preview_url``)
are rewritten the same way :meth:`ModelScanner._update_metadata_paths` does.
After the move, scanner caches are reconciled so the list API immediately
serves the new preview locations instead of stale pre-migration URLs.
Intended flow (settings-first):
1. The user switches ``sidecar_storage_mode`` (and optionally
``sidecar_storage_path``) in settings.
2. The migration runs in the direction of the NEW mode with ``force=True``.
After the switch, files in the OLD layout are the source of truth; the
guard below would otherwise refuse to run because the active mode already
matches the migration target.
Both orderings work because all path computations are mode-independent: the
alongside location is derived from the model path directly, and the mirror
location is resolved via ``get_configured_sidecar_root()``, which ignores the
active mode.
Guards (pass ``force=True`` to bypass):
- ``migrate_to_centralized`` refuses when centralized storage is already the
active, resolvable mode.
- ``migrate_to_alongside`` refuses when the active mode is ``alongside``.
"""
from __future__ import annotations
import errno
import json
import logging
import os
import shutil
from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
from ..service_registry import ServiceRegistry
from ..settings_manager import get_settings_manager
from ...utils.constants import PREVIEW_EXTENSIONS
from ...utils.file_utils import find_preview_file, get_preview_extension
from ...utils.metadata_manager import MetadataManager
from ...utils.sidecar_paths import (
METADATA_SUFFIX,
ROOT_MAP_FILENAME,
STORAGE_MODE_CENTRALIZED,
get_configured_sidecar_root,
get_sidecar_root,
get_storage_mode,
relocate_root_map,
resolve_centralized_dir_for_dir,
)
class SidecarMigrationProgressReporter(Protocol):
"""Protocol for progress reporters used during sidecar migration."""
async def on_progress(self, payload: Dict[str, Any]) -> None:
"""Handle a sidecar migration progress update."""
ScannerFactory = Callable[[], Awaitable[Any]]
DIRECTION_TO_CENTRALIZED = "to_centralized"
DIRECTION_TO_ALONGSIDE = "to_alongside"
DIRECTION_RELOCATE_ROOT = "relocate_root"
# Same candidate set find_preview_file recognizes: every PREVIEW_EXTENSIONS
# suffix plus the legacy ".example.0.jpeg" (issue #225).
_PREVIEW_CANDIDATE_EXTENSIONS = tuple(PREVIEW_EXTENSIONS) + (".example.0.jpeg",)
def _enumerate_preview_names(directory: str, stem: str) -> List[str]:
"""Return preview filenames for ``stem`` present in ``directory``.
Case-insensitive full-name match against the preview candidate set, so
files like ``model.WEBP`` or ``model.Png`` placed by external tools are
migrated along with the exact-case variants.
"""
targets = {f"{stem.lower()}{ext}" for ext in _PREVIEW_CANDIDATE_EXTENSIONS}
try:
entries = os.listdir(directory)
except OSError:
return []
return [entry for entry in entries if entry.lower() in targets]
class SidecarMigrationUseCase:
"""Move sidecars and previews between alongside and centralized layouts."""
def __init__(
self,
*,
scanner_factories: Sequence[Tuple[str, ScannerFactory]] | None = None,
settings_service=None,
logger: Optional[logging.Logger] = None,
) -> None:
self._settings = settings_service or get_settings_manager()
self._scanner_factories: Tuple[Tuple[str, ScannerFactory], ...] = tuple(
scanner_factories
or (
("lora", ServiceRegistry.get_lora_scanner),
("checkpoint", ServiceRegistry.get_checkpoint_scanner),
("embedding", ServiceRegistry.get_embedding_scanner),
("other", ServiceRegistry.get_other_scanner),
)
)
self._logger = logger or logging.getLogger(__name__)
async def migrate_to_centralized(
self,
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
*,
force: bool = False,
) -> Dict[str, Any]:
"""Move sidecars/previews from alongside the models into the mirror root."""
if (
not force
and get_storage_mode() == STORAGE_MODE_CENTRALIZED
and get_sidecar_root()
):
return self._refusal(
DIRECTION_TO_CENTRALIZED,
"sidecar storage is already centralized; pass force=true to migrate anyway",
)
return await self._migrate(
direction=DIRECTION_TO_CENTRALIZED,
to_centralized=True,
progress_cb=progress_cb,
)
async def migrate_to_alongside(
self,
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
*,
force: bool = False,
) -> Dict[str, Any]:
"""Move sidecars/previews from the mirror root back next to the models."""
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
return self._refusal(
DIRECTION_TO_ALONGSIDE,
"sidecar storage is already alongside; pass force=true to migrate anyway",
)
return await self._migrate(
direction=DIRECTION_TO_ALONGSIDE,
to_centralized=False,
progress_cb=progress_cb,
)
async def migrate_root(
self,
old_root: str,
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
*,
force: bool = False,
) -> Dict[str, Any]:
"""Relocate the whole mirror tree from a previous root to the configured one.
Used after ``sidecar_storage_path`` changes while centralized storage
is active: without it, every asset under the old root would silently
disappear from the application. Moves every file keeping the
root-relative structure, rewrites the ``preview_url`` prefix inside
moved sidecars, reconciles scanner caches, and prunes the emptied old
tree. Keep-newer conflict resolution matches :meth:`_transfer`.
"""
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
return self._refusal(
DIRECTION_RELOCATE_ROOT,
"sidecar storage is not centralized; pass force=true to relocate anyway",
)
new_root = get_configured_sidecar_root()
if not new_root:
return self._refusal(
DIRECTION_RELOCATE_ROOT,
"cannot resolve the centralized sidecar root",
)
old = (
os.path.abspath(os.path.expanduser(old_root.strip()))
if isinstance(old_root, str) and old_root.strip()
else ""
)
if not old:
return self._refusal(DIRECTION_RELOCATE_ROOT, "old_root is required")
if os.path.normpath(old) == os.path.normpath(new_root):
return self._refusal(
DIRECTION_RELOCATE_ROOT,
"old_root matches the configured sidecar root",
)
files: List[Tuple[str, str]] = []
source_map_path = os.path.join(old, ROOT_MAP_FILENAME)
if os.path.isdir(old):
for dirpath, _dirnames, filenames in os.walk(old):
rel = os.path.relpath(dirpath, old)
target_dir = new_root if rel == os.curdir else os.path.join(new_root, rel)
for filename in filenames:
source = os.path.join(dirpath, filename)
# The identity map is handled by relocate_root_map below:
# _transfer's keep-newer rule would let a destination map
# written before the relocation displace it.
if source == source_map_path:
continue
files.append((source, os.path.join(target_dir, filename)))
errors: List[Dict[str, str]] = []
counters: Dict[str, Any] = {"moved": 0, "conflicts": 0}
moved_sidecars: List[str] = []
async def emit(status: str, **extra: Any) -> None:
if progress_cb is None:
return
payload: Dict[str, Any] = {
"type": "sidecar_migration_progress",
"status": status,
"direction": DIRECTION_RELOCATE_ROOT,
"total": len(files),
"processed": extra.pop("processed", 0),
"moved": counters["moved"],
"skipped": 0,
"conflicts": counters["conflicts"],
"errors": len(errors),
}
payload.update(extra)
await progress_cb.on_progress(payload)
await emit("started")
for index, (src, dst) in enumerate(files, start=1):
try:
if self._transfer(src, dst, counters) and src.endswith(METADATA_SUFFIX):
moved_sidecars.append(dst)
except Exception as exc:
self._logger.error(
"Sidecar root relocation failed for %s: %s", src, exc, exc_info=True
)
errors.append({"model": os.path.basename(src), "error": str(exc)})
await emit("processing", processed=index, current=os.path.basename(src))
# The identity map names the directories just moved, so it travels with
# them and wins over any map the destination acquired beforehand.
# A failure here strands the moved metadata, so it is a real error.
try:
if not relocate_root_map(old, new_root):
errors.append(
{
"model": ROOT_MAP_FILENAME,
"error": "sidecar root map could not be written to the new root",
}
)
except Exception as exc:
self._logger.error(
"Sidecar root relocation failed for the root map: %s", exc, exc_info=True
)
errors.append({"model": ROOT_MAP_FILENAME, "error": str(exc)})
old_prefix = old.replace(os.sep, "/").rstrip("/") + "/"
new_prefix = new_root.replace(os.sep, "/").rstrip("/") + "/"
for sidecar in moved_sidecars:
self._rewrite_root_prefix(sidecar, old_prefix, new_prefix)
await self._reconcile_root_prefix(old_prefix, new_prefix)
# Prune the emptied old tree, best-effort.
if os.path.isdir(old):
for dirpath, dirnames, filenames in os.walk(old, topdown=False):
if filenames:
continue
for dirname in dirnames:
try:
os.rmdir(os.path.join(dirpath, dirname))
except OSError:
pass
try:
os.rmdir(dirpath)
except OSError:
pass
await emit("completed")
return {
"success": not errors,
"direction": DIRECTION_RELOCATE_ROOT,
"models_total": len(files),
"models_processed": len(files),
"models_moved": 0,
"moved": counters["moved"],
"skipped": 0,
"conflicts": counters["conflicts"],
"errors": errors,
"error_count": len(errors),
"sidecar_root": new_root,
}
def _rewrite_root_prefix(
self, sidecar_path: str, old_prefix: str, new_prefix: str
) -> None:
"""Repoint preview_url inside a relocated sidecar from old to new root."""
try:
with open(sidecar_path, "r", encoding="utf-8") as handle:
metadata = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
self._logger.warning(
"Sidecar root relocation: cannot read %s: %s", sidecar_path, exc
)
return
preview_url = metadata.get("preview_url")
if not isinstance(preview_url, str) or not preview_url.startswith(old_prefix):
return
metadata["preview_url"] = new_prefix + preview_url[len(old_prefix):]
try:
with open(sidecar_path, "w", encoding="utf-8") as handle:
json.dump(metadata, handle, ensure_ascii=False, indent=2)
except OSError as exc:
self._logger.warning(
"Sidecar root relocation: cannot rewrite %s: %s", sidecar_path, exc
)
async def _reconcile_root_prefix(self, old_prefix: str, new_prefix: str) -> None:
"""Rewrite old-root preview URLs in every scanner cache after relocation."""
for model_type, factory in self._active_scanner_factories():
try:
scanner = await factory()
cache = await scanner.get_cached_data()
changed = False
for item in cache.raw_data:
preview_url = item.get("preview_url")
if (
isinstance(preview_url, str)
and preview_url.startswith(old_prefix)
):
item["preview_url"] = new_prefix + preview_url[len(old_prefix):]
changed = True
if changed and hasattr(scanner, "_persist_current_cache"):
await scanner._persist_current_cache()
except Exception as exc:
self._logger.error(
"Sidecar root relocation: failed to reconcile %s cache: %s",
model_type,
exc,
exc_info=True,
)
@staticmethod
def _refusal(direction: str, message: str) -> Dict[str, Any]:
return {
"success": False,
"error": message,
"direction": direction,
"models_total": 0,
"models_processed": 0,
"models_moved": 0,
"moved": 0,
"skipped": 0,
"conflicts": 0,
"errors": [],
"error_count": 0,
"sidecar_root": get_configured_sidecar_root() or "",
}
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
"""Drop the opt-in other scanner while Other Models is disabled."""
if self._settings.is_other_models_enabled():
return self._scanner_factories
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
async def _collect_model_paths(
self, errors: List[Dict[str, str]]
) -> List[Tuple[Any, List[str]]]:
"""Enumerate model file paths grouped by the scanner that owns them.
Excluded models are included: they are absent from the cache but still
on disk, and leaving their sidecars behind would strand the metadata
if the user later un-excludes them (the scanner would then look the
sidecar up in the NEW layout and find nothing).
"""
groups: List[Tuple[Any, List[str]]] = []
for model_type, factory in self._active_scanner_factories():
try:
scanner = await factory()
cache = await scanner.get_cached_data()
except Exception as exc:
self._logger.error(
"Sidecar migration: failed to enumerate %s models: %s",
model_type,
exc,
)
errors.append({"model": model_type, "error": f"enumeration failed: {exc}"})
continue
paths = [
entry["file_path"]
for entry in cache.raw_data
if entry.get("file_path")
]
get_excluded = getattr(scanner, "get_excluded_models", None)
if callable(get_excluded):
try:
known = set(paths)
paths.extend(
path for path in get_excluded() if path and path not in known
)
except Exception as exc:
self._logger.error(
"Sidecar migration: failed to enumerate excluded %s models: %s",
model_type,
exc,
)
groups.append((scanner, paths))
return groups
@staticmethod
def _move_file(src: str, dst: str) -> None:
"""Move a file, tolerating EXDEV when the layouts span filesystems."""
os.makedirs(os.path.dirname(dst), exist_ok=True)
try:
os.rename(src, dst)
except OSError as exc:
if exc.errno != errno.EXDEV:
raise
shutil.copy2(src, dst)
os.remove(src)
async def _migrate(
self,
*,
direction: str,
to_centralized: bool,
progress_cb: Optional[SidecarMigrationProgressReporter],
) -> Dict[str, Any]:
root = get_configured_sidecar_root()
if not root:
return self._refusal(
direction,
"cannot resolve the centralized sidecar root",
)
errors: List[Dict[str, str]] = []
scanner_groups = await self._collect_model_paths(errors)
total = sum(len(paths) for _, paths in scanner_groups)
processed = 0
models_moved = 0
moved = 0
skipped = 0
conflicts = 0
# (file_path, final preview path at the destination layout), grouped
# by scanner so caches can be reconciled after the move.
preview_updates: List[Tuple[Any, List[Tuple[str, str]]]] = []
async def emit(status: str, **extra: Any) -> None:
if progress_cb is None:
return
payload: Dict[str, Any] = {
"type": "sidecar_migration_progress",
"status": status,
"direction": direction,
"total": total,
"processed": processed,
"moved": moved,
"skipped": skipped,
"conflicts": conflicts,
"errors": len(errors),
}
payload.update(extra)
await progress_cb.on_progress(payload)
await emit("started")
for scanner, model_paths in scanner_groups:
updates: List[Tuple[str, str]] = []
for model_path in model_paths:
processed += 1
current = os.path.basename(model_path)
try:
result = await self._migrate_model(
model_path,
root=root,
to_centralized=to_centralized,
)
moved += result["moved"]
conflicts += result["conflicts"]
if result["skipped"]:
skipped += 1
else:
updates.append((model_path, result["preview_url"]))
if result["moved"]:
models_moved += 1
except Exception as exc:
self._logger.error(
"Sidecar migration failed for %s: %s", model_path, exc, exc_info=True
)
errors.append({"model": current, "error": str(exc)})
await emit("processing", current=current)
preview_updates.append((scanner, updates))
await self._reconcile_scanner_caches(preview_updates)
await emit("completed")
return {
"success": not errors,
"direction": direction,
"models_total": total,
"models_processed": processed,
"models_moved": models_moved,
"moved": moved,
"skipped": skipped,
"conflicts": conflicts,
"errors": errors,
"error_count": len(errors),
# Effective centralized root, so the UI can show/offer to open the
# destination (or, for to_alongside, the source) after the run.
"sidecar_root": root,
}
async def _migrate_model(
self,
model_path: str,
*,
root: str,
to_centralized: bool,
) -> Dict[str, Any]:
"""Migrate one model's sidecar + previews; return per-model counters.
``preview_url`` in the result is the model's final preview path in the
destination layout ("" when none), used to reconcile scanner caches.
"""
result: Dict[str, Any] = {"moved": 0, "conflicts": 0, "skipped": 0, "preview_url": ""}
model_path = os.path.abspath(model_path)
if not os.path.exists(model_path):
self._logger.warning(
"Sidecar migration: model file missing, skipping: %s", model_path
)
result["skipped"] = 1
return result
model_dir = os.path.dirname(model_path)
mirror_dir = resolve_centralized_dir_for_dir(model_dir, sidecar_root=root)
if mirror_dir is None:
self._logger.warning(
"Sidecar migration: %s is outside configured model roots, skipping",
model_path,
)
result["skipped"] = 1
return result
if to_centralized:
src_dir, dst_dir = model_dir, mirror_dir
else:
src_dir, dst_dir = mirror_dir, model_dir
if os.path.normpath(src_dir) == os.path.normpath(dst_dir):
result["skipped"] = 1
return result
stem = os.path.splitext(os.path.basename(model_path))[0]
sidecar_name = stem + METADATA_SUFFIX
moved_previews: List[str] = []
for preview_name in _enumerate_preview_names(src_dir, stem):
src = os.path.join(src_dir, preview_name)
dst = os.path.join(dst_dir, preview_name)
if self._transfer(src, dst, result):
moved_previews.append(dst)
sidecar_src = os.path.join(src_dir, sidecar_name)
sidecar_moved = False
sidecar_dst = os.path.join(dst_dir, sidecar_name)
if os.path.exists(sidecar_src):
sidecar_moved = self._transfer(sidecar_src, sidecar_dst, result)
if sidecar_moved:
await self._rewrite_sidecar_paths(sidecar_dst, model_path, moved_previews)
# Ground truth from the destination directory: covers conflict-keep
# and partial moves, not just the previews transferred in this run.
final_preview = find_preview_file(stem, dst_dir)
if final_preview:
result["preview_url"] = final_preview.replace(os.sep, "/")
return result
async def _reconcile_scanner_caches(
self, preview_updates: List[Tuple[Any, List[Tuple[str, str]]]]
) -> None:
"""Point scanner cache entries at the post-migration preview locations.
Without this the list API keeps serving pre-migration ``preview_url``
values whose files no longer exist; hitting one triggers the preview
route's stale-URL cleanup, which would wipe the reference for good.
A failing scanner is logged and skipped — the on-disk migration has
already succeeded, and a full rescan repairs the cache.
"""
for scanner, updates in preview_updates:
if not updates:
continue
try:
cache = await scanner.get_cached_data()
changed = False
for file_path, preview_url in updates:
entry = next(
(item for item in cache.raw_data if item.get("file_path") == file_path),
None,
)
if entry is None:
continue
if entry.get("preview_url", "") == preview_url:
continue
if hasattr(cache, "update_preview_url"):
await cache.update_preview_url(
file_path,
preview_url,
entry.get("preview_nsfw_level", 0),
)
else: # pragma: no cover - minimal cache doubles
entry["preview_url"] = preview_url
changed = True
if changed and hasattr(scanner, "_persist_current_cache"):
await scanner._persist_current_cache()
except Exception as exc:
self._logger.error(
"Sidecar migration: failed to reconcile scanner cache: %s",
exc,
exc_info=True,
)
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
Returns True when the file was actually moved to the destination. On a
conflict the newer file wins: a newer source replaces the destination;
a newer (or equal) destination is kept and the source is deleted.
"""
if os.path.exists(dst):
result["conflicts"] += 1
if os.path.getmtime(src) > os.path.getmtime(dst):
self._logger.info(
"Sidecar migration: conflict at %s; source is newer, replacing", dst
)
os.remove(dst)
else:
self._logger.info(
"Sidecar migration: conflict at %s; destination is newer, keeping it",
dst,
)
os.remove(src)
return False
self._move_file(src, dst)
result["moved"] += 1
return True
async def _rewrite_sidecar_paths(
self,
sidecar_path: str,
model_path: str,
moved_previews: List[str],
) -> None:
"""Update path fields inside a moved sidecar, mirroring ModelScanner."""
with open(sidecar_path, "r", encoding="utf-8") as handle:
metadata = json.load(handle)
stem = os.path.splitext(os.path.basename(model_path))[0]
metadata["file_path"] = model_path.replace(os.sep, "/")
metadata["file_name"] = stem
if moved_previews and metadata.get("preview_url"):
recorded_ext = get_preview_extension(metadata["preview_url"])
chosen = next(
(
path
for path in moved_previews
if get_preview_extension(path) == recorded_ext
),
moved_previews[0],
)
metadata["preview_url"] = chosen.replace(os.sep, "/")
await MetadataManager.save_metadata(sidecar_path, metadata)
async def execute_with_error_handling(
self,
*,
direction: str,
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
force: bool = False,
old_root: Optional[str] = None,
) -> Dict[str, Any]:
"""Wrapper providing progress notification on unexpected failures."""
try:
if direction == DIRECTION_TO_CENTRALIZED:
return await self.migrate_to_centralized(progress_cb, force=force)
if direction == DIRECTION_TO_ALONGSIDE:
return await self.migrate_to_alongside(progress_cb, force=force)
if direction == DIRECTION_RELOCATE_ROOT:
return await self.migrate_root(old_root or "", progress_cb, force=force)
raise ValueError(
f"direction must be {DIRECTION_TO_CENTRALIZED!r}, "
f"{DIRECTION_TO_ALONGSIDE!r} or {DIRECTION_RELOCATE_ROOT!r}"
)
except Exception as exc:
if progress_cb is not None:
await progress_cb.on_progress(
{
"type": "sidecar_migration_progress",
"status": "error",
"direction": direction,
"error": str(exc),
}
)
raise
+29
View File
@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add filename template progress tracking
self._filename_template_progress: Optional[Dict[str, Any]] = None
# Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock()
@@ -170,6 +172,13 @@ class WebSocketManager:
progress_entry['status'] = data['status']
if 'message' in data:
progress_entry['message'] = data['message']
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
# the byte counter has stopped by then, so the stage is the only thing
# that still says the download is working.
if 'stage' in data:
progress_entry['stage'] = data['stage']
if 'platform' in data:
progress_entry['platform'] = data['platform']
self._download_progress[download_id] = progress_entry
@@ -199,6 +208,26 @@ class WebSocketManager:
"""Clear auto-organize progress data"""
self._auto_organize_progress = None
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
"""Broadcast filename template progress to connected clients"""
self._filename_template_progress = data
await self.broadcast(data)
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
"""Get current filename template progress"""
return self._filename_template_progress
def cleanup_filename_template_progress(self):
"""Clear filename template progress data"""
self._filename_template_progress = None
def is_filename_template_running(self) -> bool:
"""Check if a filename template operation is currently running"""
if not self._filename_template_progress:
return False
status = self._filename_template_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
"""Broadcast recipe rematch progress to connected clients"""
# Store progress data in memory
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
await ws_manager.broadcast_auto_organize_progress(progress_data)
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
"""WebSocket progress callback for filename template operations."""
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
"""Send filename template progress via WebSocket."""
await ws_manager.broadcast_filename_template_progress(progress_data)
class WebSocketBroadcastCallback:
"""Generic WebSocket progress callback broadcasting to all clients."""
+45
View File
@@ -39,6 +39,51 @@ def contains_dynamic_syntax(text: str) -> bool:
)
def _is_prompt_link(value: Any) -> bool:
"""Return True for ComfyUI prompt-graph links ([node_id, output_index])."""
return (
isinstance(value, list)
and len(value) == 2
and isinstance(value[0], str)
and isinstance(value[1], (int, float))
)
def linked_text_requires_rerun(prompt: Any, node_id: Any, input_name: str) -> bool:
"""Decide if a linked text input forces re-execution for dynamic expansion.
IS_CHANGED only receives constant inputs, so a linked text arrives as None.
This walks the prompt graph to the upstream node and returns False only
when that node is fully constant and free of dynamic syntax. Dynamic
syntax — or anything that cannot be statically resolved — returns True.
"""
if not isinstance(prompt, dict) or node_id is None:
return True
node = prompt.get(str(node_id))
if not isinstance(node, dict):
return True
inputs = node.get("inputs")
if not isinstance(inputs, dict):
return True
value = inputs.get(input_name)
if not _is_prompt_link(value):
return contains_dynamic_syntax(value)
upstream = prompt.get(value[0])
if not isinstance(upstream, dict):
return True
upstream_inputs = upstream.get("inputs")
if not isinstance(upstream_inputs, dict):
return True
for upstream_value in upstream_inputs.values():
if _is_prompt_link(upstream_value):
return True
if contains_dynamic_syntax(upstream_value):
return True
return False
def get_wildcards_dir(create: bool = False) -> str:
"""Return the managed wildcard directory inside the settings folder."""
+81
View File
@@ -0,0 +1,81 @@
"""Shared SQLite connection setup for LoRA Manager cache databases.
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
instance on the machine, so two processes can open the same cache file at once.
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
instance that writes while the first is mid-transaction fails with "database is
locked". These settings make concurrent access wait instead of failing, and keep
the write path in WAL so readers are never blocked by a writer.
"""
from __future__ import annotations
import sqlite3
from typing import Any
# How long a connection waits for a competing writer before raising.
CONCURRENT_TIMEOUT_SECONDS = 30.0
# PRAGMAs applied to every cache connection.
#
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
_TUNING_PRAGMAS = (
"PRAGMA busy_timeout = 30000",
"PRAGMA synchronous = NORMAL",
)
def connect_cache_db(
path: str,
*,
readonly: bool = False,
uri: bool = False,
detect_types: int = 0,
row_factory: Any = None,
) -> sqlite3.Connection:
"""Open a cache database with multi-instance-friendly settings.
Args:
path: Database path, or a ``file:`` URI when *uri* is True.
readonly: Open through a read-only URI. Callers still pass the
plain path; the ``mode=ro`` suffix is added here. The
write-oriented tuning pragmas are skipped in that case so a
read-only connection never attempts to change the file.
uri: Treat *path* as a SQLite URI.
detect_types: Forwarded to :func:`sqlite3.connect`.
row_factory: Optional ``row_factory`` for the connection.
Returns:
A configured :class:`sqlite3.Connection`.
"""
if readonly:
if not uri and not path.startswith("file:"):
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(
path,
check_same_thread=False,
uri=uri,
detect_types=detect_types,
timeout=CONCURRENT_TIMEOUT_SECONDS,
)
if row_factory is not None:
conn.row_factory = row_factory
try:
for pragma in _TUNING_PRAGMAS:
# A read-only connection may reject write PRAGMAs; they are not
# needed there anyway.
conn.execute(pragma)
except sqlite3.Error:
# Tuning is best-effort: a connection that cannot set pragmas still
# works, just without the concurrency headroom.
pass
return conn
+114
View File
@@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [
# Card preview image width
CARD_PREVIEW_WIDTH = 480
# Upper bound for a ComfyUI workflow embedded into a recipe preview on the
# opt-in widget save path. The workflow is by far the largest metadata field
# (tens of KB for a simple graph), so an anomalous graph — e.g. one carrying
# base64 blobs in widget values — is skipped instead of inflating the preview.
# Imports are deliberately not capped: their workflow comes from an image the
# user already chose, and preserving it is the point.
MAX_WORKFLOW_EMBED_BYTES = 256 * 1024
# Width for optimized example images
EXAMPLE_IMAGE_WIDTH = 832
@@ -127,6 +135,29 @@ def other_sub_type_folder_keys() -> Dict[str, List[str]]:
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
# Core folder_paths keys every LoRA Manager installation understands.
CORE_FOLDER_PATH_KEYS: List[str] = ["loras", "checkpoints", "unet", "embeddings"]
def folder_path_schema() -> List[Dict[str, Any]]:
"""Ordered schema describing the editable folder_paths keys.
Drives the standalone-only Model Paths settings UI: the frontend renders
one multi-path editor per entry and resolves labels via the
``settings.modelPaths.folderKeys.<key>`` i18n keys, so adding a new model
category is a constants + locale change only. ``sub_type`` lets the UI
hide editors for other-model categories the user has not enabled.
"""
schema: List[Dict[str, Any]] = [
{"key": key, "category": "core", "sub_type": None}
for key in CORE_FOLDER_PATH_KEYS
]
schema.extend(
{"key": folder_key, "category": "other", "sub_type": sub_type}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
)
return schema
def normalize_other_sub_types(value: Any) -> List[str]:
"""Normalize a stored/requested enabled-sub_type list.
@@ -250,6 +281,16 @@ CIVITAI_MODEL_TAGS = [
"action",
]
# Civitai tags that describe the listing rather than the model's content.
# Uploaders can also set these by hand, so they must not be picked as an
# automatic folder name; a user who wants one can still name it explicitly in
# their priority tag list.
CIVITAI_META_TAGS = frozenset(
{
"base model",
}
)
# Default priority tag configuration strings for each model type
DEFAULT_PRIORITY_TAG_CONFIG = {
"lora": ", ".join(CIVITAI_MODEL_TAGS),
@@ -270,6 +311,21 @@ DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"other": "",
}
# Length guards for template placeholders that end up in file and folder names.
# Windows enforces MAX_PATH (260 characters) on the full path and 255 on a
# single path component. A model folder also holds the model file, the
# ".metadata.json" sidecar written by LoRA Manager, preview images and the
# metadata files other tools drop next to the model (for example
# ".civitai.info", which LoRA Manager only reads), so names stay well below
# those limits.
#
# Tags get a much tighter budget than other names: some CivitAI uploaders dump
# their whole keyword list into a single tag (see issue #1119), and such a tag
# is only useful as a folder name after truncation.
MAX_FOLDER_NAME_LENGTH = 100
MAX_PATH_TAG_LENGTH = 50
MAX_FILENAME_STEM_LENGTH = 150
# baseModel values from CivitAI that should be treated as diffusion models (unet)
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset(
@@ -296,12 +352,15 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"PixArt E",
# Video diffusion models
"CogVideoX",
"Flux 3 Video",
"Hunyuan Video",
"LTXV",
"LTXV2",
"LTXV 2.3",
"LTXV 2.5",
"Mochi",
"SVD",
"SVD XT",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
@@ -312,11 +371,19 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
"Wan Video 2.7",
"Wan Video 3.0",
# Other diffusion models
"Boogu",
"Ernie",
"Ernie Turbo",
"HiDream-O1",
"Ming Image Design 0.1",
"Ming Image Design Layer 0.1",
"MiniMax H3",
"Nucleus",
"Qwen",
"Wan Image 2.7",
"ZImageBase",
"ZImageTurbo",
# Krea 2 — loaded via UNETLoader in ComfyUI
@@ -324,6 +391,53 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
]
)
# baseModel values from CivitAI that are true full checkpoints (loaded via
# CheckpointLoaderSimple in ComfyUI). New DiT families appear on CivitAI all
# the time, so download routing inverts the fallback: anything NOT in this
# closed set (and not a known diffusion model) is treated as a diffusion
# model by default (see py/services/download_routing.py). Cross-checked
# against CivitAI's official baseModelRecords (packages/civitai-shared
# src/basemodel.constants.ts), not only the download skip list.
# "Pony V7" is deliberately excluded: verified via the live API (model
# 1901521) to be AuraFlow-architecture shipping .gguf variants (UNETLoader),
# so it follows the unknown-base-model default (diffusion).
CHECKPOINT_BASE_MODELS = frozenset(
[
# Stable Diffusion 1.x
"SD 1.4",
"SD 1.5",
"SD 1.5 LCM",
"SD 1.5 Hyper",
# Stable Diffusion 2.x
"SD 2.0",
"SD 2.0 768",
"SD 2.1",
"SD 2.1 768",
"SD 2.1 Unclip",
# Stable Diffusion 3.x
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
# SDXL and its full-checkpoint derivatives
"SDXL 0.9",
"SDXL 1.0",
"SDXL 1.0 LCM",
"SDXL Lightning",
"SDXL Hyper",
"SDXL Turbo",
"SDXL Distilled",
"Pony",
"Illustrious",
"NoobAI",
# Other full-checkpoint families
"Playground v2",
# Stable Cascade is unCLIP-style but loads via CheckpointLoader
"Stable Cascade",
]
)
# Supported baseModel values for download exclusion settings.
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
+152
View File
@@ -0,0 +1,152 @@
"""Shared directory-browsing logic for HTTP directory pickers."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Tuple
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
_IMAGE_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
def browse_directory(directory_path: str) -> Tuple[Dict[str, Any], int]:
"""Browse a directory and return (payload, http_status).
The payload shape matches the JSON responses historically produced by
``BatchImportHandler.browse_directory``: on success a dict with
``success``, ``current_path``, ``parent_path``, ``directories``,
``image_files``, ``image_count`` and ``directory_count``; on failure a
``{"success": False, "error": ...}`` dict with a 400/403/404/500 status.
"""
if os.name == "nt" and directory_path == WINDOWS_DRIVES_TOKEN:
return _windows_drives_payload(), 200
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return {"success": False, "error": "Access denied to this directory"}, 403
if not path.exists():
return {"success": False, "error": "Directory does not exist"}, 404
if not path.is_dir():
return {"success": False, "error": "Path is not a directory"}, 400
directories = []
image_files = []
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in _IMAGE_EXTENSIONS:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
else:
parent_path = str(path.parent)
return (
{
"success": True,
"current_path": str(path),
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
},
200,
)
except PermissionError:
return {"success": False, "error": "Permission denied"}, 403
except OSError as exc:
return {"success": False, "error": f"Error reading directory: {str(exc)}"}, 500
def _windows_drives_payload() -> Dict[str, Any]:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [{"name": drive, "path": drive, "is_parent": False} for drive in drives]
return {
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
+155 -25
View File
@@ -2,7 +2,7 @@ import inspect
import logging
import os
import re
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional
from ..recipes.constants import GEN_PARAM_KEYS
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
@@ -13,9 +13,20 @@ from ..services.downloader import get_downloader
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from ..utils.video_metadata import get_video_dimensions
logger = logging.getLogger(__name__)
# Placeholder dimensions written when the real ones cannot be determined.
# Kept for backwards compatibility with pre-existing metadata entries.
_DEFAULT_MEDIA_WIDTH = 720
_DEFAULT_MEDIA_HEIGHT = 1280
# Example metadata entries carry a marker: ``customImages`` use their ``id``
# while ``images`` use the positional index. Either way the marker must be a
# plain filename-safe token, never a path fragment.
_ENTRY_MARKER_PATTERN = re.compile(r"^(?:custom_|image_)?([^./\\]+)$")
_preview_service = PreviewAssetService(
metadata_manager=MetadataManager,
downloader_factory=get_downloader,
@@ -66,6 +77,141 @@ def _build_metadata_sync_service(settings_manager: "SettingsManager") -> Metadat
)
def _read_media_dimensions(path: str, is_video: bool) -> tuple[int, int]:
"""Return ``(width, height)`` for an example image or video file.
Videos are read from their container headers (PIL cannot open them) so the
showcase viewer sizes the gallery to the real aspect ratio. Falls back to
the legacy ``720x1280`` placeholder when the dimensions cannot be
determined — e.g. an unreadable file or an exotic codec — which only
affects the displayed aspect ratio, never the file itself.
"""
dimensions = None
if is_video:
dimensions = get_video_dimensions(path)
else:
try:
from PIL import Image
if os.path.exists(path):
with Image.open(path) as img:
dimensions = img.size
except Exception:
dimensions = None
if dimensions:
width, height = dimensions
if width > 0 and height > 0:
return int(width), int(height)
return _DEFAULT_MEDIA_WIDTH, _DEFAULT_MEDIA_HEIGHT
def _is_video_entry(file_path: Optional[str], entry: Mapping[str, Any]) -> bool:
"""Return True when an example entry points at a video file.
The local file extension wins over the recorded ``type`` because files in
the wild are frequently mislabelled (animated WebP saved as ``.mp4``);
``_read_media_dimensions`` handles that correctly either way.
"""
if file_path:
ext = os.path.splitext(file_path)[1].lower()
if ext in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
return True
if ext in SUPPORTED_MEDIA_EXTENSIONS["images"]:
return False
return str(entry.get("type", "")).lower() == "video"
def _resolve_local_file(
entry: Mapping[str, Any],
index: int,
local_files: Mapping[str, str],
) -> Optional[str]:
"""Map a metadata entry onto its example file inside the model folder.
Reads the entry's own marker (``id`` for ``customImages``, positional
``index`` for ``images``) with an anchored regex, so the identifier can
never bleed into a neighbouring filename the way a prefix comparison can.
"""
marker = entry.get("id")
if not isinstance(marker, str) or not marker:
marker = str(index)
match = _ENTRY_MARKER_PATTERN.fullmatch(marker)
if not match:
return None
return local_files.get(match.group(1))
def repair_local_video_dimensions(
metadata: MutableMapping[str, Any],
local_files: Mapping[str, str],
*,
dry_run: bool = False,
) -> int:
"""Backfill real video dimensions for an entry that has local files.
Only entries with an empty ``url`` are considered: those have no remote
source, so the local file is the single source of truth for their size and
rewriting them cannot discard API-supplied data. Entries whose dimensions
already match the file are left byte-identical.
Args:
metadata: Raw metadata payload (mutated in place unless ``dry_run``).
local_files: ``{identifier: path}`` for files present in the model's
example folder, where the identifier is the entry's ``id`` (for
``customImages``) or its positional index (for ``images``).
dry_run: Count the fixes without mutating ``metadata``.
Returns:
The number of entries that were (or would be) repaired.
"""
civitai = metadata.get("civitai")
if not isinstance(civitai, dict):
return 0
repaired = 0
for key in ("customImages", "images"):
entries = civitai.get(key)
if not isinstance(entries, list) or not entries:
continue
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
if entry.get("url", "") != "":
# Remote-backed entry: never rebuilt from local state.
continue
file_path = _resolve_local_file(entry, index, local_files)
if not file_path or not os.path.isfile(file_path):
continue
dimensions = _read_media_dimensions(
file_path, _is_video_entry(file_path, entry)
)
width, height = dimensions
if width <= 0 or height <= 0:
continue
if entry.get("width") == width and entry.get("height") == height:
continue
if not dry_run:
entry["width"] = width
entry["height"] = height
repaired += 1
return repaired
def _get_metadata_sync_service() -> MetadataSyncService:
"""Return the shared metadata sync service, initialising it lazily."""
@@ -231,28 +377,20 @@ class MetadataUpdater:
file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
width, height = _read_media_dimensions(path, is_video)
# Create image metadata entry
image_entry = {
"url": "", # Empty URL as required
"nsfwLevel": 0,
"width": 720, # Default dimensions
"height": 1280,
"width": width,
"height": height,
"type": "video" if is_video else "image",
"meta": None,
"hasMeta": False,
"hasPositivePrompt": False
}
# If it's an image, try to get actual dimensions (optional enhancement)
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
images.append(image_entry)
# Update the model's civitai.images field
@@ -322,13 +460,15 @@ class MetadataUpdater:
file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
width, height = _read_media_dimensions(path, is_video)
# Create image metadata entry
image_entry = {
"url": "", # Empty URL as requested
"id": short_id,
"nsfwLevel": 0,
"width": 720, # Default dimensions
"height": 1280,
"width": width,
"height": height,
"type": "video" if is_video else "image",
"meta": None,
"hasMeta": False,
@@ -353,16 +493,6 @@ class MetadataUpdater:
except Exception as e:
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
# If it's an image, try to get actual dimensions
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
# Append to existing customImages array
custom_images.append(image_entry)
+146 -2
View File
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
)
from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.example_images_metadata import update_cache_from_metadata
from ..utils.example_images_metadata import (
repair_local_video_dimensions,
update_cache_from_metadata,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
# Example files worth inspecting during the dimension repair.
_REPAIRABLE_EXTENSIONS = frozenset(
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
)
class _SettingsProxy:
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
if from_version < 2 and to_version >= 2:
await ExampleImagesMigration._migrate_to_v2(model_folders)
if from_version < 3 and to_version >= 3:
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
# Update version in progress file
progress_file = os.path.join(example_images_path, '.download_progress.json')
try:
@@ -438,3 +449,136 @@ class ExampleImagesMigration:
migration_errors += 1
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
@staticmethod
def _build_local_file_map(folder):
"""Map entry markers to their files inside a model's example folder.
Keys are the marker alone (``custom_<id>`` → ``<id>``,
``image_<index>`` → ``<index>``) so they line up with the metadata
entries' ``id``/positional index without any prefix ambiguity.
"""
local_files = {}
try:
entries = os.listdir(folder)
except OSError as exc:
logger.debug("Could not list example folder %s: %s", folder, exc)
return local_files
for name in entries:
stem, ext = os.path.splitext(name)
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
continue
if stem.startswith("custom_"):
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
elif stem.startswith("image_"):
local_files[stem[len("image_"):]] = os.path.join(folder, name)
return local_files
@staticmethod
async def _find_scanner_for_hash(model_hash):
"""Return the scanner owning ``model_hash``, or ``None``."""
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
if scanner is None:
continue
try:
if scanner.has_hash(model_hash):
return scanner
except Exception as exc: # pragma: no cover - defensive
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
return None
@staticmethod
async def _migrate_to_v3(example_images_path, model_folders):
"""Backfill real dimensions for locally imported example videos.
Imported videos were stored with a hardcoded ``720x1280`` placeholder
(issue #1115), so landscape clips were rendered inside a portrait
container. Only entries with an empty ``url`` are touched — those have
no remote source, which makes the local file authoritative and the
rewrite lossless. Entries already carrying the right size are left
untouched, so re-running this migration is a no-op.
This runs once per library via the ``naming_version`` gate in
``run_migrations``; it is deliberately not wired into any request path.
"""
repaired_entries = 0
updated_models = 0
migration_errors = 0
logger.info(
"Starting v3 migration (local example video dimensions) for %d model folders",
len(model_folders),
)
for folder in model_folders:
try:
model_hash = os.path.basename(folder)
if not model_hash or len(model_hash) != 64:
continue
local_files = ExampleImagesMigration._build_local_file_map(folder)
if not local_files:
continue
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
if scanner is None:
logger.debug(
"Model %s not found in any scanner cache, skipping dimension repair",
model_hash,
)
continue
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get("sha256") == model_hash:
model_data = item
break
if not model_data:
continue
file_path = model_data.get("file_path")
if not file_path:
continue
payload = await MetadataManager.load_metadata_payload(file_path)
if not isinstance(payload, dict):
continue
repaired = repair_local_video_dimensions(payload, local_files)
if repaired <= 0:
continue
# The model cache shape differs from the on-disk payload, so
# persist the file first and let the cache sync re-read it.
await MetadataManager.save_metadata(file_path, payload)
await update_cache_from_metadata(scanner, file_path, payload)
repaired_entries += repaired
updated_models += 1
except Exception as exc:
logger.error(
"Failed to repair example video dimensions for %s: %s",
folder,
exc,
)
migration_errors += 1
logger.info(
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
"with %d error(s)",
repaired_entries,
updated_models,
migration_errors,
)
+42 -17
View File
@@ -26,6 +26,42 @@ class ExampleImagesValidationError(ExampleImagesImportError):
class ExampleImagesProcessor:
"""Processes and manipulates example images"""
@staticmethod
async def _model_scanners() -> list:
"""Return every scanner whose models can carry example images."""
return [
await ServiceRegistry.get_lora_scanner(),
await ServiceRegistry.get_checkpoint_scanner(),
await ServiceRegistry.get_embedding_scanner(),
await ServiceRegistry.get_other_scanner(),
]
@staticmethod
async def resolve_hash_for_file_path(file_path: str) -> str:
"""Return the SHA256 for a cached model file, computing it on demand.
Checkpoint and Other scanners record ``hash_status="pending"`` with an
empty sha256 until something needs the hash; importing example images
is such a moment because example folders are keyed by hash. Returns
``''`` when the file is unknown to every scanner or hashing failed.
"""
if not file_path:
return ''
normalized = file_path.replace(os.sep, '/')
for scanner in await ExampleImagesProcessor._model_scanners():
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('file_path') != normalized:
continue
sha256 = (item.get('sha256') or '').strip()
if sha256 and item.get('hash_status', 'completed') == 'completed':
return sha256
calculate = getattr(scanner, 'calculate_hash_for_model', None)
if calculate is None:
return sha256
return (await calculate(normalized)) or ''
return ''
@staticmethod
def generate_short_id(length=8):
"""Generate a short random alphanumeric identifier"""
@@ -450,15 +486,11 @@ class ExampleImagesProcessor:
raise ExampleImagesValidationError('No example images path configured')
# Find the model and get current metadata
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
model_data = None
scanner = None
# Check both scanners to find the model
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
# Check every scanner to find the model
for scan_obj in await ExampleImagesProcessor._model_scanners():
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
@@ -536,6 +568,7 @@ class ExampleImagesProcessor:
'errors': errors,
'regular_images': regular_images,
'custom_images': custom_images,
'model_hash': model_hash,
"model_file_path": model_data.get('file_path', ''),
}
@@ -577,15 +610,11 @@ class ExampleImagesProcessor:
}, status=400)
# Find the model and get current metadata
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
model_data = None
scanner = None
# Check both scanners to find the model
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
# Check every scanner to find the model
for scan_obj in await ExampleImagesProcessor._model_scanners():
if scan_obj.has_hash(model_hash):
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
@@ -737,14 +766,10 @@ class ExampleImagesProcessor:
)
try:
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
model_data = None
scanner = None
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
for scan_obj in await ExampleImagesProcessor._model_scanners():
if scan_obj.has_hash(model_hash):
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
+150 -22
View File
@@ -177,6 +177,11 @@ class ExifUtils:
return brotli_meta
with Image.open(image_path) as img:
# PNG text chunks may legally follow IDAT. Pillow reads those only
# when loading the image, so inspecting info immediately after open
# can incorrectly report a metadata-free image.
if img.format == "PNG":
img.load()
info = getattr(img, "info", {}) or {}
if "parameters" in info:
@@ -193,6 +198,18 @@ class ExifUtils:
exif[piexif.ExifIFD.UserComment]
)
# ComfyUI's WebP exporter stores JSON in EXIF Make/Model with
# prompt:/workflow: prefixes instead of UserComment.
exif = img.getexif()
for tag in (piexif.ImageIFD.Make, piexif.ImageIFD.Model):
text = ExifUtils._decode_exif_text(exif.get(tag))
if not text:
continue
for key in ("prompt", "workflow"):
prefix = key + ":"
if text.startswith(prefix) and not metadata[key]:
metadata[key] = text[len(prefix):].rstrip("\x00")
try:
exif_dict = piexif.load(image_path)
except Exception as e:
@@ -324,29 +341,125 @@ class ExifUtils:
metadata_fields = ExifUtils._load_structured_metadata(image_path)
metadata_fields["parameters"] = metadata
with Image.open(image_path) as img:
img_format = img.format
if img_format == "PNG":
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
img.save(image_path, format="PNG", pnginfo=png_info)
return image_path
exif_bytes = ExifUtils._build_exif_bytes(
metadata_fields, img.info.get("exif")
)
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
if img_format == "WEBP":
save_kwargs["quality"] = 85
img.save(image_path, format=img_format, **save_kwargs)
return image_path
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
except Exception as e:
logger.error(f"Error updating metadata in {image_path}: {e}")
return image_path
@staticmethod
def _write_structured_metadata(
image_path: str, metadata_fields: dict[str, Optional[str]]
) -> str:
"""Write structured metadata fields back into an image.
PNG keeps them as text chunks (``parameters``/``prompt``/``workflow``);
every other supported container stores them in EXIF, where the workflow
travels in ``ImageDescription`` behind a ``Workflow:`` prefix (see
:meth:`_build_exif_bytes`).
"""
with Image.open(image_path) as img:
img_format = img.format
if img_format == "PNG":
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
img.save(image_path, format="PNG", pnginfo=png_info)
return image_path
exif_bytes = ExifUtils._build_exif_bytes(
metadata_fields, img.info.get("exif")
)
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
if img_format == "WEBP":
save_kwargs["quality"] = 85
img.save(image_path, format=img_format, **save_kwargs)
return image_path
@staticmethod
def normalise_workflow(workflow: Any) -> Optional[str]:
"""Coerce a workflow payload into the JSON string metadata form.
Accepts the string form stored in image chunks as well as already
decoded dict/list payloads; anything else yields ``None``.
"""
if isinstance(workflow, str):
return workflow or None
if isinstance(workflow, (dict, list)):
try:
return json.dumps(workflow)
except (TypeError, ValueError):
return None
return None
@staticmethod
def _merge_workflow(
metadata_fields: Optional[dict[str, Optional[str]]], workflow: Any
) -> Optional[dict[str, Optional[str]]]:
"""Add a caller-supplied workflow to extracted metadata fields.
Returns ``metadata_fields`` untouched when there is nothing to add, and
never overwrites a workflow the source image already carries.
"""
workflow_json = ExifUtils.normalise_workflow(workflow)
if not workflow_json:
return metadata_fields
if metadata_fields is None:
metadata_fields = {
"parameters": None,
"prompt": None,
"workflow": None,
"comment": None,
}
if not metadata_fields.get("workflow"):
metadata_fields["workflow"] = workflow_json
return metadata_fields
@staticmethod
def embed_workflow(image_path: str, workflow: Any) -> str:
"""Embed a ComfyUI workflow into an image that does not carry one.
Recipe imports recover the workflow from the source's original
rendition (CivitAI's optimized preview is re-encoded and metadata-free)
and hand it over as data rather than as image bytes. Images that
already embed a workflow are left untouched.
WebP files are patched at the byte level so preview pixels are not
re-encoded a second time.
"""
workflow_json = ExifUtils.normalise_workflow(workflow)
if not image_path or not workflow_json:
return image_path
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path
try:
metadata_fields = ExifUtils._load_structured_metadata(image_path)
if metadata_fields.get("workflow"):
return image_path
metadata_fields["workflow"] = workflow_json
if ext == '.webp':
try:
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
with open(image_path, "rb") as file_obj:
image_bytes = file_obj.read()
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
with open(image_path, "wb") as file_obj:
file_obj.write(updated)
return image_path
except ValueError:
# Container without an EXIF chunk: fall through to a full
# rewrite so the workflow is still embedded.
pass
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
except Exception as e:
logger.error(f"Error embedding workflow in {image_path}: {e}")
return image_path
@staticmethod
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
"""Append recipe metadata to an image's EXIF data
@@ -533,7 +646,7 @@ class ExifUtils:
return None
@staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False, workflow=None):
"""
Optimize an image by resizing and converting to WebP format
@@ -543,10 +656,19 @@ class ExifUtils:
format: Output format (default: webp)
quality: Output quality (0-100)
preserve_metadata: Whether to preserve EXIF metadata
workflow: Optional ComfyUI workflow (JSON string, dict or list) to
embed when the source image does not carry one. Used by import
paths that recover the workflow from a higher-fidelity source
(e.g. CivitAI's original rendition) while the preview pixels
come from a metadata-free optimized rendition.
Returns:
Tuple of (optimized_image_data, extension)
"""
# A supplied workflow can only survive when metadata is embedded, so
# treat it as an implicit request for preservation.
if workflow is not None:
preserve_metadata = True
try:
if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower()
@@ -610,6 +732,12 @@ class ExifUtils:
logger.warning(f"Failed to extract metadata, continuing without it: {e}")
# Continue without metadata
# Merge in a workflow recovered elsewhere (e.g. from CivitAI's
# original rendition). The source image wins when it already has
# one, and this is what lets the metadata-free optimized preview
# still end up with the workflow embedded.
metadata_fields = ExifUtils._merge_workflow(metadata_fields, workflow)
# Calculate new height to maintain aspect ratio
width, height = img.size
new_height = int(height * (target_width / width))
@@ -669,8 +797,8 @@ class ExifUtils:
temp_file.write(optimized_data)
try:
ExifUtils.update_image_metadata(
temp_path, metadata_fields.get("parameters") or ""
ExifUtils._write_structured_metadata(
temp_path, metadata_fields
)
# Read back the file
with open(temp_path, 'rb') as f:
+146
View File
@@ -0,0 +1,146 @@
"""Cross-process advisory locking for shared LoRA Manager state.
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
ComfyUI installs pointed at the same settings directory) can open the same cache
database. SQLite serializes individual statements, but it cannot make a
read-modify-write *sequence* atomic across processes: two full-table cache
replacements can interleave so that one process's snapshot overwrites the
other's.
This module provides a small advisory file lock for those sequences. It is
deliberately non-fatal: if locking is unavailable or the wait times out, callers
keep working with SQLite's own ``busy_timeout`` as the fallback.
"""
from __future__ import annotations
import logging
import os
import time
logger = logging.getLogger(__name__)
# How long to wait for another process to release the lock before giving up.
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
_POLL_INTERVAL_SECONDS = 0.05
# Windows byte-range locks; fcntl.flock on POSIX.
try: # pragma: no cover - platform dependent
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
try: # pragma: no cover - Windows only
import msvcrt
except ImportError: # pragma: no cover - POSIX
msvcrt = None # type: ignore[assignment]
class FileLockUnavailable(RuntimeError):
"""Raised when the lock could not be acquired within the timeout."""
def lock_path_for(db_path: str) -> str:
"""Return the sibling lock file path used for *db_path*."""
absolute = os.path.abspath(db_path)
directory = os.path.dirname(absolute)
if not directory:
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
class CrossProcessLock:
"""A best-effort advisory lock backed by a lock file.
The lock file is a sibling of the guarded resource and is never deleted:
unlinking it would let a second process create a fresh inode and lock that
instead, defeating mutual exclusion.
"""
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
self.path = path
self.timeout = timeout
self._handle = None
def acquire(self) -> bool:
"""Try to take the lock, waiting up to ``timeout`` seconds.
Returns:
True when the lock is held (including when another lock is already
held by *this* process — the calls are not reentrant, so callers must
not nest them). False when locking is unsupported or timed out; the
caller should proceed and rely on the SQLite busy timeout instead.
"""
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
return False
os.makedirs(os.path.dirname(self.path), exist_ok=True)
try:
handle = open(self.path, "a+b")
except OSError as exc:
logger.debug("Could not open lock file %s: %s", self.path, exc)
return False
deadline = time.monotonic() + max(0.0, self.timeout)
while True:
if self._try_lock(handle):
self._handle = handle
return True
if time.monotonic() >= deadline:
handle.close()
return False
time.sleep(_POLL_INTERVAL_SECONDS)
def release(self) -> None:
"""Release the lock if held. Safe to call more than once."""
handle = self._handle
if handle is None:
return
self._handle = None
try:
self._unlock(handle)
except OSError as exc: # pragma: no cover - defensive
logger.debug("Failed to release lock %s: %s", self.path, exc)
finally:
try:
handle.close()
except OSError: # pragma: no cover - defensive
pass
def __enter__(self) -> "CrossProcessLock":
self.acquire()
return self
def __exit__(self, *_exc_info: object) -> None:
self.release()
# -- platform primitives -------------------------------------------------
def _try_lock(self, handle) -> bool:
if fcntl is not None:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except OSError:
return False
if msvcrt is not None: # pragma: no cover - Windows
try:
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
return True
except OSError:
return False
return False
def _unlock(self, handle) -> None:
if fcntl is not None:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
return
if msvcrt is not None: # pragma: no cover - Windows
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
+578
View File
@@ -0,0 +1,578 @@
"""Offline extraction of reusable generation settings from image metadata.
Embedded graphs are data: only explicit adapters are followed, never executed.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass, field
from typing import Any
class MetadataError(ValueError):
"""Metadata cannot be interpreted without a user decision."""
@dataclass
class GenerationMetadata:
values: dict[str, Any] = field(default_factory=dict)
loras: list[tuple[str, float, float]] = field(default_factory=list)
issues: dict[str, str] = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
resource_hints: list[dict[str, Any]] = field(default_factory=list)
LORA_PATTERN = re.compile(r"<lora:([^<>]+?):([+-]?[\d.eE]+)(?::([+-]?[\d.eE]+))?>", re.I)
SAMPLERS = {
"euler": "euler", "euler a": "euler_ancestral", "heun": "heun",
"lms": "lms", "dpm2": "dpm_2", "dpm2 a": "dpm_2_ancestral",
"dpm++ 2m": "dpmpp_2m", "dpm++ 2s a": "dpmpp_2s_ancestral",
"dpm++ sde": "dpmpp_sde", "dpm++ 2m sde": "dpmpp_2m_sde",
"dpm++ 3m sde": "dpmpp_3m_sde", "ddim": "ddim", "uni pc": "uni_pc",
}
def finite_number(value: Any) -> float:
if isinstance(value, bool):
raise MetadataError("Boolean is not a numeric generation setting")
number = float(value)
if not math.isfinite(number):
raise MetadataError("Generation settings must be finite numbers")
return number
def split_lora_tags(text: str) -> tuple[str, list[tuple[str, float, float]]]:
loras = []
def remove(match: re.Match[str]) -> str:
model = finite_number(match[2])
clip = finite_number(match[3]) if match[3] is not None else model
loras.append((match[1].strip(), model, clip))
return ""
clean = LORA_PATTERN.sub(remove, text).strip()
if re.search(r"<lora:", clean, re.I):
raise MetadataError("Malformed LoRA directive; correct the prompt with overrides_json")
return clean, loras
def _json_object(value: Any) -> dict[str, Any]:
if isinstance(value, str):
if len(value) > 16 * 1024 * 1024:
raise MetadataError("Metadata exceeds the 16 MiB parsing limit")
value = json.loads(value)
if not isinstance(value, dict):
raise MetadataError("Expected a metadata JSON object")
return value
class GraphReader:
"""Follow a selected sampler's inputs without mixing workflow branches."""
def __init__(self, graph: dict[str, Any], inactive_ids: set[str] | None = None) -> None:
if len(graph) > 10000:
raise MetadataError("Workflow exceeds the 10,000 node parsing limit")
self.graph = {str(key): value for key, value in graph.items()}
self.inactive_ids = inactive_ids or set()
self.result = GenerationMetadata()
def node(self, link: Any, seen: tuple[str, ...]) -> tuple[str, str, dict[str, Any]]:
if not (isinstance(link, list) and len(link) == 2 and isinstance(link[1], int)):
raise MetadataError("Expected a workflow connection")
node_id = str(link[0])
if node_id in seen or len(seen) >= 100:
raise MetadataError("Cyclic or excessively deep workflow connection")
node = self.graph.get(node_id)
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
raise MetadataError(f"Missing or malformed node {node_id}")
return node_id, node.get("class_type", ""), node["inputs"]
def scalar(self, value: Any, seen: tuple[str, ...] = ()) -> Any:
if not isinstance(value, list):
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
return value
raise MetadataError("Missing or non-scalar setting")
node_id, kind, inputs = self.node(value, seen)
if kind == "Input Parameters (Image Saver)":
keys = ("seed", "steps", "cfg", "sampler", "scheduler", "denoise")
if not 0 <= value[1] < len(keys):
raise MetadataError(f"Unsupported parameter output {value[1]} on {node_id}")
return self.scalar(inputs.get(keys[value[1]]), (*seen, node_id))
if value[1] != 0:
raise MetadataError(f"Unsupported output {value[1]} on {kind} ({node_id})")
keys = {
"PrimitiveNode": "value", "PrimitiveInt": "value", "PrimitiveFloat": "value",
"PrimitiveString": "value", "PrimitiveStringMultiline": "value",
"easy int": "value", "easy float": "value", "easy string": "value",
"Seed (rgthree)": "seed",
"Sampler Selector (Image Saver)": "sampler_name",
"Scheduler Selector (Image Saver)": "scheduler",
"Text (LoraManager)": "text", "Reroute": "value",
}
if kind not in keys:
raise MetadataError(f"Unsupported value node {kind} ({node_id})")
resolved = self.scalar(inputs.get(keys[kind]), (*seen, node_id))
if kind == "Text (LoraManager)" and isinstance(resolved, str) and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", resolved):
raise MetadataError("Dynamic text expansion requires an explicit prompt override")
return resolved
def text(self, link: Any, seen: tuple[str, ...] = ()) -> str:
node_id, kind, inputs = self.node(link, seen)
if link[1] != 0:
raise MetadataError(f"Unsupported conditioning output on {kind} ({node_id})")
if kind in ("CLIPTextEncode", "Prompt (LoraManager)"):
if kind == "Prompt (LoraManager)" and any(k.startswith("trigger_words") for k in inputs):
raise MetadataError("Prompt has dynamic trigger words; provide an explicit prompt override")
value = self.scalar(inputs.get("text"), (*seen, node_id))
if not isinstance(value, str):
raise MetadataError("Prompt is not text")
if kind == "Prompt (LoraManager)" and re.search(r"__[^\n]+?__|\{[^{}]*\|[^{}]*\}", value):
raise MetadataError("Dynamic prompt expansion cannot be recovered from source text; provide an explicit prompt override")
return value
if kind in ("CLIPTextEncodeSDXL", "CLIPTextEncodeFlux"):
keys = ("text_g", "text_l") if kind == "CLIPTextEncodeSDXL" else ("clip_l", "t5xxl")
texts = [self.scalar(inputs.get(key), (*seen, node_id)) for key in keys]
if texts[0] != texts[1] or not isinstance(texts[0], str):
raise MetadataError(f"{kind} has distinct encoder prompts; a single string cannot reproduce it")
self.result.notes.append(f"{kind}: restore architecture-specific conditioning separately.")
return texts[0]
if kind == "ConditioningZeroOut":
raise MetadataError("Zeroed conditioning is not equivalent to encoding an empty prompt")
raise MetadataError(f"Unsupported conditioning node {kind} ({node_id}); use a prompt override")
def widget_loras(self, value: Any) -> list[tuple[str, float, float]]:
if isinstance(value, dict):
value = value.get("__value__")
if isinstance(value, list) and len(value) == 1 and isinstance(value[0], list):
value = value[0]
if not isinstance(value, list):
raise MetadataError("Unsupported LoRA widget data")
entries = []
for item in value:
if not isinstance(item, dict):
raise MetadataError("Malformed LoRA widget entry")
if item.get("active", False):
name = item.get("name")
if not isinstance(name, str) or not name:
raise MetadataError("LoRA name is missing")
strength = finite_number(item.get("strength"))
entries.append((name, strength, finite_number(item.get("clipStrength", strength))))
return entries
def stack(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float, float]]:
node_id, kind, inputs = self.node(link, seen)
if link[1] != 0:
raise MetadataError("Unsupported LoRA stack output")
seen = (*seen, node_id)
if kind == "Lora Stacker (LoraManager)":
previous = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
return previous + self.widget_loras(inputs.get("loras", []))
if kind == "Lora Stack Combiner (LoraManager)":
entries = []
keys = [key for key in inputs if re.fullmatch(r"lora_stack\d+", key)]
for key in sorted(keys, key=lambda key: int(key[len("lora_stack"):])):
entries.extend(self.stack(inputs[key], seen))
return entries
raise MetadataError(f"Unsupported LoRA stack node {kind} ({node_id})")
def model(self, link: Any, seen: tuple[str, ...] = ()) -> None:
node_id, kind, inputs = self.node(link, seen)
if link[1] != 0:
raise MetadataError("Unsupported model output")
seen = (*seen, node_id)
loaders = {
"CheckpointLoaderSimple": ("checkpoint_name", "ckpt_name"),
"CheckpointLoader": ("checkpoint_name", "ckpt_name"),
"Checkpoint Loader (LoraManager)": ("checkpoint_name", "ckpt_name"),
"UNETLoader": ("unet_name", "unet_name"),
"Unet Loader (LoraManager)": ("unet_name", "unet_name"),
}
if kind in loaders:
output, key = loaders[kind]
self.result.values[output] = self.scalar(inputs.get(key), seen)
return
if kind in ("LoraLoader", "LoraLoaderModelOnly", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)"):
self.model(inputs.get("model"), seen)
if "lora_stack" in inputs:
self.result.loras.extend(self.stack(inputs["lora_stack"], seen))
if kind in ("LoraLoader", "LoraLoaderModelOnly"):
strength = finite_number(self.scalar(inputs.get("strength_model"), seen))
clip = 0.0 if kind == "LoraLoaderModelOnly" else finite_number(self.scalar(inputs.get("strength_clip"), seen))
name = self.scalar(inputs.get("lora_name"), seen)
if not isinstance(name, str):
raise MetadataError("LoRA name is not text")
self.result.loras.append((name, strength, clip))
elif kind == "LoRA Text Loader (LoraManager)":
_, entries = split_lora_tags(self.scalar(inputs.get("lora_syntax"), seen))
self.result.loras.extend(entries)
else:
self.result.loras.extend(self.widget_loras(inputs.get("loras", [])))
return
raise MetadataError(f"Unsupported model node {kind} ({node_id}); model/LoRA chain is incomplete")
def clip_loras(self, link: Any, seen: tuple[str, ...] = ()) -> list[tuple[str, float]]:
"""Check that prompt CLIP branches actually use the recovered LoRA stack."""
node_id, kind, inputs = self.node(link, seen)
seen = (*seen, node_id)
if kind in ("CheckpointLoaderSimple", "CheckpointLoader", "Checkpoint Loader (LoraManager)") and link[1] == 1:
return []
if kind in ("CLIPLoader", "DualCLIPLoader", "TripleCLIPLoader") and link[1] == 0:
return []
if kind in ("LoraLoader", "Lora Loader (LoraManager)", "LoraLoaderLM", "LoRA Text Loader (LoraManager)") and link[1] == 1:
previous = self.clip_loras(inputs.get("clip"), seen)
entries = self.stack(inputs["lora_stack"], seen) if "lora_stack" in inputs else []
if kind == "LoraLoader":
entries.append((self.scalar(inputs.get("lora_name")), 0, finite_number(self.scalar(inputs.get("strength_clip")))))
elif kind == "LoRA Text Loader (LoraManager)":
_, parsed = split_lora_tags(self.scalar(inputs.get("lora_syntax")))
entries.extend(parsed)
else:
entries.extend(self.widget_loras(inputs.get("loras", [])))
return previous + [(name, clip) for name, _, clip in entries if clip != 0]
raise MetadataError(f"Unsupported CLIP branch {kind} ({node_id}); restore text encoder/conditioning separately")
def select_sampler(self, sampler_id: str) -> str:
candidates = [key for key, node in self.graph.items() if isinstance(node, dict) and node.get("class_type") in ("KSampler", "KSamplerAdvanced", "SamplerCustomAdvanced")
and node.get("mode", 0) == 0
and not any(key == prefix or key.startswith(prefix + ":") for prefix in self.inactive_ids)]
selector = sampler_id.strip()
if selector in candidates:
return selector
# ComfyUI API prompts expand native subgraphs into colon-qualified IDs.
# Accept slash paths too, as well as an unambiguous container/leaf ID.
selector = selector.replace("/", ":")
if selector in self.graph and selector not in candidates:
raise MetadataError(f"Sampler {selector} is muted, bypassed or unsupported; active sampler IDs: {', '.join(candidates) or 'none'}")
if selector in candidates:
return selector
matches = candidates if not selector else [key for key in candidates if key.startswith(selector + ":") or key.endswith(":" + selector)]
if len(matches) == 1:
return matches[0]
choices = ", ".join(matches or candidates) or "none"
raise MetadataError(f"Choose a unique sampler_node_id; supported sampler IDs: {choices}")
def custom_sampler_inputs(self, inputs: dict[str, Any]) -> dict[str, Any]:
"""Adapt the core advanced sampling pipeline without executing any nodes."""
result = {"latent_image": inputs.get("latent_image")}
adapters = (
("noise", {"RandomNoise": {"seed": "noise_seed"}}, ("seed",)),
("guider", {
"CFGGuider": {"cfg": "cfg", "model": "model", "positive": "positive", "negative": "negative"},
"BasicGuider": {"model": "model", "positive": "conditioning"},
}, ("cfg", "model", "positive", "negative")),
("sigmas", {"BasicScheduler": {"steps": "steps", "scheduler": "scheduler", "denoise": "denoise"}}, ("steps", "scheduler", "denoise")),
)
for key, kinds, fields in adapters:
try:
link = inputs.get(key)
node_id, kind, upstream = self.node(link, ())
if link[1] != 0 or kind not in kinds:
raise MetadataError(f"Unsupported {key} node {kind} ({node_id})")
for output, source in kinds[kind].items():
result[output] = upstream.get(source)
if kind == "BasicGuider":
result["cfg"] = 1.0
self.result.issues["negative"] = "BasicGuider has no negative conditioning; restore that architecture-specific setup separately"
except MetadataError as exc:
for field in fields:
self.result.issues[field] = str(exc)
try:
link = inputs.get("sampler")
seen = ()
while True:
node_id, kind, upstream = self.node(link, seen)
seen = (*seen, node_id)
if link[1] != 0:
raise MetadataError("Unsupported sampler output")
if kind == "KSamplerSelect":
result["sampler_name"] = upstream.get("sampler_name")
break
if kind == "DetailDaemonSamplerNode":
self.result.issues["sampler_effects"] = "Detail Daemon modifies sampling; recovered base sampler settings do not reproduce this effect"
link = upstream.get("sampler")
continue
raise MetadataError(f"Unsupported sampler node {kind} ({node_id})")
except MetadataError as exc:
self.result.issues["sampler_name"] = str(exc)
return result
def read(self, sampler_id: str) -> GenerationMetadata:
sampler_id = self.select_sampler(sampler_id)
node = self.graph[sampler_id]
inputs = node.get("inputs")
if not isinstance(inputs, dict):
raise MetadataError("Malformed sampler inputs")
self.result.notes.append(f"ComfyUI API graph; sampler {sampler_id} ({node['class_type']}).")
if node["class_type"] == "SamplerCustomAdvanced":
inputs = self.custom_sampler_inputs(inputs)
for output, key in {"seed": "noise_seed" if node["class_type"] == "KSamplerAdvanced" else "seed", "steps": "steps", "cfg": "cfg", "sampler_name": "sampler_name", "scheduler": "scheduler"}.items():
try:
self.result.values[output] = self.scalar(inputs.get(key))
except (ValueError, TypeError) as exc:
self.result.issues[output] = str(exc)
if node["class_type"] == "KSamplerAdvanced":
self.result.issues["denoise"] = "KSamplerAdvanced start/end/noise settings cannot be represented by denoise alone"
else:
try:
self.result.values["denoise"] = self.scalar(inputs.get("denoise", 1.0))
except (ValueError, TypeError) as exc:
self.result.issues["denoise"] = str(exc)
for key in ("positive", "negative"):
try:
self.result.values[key] = self.text(inputs.get(key))
except (ValueError, TypeError) as exc:
self.result.issues[key] = str(exc)
try:
self.model(inputs.get("model"))
except (ValueError, TypeError) as exc:
self.result.issues["model"] = str(exc)
self.result.issues["loras"] = "Model/LoRA chain could not be fully recovered"
expected_clip = [(name, clip) for name, _, clip in self.result.loras if clip != 0]
for polarity in ("positive", "negative"):
if polarity in self.result.issues:
continue
try:
_, _, encoder = self.node(inputs.get(polarity), ())
if "clip" in encoder:
actual_clip = self.clip_loras(encoder["clip"])
if actual_clip != expected_clip:
self.result.issues["loras"] = "Model and prompt CLIP branches use different LoRAs; explicitly choose a reusable stack with a loras override"
except MetadataError as exc:
self.result.issues[polarity] = str(exc)
try:
_, kind, latent = self.node(inputs.get("latent_image"), ())
if kind in ("EmptyLatentImage", "EmptySD3LatentImage"):
for key in ("width", "height"):
self.result.values[key] = self.scalar(latent.get(key))
else:
self.result.notes.append("Latent dimensions unavailable; using image dimensions. Restore the original latent/img2img setup separately.")
except MetadataError:
self.result.notes.append("Latent dimensions unavailable; using image dimensions.")
return self.result
def _parameter_fields(text: str) -> dict[str, str]:
"""Split multiline parameters without splitting JSON objects or quoted names."""
parts = []
start = 0
depth = 0
quoted = False
escaped = False
for index, char in enumerate(text):
if quoted:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
quoted = False
elif char == '"':
quoted = True
elif char in "[{":
depth += 1
elif char in "]}":
depth = max(0, depth - 1)
elif char == "," and depth == 0:
parts.append(text[start:index])
start = index + 1
parts.append(text[start:])
fields = {}
for part in parts:
match = re.match(r"^\s*([\w ]+):\s*([\s\S]*)$", part)
if match:
fields[match[1].strip()] = match[2].strip()
return fields
def _parameter_loras(fields: dict[str, str], result: GenerationMetadata) -> None:
for key in ("positive", "negative"):
result.values[key], entries = split_lora_tags(result.values[key])
result.loras.extend(entries)
try:
hashes = json.loads(fields.get("Hashes", "{}"))
resources = json.loads(fields.get("Civitai resources", "[]"))
if not isinstance(hashes, dict) or not isinstance(resources, list):
raise ValueError("Invalid resource containers")
except (ValueError, TypeError) as exc:
result.issues["loras"] = f"Malformed embedded resource metadata: {exc}"
return
names = [(key[5:], value) for key, value in hashes.items() if key.upper().startswith("LORA:")]
weighted = [item for item in resources if isinstance(item, dict) and "weight" in item]
result.resource_hints = [{"name": name, "hash": value} for name, value in names]
if result.loras:
if len(names) == 1 and len(weighted) == 1:
strength = finite_number(weighted[0]["weight"])
single = (names[0][0], strength, strength)
if len(result.loras) > 1 and all(entry == single for entry in result.loras):
result.loras = [single]
result.notes.append("Repeated identical prompt tags collapsed to the single LoRA recorded in resource metadata.")
return
# Without a catalog there is no general mapping between a hash name and
# a Civitai version ID. One name and one resource are unambiguous; multiple
# resources must not be paired by their incidental JSON ordering.
if len(names) == 1 and len(weighted) == 1:
strength = finite_number(weighted[0]["weight"])
result.loras.append((names[0][0], strength, strength))
result.resource_hints[0].update(weighted[0])
result.notes.append("LoRA name recovered from Hashes and its sole resource weight; separate CLIP strength was not saved, so model strength is used for both.")
elif names or weighted:
result.issues["loras"] = "LoRA resource names/weights cannot be paired unambiguously without a catalog; provide an explicit loras override"
def parse_parameters(text: str) -> GenerationMetadata:
match = re.search(r"^Steps:\s*\d+.*$", text, re.M)
if not match:
raise MetadataError("No supported A1111/Forge generation parameters found")
prompt = text[:match.start()].strip()
positive, separator, negative = prompt.partition("Negative prompt:")
fields = _parameter_fields(text[match.start():])
result = GenerationMetadata(notes=["A1111/Forge parameters."])
result.values.update(positive=positive.strip(), negative=negative.strip() if separator else "")
for output, key in {"seed": "Seed", "steps": "Steps", "cfg": "CFG scale", "sampler_name": "Sampler", "scheduler": "Schedule type", "checkpoint_name": "Model", "denoise": "Denoising strength"}.items():
if key in fields:
result.values[output] = fields[key].strip().strip('"')
result.values.setdefault("denoise", 1.0)
size = re.fullmatch(r"(\d+)x(\d+)", fields.get("Size", "").strip())
if size:
result.values.update(width=int(size[1]), height=int(size[2]))
sampler = str(result.values.get("sampler_name", "")).lower().strip()
for suffix, scheduler in (
(" sgm uniform", "sgm_uniform"), (" sgm_uniform", "sgm_uniform"),
(" karras", "karras"), (" exponential", "exponential"),
(" simple", "simple"), ("_simple", "simple"),
(" normal", "normal"), ("_normal", "normal"), ("_sgm_uniform", "sgm_uniform"),
(" ddim uniform", "ddim_uniform"),
(" beta", "beta"), (" linear quadratic", "linear_quadratic"),
):
if sampler.endswith(suffix):
sampler = sampler[:-len(suffix)]
result.values.setdefault("scheduler", scheduler)
break
result.values["sampler_name"] = SAMPLERS.get(sampler, sampler)
if "scheduler" in result.values:
result.values["scheduler"] = result.values["scheduler"].lower()
if result.values["scheduler"] == "automatic":
result.values.pop("scheduler")
if "scheduler" not in result.values:
result.issues["scheduler"] = "A1111 scheduler is unspecified/Automatic; choose an explicit ComfyUI scheduler"
for key in ("Clip skip", "Hires upscale", "Hires steps", "Hires upscaler"):
if key in fields:
result.notes.append(f"Restore separately: {key}: {fields[key]}")
_parameter_loras(fields, result)
return result
def inactive_workflow_nodes(workflow: dict[str, Any]) -> set[str]:
"""Map muted/bypassed instances and nested nodes to API-qualified IDs."""
inactive: set[str] = set()
definitions = {str(item["id"]): item for item in workflow.get("definitions", {}).get("subgraphs", []) if isinstance(item, dict) and "id" in item}
count = 0
def visit(container: dict[str, Any], prefix: str, ancestors: tuple[str, ...]) -> None:
nonlocal count
for node in container.get("nodes", []):
count += 1
if count > 10000 or len(ancestors) > 100:
raise MetadataError("Workflow subgraph traversal limit exceeded")
if not isinstance(node, dict) or "id" not in node:
continue
node_id = prefix + str(node["id"])
if node.get("mode", 0) != 0:
inactive.add(node_id)
continue
kind = node.get("type")
if kind in definitions:
if kind in ancestors:
raise MetadataError("Cyclic workflow subgraph definition")
visit(definitions[kind], node_id + ":", (*ancestors, kind))
visit(workflow, "", ())
return inactive
def extract_generation_metadata(
fields: dict[str, Any], sampler_id: str = "", prefer_saved_image_metadata: bool = True,
) -> GenerationMetadata:
parameters = fields.get("parameters") or fields.get("comment")
saved_text = isinstance(parameters, str) and bool(parameters.strip()) and not parameters.lstrip().startswith("{")
recovery_notes = []
if prefer_saved_image_metadata and saved_text:
try:
result = parse_parameters(parameters)
result.notes.append("Source: saved image generation parameters (preferred).")
if sampler_id.strip():
result.notes.append("sampler_node_id is ignored while using saved image generation parameters.")
return result
except (ValueError, TypeError) as exc:
recovery_notes.append(f"ERROR: Saved image metadata could not be parsed: {exc}; trying workflow metadata.")
prompt = fields.get("prompt")
workflow = _json_object(fields["workflow"]) if fields.get("workflow") else None
if prompt:
try:
graph = _json_object(prompt)
except (ValueError, TypeError) as exc:
raise MetadataError(f"Malformed embedded prompt: {exc}") from exc
result = GraphReader(graph, inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
elif isinstance(parameters, str) and parameters.lstrip().startswith("{"):
result = GraphReader(_json_object(parameters), inactive_workflow_nodes(workflow) if workflow else None).read(sampler_id.strip())
elif workflow:
result = GraphReader(workflow_to_prompt(workflow)).read(sampler_id.strip())
result.notes.insert(0, "UI workflow fallback: only known core widget layouts are supported; saved widget values may differ from executed values.")
elif saved_text:
result = parse_parameters(parameters)
result.notes.append("Source: saved image generation parameters; no workflow metadata available.")
else:
raise MetadataError("Image contains no supported generation metadata")
result.notes.extend(recovery_notes)
return result
def workflow_to_prompt(workflow: dict[str, Any]) -> dict[str, Any]:
"""Decode only known core widget layouts; preserve links to unknown nodes."""
nodes = workflow.get("nodes")
links = workflow.get("links", [])
if not isinstance(nodes, list) or not isinstance(links, list) or len(nodes) > 10000:
raise MetadataError("Malformed or excessively large UI workflow")
link_map = {}
for link in links:
if isinstance(link, list) and len(link) >= 5:
link_map[str(link[0])] = [str(link[1]), link[2]]
layouts = {
"CheckpointLoaderSimple": ["ckpt_name"],
"UNETLoader": ["unet_name", "weight_dtype"],
"LoraLoader": ["lora_name", "strength_model", "strength_clip"],
"LoraLoaderModelOnly": ["lora_name", "strength_model"],
"CLIPTextEncode": ["text"],
"EmptyLatentImage": ["width", "height", "batch_size"],
"EmptySD3LatentImage": ["width", "height", "batch_size"],
"KSampler": ["seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
"PrimitiveNode": ["value"],
"PrimitiveInt": ["value"], "PrimitiveFloat": ["value"],
"PrimitiveString": ["value"], "PrimitiveStringMultiline": ["value"],
}
graph = {}
for node in nodes:
if not isinstance(node, dict) or "id" not in node:
raise MetadataError("Malformed workflow node")
kind = node.get("type", "")
widgets = node.get("widgets_values", [])
inputs = {}
layout = layouts.get(kind)
if node.get("mode", 0) != 0:
kind = "Unsupported muted/bypassed " + kind
elif layout is not None:
if not isinstance(widgets, list):
raise MetadataError(f"Unsupported widget layout for {kind}")
if kind == "KSampler" and len(widgets) == 6:
layout = [key for key in layout if key != "control_after_generate"]
for key, value in zip(layout, widgets):
inputs[key] = value
for slot in node.get("inputs", []):
if not isinstance(slot, dict) or not isinstance(slot.get("name"), str):
raise MetadataError("Malformed workflow input")
if slot.get("link") is not None:
inputs[slot["name"]] = link_map.get(str(slot["link"]), ["missing", 0])
graph[str(node["id"])] = {"class_type": kind, "inputs": inputs}
return graph
+15 -19
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
from .sidecar_paths import get_metadata_path, get_preview_dir, resolve_metadata_path
logger = logging.getLogger(__name__)
@@ -32,7 +33,7 @@ class MetadataManager:
- metadata: BaseModelMetadata instance or None
- should_skip: True if corrupted metadata file exists and model should be skipped
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = get_metadata_path(file_path)
# Check if metadata file exists
if not os.path.exists(metadata_path):
@@ -98,11 +99,7 @@ class MetadataManager:
payload.update(unknown_fields)
else:
if not should_skip:
metadata_path = (
file_path
if file_path.endswith(".metadata.json")
else f"{os.path.splitext(file_path)[0]}.metadata.json"
)
metadata_path = resolve_metadata_path(file_path)
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
@@ -150,7 +147,7 @@ class MetadataManager:
return model_data
folder = model_data.get("folder")
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = get_metadata_path(file_path)
sidecar_exists = os.path.exists(metadata_path)
cached = model_data.copy()
payload = await MetadataManager.load_metadata_payload(file_path)
@@ -188,15 +185,14 @@ class MetadataManager:
bool: Success or failure
"""
# Determine if the input is a metadata path or a model file path
if path.endswith('.metadata.json'):
metadata_path = path
else:
# Use existing logic for model file paths
file_path = path
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata_path = resolve_metadata_path(path)
temp_path = f"{metadata_path}.tmp"
try:
# Centralized sidecar mirrors may not exist yet (unlike the model's
# own directory in alongside mode, which always does).
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
# Convert to dict if needed
if isinstance(metadata, BaseModelMetadata):
metadata_dict = metadata.to_dict()
@@ -259,10 +255,9 @@ class MetadataManager:
try:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
# Calculate file hash
start_hash_time = time.perf_counter()
@@ -386,15 +381,16 @@ class MetadataManager:
# Check if preview exists at the current location
preview_url = metadata.preview_url
if preview_url:
# Get directory parts of both paths
file_dir = os.path.dirname(file_path)
# Get directory parts of both paths; the preview directory is the
# sidecar/preview dir (the model's own dir in alongside mode, the
# centralized mirror otherwise).
file_dir = get_preview_dir(file_path)
preview_dir = os.path.dirname(preview_url)
# Update preview if it doesn't exist OR if model and preview are in different directories
if not os.path.exists(preview_url) or file_dir != preview_dir:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
new_preview_url = find_preview_file(base_name, file_dir)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
need_update = True

Some files were not shown because too many files have changed in this diff Show More