Compare commits

..

145 Commits

Author SHA1 Message Date
Will Miao
d8e5fe1247 docs: add v1.0.1 release notes, bump version to 1.0.1 2026-04-02 11:54:04 +08:00
Will Miao
3e9210394a feat(settings): Improve Extra Folder Paths UX with restart indicators
- Replace tooltip with restart-required icon for better visibility
- Update descriptions to accurately reflect feature purpose
- Fix toast message to show correct restart notification
- Sync i18n keys across all supported languages
2026-04-02 08:57:04 +08:00
Will Miao
4dd2c0526f chore(supporters): Update supporters 2026-04-01 22:56:20 +08:00
Will Miao
9bdb337962 fix(settings): enforce valid default model roots 2026-04-01 20:36:37 +08:00
Will Miao
f93baf5fc0 chore(workflow): Update example workflows 2026-04-01 15:39:20 +08:00
Will Miao
14cb7fec47 feat(cycler): add preset strength scale (#865) 2026-04-01 11:05:38 +08:00
Will Miao
f3b3e0adad fix(randomizer): defer UI updates until workflow completion (fixes #824) 2026-04-01 10:29:27 +08:00
Will Miao
ba3f15dbc6 feat(checkpoints): add 'Send to Workflow' option in context menu
- Add 'Send to Workflow' menu item to checkpoint context menu (templates/checkpoints.html)
- Implement sendCheckpointToWorkflow() method in CheckpointContextMenu.js
- Use unified 'Model' terminology for toast messages instead of differentiating checkpoint/diffusion model
- Add translation keys: checkpoints.contextMenu.sendToWorkflow, uiHelpers.workflow.modelUpdated, modelFailed
- Complete translations for all 10 locales (en, zh-CN, zh-TW, ja, ko, de, fr, es, ru, he)
2026-03-31 19:52:20 +08:00
Will Miao
8dc2a2f76b fix(recipe): show checkpoint-linked recipes in model modal (#851) 2026-03-31 16:45:01 +08:00
Will Miao
316f17dd46 fix(recipe): Import LoRAs from Civitai image URLs using modelVersionIds (#868)
When importing recipes from Civitai image URLs, the API returns modelVersionIds
at the root level instead of inside the meta object. This caused LoRA information
to not be recognized and imported.

Changes:
- analysis_service.py: Merge modelVersionIds from image_info into metadata
- civitai_image.py: Add modelVersionIds field recognition and processing logic
- test_civitai_image_parser.py: Add test for modelVersionIds handling
2026-03-31 14:34:13 +08:00
Will Miao
3dc10b1404 feat(recipe): add editable prompts in recipe modal (#869) 2026-03-31 14:11:56 +08:00
Will Miao
331889d872 chore(i18n): improve recursive toggle button labels for clarity (#875)
Update translations for sidebar recursive toggle from 'Search subfolders'
to 'Include subfolders' / 'Current folder only' across all 10 languages.

This better describes the actual functionality - controlling whether
models/recipes from subfolders are included in the current view.

Related to #875
2026-03-30 15:26:15 +08:00
Will Miao
06f1a82d4c fix(tests): add missing MODEL_TYPES mock in ModelModal tests
Add mock for apiConfig.js MODEL_TYPES constant in test files to fix
'Cannot read properties of undefined' errors when running npm test.

- tests/frontend/components/modelMetadata.renamePath.test.js
- tests/frontend/components/modelModal.licenseIcons.test.js
2026-03-30 08:37:12 +08:00
Will Miao
267082c712 feat: add 'Send to ComfyUI' button to ModelModal and RecipeModal
- Add send button to ModelModal header for all model types (LoRA, Checkpoint, Embedding)
- Add send button to RecipeModal header for sending entire recipes
- Style buttons to match existing modal action buttons
- Add translations for all supported languages
2026-03-29 20:35:08 +08:00
Will Miao
a4cb51e96c fix(nodes): preserve autocomplete widget values across workflow restore 2026-03-29 19:25:30 +08:00
Will Miao
ca44c367b3 fix(recipe): improve Civitai URL generation for missing LoRAs
Use model-versions endpoint (https://civitai.com/model-versions/{id}) which
auto-redirects to the correct model page when only versionId is available.

This fixes the UX issue where clicking on 'Not in Library' LoRA entries in
Recipe Modal would open a search page instead of the actual model page.

Changes:
- uiHelpers.js: Prioritize versionId over modelId for Civitai URLs
- RecipeModal.js: Include versionId in navigation condition checks
2026-03-29 15:33:30 +08:00
Will Miao
301ab14781 fix(nodes): restore autocomplete widget sync after metadata insertion (#879) 2026-03-29 10:09:39 +08:00
Will Miao
2626dbab8e feat: add lora stack combiner node 2026-03-29 08:28:00 +08:00
Will Miao
12bbb0572d fix: Add missing mock for getMappableBaseModelsDynamic in tests (#854)
- Add getMappableBaseModelsDynamic to constants.js mocks in test files
- Remove refs/enums.json temporary file from repository

Fixes test failures introduced in previous commit.
2026-03-29 00:24:20 +08:00
Will Miao
00f5c1e887 feat: Dynamic base model fetching from Civitai API (#854)
Implement automatic fetching of base models from Civitai API to keep
data up-to-date without manual updates.

Backend:
- Add CivitaiBaseModelService with 7-day TTL caching
- Add /api/lm/base-models endpoints for fetching and refreshing
- Merge hardcoded and remote models for backward compatibility
- Smart abbreviation generation for unknown models

Frontend:
- Add civitaiBaseModelApi client for API communication
- Dynamic base model loading on app initialization
- Update SettingsManager to use merged model lists
- Add support for 8 new models: Anima, CogVideoX, LTXV 2.3, Mochi,
  Pony V7, Wan Video 2.5 T2V/I2V

API Endpoints:
- GET /api/lm/base-models - Get merged models
- POST /api/lm/base-models/refresh - Force refresh
- GET /api/lm/base-models/categories - Get categories
- GET /api/lm/base-models/cache-status - Check cache status

Closes #854
2026-03-29 00:18:15 +08:00
Will Miao
89b1675ec7 fix: wheel zoom behavior for LoRA Manager widgets
- Add forwardWheelToCanvas() utility for vanilla JS widgets
- Implement wheel event handling in Vue widgets (LoraCyclerWidget, LoraRandomizerWidget, LoraPoolWidget)
- Update SingleSlider and DualRangeSlider to stop event propagation after value adjustment
- Ensure consistent behavior: slider adjusts value only, other areas trigger canvas zoom
- Support pinch-to-zoom (Ctrl+wheel) and horizontal scroll forwarding
2026-03-28 22:42:26 +08:00
Will Miao
dcc7bd33b5 fix(autocomplete): make accept key behavior configurable (#863) 2026-03-28 20:21:23 +08:00
Will Miao
e5152108ba fix(autocomplete): treat newline as a hard boundary 2026-03-28 19:29:30 +08:00
Will Miao
1ed5eef985 feat(autocomplete): support Tab accept and configurable suffix behavior (#863) 2026-03-28 19:18:23 +08:00
Will Miao
a82f89d14a fix(nodes): expose save image outputs to generated assets 2026-03-28 14:28:48 +08:00
Will Miao
16e30ea689 fix(nodes): add save_with_metadata toggle to save image 2026-03-28 11:17:36 +08:00
pixelpaws
ad3bdddb72 Merge pull request #876 from willmiao/codex/analyze-issue-869-on-github
Handle Enter on tag input to add tags and add unit tests
2026-03-27 19:55:12 +08:00
pixelpaws
9121306b06 Guard Enter tag add during IME composition 2026-03-27 19:52:53 +08:00
Will Miao
ca0baf9462 fix(nodes): lazy load qwen lora helper 2026-03-27 19:44:05 +08:00
pixelpaws
20e50156a2 fix(recipes): allow Enter to add import tags 2026-03-27 19:28:58 +08:00
Will Miao
0b66bf5479 chore: update AGENTS commit guidance 2026-03-27 19:26:13 +08:00
Will Miao
1e8aca4787 Add experimental Nunchaku Qwen LoRA support (#873) 2026-03-27 19:24:43 +08:00
Will Miao
76ee59cdb9 fix(paths): deduplicate LoRA path overlap (#871) 2026-03-27 17:35:24 +08:00
Will Miao
a5191414cc feat(download): add configurable base model download exclusions 2026-03-26 23:07:12 +08:00
Will Miao
5b065b47d4 feat(i18n): complete translations for mature blur threshold setting
Add translations for the new mature_blur_level setting across all
supported languages:
- zh-CN: 成人内容模糊阈值
- zh-TW: 成人內容模糊閾值
- ja: 成人コンテンツぼかし閾値
- ko: 성인 콘텐츠 블러 임계값
- de: Schwelle für Unschärfe bei jugendgefährdenden Inhalten
- fr: Seuil de floutage pour contenu adulte
- es: Umbral de difuminado para contenido adulto
- ru: Порог размытия взрослого контента
- he: סף טשטוש תוכן מבוגרים

Completes TODOs from previous commit.
2026-03-26 18:40:33 +08:00
Will Miao
ceeab0c998 feat: add configurable mature blur threshold setting
Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control
which NSFW rating level triggers blur filtering when NSFW blur is enabled.

- Backend: update preview selection logic to respect threshold
- Frontend: update UI components to use configurable threshold
- Settings: add validation and normalization for mature_blur_level
- Tests: add coverage for new threshold behavior
- Translations: add keys for all supported languages

Fixes #867
2026-03-26 18:24:47 +08:00
Will Miao
3b001a6cd8 fix(tests): update tests to match current download implementation
- Remove calculate_sha256 mocking from download_manager tests since
  SHA256 now comes from API metadata (not recalculated during download)
- Update chunk_size assertion from 4MB to 16MB in downloader config test
2026-03-26 18:00:04 +08:00
Will Miao
95e5bc26d1 feat: Add bulk download missing LoRAs feature for recipes
- Add BulkMissingLoraDownloadManager.js for handling bulk LoRA downloads
- Add context menu item to bulk mode for downloading missing LoRAs
- Add confirmation modal with deduplicated LoRA list preview
- Implement sequential downloading with WebSocket progress updates
- Fix CSS class naming conflicts to avoid import-modal.css collision
- Update translations for 9 languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he)
- Style modal without internal scrolling for better UX
2026-03-26 17:46:53 +08:00
Will Miao
de3d0571f8 fix: verify returned image ID matches requested ID in CivitAI API
Fix issue #870 where importing recipes from CivitAI image URLs would
return the wrong image when the API response did not contain the
requested image ID.

The get_image_info() method now:
- Iterates through all returned items to find matching ID
- Returns None when no match is found and logs warning with returned IDs
- Handles invalid (non-numeric) ID formats

New test cases:
- test_get_image_info_returns_matching_item
- test_get_image_info_returns_none_when_id_mismatch
- test_get_image_info_handles_invalid_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 20:37:51 +08:00
Will Miao
6f2a01dc86 优化下载性能:移除 SHA256 计算并使用 16MB chunks
- 移除下载后的 SHA256 计算,直接使用 API 返回的 hash 值
- 将 chunk size 从 4MB 调整为 16MB,减少 75% 的 I/O 操作
- 这有助于缓解 ComfyUI 执行期间的卡顿问题
2026-03-25 19:29:48 +08:00
Will Miao
c5c1b8fd2a Fix: border corner clipping in duplicate recipe warning
Fix the bottom corners of duplicate warning border being clipped
due to parent container overflow:hidden and mismatched border-radius.

- Changed border-radius from top-only to all corners
- Ensures yellow border displays fully without being cut off
2026-03-25 13:57:38 +08:00
Will Miao
e97648c70b feat(import): add import-only option for recipes without downloading missing LoRAs
Add dual-button design in recipe import flow:
- Details step: [Import Recipe Only] [Import & Download]
- Location step: [Back] [Import & Download] (removed redundant Import Only)

Changes:
- templates/components/import_modal.html: Add secondary button for import-only
- static/js/managers/ImportManager.js: Add saveRecipeOnlyFromDetails() method
- static/js/managers/import/RecipeDataManager.js: Update button state management
- static/js/managers/import/DownloadManager.js: Support skipDownload flag
- locales/*.json: Complete all translation TODOs

Closes #868
2026-03-25 11:56:34 +08:00
Will Miao
8b85e083e2 feat(recipe-parser): add SuiImage metadata format support
- Add SuiImageParamsParser for sui_image_params JSON format
- Register new parser in RecipeParserFactory
- Fix metadata_provider auto-initialization when not ready
- Add 10 test cases for SuiImageParamsParser

Fixes batch import failure for images with sui_image_params metadata.
2026-03-25 08:43:33 +08:00
Will Miao
9112cd3b62 chore: Add .claude/ to gitignore
Exclude Claude Code personal configuration directory containing:
- settings.local.json (personal permissions and local paths)
- skills/ (personal skills)

These contain machine-specific paths and personal preferences
that should not be shared across the team.
2026-03-22 14:17:15 +08:00
Will Miao
7df4e8d037 fix(metadata_hook): correct function signature to fix bound method error
Fix issue #866 where the metadata hook's async wrapper used *args/**kwargs
which caused AttributeError when ComfyUI's make_locked_method_func tried
to access __func__ on the func parameter.

The async_map_node_over_list_with_metadata wrapper now uses the exact
same signature as ComfyUI's _async_map_node_over_list:
- Removed: *args, **kwargs
- Added: explicit v3_data=None parameter

This ensures the func parameter (always a string like obj.FUNCTION) is
passed correctly to make_locked_method_func without any type conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:25:04 +08:00
Will Miao
4000b7f7e7 feat: Add configurable LoRA strength adjustment step setting
Implements issue #808 - Allow users to customize the strength
variation range for LoRA widget arrow buttons.

Changes:
- Add 'Strength Adjustment Step' setting (0.01-0.1) in settings.js
- Replace hardcoded 0.05 increments with configurable step value
- Apply to both LoRA strength and CLIP strength controls

Fixes #808
2026-03-19 17:33:18 +08:00
Will Miao
76c15105e6 feat(lora-pool): add regex include/exclude name pattern filtering (#839)
Add name pattern filtering to LoRA Pool node allowing users to filter
LoRAs by filename or model name using either plain text or regex patterns.

Features:
- Include patterns: only show LoRAs matching at least one pattern
- Exclude patterns: exclude LoRAs matching any pattern
- Regex toggle: switch between substring and regex matching
- Case-insensitive matching for both modes
- Invalid regex automatically falls back to substring matching
- Filters apply to both file_name and model_name fields

Backend:
- Update LoraPoolLM._default_config() with namePatterns structure
- Add name pattern filtering to _apply_pool_filters() and _apply_specific_filters()
- Add API parameter parsing for name_pattern_include/exclude/use_regex
- Update LoraPoolConfig type with namePatterns field

Frontend:
- Add NamePatternsSection.vue component with pattern input UI
- Update useLoraPoolState to manage pattern state and API integration
- Update LoraPoolSummaryView to display NamePatternsSection
- Increase LORA_POOL_WIDGET_MIN_HEIGHT to accommodate new UI

Tests:
- Add 7 test cases covering text/regex include, exclude, combined
  filtering, model name fallback, and invalid regex handling

Closes #839
2026-03-19 17:15:05 +08:00
Will Miao
b11c90e19b feat: add type ignore comments and remove unused imports
- Add `# type: ignore` comments to comfy.sd and folder_paths imports
- Remove unused imports: os, random, and extract_lora_name
- Clean up import statements across checkpoint_loader, lora_randomizer, and unet_loader nodes
2026-03-19 15:54:49 +08:00
pixelpaws
9f5d2d0c18 Merge pull request #862 from EnragedAntelope/claude/add-webp-image-support-t8kG9
Improve webp image support
2026-03-19 15:35:16 +08:00
Will Miao
a0dc5229f4 feat(unet_loader): move torch import inside methods for lazy loading
- Delay torch import until needed in load_unet and load_unet_gguf methods
- This improves module loading performance by avoiding unnecessary imports
- Maintains functionality while reducing initial import overhead
2026-03-19 15:29:41 +08:00
Will Miao
61c31ecbd0 fix: exclude __init__.py from pytest collection to prevent CI import errors 2026-03-19 14:43:45 +08:00
Will Miao
1ae1b0d607 refactor: move No LoRA feature from LoRA Pool to Lora Cycler widget
Move the 'empty/no LoRA' cycling functionality from the LoRA Pool node
to the Lora Cycler widget for cleaner architecture:

Frontend changes:
- Add include_no_lora field to CyclerConfig interface
- Add includeNoLora state and logic to useLoraCyclerState composable
- Add toggle UI in LoraCyclerSettingsView with special styling
- Show 'No LoRA' entry in LoraListModal when enabled
- Update LoraCyclerWidget to integrate new logic

Backend changes:
- lora_cycler.py reads include_no_lora from config
- Calculate effective_total_count (actual count + 1 when enabled)
- Return empty lora_stack when on No LoRA position
- Return actual LoRA count in total_count (not effective count)

Reverted files to pre-PR state:
- lora_loader.py, lora_pool.py, lora_randomizer.py, lora_stacker.py
- lora_routes.py, lora_service.py
- LoraPoolWidget.vue and related files

Related to PR #861

Co-authored-by: dogatech <dogatech@dogatech.home>
2026-03-19 14:19:49 +08:00
dogatech
8dd849892d Allow for empty lora (no loras option) in Lora Pool 2026-03-19 09:23:03 +08:00
Will Miao
03e1fa75c5 feat: auto-focus URL input when batch import modal opens 2026-03-18 22:33:45 +08:00
Will Miao
fefcaa4a45 fix: improve Civitai recipe import by extracting EXIF when API metadata is empty
- Add validation to check if Civitai API metadata contains recipe fields
- Fall back to EXIF extraction when API returns empty metadata (meta.meta=null)
- Improve error messages to distinguish between missing metadata and unsupported format
- Add _has_recipe_fields() helper method to validate metadata content

This fixes import failures for Civitai images where the API returns
metadata wrapper but no actual generation parameters (e.g., images
edited in Photoshop that lost their original generation metadata)
2026-03-18 22:30:36 +08:00
Will Miao
701a6a6c44 refactor: remove GGUF loading logic from CheckpointLoaderLM
GGUF models are pure Unet models and should be handled by UNETLoaderLM.
2026-03-18 21:36:07 +08:00
Will Miao
0ef414d17e feat: standardize Checkpoint/Unet loader names and use OS-native path separators
- Rename nodes to 'Checkpoint Loader (LoraManager)' and 'Unet Loader (LoraManager)'\n- Use os.sep for relative path formatting in model COMBO inputs\n- Update path matching to be robust across OS separators\n- Update docstrings and comments
2026-03-18 21:33:19 +08:00
Will Miao
75dccaef87 test: fix cache validator tests to account for new hash_status field and side effects 2026-03-18 21:10:56 +08:00
Will Miao
7e87ec9521 fix: persist hash_status in model cache to support lazy hashing on restart 2026-03-18 21:07:40 +08:00
Will Miao
46522edb1b refactor: simplify GGUF import helper with dynamic path detection
- Add _get_gguf_path() to dynamically derive ComfyUI-GGUF path from current file location
- Remove Strategy 2 and 3, keeping only Strategy 1 (sys.modules path-based lookup)
- Remove hard-coded absolute paths
- Streamline logging output
- Code cleanup: reduced from 235 to 154 lines
2026-03-18 19:55:54 +08:00
Will Miao
2dae4c1291 fix: isolate extra unet paths from checkpoints to prevent type misclassification
Refactor _prepare_checkpoint_paths() to return a tuple instead of having
side effects on instance variables. This prevents extra unet paths from
being incorrectly classified as checkpoints when processing extra paths.

- Changed return type from List[str] to Tuple[List[str], List[str], List[str]]
  (all_paths, checkpoint_roots, unet_roots)
- Updated _init_checkpoint_paths() and _apply_library_paths() callers
- Fixed extra paths processing to properly isolate main and extra roots
- Updated test_checkpoint_path_overlap.py tests for new API

This ensures models in extra unet paths are correctly identified as
diffusion_model type and don't appear in checkpoints list.
2026-03-17 22:03:57 +08:00
EnragedAntelope
a32325402e Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9 2026-03-17 08:37:46 -04:00
Will Miao
70c150bd80 fix(services): implement stable sorting for model and recipe caches
Add file_path as a tie-breaker for all sort modes in ModelCache, BaseModelService, LoraService, and RecipeCache to ensure deterministic ordering when primary keys are identical. Resolves issue #859.
2026-03-17 14:20:23 +08:00
Will Miao
9e81c33f8a fix(utils): make sanitize_folder_name idempotent by combining strip/rstrip calls 2026-03-17 11:24:59 +08:00
Will Miao
22c0dbd734 feat(recipes): persist 'Skip images without metadata' choice in batch import 2026-03-17 11:01:41 +08:00
Will Miao
d0c58472be fix(i18n): add missing common.actions.close translation key 2026-03-17 09:57:27 +08:00
Will Miao
b3c530bf36 fix(autocomplete): handle multi-word tag matching with normalized spaces
- Replace multiple consecutive spaces with single underscore for tag matching
  (e.g., 'looking  to   the side' → 'looking_to_the_side')
- Support prefix/suffix matching for flexible multi-word autocomplete
  (e.g., 'looking to the' → 'looking_to_the_side')
- Add comprehensive test coverage for multi-word scenarios

Test coverage:
- Multi-word exact match (Danbooru convention)
- Partial match with last token replacement
- Command mode with multi-word phrases
- Multiple consecutive spaces handling
- Backend LOG10 popularity weight validation

Fixes: 'looking to the side' input now correctly replaces with
'looking_to_the_side, ' (or 'looking to the side, ' with space replacement)
2026-03-17 09:34:01 +08:00
Claude
05ebd7493d chore: update package-lock.json after npm install
https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:33:34 +00:00
Claude
90986bd795 feat: add case-insensitive webp support for lora cover photos
Make preview file discovery case-insensitive so files with uppercase
extensions like .WEBP are found on case-sensitive filesystems. Also
explicitly list image/webp in the file picker accept attribute for
broader browser compatibility.

https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:32:48 +00:00
Will Miao
b5a0725d2c fix(autocomplete): improve tag search ranking with popularity-based sorting
- Add LOG10(post_count) weighting to BM25 score for better relevance ranking
- Prioritize tag_name prefix matches above alias matches using CASE statement
- Remove frontend re-scoring logic to trust backend排序 results
- Fix pagination consistency: page N+1 scores <= page N minimum score

Key improvements:
- '1girl' (6M posts) now ranks #1 instead of #149 for search '1'
- tag_name prefix matches always appear before alias matches
- Popular tags rank higher than obscure ones with same prefix
- Consistent ordering across pagination boundaries

Test coverage:
- Add test_search_tag_name_prefix_match_priority
- Add test_search_ranks_popular_tags_higher
- Add test_search_pagination_ordering_consistency
- Add test_search_rank_score_includes_popularity_weight
- Update test data with 15 tags starting with '1'

Fixes issues with autocomplete dropdown showing inconsistent results
when scrolling through paginated search results.
2026-03-16 19:09:07 +08:00
Will Miao
ef38bda04f docs: remove redundant example metadata files (#856)
- Delete examples/metadata/ directory and all example files
  - Real metadata.json files in model roots are better examples
  - Examples were artificial and could become outdated
  - Maintenance burden outweighs benefit

- Remove 'Complete Examples' section from docs/metadata-json-schema.md
- Remove reference to example files in 'See Also' section

Rationale:
Users have access to real-world metadata.json files in their actual
model directories, which contain complete Civitai API responses with
authentic data structures (images arrays with prompts, files with hashes,
creator information, etc.). These are more valuable than simplified
artificial examples.
2026-03-16 09:41:58 +08:00
Will Miao
58713ea6e0 fix(top-menu): use dynamic imports to eliminate deprecation warnings
- Replace static imports of deprecated ComfyButton and ComfyButtonGroup with dynamic imports
- Only loads legacy API files when frontend version < 1.33.9 (backward compatibility path)
- Frontend >= 1.33.9 users no longer see deprecation warnings since legacy code is never loaded
- Preserves full backward compatibility for older ComfyUI frontend versions
- All existing tests pass (159 JS + 65 Vue tests)
2026-03-16 09:41:58 +08:00
Will Miao
8b91920058 docs: add comprehensive metadata.json schema documentation (#856)
- Create docs/metadata-json-schema.md with complete field reference
  - All base fields for LoRA, Checkpoint, and Embedding models
  - Complete civitai object structure with Used vs Stored field classification
  - Model-level fields (allowCommercialUse, allowDerivatives, etc.)
  - Creator fields (username, image)
  - customImages structure with actual field names and types
  - Field behavior categories (Auto-Updated, Set Once, User-Editable)

- Add .specs/metadata.schema.json for programmatic validation
  - JSON Schema draft-07 format
  - oneOf schemas for each model type
  - Definitions for civitaiObject and usageTips

- Add example metadata files for each model type
  - lora-civitai.json: LoRA with full Civitai data
  - lora-custom.json: User-defined LoRA with trigger words
  - lora-no-triggerwords.json: LoRA without trigger words
  - checkpoint-civitai.json: Checkpoint from Civitai
  - embedding-custom.json: Custom embedding

Key clarifications:
  - modified: Import timestamp (Set Once, never changes after import)
  - size: File size at import time (Set Once)
  - base_model: Optional with actual values (SDXL 1.0, Flux.1 D, etc.)
  - model_type: Used in metadata.json (not sub_type which is internal)
  - allowCommercialUse: ["Image", "Video", "RentCivit", "Rent"]
  - civitai.files/images: Marked as Used by Lora Manager
  - User-editable fields clearly documented (model_name, tags, etc.)
2026-03-16 09:41:58 +08:00
Will Miao
ee466113d5 feat: implement batch import recipe functionality (frontend + backend fixes)
Backend fixes:
- Add missing API route for /api/lm/recipes/batch-import/progress (GET)
- Add missing API route for /api/lm/recipes/batch-import/directory (POST)
- Add missing API route for /api/lm/recipes/browse-directory (POST)
- Register WebSocket endpoint for batch import progress
- Fix skip_no_metadata default value (True -> False) to allow no-LoRA imports
- Add items array to BatchImportProgress.to_dict() for detailed results

Frontend implementation:
- Create BatchImportManager.js with complete batch import workflow
- Add directory browser UI for selecting folders
- Add batch import modal with URL list and directory input modes
- Implement real-time progress tracking (WebSocket + HTTP polling)
- Add results summary with success/failed/skipped statistics
- Add expandable details view showing individual item status
- Auto-refresh recipe list after import completion

UI improvements:
- Add spinner animation for importing status
- Simplify results summary UI to match progress stats styling
- Fix current item text alignment
- Fix dark theme styling for directory browser button
- Fix batch import button styling consistency

Translations:
- Add batch import related i18n keys to all locale files
- Run sync_translation_keys.py to sync all translations

Fixes:
- Batch import now allows images without LoRAs (matches single import behavior)
- Progress endpoint now returns complete items array with status details
- Results view correctly displays skipped items with error messages
2026-03-16 09:41:58 +08:00
Will Miao
f86651652c feat(batch-import): implement backend batch import service with adaptive concurrency
- Add BatchImportService with concurrent execution using asyncio.gather
- Implement AdaptiveConcurrencyController with dynamic adjustment
- Add input validation for URLs and local paths
- Support duplicate detection via skip_duplicates parameter
- Add WebSocket progress broadcasting for real-time updates
- Create comprehensive unit tests for batch import functionality
- Update API handlers and route registrations
- Add i18n translation keys for batch import UI
2026-03-16 09:41:58 +08:00
Will Miao
c89d4dae85 fix(extra-paths): support trigger words for LoRAs in extra folder paths, fixes #860
- Update get_lora_info() to check both loras_roots and extra_loras_roots
- Add fallback logic to return trigger words even if path not in recognized roots
- Ensure Trigger Word Toggle node displays trigger words for LoRAs from extra folder paths

Fixes issue where LoRAs added from extra folder paths would not show their trigger words in connected Trigger Word Toggle nodes.
2026-03-16 09:38:21 +08:00
pixelpaws
55a18d401b Merge pull request #858 from botchedchuckle/patch-1
Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
2026-03-14 14:43:46 +08:00
botchedchuckle
7570936c75 Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
* Fixed a bug where `prompt` and `negativePrompt` were both being
  added directly to HTML without escaping them. Given prompts are
  allowed to have HTML characters (e.g. `<lora:something:0.75>`), by
  forgetting to escape them some tags were missing in the metadata
  views for example images using those characters.
2026-03-13 01:29:04 -07:00
Will Miao
4fcf641d57 fix(bulk-context-menu): escape special characters in data-filepath selector to support double quotes in filenames (#845) 2026-03-12 08:49:10 +08:00
Will Miao
5c29e26c4e fix(top-menu): add backward compatibility for actionBarButtons API (#853)
- Implement version detection using __COMFYUI_FRONTEND_VERSION__ and /system_stats API
- Add version parsing and comparison utilities
- Dynamically register extension based on frontend version
- Use actionBarButtons API for frontend >= 1.33.9
- Fallback to legacy ComfyButton approach for older versions
- Add comprehensive version detection tests
2026-03-12 07:41:29 +08:00
Will Miao
ee765a6d22 fix(sidebar): escape folder names and paths to support double quotes
- Import and use escapeHtml and escapeAttribute in SidebarManager.js
- Escape data-path and title attributes in folder tree and breadcrumbs
- Use CSS.escape() for attribute selectors in updateTreeSelection
- Fixes issue #843 where folders with double quotes broke navigation
2026-03-11 23:33:11 +08:00
Will Miao
c02f603ed2 fix(autocomplete): add wheel event handler for canvas zoom support
Add @wheel event listener to AutocompleteTextWidget textarea to enable canvas zoom when textarea has no scrollbar.

The onWheel handler:
- Forwards pinch-to-zoom (ctrl+wheel) to canvas
- Passes horizontal scroll to canvas
- When textarea has vertical scrollbar: lets textarea scroll
- When textarea has NO scrollbar: forwards to canvas for zoom

Behavior now matches ComfyUI built-in multiline widget.

Fixes #850
2026-03-11 20:58:01 +08:00
Will Miao
ee84b30023 Fix node selector z-index issue in recipe modal
Change node-selector z-index from 1000 to var(--z-overlay) (2000)
to ensure the model selector UI appears above the recipe modal
when sending checkpoints to workflow with multiple targets.
2026-03-09 19:29:13 +08:00
Will Miao
97979d9e7c fix(send-to-workflow): strip file extension before searching relative paths
Backend _relative_path_matches_tokens() removes extensions from paths
before matching (commit 43f6bfab). This fix ensures frontend also
removes extensions from search terms to avoid matching failures.

Fixes issue where send model to workflow would receive absolute
paths instead of relative paths because the API returned empty
results when searching with file extension.
2026-03-09 15:49:37 +08:00
Will Miao
cda271890a feat(workflow-template): add new tab template workflow with auto-zoom
- Add GET /api/lm/example-workflows endpoint to list available templates
- Add GET /api/lm/example-workflows/{filename} to retrieve specific workflow
- Add 'New Tab Template Workflow' setting in LoRA Manager settings
- Automatically apply 80% zoom level when loading template workflows
- Override workflow's saved view settings to prevent visual zoom flicker

The feature allows users to select a template workflow from example_workflows/
directory to load when creating new workflow tabs, with a hardcoded 0.8 zoom
level for better initial view experience.
2026-03-08 21:03:14 +08:00
Will Miao
2fbe6c8843 fix(autocomplete): fix dropdown width calculation bug
Temporarily remove width constraints when measuring content to prevent
scrollWidth from being limited by narrow container. This fixes the issue
where dropdown width was incorrectly calculated as ~120px.

Also update test to match maxItems default value (100).
2026-03-07 23:23:26 +08:00
Will Miao
4fb07370dd fix(tests): add offset parameter to MockTagFTSIndex.search()
Add missing offset parameter to MockTagFTSIndex to support
pagination changes from commit a802a89.

- Update search() signature to include offset=0
- Implement pagination logic with offset/limit slicing
2026-03-07 23:10:00 +08:00
Will Miao
43f6bfab36 fix(autocomplete): strip file extensions from model names in search suggestions
Remove .safetensors/.ckpt/.pt/.bin extensions from model names in autocomplete
suggestions to improve UX and search relevance:

Frontend (web/comfyui/autocomplete.js):
- Add _getDisplayText() helper to strip extensions from model paths
- Update _matchItem() to match against filename without extension
- Update render() and createItemElement() to display clean names

Backend (py/services/base_model_service.py):
- Add _remove_model_extension() helper method
- Update _relative_path_matches_tokens() to ignore extensions in matching
- Update _relative_path_sort_key() to sort based on names without extensions

Tests (tests/services/test_relative_path_search.py):
- Add tests to verify 's' and 'safe' queries don't match all .safetensors files

Fixes issue where typing 's' would match all .safetensors files and cluttered
suggestions with redundant extension names.
2026-03-07 23:07:10 +08:00
Will Miao
a802a89ff9 feat(autocomplete): implement virtual scrolling and pagination
- Add virtual scrolling with configurable visible items (default: 15)
- Implement pagination with offset/limit for backend APIs
- Support loading more items on scroll
- Fix width calculation for suggestions dropdown
- Update backend services to support offset parameter

Files modified:
- web/comfyui/autocomplete.js (virtual scroll, pagination)
- py/services/base_model_service.py (offset support)
- py/services/custom_words_service.py (offset support)
- py/services/tag_fts_index.py (offset support)
- py/routes/handlers/model_handlers.py (offset param)
- py/routes/handlers/misc_handlers.py (offset param)
2026-03-07 22:17:26 +08:00
Will Miao
343dd91e4b feat(ui): improve clear button UX in autocomplete text widget
Move clear button from top-right to bottom-right to avoid

obscuring text content. Add hover visibility for cleaner UI.

Reserve bottom padding in textarea for button placement.
2026-03-07 21:09:59 +08:00
Will Miao
3756f88368 feat(autocomplete): improve multi-word tag search with query normalization
Implement search query variation generation to improve matching for multi-word tags:
- Generate multiple query forms: original, underscore (spaces->_), no-space, last token
- Execute up to 4 parallel queries with result merging and deduplication
- Add smart matching with symbol-insensitive comparison (blue hair matches blue_hair)
- Sort results with exact matches prioritized over partial matches

This allows users to type natural language queries like 'looking to the side' and
find tags like 'Looking_to_the_side' while maintaining backward compatibility
with continuous typing workflows.
2026-03-07 20:24:35 +08:00
Will Miao
acc625ead3 feat(recipes): add sync changes dropdown menu for recipe refresh
- Add syncChanges() function to recipeApi.js for quick refresh without cache rebuild
- Implement dropdown menu UI in recipes page with quick refresh and full rebuild options
- Add initDropdowns() method to RecipeManager for dropdown interaction handling
- Update AGENTS.md with more precise instruction about running sync_translation_keys.py
- Integrate sync changes functionality as default refresh behavior
2026-03-04 20:31:58 +08:00
Will Miao
f402505f97 i18n: complete TODO translations in locale files
- Add missing translations for modelTypes, recipe refresh, and sync notifications
- Translate for all supported languages (zh-CN, zh-TW, ja, ko, fr, de, es, ru, he)
- Run sync_translation_keys.py to ensure key consistency
2026-03-04 20:27:21 +08:00
Will Miao
4d8113464c perf(recipe_scanner): eliminate event loop blocking during cache rebuild
Refactor force_refresh path to use thread pool execution instead of blocking
the event loop shared with ComfyUI. Key changes:

- Fix 1: Route force_refresh through _initialize_recipe_cache_sync() in thread pool
- Fix 2: Add GIL release points (time.sleep(0)) every 100 files in sync loops
- Fix 3: Move RecipeCache.resort() to thread pool via run_in_executor
- Fix 4: Persist cache automatically after force_refresh
- Fix 5: Increase yield frequency in _enrich_cache_metadata (every recipe)

This eliminates the ~5 minute freeze when rebuilding 30K recipe cache.

Fixes performance issue where ComfyUI became unresponsive during recipe
scanning due to shared Python event loop blocking.
2026-03-04 15:10:46 +08:00
Will Miao
1ed503a6b5 docs: add lazy hash computation to v1.0.0 release notes 2026-03-04 07:41:19 +08:00
Will Miao
d67914e095 docs: update portable package download link to v1.0.0 2026-03-03 22:06:29 +08:00
Will Miao
2c810306fb feat: implement automated supporter recognition in README
- Add scripts/update_supporters.py to generate supporter list from JSON
- Set up GitHub Action to auto-update README.md on supporters.json change
- Update README.md with placeholders and personalized gratitude message
2026-03-03 21:52:08 +08:00
Will Miao
dd94c6b31a chore: add v1.0.0 release notes and update version in pyproject.toml 2026-03-03 21:19:50 +08:00
Will Miao
1a0edec712 feat: enhance supporters modal with auto-scrolling and visual improvements
- Add auto-scrolling functionality to supporters list with user interaction controls (pause on hover, manual scroll)
- Implement gradient overlays at top/bottom for credits-like appearance
- Style custom scrollbar with subtle hover effects for better UX
- Adjust padding and positioning to ensure all supporters remain visible during scroll
2026-03-03 21:18:12 +08:00
Will Miao
7ba9b998d3 fix(stats): resolve dashboard initialization race condition and test failure
- Refactor StatisticsManager to return promises from initializeVisualizations and initializeLists
- Update fetchAndRenderList to use the fetchData wrapper for consistent mocking
- Update statistics dashboard test to include mock data for paginated model-usage-list endpoint
2026-03-03 15:08:33 +08:00
Will Miao
8c5d5a8ca0 feat(stats): implement infinite scrolling and paginated model usage lists (fixes #812)
- Add get_model_usage_list API endpoint for paginated stats
- Replace static rendering with client-side infinite scroll logic
- Add scrollbars and max-height to model usage lists
2026-03-03 15:00:01 +08:00
Will Miao
672e4cff90 fix(move): reset manual folder selection when using default path (fixes #836) 2026-03-02 23:29:16 +08:00
Will Miao
c2716e3c39 fix(i18n): resolve missing translation keys and complete multi-language support
- Add missing keys 'common.cancel', 'common.confirm', and 'sidebar.dragDrop.noDragState' to en.json
- Synchronize all locale files using sync_translation_keys.py
- Complete translations for zh-CN, zh-TW, ja, ru, de, fr, es, ko, and he
- Implement sidebar drag-and-drop folder creation with visual feedback and input validation
- Optimize MoveManager to use resetAndReload for consistent UI state after moving models
- Fix recursive visibility check for root folder in MoveManager
2026-03-02 22:02:47 +08:00
Will Miao
b72cf7ba98 feat(showcase): optimize CivitAI media URLs for better performance
- Add CivitAI URL utility with optimization strategies for showcase and thumbnail modes
- Replace /original=true with /optimized=true for showcase videos to reduce bandwidth
- Remove redundant crossorigin and referrerpolicy attributes from video elements
- Use media type detection to apply appropriate optimization (image vs video)
- Integrate URL optimization into showcase rendering for improved loading times
2026-03-02 14:05:44 +08:00
Will Miao
bde11b153f fix(preview): resolve CORS error when setting CivitAI remote media as preview
- Add new endpoint POST /api/lm/{prefix}/set-preview-from-url to handle
  remote image downloads server-side, avoiding CORS issues
- Use rewrite_preview_url() to download optimized smaller images (450px width)
- Use Downloader service for reliable downloads with retry logic and proxy support
- Update frontend to call new endpoint instead of fetching images in browser

fixes #837
2026-03-02 13:21:18 +08:00
Will Miao
8b924b1551 feat: add draggable attribute to recipe card elements
- Set draggable=true on recipe card div elements to enable drag-and-drop functionality
- This allows users to drag recipe cards for reordering or other interactions
2026-03-02 10:28:36 +08:00
Will Miao
ce08935b1e fix(showcase): support middle-click and left-click to expand showcase
Fix showcase expansion to work with both left-click and middle-click (drag scroll).

Problem: The scroll-indicator click events were only bound when the carousel
was in expanded state. Initial collapsed state meant no click handlers were
attached, so clicking did nothing.

Solution:
- Extract scroll-indicator event binding into separate bindScrollIndicatorEvents()
- Call bindScrollIndicatorEvents() immediately when showcase loads, regardless
  of collapsed state
- Separate handlers for left-click (click event) and middle-click (mousedown
  event) to avoid double-triggering

Changes:
- Add bindScrollIndicatorEvents() function for early event binding
- Use click event for left mouse button (button 0)
- Use mousedown event for middle mouse button (button 1)
- Update loadExampleImages() to bind events immediately
- Update initShowcaseContent() to use the new function
2026-03-02 08:44:15 +08:00
Will Miao
24fcbeaf76 Skip performance tests by default
- Add 'performance' marker to pytest.ini
- Add pytestmark to test_cache_performance.py
- Use -m 'not performance' by default in addopts
- Allows manual execution with 'pytest -m performance'
2026-02-28 21:46:20 +08:00
Will Miao
c9e5ea42cb Fix null-safety issues and apply code formatting
Bug fixes:
- Add null guards for base_models_roots/embeddings_roots in backup cleanup
- Fix null-safety initialization of extra_unet_roots

Formatting:
- Apply consistent code style across Python files
- Fix line wrapping, quote consistency, and trailing commas
- Add type ignore comments for dynamic/platform-specific code
2026-02-28 21:38:41 +08:00
Will Miao
b005961ee5 feat(ui): improve changelog styling and spacing
- Remove left padding from changelog content container
- Add consistent padding to all changelog items
- Simplify latest changelog item styling by removing redundant padding
- Maintain visual distinction for latest items with background and border
2026-02-28 20:47:44 +08:00
Will Miao
ce03bbbc4e fix(frontend): defer LoadingManager DOM initialization to resolve i18n warning
Delay DOM creation in LoadingManager constructor to first use time,
ensuring window.i18n is ready before translate() is called.

This eliminates the 'i18n not available' console warning during
module initialization while maintaining correct translations
for cancel button and loading status text.
2026-02-28 20:30:16 +08:00
Will Miao
78b55d10ba refactor: move supporters loading to separate API endpoint
- Add SupportersHandler in misc_handlers.py to serve /api/lm/supporters
- Register new endpoint in misc_route_registrar.py
- Remove supporters from page load template context in model_handlers.py
- Create supportersService.js for frontend data fetching
- Update Header.js to fetch supporters when support modal opens
- Modify support_modal.html to use client-side rendering

This change improves page load performance by loading supporters data
on-demand instead of during initial page render.
2026-02-28 20:14:20 +08:00
Will Miao
77a2215e62 Fix lazy hash calculation for checkpoints in extra paths
- Allow empty sha256 when hash_status is 'pending' in cache entry validator
- Add on-demand hash calculation during bulk metadata refresh for checkpoints
  with pending hash status
- Add comprehensive tests for both fixes

Fixes issue where checkpoints in extra paths were not visible in UI and
not processed during bulk metadata refresh due to empty sha256.
2026-02-27 19:19:16 +08:00
pixelpaws
31901f1f0e Merge pull request #829 from willmiao/feature/lazy-hash-checkpoints
feat: lazy hash calculation for checkpoints
2026-02-27 11:02:39 +08:00
Will Miao
12a789ef96 fix(extra-folder-paths): fix extra folder paths support for checkpoint and unet roots
- Fix config.py: save and restore main paths when processing extra folder paths to prevent
  _prepare_checkpoint_paths from overwriting checkpoints_roots and unet_roots
- Fix lora_manager.py: apply library settings during initialization to load extra folder paths
  in ComfyUI plugin mode
- Fix checkpoint_routes.py: merge checkpoints/unet roots with extra paths in API endpoints
- Add logging for extra folder paths

Fixes issue where extra folder paths were not recognized for checkpoints and unet models.
2026-02-27 10:37:15 +08:00
Will Miao
d50bbe71c2 fix(extra-folder-paths): fix extra folder paths support for checkpoint and unet roots
- Fix config.py: save and restore main paths when processing extra folder paths to prevent
  _prepare_checkpoint_paths from overwriting checkpoints_roots and unet_roots
- Fix lora_manager.py: apply library settings during initialization to load extra folder paths
  in ComfyUI plugin mode
- Fix checkpoint_routes.py: merge checkpoints/unet roots with extra paths in API endpoints
- Add logging for extra folder paths

Fixes issue where extra folder paths were not recognized for checkpoints and unet models.
2026-02-27 10:27:29 +08:00
Will Miao
40d9f8d0aa feat: lazy hash calculation for checkpoints
Checkpoints are typically large (10GB+). This change delays SHA256
hash calculation until metadata fetch from Civitai is requested,
significantly improving initial scan performance.

- Add hash_status field to BaseModelMetadata
- CheckpointScanner skips hash during initial scan
- On-demand hash calculation during Civitai fetch
- Background bulk hash calculation support
2026-02-26 22:41:44 +08:00
Will Miao
9f15c1fc06 feat: Add Extra Folder Paths feature with improved layout
- Add Extra Folder Paths section in Library settings for configuring
  additional model folders (LoRA, Checkpoint, Diffusion Model, Embedding)
- Implement dynamic path input rows with add/remove functionality
- Add dedicated CSS styles with flex-based layout for better UX
- Add translations for 10 languages (DE, EN, ES, FR, HE, JA, KO, RU, ZH-CN, ZH-TW)
- Integrate settings loading and saving via SettingsManager

Closes layout issues with single-input path rows
2026-02-26 19:31:10 +08:00
Will Miao
87b462192b feat: Add extra folder paths support for LoRA Manager
Introduce extra_folder_paths feature to allow users to add additional
model roots that are managed by LoRA Manager but not shared with ComfyUI.

Changes:
- Add extra_folder_paths support in SettingsManager (stored per library)
- Add extra path attributes in Config class (extra_loras_roots, etc.)
- Merge folder_paths with extra_folder_paths when applying library settings
- Update LoraScanner, CheckpointScanner, EmbeddingScanner to include
  extra paths in their model roots
- Add comprehensive tests for the new functionality

This enables users to manage models from additional directories without
modifying ComfyUI's model folder configuration.
2026-02-25 18:16:17 +08:00
Will Miao
8ecdd016e6 Increase trigger words limit from 30 to 100 2026-02-25 17:11:21 +08:00
Will Miao
71b347b4bb fix(settings): Auto-scroll to first search match in settings modal
When searching in settings, the view now automatically scrolls to the
first matching element after switching to the matching section.

- Modified performSearch() to track and scroll to first match
- Modified highlightSearchMatches() to return the first highlight element
- Uses requestAnimationFrame and scrollIntoView with block: 'center'
2026-02-25 13:26:59 +08:00
Will Miao
41d2f9d8b4 i18n: Update settings navigation and section translations
- Restructure settings.sections and settings.nav in en.json
- Restore translations for existing keys across all locales (de, es, fr, he, ja, ko, ru, zh-CN, zh-TW)
- Add translations for new keys: metadata, library
- Translate autoOrganize section titles
- Complete all TODO translations in settings.search
2026-02-25 13:16:38 +08:00
Will Miao
0f5b442ec4 refactor(settings): restructure Language, Auto-organize and Metadata settings for better searchability 2026-02-25 11:13:41 +08:00
Will Miao
1d32f1b24e refactor(settings): shorten folder settings labels for better readability
- Rename section title: 'Folder Settings' → 'Default Roots'
- Remove 'Default' prefix from root directory labels:
  - 'Default LoRA Root' → 'LoRA Root'
  - 'Default Checkpoint Root' → 'Checkpoint Root'
  - 'Default Diffusion Model Root' → 'Diffusion Model Root'
  - 'Default Embedding Root' → 'Embedding Root'
- Update translations for all supported languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he)
2026-02-25 08:20:05 +08:00
Will Miao
ede97f3f3e Fix calculate_recipe_fingerprint to handle non-string hash and invalid strength values
- Handle non-string hash values by converting to string before lower()
- Add try-except for strength conversion to handle invalid values like empty strings
- Fixes hypothesis test failures when random data generates unexpected types
2026-02-25 00:11:38 +08:00
Will Miao
099f885c87 Fix pytest import errors and i18n translation keys
- Add missing mocks for comfy.sd and comfy.utils modules in conftest.py
- Fix i18n translation keys: use .help instead of .description for tooltip keys
2026-02-25 00:07:18 +08:00
Will Miao
fc98c752dc Fix Windows FileNotFoundError when loading LoRAs from lora_stack
lora_stack stores relative paths (e.g., 'Illustrious/style/file.safetensors'),
but comfy.utils.load_torch_file requires absolute paths. Previously, when
loading LoRAs from lora_stack, the relative path was passed directly to the
low-level API, causing FileNotFoundError on Windows.

This fix extracts the lora name from the relative path and uses
get_lora_info_absolute() to resolve the full absolute path before passing
it to load_torch_file(). This maintains compatibility with the lora_stack
format while ensuring correct file loading across all platforms.

Fixes: FileNotFoundError for relative paths in LoraLoaderLM and LoraTextLoaderLM
when processing lora_stack input.
2026-02-25 00:01:41 +08:00
Will Miao
c2754ea937 feat(ui): improve settings layout with inline help tooltips
- Remove bottom margin from setting items and last-child override
- Add flex layout to setting-info for inline label and info icon alignment
- Replace label opacity with rgba color for better tooltip visibility
- Add info-icon styling with hover tooltips using data-tooltip attribute
- Move help text from separate divs to inline tooltips on labels and section headers
- Improve tooltip positioning with edge case handling for left-aligned icons
2026-02-24 23:28:42 +08:00
Will Miao
f0cbe55040 refactor(settings): improve settings modal visual hierarchy and alignment
- Remove sidebar micro-transparent background for cleaner look
- Align Settings header with nav items using consistent left padding
- Enhance section headers: 18px, 700 weight for better visual hierarchy
- Mute setting labels: 400 weight, 0.85 opacity to de-emphasize
- Remove duplicate CSS rules and clean up styling
2026-02-24 15:44:33 +08:00
Will Miao
1f8ab377f7 refactor(settings): Move Priority Tags into Download Path Templates section
- Move Priority Tags setting from separate section to bottom of Download Path Templates
- Fix help link button position to be inline with label using flexbox layout
- Add CSS styles for .priority-tags-header-row and .priority-tags-header
2026-02-24 14:57:28 +08:00
Will Miao
de53ab9304 refactor(settings): restructure settings modal with subsection headers
- Replace duplicate section headers with meaningful subsection titles
- Group settings under logical subsections using existing i18n keys
- Add new translation key 'settings.sections.apiConfiguration'
- Update CSS for subsection styling with proper visual hierarchy
- Improve UX by making settings organization clearer

Subsections now use familiar titles from existing translations:
- API Configuration, Storage Location, Language (General)
- Content Filtering, Video Settings, Layout Settings (Interface)
- Folder Settings, Download Path Templates, Priority Tags,
  Update Flags, Example Images (Download)
- Auto-organize Exclusions, Metadata Refresh Skip Paths (Organization)
- Metadata Archive, Misc (System)
- Proxy Settings (Network)
2026-02-24 14:33:09 +08:00
Will Miao
8d7e861458 fix: correct i18n keys in settings modal for metadata archive and proxy settings
- Fix metadata archive DB setting to use correct i18n keys (enableArchiveDb, etc.)
- Restore metadata archive status display and management buttons
- Fix proxy settings to use correct i18n keys (enableProxy, proxyType, proxyHost, etc.)
- Add missing help text for proxy settings
- Add SOCKS4 proxy option
- Add onblur/onkeydown handlers for proxy input fields
- Update locales for new nav items (organization, system, network)
2026-02-24 11:30:43 +08:00
Will Miao
60674feb10 feat(ui): increase settings modal width and adjust height for better responsiveness
- Increase modal width from 800px to 1000px to accommodate more content
- Change height from fixed 600px to dynamic calculation based on viewport height
- Maintain responsive constraints with max-width and max-height properties
2026-02-24 09:12:07 +08:00
Will Miao
a221682a0d refactor(settings): implement macOS Settings style for settings modal
- Reorganize settings into 4 sections: General, Interface, Download, Advanced
- Implement section switching instead of scrolling (macOS Settings style)
- Remove collapsible/expandable sections and redundant 'SETTINGS' label
- Add accent-colored underline for section headers
- Update navigation with larger, more prominent active state
- Add fade-in animation for section transitions
- Update search to auto-switch to matching section
- Refactor CSS: 800x600 fixed modal size, remove collapse styles
- Refactor JS: simplify navigation logic, remove scroll spy and collapse code

Refs: Phase 0 settings modal optimization
2026-02-24 07:19:32 +08:00
Will Miao
3f0227ba9d feat(settings): add search functionality to settings modal (P2)
Implement Phase 2 search bar feature for settings modal:

- Add search input to settings modal header with icon and clear button
- Implement real-time filtering with 150ms debounce for performance
- Add visual highlighting for matched search terms using accent color
- Implement empty search results state with user-friendly message
- Add keyboard shortcuts (Escape to clear search)
- Auto-expand sections containing matching content during search
- Fix header layout to prevent overlap with close button
- Update progress tracker documenting P2 completion
- Add translation keys for search feature (placeholder, clear, no results)
- Sync translations across all language files

Files changed:
- templates/components/modals/settings_modal.html
- static/css/components/modal/settings-modal.css
- static/js/managers/SettingsManager.js
- locales/*.json (10 language files)
- docs/ui-ux-optimization/progress-tracker.md
2026-02-24 06:36:49 +08:00
Will Miao
528225ffbd feat(settings): add left navigation sidebar to settings modal
Implement two-column layout for improved settings navigation:
- Add 200px fixed navigation sidebar with 4 groups (General, Interface, Download, Advanced)
- Implement scroll spy to highlight current section during scroll
- Add smooth scrolling when clicking navigation items
- Extend modal width from 700px to 950px for better content display
- Add responsive mobile layout (switches to stacked view below 768px)
- Add i18n keys for navigation group titles
- Create documentation for optimization phases and progress tracking

Files changed:
- settings-modal.css: Add sidebar, navigation, and responsive styles
- settings_modal.html: Restructure with two-column layout and section IDs
- SettingsManager.js: Add initializeNavigation() with scroll spy
- locales/*.json: Add settings.nav translations (en, zh-CN, zh-TW, ja, ru, de, fr, es, ko, he)
- docs/ui-ux-optimization/: Add proposal and progress tracker documentation
2026-02-23 21:12:15 +08:00
Will Miao
916bfb0ab0 Allow adaptive multi-line model names in cards
- Remove fixed min-height from card-footer for adaptive sizing
- Increase model-name max-height to 5.6em (4 lines)

Enables full display of long custom-trained LoRA filenames
2026-02-23 18:19:02 +08:00
Will Miao
70398ed985 feat(lora-loader): Load LoRAs using lower-level API to bypass folder_paths validation
- Add get_lora_info_absolute() function to return absolute file paths
- Replace LoraLoader().load_lora() with comfy.utils.load_torch_file() +
  comfy.sd.load_lora_for_models() to enable loading LoRAs from any path
- This allows LoRA Manager to load LoRAs from non-standard paths (multi-library support)
- Fixes #805
2026-02-23 18:06:15 +08:00
Will Miao
1f5baec7fd docs: add recipe batch import feature requirements document 2026-02-23 17:07:03 +08:00
Will Miao
f1eb89af7a refactor: Extract isNodeEnabled helper to eliminate mode check duplication
Consolidate node enabled state checks into isNodeEnabled() helper function
to improve code clarity and maintainability. Follows DRY principle.
2026-02-23 16:47:09 +08:00
pixelpaws
7a04cec08d Merge pull request #825 from RanKaze/main
feat: filter node with mode:0
2026-02-23 16:39:45 +08:00
Will Miao
ec5fd923ba fix(randomizer): Initialize RANDOMIZER_CONFIG widget with default config
Initialize internalValue with default RandomizerConfig object instead of
undefined to prevent frontend from sending empty string to backend when
widget is first created.

This fixes the 'str' object has no attribute 'get' error that occurred
when running a newly created Lora Randomizer node before any user
interaction.

Fixes #4
2026-02-23 14:25:55 +08:00
Will Miao
26b139884c perf(usage-stats): prevent unnecessary writes when idle
- Add is_dirty flag to track if statistics have changed
- Only write stats file when data actually changes
- Add enable_usage_statistics setting in ComfyUI settings
- Skip backend requests when usage statistics is disabled
- Fix standalone mode compatibility for MetadataRegistry

Fixes #826
2026-02-23 14:00:00 +08:00
Will Miao
ec76ac649b Fix long model name display issues in modal and cards
- Add overflow-wrap: anywhere to modal title for proper wrapping of hyphenated names
- Add tooltip to model cards showing full filename on hover

Fixes overlap issues with long filenames like s0r4B35G_Zibv3_Prodigy_ID_Version2_Final_00800
2026-02-23 08:53:33 +08:00
K1einB1ue
60324c1299 feat: filter node with mode:0 2026-02-22 07:19:08 +08:00
236 changed files with 31623 additions and 4938 deletions

View File

@@ -0,0 +1,153 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

31
.github/workflows/update-supporters.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Update Supporters in README
on:
push:
paths:
- 'data/supporters.json'
branches:
- main
workflow_dispatch: # Allow manual trigger
jobs:
update-readme:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Update README
run: python scripts/update_supporters.py
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: auto-update supporters list in README"
file_pattern: "README.md"

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/
.codex
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/

464
.specs/metadata.schema.json Normal file
View File

@@ -0,0 +1,464 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/willmiao/ComfyUI-Lora-Manager/.specs/metadata.schema.json",
"title": "ComfyUI LoRa Manager Model Metadata",
"description": "Schema for .metadata.json sidecar files used by ComfyUI LoRa Manager",
"type": "object",
"oneOf": [
{
"title": "LoRA Model Metadata",
"properties": {
"file_name": {
"type": "string",
"description": "Filename without extension"
},
"model_name": {
"type": "string",
"description": "Display name of the model"
},
"file_path": {
"type": "string",
"description": "Full absolute path to the model file"
},
"size": {
"type": "integer",
"minimum": 0,
"description": "File size in bytes at time of import/download"
},
"modified": {
"type": "number",
"description": "Unix timestamp when model was imported/added (Date Added)"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "SHA256 hash of the model file (lowercase)"
},
"base_model": {
"type": "string",
"description": "Base model type (SD1.5, SD2.1, SDXL, SD3, Flux, Unknown, etc.)"
},
"preview_url": {
"type": "string",
"description": "Path to preview image file"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "NSFW level using bitmask values: 0 (none), 1 (PG), 2 (PG13), 4 (R), 8 (X), 16 (XXX), 32 (Blocked)"
},
"notes": {
"type": "string",
"default": "",
"description": "User-defined notes"
},
"from_civitai": {
"type": "boolean",
"default": true,
"description": "Whether the model originated from Civitai"
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Model tags"
},
"modelDescription": {
"type": "string",
"default": "",
"description": "Full model description"
},
"civitai_deleted": {
"type": "boolean",
"default": false,
"description": "Whether the model was deleted from Civitai"
},
"favorite": {
"type": "boolean",
"default": false,
"description": "Whether the model is marked as favorite"
},
"exclude": {
"type": "boolean",
"default": false,
"description": "Whether to exclude from cache/scanning"
},
"db_checked": {
"type": "boolean",
"default": false,
"description": "Whether checked against archive database"
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false,
"description": "Skip this model during bulk metadata refresh"
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null,
"description": "Last provider that supplied metadata"
},
"last_checked_at": {
"type": "number",
"default": 0,
"description": "Unix timestamp of last metadata check"
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed",
"description": "Hash calculation status"
},
"usage_tips": {
"type": "string",
"default": "{}",
"description": "JSON string containing recommended usage parameters (LoRA only)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Checkpoint Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "checkpoint",
"description": "Model sub-type (checkpoint, diffusion_model, etc.)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Embedding Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "embedding",
"description": "Model sub-type"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
}
],
"definitions": {
"civitaiObject": {
"type": "object",
"default": {},
"description": "Civitai/CivArchive API data and user-defined fields",
"properties": {
"id": {
"type": "integer",
"description": "Version ID from Civitai"
},
"modelId": {
"type": "integer",
"description": "Model ID from Civitai"
},
"name": {
"type": "string",
"description": "Version name"
},
"description": {
"type": "string",
"description": "Version description"
},
"baseModel": {
"type": "string",
"description": "Base model type from Civitai"
},
"type": {
"type": "string",
"description": "Model type (checkpoint, embedding, etc.)"
},
"trainedWords": {
"type": "array",
"items": {
"type": "string"
},
"description": "Trigger words for the model (from API or user-defined)"
},
"customImages": {
"type": "array",
"items": {
"type": "object"
},
"description": "Custom example images added by user"
},
"model": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"files": {
"type": "array",
"items": {
"type": "object"
}
},
"images": {
"type": "array",
"items": {
"type": "object"
}
},
"creator": {
"type": "object"
}
},
"additionalProperties": true
},
"usageTips": {
"type": "object",
"description": "Structure for usage_tips JSON string (LoRA models)",
"properties": {
"strength_min": {
"type": "number",
"description": "Minimum recommended model strength"
},
"strength_max": {
"type": "number",
"description": "Maximum recommended model strength"
},
"strength_range": {
"type": "string",
"description": "Human-readable strength range"
},
"strength": {
"type": "number",
"description": "Single recommended strength value"
},
"clip_strength": {
"type": "number",
"description": "Recommended CLIP/embedding strength"
},
"clip_skip": {
"type": "integer",
"description": "Recommended CLIP skip value"
}
},
"additionalProperties": true
}
}
}

View File

@@ -135,9 +135,16 @@ npm run test:coverage # Generate coverage report
- ALWAYS use English for comments (per copilot-instructions.md) - ALWAYS use English for comments (per copilot-instructions.md)
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json) - Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"` - Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Run `python scripts/sync_translation_keys.py` after UI string updates - Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths - Symlinks require normalized paths
## Git / Commit Messages
- Follow the style of recent repository commits when writing commit messages
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
## Frontend UI Architecture ## Frontend UI Architecture
### 1. Standalone Web UI ### 1. Standalone Web UI

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,13 @@
try: # pragma: no cover - import fallback for pytest collection try: # pragma: no cover - import fallback for pytest collection
from .py.lora_manager import LoraManager from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM from .py.nodes.text import TextLM
from .py.nodes.lora_stacker import LoraStackerLM from .py.nodes.lora_stacker import LoraStackerLM
from .py.nodes.lora_stack_combiner import LoraStackCombinerLM
from .py.nodes.save_image import SaveImageLM from .py.nodes.save_image import SaveImageLM
from .py.nodes.debug_metadata import DebugMetadataLM from .py.nodes.debug_metadata import DebugMetadataLM
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
@@ -27,16 +30,19 @@ except (
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
TextLM = importlib.import_module("py.nodes.text").TextLM TextLM = importlib.import_module("py.nodes.text").TextLM
LoraManager = importlib.import_module("py.lora_manager").LoraManager LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraLoaderLM = importlib.import_module( LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
"py.nodes.lora_loader" LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
).LoraLoaderLM CheckpointLoaderLM = importlib.import_module(
LoraTextLoaderLM = importlib.import_module( "py.nodes.checkpoint_loader"
"py.nodes.lora_loader" ).CheckpointLoaderLM
).LoraTextLoaderLM UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
TriggerWordToggleLM = importlib.import_module( TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle" "py.nodes.trigger_word_toggle"
).TriggerWordToggleLM ).TriggerWordToggleLM
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM
LoraStackCombinerLM = importlib.import_module(
"py.nodes.lora_stack_combiner"
).LoraStackCombinerLM
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM
WanVideoLoraSelectLM = importlib.import_module( WanVideoLoraSelectLM = importlib.import_module(
@@ -49,9 +55,7 @@ except (
LoraRandomizerLM = importlib.import_module( LoraRandomizerLM = importlib.import_module(
"py.nodes.lora_randomizer" "py.nodes.lora_randomizer"
).LoraRandomizerLM ).LoraRandomizerLM
LoraCyclerLM = importlib.import_module( LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
"py.nodes.lora_cycler"
).LoraCyclerLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
@@ -59,8 +63,11 @@ NODE_CLASS_MAPPINGS = {
TextLM.NAME: TextLM, TextLM.NAME: TextLM,
LoraLoaderLM.NAME: LoraLoaderLM, LoraLoaderLM.NAME: LoraLoaderLM,
LoraTextLoaderLM.NAME: LoraTextLoaderLM, LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM, TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM, LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
SaveImageLM.NAME: SaveImageLM, SaveImageLM.NAME: SaveImageLM,
DebugMetadataLM.NAME: DebugMetadataLM, DebugMetadataLM.NAME: DebugMetadataLM,
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM, WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,

673
data/supporters.json Normal file
View File

@@ -0,0 +1,673 @@
{
"specialThanks": [
"dispenser",
"EbonEagle",
"DanielMagPizza",
"Scott R"
],
"allSupporters": [
"Insomnia Art Designs",
"megakirbs",
"Brennok",
"2018cfh",
"W+K+White",
"wackop",
"Takkan",
"Carl G.",
"$MetaSamsara",
"itismyelement",
"onesecondinosaur",
"stone9k",
"Rosenthal",
"Francisco Tatis",
"Andrew Wilson",
"Greybush",
"Gooohokrbe",
"Ricky Carter",
"JongWon Han",
"OldBones",
"VantAI",
"runte3221",
"FreelancerZ",
"Edgar Tejeda",
"Liam MacDougal",
"Fraser Cross",
"Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen",
"Jorge Hussni",
"Kiba",
"Skalabananen",
"Reno Lam",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon",
"KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Mark Corneglio",
"SarcasticHashtag",
"Cosmosis",
"iamresist",
"RedrockVP",
"Wolffen",
"FloPro4Sho",
"James Todd",
"Steven Pfeiffer",
"Tim",
"Lisster",
"Michael Wong",
"Illrigger",
"Tom Corrigan",
"JackieWang",
"fnkylove",
"Julian V",
"Steven Owens",
"Yushio",
"Vik71it",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Mozzel",
"Gingko Biloba",
"Sterilized",
"BadassArabianMofo",
"Pascal Dahle",
"quarz",
"Greg",
"Penfore",
"JSST",
"esthe",
"lmsupporter",
"IamAyam",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"contrite831",
"Alex",
"bh",
"confiscated Zyra",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Timmy",
"Johnny",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"whudunit",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"Nick Walker",
"lh qwe",
"Bishoujoker",
"conner",
"aai",
"Briton Heilbrun",
"Tori",
"wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"Markus",
"S Sang",
"Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole",
"yuxz69",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"andrew.tappan",
"dw",
"N/A",
"The Spawn",
"Phil",
"graysock",
"Greenmoustache",
"zounic",
"fancypants",
"Digital",
"JaxMax",
"takyamtom",
"奚明 刘",
"Jwk0205",
"Bro Xie",
"준희 김",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX",
"Nicfit23",
"Noora",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Mattssn",
"Lex Song",
"John Saveas",
"Christopher Michel",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"Dustin Chen",
"dan",
"Yaboi",
"Mouthlessman",
"Steam Steam",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"otaku fra",
"Ran C",
"tedcor",
"Fotek Design",
"Adam Taylor",
"Weird_With_A_Beard",
"MadSpin",
"Pozadine1",
"Qarob",
"AIGooner",
"inbijiburu",
"Luc",
"ProtonPrince",
"DiffDuck",
"elu3199",
"Nick “Loadstone” D",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"linnfrey",
"Gamalonia",
"Vir",
"Pkrsky",
"Joboshy",
"Bohemian Corporal",
"Dan",
"Josef Lanzl",
"Seth Christensen",
"Griffin Dahlberg",
"Draven T",
"yer fey",
"Error_Rule34_Not_found",
"Gerald Welly",
"Roslynd",
"Geolog",
"jinxedx",
"Neco28",
"Aquatic Coffee",
"Dankin",
"ethanfel",
"Cristian Vazquez",
"Frank Nitty",
"Magic Noob",
"Focuschannel",
"DougPeterson",
"Jeff",
"Bruce",
"Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher",
"Ouro Boros",
"Blackfish95",
"dd",
"Paul Kroll",
"MiraiKuriyamaSy",
"semicolon drainpipe",
"Thesharingbrother",
"Bas Imagineer",
"Pat Hen",
"John Statham",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"decoy",
"Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄",
"MJG",
"David LaVallee",
"ae",
"Tr4shP4nda",
"WRL_SPR",
"capn",
"Joseph",
"Mirko Katzula",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"starbugx",
"Moon Knight",
"몽타주",
"Kland",
"zenobeus",
"Jackthemind",
"ryoma",
"Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris",
"Brian M",
"Nerezza",
"sanborondon",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Kevin Picco",
"Erik Lopez",
"Shock Shockor",
"Mateo Curić",
"Goldwaters",
"Zude",
"Eris3D",
"m",
"Pierce McBride",
"Joshua Gray",
"Kyler",
"Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree",
"a _",
"James Coleman",
"CrimsonDX",
"Martial",
"battu",
"Emil Andersson",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Yuji Kaneko",
"Probis",
"Dušan Ryban",
"ItsGeneralButtNaked",
"Jordan Shaw",
"Rops Alot",
"Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura",
"g unit",
"Youguang",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"momokai",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Kevinj",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
"squid_actually",
"Nat_20",
"Edward Weeks",
"kyoumei",
"RadStorm04",
"JohnDoe42054",
"BillyHill",
"emyth",
"chriphost",
"KitKatM",
"socrasteeze",
"ResidentDeviant",
"gzmzmvp",
"Welkor",
"John Martin",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Sadlip",
"Haru Yotu",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Mike Simone",
"Morgandel",
"Kyron Mahan",
"Matura Arbeit",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus",
"Artokun",
"Michael Taylor",
"SendingRavens",
"Derek Baker",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan",
"Decx _",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Distortik",
"David",
"Meilo",
"Pen Bouryoung",
"四糸凜音",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Tree Tagger",
"Janik",
"Crocket",
"Cruel",
"MRBlack",
"Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader",
"OrganicArtifact",
"Scott",
"MudkipMedkitz",
"deanbrian",
"POPPIN",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Buecyb99",
"4IXplr0r3r",
"dfklsjfkljslfjd",
"hayden",
"ahoystan",
"Leland Saunders",
"Wolfe7D1",
"Ink Temptation",
"Bob Barker",
"edk",
"Kalli Core",
"Aeternyx",
"elleshar666",
"YOU SINWOO",
"ja s",
"Doug Mason",
"Kauffy",
"Jeremy Townsend",
"EpicElric",
"Sean voets",
"Owen Gwosdz",
"John J Linehan",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Edward Kennedy",
"Justin Blaylock",
"Devil Lude",
"Nick Kage",
"kevin stoddard",
"Jack Dole",
"Vane Holzer",
"psytrax",
"Ezokewn",
"hexxish",
"CptNeo",
"notedfakes",
"Maso",
"Eric Ketchum",
"NICHOLAS BAXLEY",
"Michael Scott",
"Kevin Wallace",
"Matheus Couto",
"Saya",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Yves Poezevara",
"Teriak47",
"Just me",
"Raf Stahelin",
"Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
"Zertens",
"NOHOW",
"Apo",
"nekotxt",
"choowkee",
"Clusters",
"ibrahim",
"Highlandrise",
"philcoraz",
"mztn",
"ImagineerNL",
"MrAcrtosSursus",
"al300680",
"pixl",
"Robin",
"chahknoir",
"Marcus thronico",
"nd",
"keno94d",
"James Melzer",
"Bartleby",
"Renvertere",
"Rahuy",
"Hermann003",
"D",
"Foolish",
"RevyHiep",
"Captain_Swag",
"obkircher",
"gwyar",
"D",
"edgecase",
"Neoxena",
"mrmhalo",
"dg",
"Maarten Harms",
"Israel",
"Muratoraccio",
"SelfishMedic",
"Ginnie",
"adderleighn",
"EnragedAntelope",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon",
"David Schenck",
"han b",
"Nico",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"JBsuede",
"Michael Eid",
"beersandbacon",
"Maximilian Pyko",
"Invis",
"Justin Houston",
"Time Valentine",
"james",
"OrochiNights",
"Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo",
"Seraphy",
"Михал Михалыч",
"雨の心 落",
"Matt",
"AllTimeNoobie",
"jumpd",
"John C",
"Rim",
"Dismem",
"Frogmilk",
"SPJ",
"Xan Dionysus",
"Nathan lee",
"Mewtora",
"Middo",
"Forbidden Atelier",
"Bryan Rutkowski",
"Adictedtohumping",
"Towelie",
"Cyrus Fett",
"Jean-françois SEMA",
"Kurt",
"max blo",
"Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck",
"Chase Kwon",
"Inyoshu",
"Goober719",
"Chad Barnes",
"James Ming",
"vanditking",
"kripitonga",
"Rizzi",
"nimin",
"OMAR LUCIANO",
"hannibal",
"Jo+Example",
"BrentBertram",
"eumelzocker",
"dxjaymz",
"L C",
"Dude"
],
"totalCount": 666
}

View File

@@ -0,0 +1,170 @@
# Recipe Batch Import Feature Requirements
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## User Stories
### US-1: Directory Batch Import
As a user with a folder of reference images or workflow screenshots, I want to import all images from a directory at once so that I don't have to import them one by one.
**Acceptance Criteria:**
- User can specify a local directory path containing images
- System discovers all supported image files in the directory
- Each image is analyzed for metadata and converted to a recipe
- Results show which images succeeded, failed, or were skipped
### US-2: URL Batch Import
As a user with a list of image URLs (e.g., from Civitai or other sources), I want to import multiple images by URL in one operation.
**Acceptance Criteria:**
- User can provide multiple image URLs (one per line or as a list)
- System downloads and processes each image
- URL-specific metadata (like Civitai info) is preserved when available
- Failed URLs are reported with clear error messages
### US-3: Concurrent Processing Control
As a user with varying system resources, I want to control how many images are processed simultaneously to balance speed and system load.
**Acceptance Criteria:**
- User can configure the number of concurrent operations (1-10)
- System provides sensible defaults based on common hardware configurations
- Processing respects the concurrency limit to prevent resource exhaustion
### US-4: Import Results Summary
As a user performing a batch import, I want to see a clear summary of the operation results so I understand what succeeded and what needs attention.
**Acceptance Criteria:**
- Total count of images processed is displayed
- Number of successfully imported recipes is shown
- Number of failed imports with error details is provided
- Number of skipped images (no metadata) is indicated
- Results can be exported or saved for reference
### US-5: Progress Visibility
As a user importing a large batch, I want to see the progress of the operation so I know it's working and can estimate completion time.
**Acceptance Criteria:**
- Progress indicator shows current status (e.g., "Processing image 5 of 50")
- Real-time updates as each image completes
- Ability to view partial results before completion
- Clear indication when the operation is finished
## Functional Requirements
### FR-1: Image Discovery
The system shall discover image files in a specified directory recursively or non-recursively based on user preference.
**Supported formats:** JPG, JPEG, PNG, WebP, GIF, BMP
### FR-2: Metadata Extraction
For each image, the system shall:
- Extract EXIF metadata if present
- Parse embedded workflow data (ComfyUI PNG metadata)
- Fetch external metadata for known URL patterns (e.g., Civitai)
- Generate recipes from extracted information
### FR-3: Concurrent Processing
The system shall support concurrent processing of multiple images with:
- Configurable concurrency limit (default: 3)
- Resource-aware execution
- Graceful handling of individual failures without stopping the batch
### FR-4: Error Handling
The system shall handle various error conditions:
- Invalid directory paths
- Inaccessible files
- Network errors for URL imports
- Images without extractable metadata
- Malformed or corrupted image files
### FR-5: Recipe Persistence
Successfully analyzed images shall be persisted as recipes with:
- Extracted generation parameters
- Preview image association
- Tags and metadata
- Source information (file path or URL)
## Non-Functional Requirements
### NFR-1: Performance
- Batch operations should complete in reasonable time (< 5 seconds per image on average)
- UI should remain responsive during batch operations
- Memory usage should scale gracefully with batch size
### NFR-2: Scalability
- Support batches of 1-1000 images
- Handle mixed success/failure scenarios gracefully
- No hard limits on concurrent operations (configurable)
### NFR-3: Usability
- Clear error messages for common failure cases
- Intuitive UI for configuring import options
- Accessible from the main Recipes interface
### NFR-4: Reliability
- Failed individual imports should not crash the entire batch
- Partial results should be preserved on unexpected termination
- All operations should be idempotent (re-importing same image doesn't create duplicates)
## API Requirements
### Batch Import Endpoints
The system should expose endpoints for:
1. **Directory Import**
- Accept directory path and configuration options
- Return operation ID for status tracking
- Async or sync operation support
2. **URL Import**
- Accept list of URLs and configuration options
- Support URL validation before processing
- Return operation ID for status tracking
3. **Status/Progress**
- Query operation status by ID
- Get current progress and partial results
- Retrieve final results after completion
## UI/UX Requirements
### UIR-1: Entry Point
Batch import should be accessible from the Recipes page via a clearly labeled button in the toolbar.
### UIR-2: Import Modal
A modal dialog should provide:
- Tab or section for Directory import
- Tab or section for URL import
- Configuration options (concurrency, options)
- Start/Stop controls
- Results display area
### UIR-3: Results Display
Results should be presented with:
- Summary statistics (total, success, failed, skipped)
- Expandable details for each category
- Export or copy functionality for results
- Clear visual distinction between success/failure/skip
## Future Considerations
- **Scheduled Imports**: Ability to schedule batch imports for later execution
- **Import Templates**: Save import configurations for reuse
- **Cloud Storage**: Import from cloud storage services (Google Drive, Dropbox)
- **Duplicate Detection**: Advanced duplicate detection based on image hash
- **Tag Suggestions**: AI-powered tag suggestions for imported recipes
- **Batch Editing**: Apply tags or organization to multiple imported recipes at once
## Dependencies
- Recipe analysis service (metadata extraction)
- Recipe persistence service (storage)
- Image download capability (for URL imports)
- Recipe scanner (for refresh after import)
- Civitai client (for enhanced URL metadata)
---
*Document Version: 1.0*
*Status: Requirements Definition*

View File

@@ -0,0 +1,363 @@
# metadata.json Schema Documentation
This document defines the complete schema for `.metadata.json` files used by Lora Manager. These sidecar files store model metadata alongside model files (LoRA, Checkpoint, Embedding).
## Overview
- **File naming**: `<model_name>.metadata.json` (e.g., `my_lora.safetensors``my_lora.metadata.json`)
- **Format**: JSON with UTF-8 encoding
- **Purpose**: Store model metadata, tags, descriptions, preview images, and Civitai/CivArchive integration data
- **Extensibility**: Unknown fields are preserved via `_unknown_fields` mechanism for forward compatibility
---
## Base Fields (All Model Types)
These fields are present in all model metadata files.
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `file_name` | string | ✅ Yes | ✅ Yes | Filename without extension (e.g., `"my_lora"`) |
| `model_name` | string | ✅ Yes | ❌ No | Display name of the model. **Default**: `file_name` if no other source |
| `file_path` | string | ✅ Yes | ✅ Yes | Full absolute path to the model file (normalized with `/` separators) |
| `size` | integer | ✅ Yes | ❌ No | File size in bytes. **Set at**: Initial scan or download completion. Does not change thereafter. |
| `modified` | float | ✅ Yes | ❌ No | **Import timestamp** — Unix timestamp when the model was first imported/added to the system. Used for "Date Added" sorting. Does not change after initial creation. |
| `sha256` | string | ⚠️ Conditional | ✅ Yes | SHA256 hash of the model file (lowercase). **LoRA**: Required. **Checkpoint**: May be empty when `hash_status="pending"` (lazy hash calculation) |
| `base_model` | string | ❌ No | ❌ No | Base model type. **Examples**: `"SD 1.5"`, `"SDXL 1.0"`, `"SDXL Lightning"`, `"Flux.1 D"`, `"Flux.1 S"`, `"Flux.1 Krea"`, `"Illustrious"`, `"Pony"`, `"AuraFlow"`, `"Kolors"`, `"ZImageTurbo"`, `"Wan Video"`, etc. **Default**: `"Unknown"` or `""` |
| `preview_url` | string | ❌ No | ✅ Yes | Path to preview image file |
| `preview_nsfw_level` | integer | ❌ No | ❌ No | NSFW level using **bitmask values** from Civitai: `1` (PG), `2` (PG13), `4` (R), `8` (X), `16` (XXX), `32` (Blocked). **Default**: `0` (none) |
| `notes` | string | ❌ No | ❌ No | User-defined notes |
| `from_civitai` | boolean | ❌ No (default: `true`) | ❌ No | Whether the model originated from Civitai |
| `civitai` | object | ❌ No | ⚠️ Partial | Civitai/CivArchive API data and user-defined fields |
| `tags` | array[string] | ❌ No | ⚠️ Partial | Model tags (merged from API and user input) |
| `modelDescription` | string | ❌ No | ⚠️ Partial | Full model description (from API or user) |
| `civitai_deleted` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model was deleted from Civitai |
| `favorite` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model is marked as favorite |
| `exclude` | boolean | ❌ No (default: `false`) | ❌ No | Whether to exclude from cache/scanning. User can set from `false` to `true` (currently no UI to revert) |
| `db_checked` | boolean | ❌ No (default: `false`) | ❌ No | Whether checked against archive database |
| `skip_metadata_refresh` | boolean | ❌ No (default: `false`) | ❌ No | Skip this model during bulk metadata refresh |
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
---
## Model-Specific Fields
### LoRA Models
LoRA models do not have a `model_type` field in metadata.json. The type is inferred from context or `civitai.type` (e.g., `"LoRA"`, `"LoCon"`, `"DoRA"`).
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `usage_tips` | string (JSON) | ❌ No (default: `"{}"`) | ❌ No | JSON string containing recommended usage parameters |
**`usage_tips` JSON structure:**
```json
{
"strength_min": 0.3,
"strength_max": 0.8,
"strength_range": "0.3-0.8",
"strength": 0.6,
"clip_strength": 0.5,
"clip_skip": 2
}
```
| Key | Type | Description |
|-----|------|-------------|
| `strength_min` | number | Minimum recommended model strength |
| `strength_max` | number | Maximum recommended model strength |
| `strength_range` | string | Human-readable strength range |
| `strength` | number | Single recommended strength value |
| `clip_strength` | number | Recommended CLIP/embedding strength |
| `clip_skip` | integer | Recommended CLIP skip value |
---
### Checkpoint Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"checkpoint"`) | ❌ No | Model type: `"checkpoint"`, `"diffusion_model"` |
---
### Embedding Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"embedding"`) | ❌ No | Model type: `"embedding"` |
---
## The `civitai` Field Structure
The `civitai` object stores the complete Civitai/CivArchive API response. Lora Manager preserves all fields from the API for future compatibility and extracts specific fields for use in the application.
### Version-Level Fields (Civitai API)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Version ID |
| `modelId` | integer | Parent model ID |
| `name` | string | Version name (e.g., `"v1.0"`, `"v2.0-pruned"`) |
| `nsfwLevel` | integer | NSFW level (bitmask: 1=PG, 2=PG13, 4=R, 8=X, 16=XXX, 32=Blocked) |
| `baseModel` | string | Base model (e.g., `"SDXL 1.0"`, `"Flux.1 D"`, `"Illustrious"`, `"Pony"`) |
| `trainedWords` | array[string] | **Trigger words** for the model |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `earlyAccessEndsAt` | string\|null | Early access end date (used for update notifications) |
| `description` | string | Version description (HTML) |
| `model` | object | Parent model object (see Model-Level Fields below) |
| `creator` | object | Creator information (see Creator Fields below) |
| `files` | array[object] | File list with hashes, sizes, download URLs (used for metadata extraction) |
| `images` | array[object] | Image list with metadata, prompts, NSFW levels (used for preview/examples) |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `createdAt` | string (ISO 8601) | Creation timestamp |
| `updatedAt` | string (ISO 8601) | Last update timestamp |
| `status` | string | Version status (e.g., `"Published"`, `"Draft"`) |
| `publishedAt` | string (ISO 8601) | Publication timestamp |
| `baseModelType` | string | Base model type (e.g., `"Standard"`, `"Inpaint"`, `"Refiner"`) |
| `earlyAccessConfig` | object | Early access configuration |
| `uploadType` | string | Upload type (`"Created"`, `"FineTuned"`, etc.) |
| `usageControl` | string | Usage control setting |
| `air` | string | Artifact ID (URN format: `urn:air:sdxl:lora:civitai:122359@135867`) |
| `stats` | object | Download count, ratings, thumbs up count |
| `videos` | array[object] | Video list |
| `downloadUrl` | string | Direct download URL |
| `trainingStatus` | string\|null | Training status (for on-site training) |
| `trainingDetails` | object\|null | Training configuration |
### Model-Level Fields (`civitai.model.*`)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Model name |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `description` | string | Model description (HTML, used for `modelDescription`) |
| `tags` | array[string] | Model tags (used for `tags` field) |
| `allowNoCredit` | boolean | License: allow use without credit |
| `allowCommercialUse` | array[string] | License: allowed commercial uses. **Values**: `"Image"` (sell generated images), `"Video"` (sell generated videos), `"RentCivit"` (rent on Civitai), `"Rent"` (rent elsewhere) |
| `allowDerivatives` | boolean | License: allow derivatives |
| `allowDifferentLicense` | boolean | License: allow different license |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `nsfw` | boolean | Model NSFW flag |
| `poi` | boolean | Person of Interest flag |
### Creator Fields (`civitai.creator.*`)
Both fields are used by Lora Manager:
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | Creator username (used for author display and search) |
| `image` | string | Creator avatar URL (used for display) |
### Model Type Field (Top-Level, Outside `civitai`)
| Field | Type | Values | Description |
|-------|------|--------|-------------|
| `model_type` | string | `"checkpoint"`, `"diffusion_model"`, `"embedding"` | Stored in metadata.json for Checkpoint and Embedding models. **Note**: LoRA models do not have this field; type is inferred from `civitai.type` or context. |
### User-Defined Fields (Within `civitai`)
For models not from Civitai or user-added data:
| Field | Type | Description |
|-------|------|-------------|
| `trainedWords` | array[string] | **Trigger words** — manually added by user |
| `customImages` | array[object] | Custom example images added by user |
### customImages Structure
Each custom image entry has the following structure:
```json
{
"url": "",
"id": "short_id",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "...",
"negativePrompt": "...",
"steps": 20,
"cfgScale": 7,
"seed": 123456
},
"hasMeta": true,
"hasPositivePrompt": true
}
```
| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Empty for local custom images |
| `id` | string | Short ID or filename |
| `nsfwLevel` | integer | NSFW level (bitmask) |
| `width` | integer | Image width in pixels |
| `height` | integer | Image height in pixels |
| `type` | string | `"image"` or `"video"` |
| `meta` | object\|null | Generation metadata (prompt, seed, etc.) extracted from image |
| `hasMeta` | boolean | Whether metadata is available |
| `hasPositivePrompt` | boolean | Whether a positive prompt is available |
### Minimal Non-Civitai Example
```json
{
"civitai": {
"trainedWords": ["my_trigger_word"]
}
}
```
### Non-Civitai Example Without Trigger Words
```json
{
"civitai": {}
}
```
### Example: User-Added Custom Images
```json
{
"civitai": {
"trainedWords": ["custom_style"],
"customImages": [
{
"url": "",
"id": "example_1",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "example prompt",
"seed": 12345
},
"hasMeta": true,
"hasPositivePrompt": true
}
]
}
}
```
---
## Metadata Source Values
The `metadata_source` field indicates which provider last updated the metadata:
| Value | Source |
|-------|--------|
| `"civitai_api"` | Civitai API |
| `"civarchive"` | CivArchive API |
| `"archive_db"` | Metadata Archive Database |
| `null` | No external source (user-defined only) |
---
## Auto-Update Behavior
### Fields Updated During Scanning
These fields are automatically synchronized with the filesystem:
- `file_name` — Updated if actual filename differs
- `file_path` — Normalized and updated if path changes
- `preview_url` — Updated if preview file is moved/removed
- `sha256` — Updated during hash calculation (when `hash_status="pending"`)
- `hash_status` — Updated during hash calculation
- `last_checked_at` — Timestamp of scan
- `metadata_source` — Set based on metadata provider
### Fields Set Once (Immutable After Import)
These fields are set when the model is first imported/scanned and **never change** thereafter:
- `modified` — Import timestamp (used for "Date Added" sorting)
- `size` — File size at time of import/download
### User-Editable Fields
These fields can be edited by users at any time through the Lora Manager UI or by manually editing the metadata.json file:
- `model_name` — Display name
- `tags` — Model tags
- `modelDescription` — Model description
- `notes` — User notes
- `favorite` — Favorite flag
- `exclude` — Exclude from scanning (user can set `false``true`, currently no UI to revert)
- `skip_metadata_refresh` — Skip during bulk refresh
- `civitai.trainedWords` — Trigger words
- `civitai.customImages` — Custom example images
- `usage_tips` — Usage recommendations (LoRA only)
---
## Field Reference by Behavior
### Required Fields (Must Always Exist)
- `file_name`
- `model_name` (defaults to `file_name` if not provided)
- `file_path`
- `size`
- `modified`
- `sha256` (LoRA: always required; Checkpoint: may be empty when `hash_status="pending"`)
### Optional Fields with Defaults
| Field | Default |
|-------|---------|
| `base_model` | `"Unknown"` or `""` |
| `preview_nsfw_level` | `0` |
| `from_civitai` | `true` |
| `civitai` | `{}` |
| `tags` | `[]` |
| `modelDescription` | `""` |
| `notes` | `""` |
| `civitai_deleted` | `false` |
| `favorite` | `false` |
| `exclude` | `false` |
| `db_checked` | `false` |
| `skip_metadata_refresh` | `false` |
| `metadata_source` | `null` |
| `last_checked_at` | `0` |
| `hash_status` | `"completed"` |
| `usage_tips` | `"{}"` (LoRA only) |
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-03 | Initial schema documentation |
---
## See Also
- [JSON Schema Definition](../.specs/metadata.schema.json) — Formal JSON Schema for validation

View File

@@ -0,0 +1,196 @@
# Settings Modal Optimization Progress Tracker
## Project Overview
**Goal**: Optimize Settings Modal UI/UX with left navigation sidebar
**Started**: 2026-02-23
**Current Phase**: P2 - Search Bar (Completed)
---
## Phase 0: Left Navigation Sidebar (P0)
### Status: Completed ✓
### Completion Notes
- All CSS changes implemented
- HTML structure restructured successfully
- JavaScript navigation functionality added
- Translation keys added and synchronized
- Ready for testing and review
### Tasks
#### 1. CSS Changes
- [x] Add two-column layout styles
- [x] `.settings-modal` flex layout
- [x] `.settings-nav` sidebar styles
- [x] `.settings-content` content area styles
- [x] `.settings-nav-item` navigation item styles
- [x] `.settings-nav-item.active` active state styles
- [x] Adjust modal width to 950px
- [x] Add smooth scroll behavior
- [x] Add responsive styles for mobile
- [x] Ensure dark theme compatibility
#### 2. HTML Changes
- [x] Restructure modal HTML
- [x] Wrap content in two-column container
- [x] Add navigation sidebar structure
- [x] Add navigation items for each section
- [x] Add ID anchors to each section
- [x] Update section grouping if needed
#### 3. JavaScript Changes
- [x] Add navigation click handlers
- [x] Implement smooth scroll to section
- [x] Add scroll spy for active nav highlighting
- [x] Handle nav item click events
- [x] Update SettingsManager initialization
#### 4. Translation Keys
- [x] Add translation keys for navigation groups
- [x] `settings.nav.general`
- [x] `settings.nav.interface`
- [x] `settings.nav.download`
- [x] `settings.nav.advanced`
#### 4. Testing
- [x] Verify navigation clicks work
- [x] Verify active highlighting works
- [x] Verify smooth scrolling works
- [ ] Test on mobile viewport (deferred to final QA)
- [ ] Test dark/light theme (deferred to final QA)
- [x] Verify all existing settings work
- [x] Verify save/load functionality
### Blockers
None currently
### Notes
- Started implementation on 2026-02-23
- Following existing design system and CSS variables
---
## Phase 1: Section Collapse/Expand (P1)
### Status: Completed ✓
### Completion Notes
- All sections now have collapse/expand functionality
- Chevron icon rotates smoothly on toggle
- State persistence via localStorage working correctly
- CSS animations for smooth height transitions
- Settings order reorganized to match sidebar navigation
### Tasks
- [x] Add collapse/expand toggle to section headers
- [x] Add chevron icon with rotation animation
- [x] Implement localStorage for state persistence
- [x] Add CSS animations for smooth transitions
- [x] Reorder settings sections to match sidebar navigation
---
## Phase 2: Search Bar (P1)
### Status: Completed ✓
### Completion Notes
- Search input added to settings modal header with icon and clear button
- Real-time filtering with debounced input (150ms delay)
- Highlight matching terms with accent color background
- Handle empty search results with user-friendly message
- Keyboard shortcuts: Escape to clear search
- Sections with matches are automatically expanded
- All translation keys added and synchronized across languages
### Tasks
- [x] Add search input to header area
- [x] Implement real-time filtering
- [x] Add highlight for matched terms
- [x] Handle empty search results
---
## Phase 3: Visual Hierarchy (P2)
### Status: Planned
### Tasks
- [ ] Add accent border to section headers
- [ ] Bold setting labels
- [ ] Increase section spacing
---
## Phase 4: Quick Actions (P3)
### Status: Planned
### Tasks
- [ ] Add reset to defaults button
- [ ] Add export config button
- [ ] Add import config button
- [ ] Implement corresponding functionality
---
## Change Log
### 2026-02-23 (P2)
- Completed Phase 2: Search Bar
- Added search input to settings modal header with search icon and clear button
- Implemented real-time filtering with 150ms debounce for performance
- Added visual highlighting for matched search terms using accent color
- Implemented empty search results state with user-friendly message
- Added keyboard shortcuts (Escape to clear search)
- Sections with matching content are automatically expanded during search
- Updated SettingsManager.js with search initialization and filtering logic
- Added comprehensive CSS styles for search input, highlights, and responsive design
- Added translation keys for search feature (placeholder, clear, no results)
- Synchronized translations across all language files
### 2026-02-23 (P1)
- Completed Phase 1: Section Collapse/Expand
- Added collapse/expand functionality to all settings sections
- Implemented chevron icon with smooth rotation animation
- Added localStorage persistence for collapse state
- Reorganized settings sections to match sidebar navigation order
- Updated SettingsManager.js with section collapse initialization
- Added CSS styles for smooth transitions and animations
### 2026-02-23 (P0)
- Created project documentation
- Started Phase 0 implementation
- Analyzed existing code structure
- Implemented two-column layout with left navigation sidebar
- Added CSS styles for navigation and responsive design
- Restructured HTML to support new layout
- Added JavaScript navigation functionality with scroll spy
- Added translation keys for navigation groups
- Synchronized translations across all language files
- Tested in browser - navigation working correctly
---
## Testing Checklist
### Functional Testing
- [ ] All settings save correctly
- [ ] All settings load correctly
- [ ] Navigation scrolls to correct section
- [ ] Active nav updates on scroll
- [ ] Mobile responsive layout
### Visual Testing
- [ ] Design matches existing UI
- [ ] Dark theme looks correct
- [ ] Light theme looks correct
- [ ] Animations are smooth
- [ ] No layout shifts or jumps
### Cross-browser Testing
- [ ] Chrome/Chromium
- [ ] Firefox
- [ ] Safari (if available)

View File

@@ -0,0 +1,331 @@
# Settings Modal UI/UX Optimization
## Overview
当前Settings Modal采用单列表长页面设计随着设置项不断增加已难以高效浏览和定位。本方案采用 **macOS Settings 模式**(左侧导航 + 右侧单Section独占显示在保持原有设计语言的前提下重构信息架构大幅提升用户体验。
## Goals
1. **提升浏览效率**:用户能够快速定位和修改设置
2. **保持设计一致性**:延续现有的颜色、间距、动画系统
3. **简化交互模型**移除冗余元素SETTINGS label、折叠功能
4. **清晰的视觉层次**Section级导航右侧独占显示
5. **向后兼容**:不影响现有功能逻辑
## Design Principles
- **macOS Settings模式**点击左侧导航右侧仅显示该Section内容
- **贴近原有设计语言**使用现有CSS变量和样式模式
- **最小化风格改动**在提升UX的同时保持视觉风格稳定
- **简化优于复杂**:移除不必要的折叠/展开交互
---
## New Design Architecture
### Layout Structure
```
┌─────────────────────────────────────────────────────────────┐
│ Settings [×] │
├──────────────┬──────────────────────────────────────────────┤
│ NAVIGATION │ CONTENT │
│ │ │
│ General → │ ┌─────────────────────────────────────────┐ │
│ Interface │ │ General │ │
│ Download │ │ ═══════════════════════════════════════ │ │
│ Advanced │ │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │
│ │ │ │ Civitai API Key │ │ │
│ │ │ │ [ ] [?] │ │ │
│ │ │ └─────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │
│ │ │ │ Settings Location │ │ │
│ │ │ │ [/path/to/settings] [Browse] │ │ │
│ │ │ └─────────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────┘ │
│ │ │
│ │ [Cancel] [Save Changes] │
└──────────────┴──────────────────────────────────────────────┘
```
### Key Design Decisions
#### 1. 移除冗余元素
- ❌ 删除 sidebar 中的 "SETTINGS" label
-**取消折叠/展开功能**(增加交互成本,无实际收益)
- ❌ 不再在左侧导航显示具体设置项(减少认知负荷)
#### 2. 导航简化
- 左侧仅显示 **4个Section**General / Interface / Download / Advanced
- 当前选中项用 accent 色 background highlight
- 无需滚动监听,点击即切换
#### 3. 右侧单Section独占
- 点击左侧导航右侧仅显示该Section的所有设置项
- Section标题作为页面标题大号字体 + accent色下划线
- 所有设置项平铺展示,无需折叠
#### 4. 视觉层次
```
Section Header (20px, bold, accent underline)
├── Setting Group (card container, subtle border)
│ ├── Setting Label (14px, semibold)
│ ├── Setting Description (12px, muted color)
│ └── Setting Control (input/select/toggle)
```
---
## Optimization Phases
### Phase 0: macOS Settings模式重构 (P0)
**Status**: Ready for Development
**Priority**: High
#### Goals
- 重构为两栏布局(左侧导航 + 右侧内容)
- 实现Section级导航切换
- 优化视觉层次和间距
- 移除冗余元素
#### Implementation Details
##### Layout Specifications
| Element | Specification |
|---------|--------------|
| Modal Width | 800px (比原700px稍宽) |
| Modal Height | 600px (固定高度) |
| Left Sidebar | 200px 固定宽度 |
| Right Content | flex: 1自动填充 |
| Content Padding | --space-3 (24px) |
##### Navigation Structure
```
General (通用)
├── Language
├── Civitai API Key
└── Settings Location
Interface (界面)
├── Layout Settings
├── Video Settings
└── Content Filtering
Download (下载)
├── Folder Settings
├── Download Path Templates
├── Example Images
└── Update Flags
Advanced (高级)
├── Priority Tags
├── Auto-organize exclusions
├── Metadata refresh skip paths
├── Metadata Archive Database
├── Proxy Settings
└── Misc
```
##### CSS Style Guide
**Section Header**
```css
.settings-section-header {
font-size: 20px;
font-weight: 600;
padding-bottom: var(--space-2);
border-bottom: 2px solid var(--lora-accent);
margin-bottom: var(--space-3);
}
```
**Setting Group (Card)**
```css
.settings-group {
background: var(--card-bg);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
```
**Setting Item**
```css
.setting-item {
margin-bottom: var(--space-3);
}
.setting-item:last-child {
margin-bottom: 0;
}
.setting-label {
font-size: 14px;
font-weight: 500;
margin-bottom: var(--space-1);
}
.setting-description {
font-size: 12px;
color: var(--text-muted);
margin-bottom: var(--space-2);
}
```
**Sidebar Navigation**
```css
.settings-nav-item {
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background 0.2s ease;
}
.settings-nav-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.settings-nav-item.active {
background: var(--lora-accent);
color: white;
}
```
#### Files to Modify
1. **static/css/components/modal/settings-modal.css**
- [ ] 新增两栏布局样式
- [ ] 新增侧边栏导航样式
- [ ] 新增Section标题样式
- [ ] 调整设置项卡片样式
- [ ] 移除折叠相关的CSS
2. **templates/components/modals/settings_modal.html**
- [ ] 重构为两栏HTML结构
- [ ] 添加4个导航项
- [ ] 将Section改为独立内容区域
- [ ] 移除折叠按钮HTML
3. **static/js/managers/SettingsManager.js**
- [ ] 添加导航点击切换逻辑
- [ ] 添加Section显示/隐藏控制
- [ ] 移除折叠/展开相关代码
- [ ] 默认显示第一个Section
---
### Phase 1: 搜索功能 (P1)
**Status**: Planned
**Priority**: Medium
#### Goals
- 快速定位特定设置项
- 支持关键词搜索设置标签和描述
#### Implementation
- 搜索框保持在顶部右侧
- 实时过滤显示匹配的Section和设置项
- 高亮匹配的关键词
- 无结果时显示友好提示
---
### Phase 2: 操作按钮优化 (P2)
**Status**: Planned
**Priority**: Low
#### Goals
- 增强功能完整性
- 提供批量操作能力
#### Implementation
- 底部固定操作栏position: sticky
- [Cancel] 和 [Save Changes] 按钮
- 可选:重置为默认、导出配置、导入配置
---
## Migration Notes
### Removed Features
| Feature | Reason |
|---------|--------|
| Section折叠/展开 | 单Section独占显示后不再需要 |
| 滚动监听高亮 | 改为点击切换,无需监听滚动 |
| 长页面平滑滚动 | 内容不再超长,无需滚动 |
| "SETTINGS" label | 冗余信息移除以简化UI |
### Preserved Features
- 所有设置项功能和逻辑
- 表单验证
- 设置项描述和提示
- 原有的CSS变量系统
---
## Success Criteria
### Phase 0
- [ ] Modal显示为两栏布局
- [ ] 左侧显示4个Section导航
- [ ] 点击导航切换右侧显示的Section
- [ ] 当前选中导航项高亮显示
- [ ] Section标题有accent色下划线
- [ ] 设置项以卡片形式分组展示
- [ ] 移除所有折叠/展开功能
- [ ] 移动端响应式正常(单栏堆叠)
- [ ] 所有现有设置功能正常工作
- [ ] 设计风格与原有UI一致
### Phase 1
- [ ] 搜索框可输入关键词
- [ ] 实时过滤显示匹配项
- [ ] 高亮匹配的关键词
### Phase 2
- [ ] 底部有固定操作按钮栏
- [ ] Cancel和Save Changes按钮工作正常
---
## Timeline
| Phase | Estimated Time | Status |
|-------|---------------|--------|
| P0 | 3-4 hours | Ready for Development |
| P1 | 2-3 hours | Planned |
| P2 | 1-2 hours | Planned |
---
## Reference
### Design Inspiration
- **macOS System Settings**: 左侧导航 + 右侧单Section独占
- **VS Code Settings**: 清晰的视觉层次和搜索体验
- **Linear**: 简洁的两栏布局设计
### CSS Variables Reference
```css
/* Colors */
--lora-accent: #007AFF;
--lora-border: rgba(255, 255, 255, 0.1);
--card-bg: rgba(255, 255, 255, 0.05);
--text-color: #ffffff;
--text-muted: rgba(255, 255, 255, 0.6);
/* Spacing */
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 24px;
/* Border Radius */
--border-radius-xs: 4px;
--border-radius-sm: 8px;
```
---
**Last Updated**: 2025-02-24
**Author**: AI Assistant
**Status**: Ready for Implementation

View File

@@ -0,0 +1,191 @@
# Settings Modal Optimization Progress
**Project**: Settings Modal UI/UX Optimization
**Status**: Phase 0 - Ready for Development
**Last Updated**: 2025-02-24
---
## Phase 0: macOS Settings模式重构
### Overview
重构Settings Modal为macOS Settings模式左侧Section导航 + 右侧单Section独占显示。移除冗余元素优化视觉层次。
### Tasks
#### 1. CSS Updates ✅
**File**: `static/css/components/modal/settings-modal.css`
- [x] **Layout Styles**
- [x] Modal固定尺寸 800x600px
- [x] 左侧 sidebar 固定宽度 200px
- [x] 右侧 content flex: 1 自动填充
- [x] **Navigation Styles**
- [x] `.settings-nav` 容器样式
- [x] `.settings-nav-item` 基础样式更大字体更醒目的active状态
- [x] `.settings-nav-item.active` 高亮样式accent背景
- [x] `.settings-nav-item:hover` 悬停效果
- [x] 隐藏 "SETTINGS" label
- [x] 隐藏 group titles
- [x] **Content Area Styles**
- [x] `.settings-section` 默认隐藏(仅当前显示)
- [x] `.settings-section.active` 显示状态
- [x] `.settings-section-header` 标题样式20px + accent下划线
- [x] 添加 fadeIn 动画效果
- [x] **Cleanup**
- [x] 移除折叠相关样式
- [x] 移除 `.settings-section-toggle` 按钮样式
- [x] 移除展开/折叠动画样式
**Status**: ✅ Completed
---
#### 2. HTML Structure Update ✅
**File**: `templates/components/modals/settings_modal.html`
- [x] **Navigation Items**
- [x] General (通用)
- [x] Interface (界面)
- [x] Download (下载)
- [x] Advanced (高级)
- [x] 移除 "SETTINGS" label
- [x] 移除 group titles
- [x] **Content Sections**
- [x] 重组为4个Section (general/interface/download/advanced)
- [x] 每个section添加 `data-section` 属性
- [x] 添加Section标题带accent下划线
- [x] 移除所有折叠按钮chevron图标
- [x] 平铺显示所有设置项
**Status**: ✅ Completed
---
#### 3. JavaScript Logic Update ✅
**File**: `static/js/managers/SettingsManager.js`
- [x] **Navigation Logic**
- [x] `initializeNavigation()` 改为Section切换模式
- [x] 点击导航项显示对应Section
- [x] 更新导航高亮状态
- [x] 默认显示第一个Section
- [x] **Remove Legacy Code**
- [x] 移除 `initializeSectionCollapse()` 方法
- [x] 移除滚动监听相关代码
- [x] 移除 `localStorage` 折叠状态存储
- [x] **Search Function**
- [x] 更新搜索功能以适配新显示模式
- [x] 搜索时自动切换到匹配的Section
- [x] 高亮匹配的关键词
**Status**: ✅ Completed
---
### Testing Checklist
#### Visual Testing
- [ ] 两栏布局正确显示
- [ ] 左侧导航4个Section正确显示
- [ ] 点击导航切换右侧内容
- [ ] 当前导航项高亮显示accent背景
- [ ] Section标题有accent色下划线
- [ ] 设置项以卡片形式分组
- [ ] 无"SETTINGS" label
- [ ] 无折叠/展开按钮
#### Functional Testing
- [ ] 所有设置项可正常编辑
- [ ] 设置保存功能正常
- [ ] 设置加载功能正常
- [ ] 表单验证正常工作
- [ ] 帮助提示tooltip正常显示
#### Responsive Testing
- [ ] 桌面端(>768px两栏布局
- [ ] 移动端(<768px单栏堆叠
- [ ] 移动端导航可正常切换
#### Cross-Browser Testing
- [ ] Chrome/Edge
- [ ] Firefox
- [ ] Safari如适用
---
## Phase 1: 搜索功能
### Tasks
- [ ] 搜索框UI更新
- [ ] 搜索逻辑实现
- [ ] 实时过滤显示
- [ ] 关键词高亮
**Estimated Time**: 2-3 hours
**Status**: 📋 Planned
---
## Phase 2: 操作按钮优化
### Tasks
- [ ] 底部操作栏样式
- [ ] 固定定位sticky
- [ ] Cancel/Save按钮功能
- [ ] 可选Reset/Export/Import
**Estimated Time**: 1-2 hours
**Status**: 📋 Planned
---
## Progress Summary
| Phase | Progress | Status |
|-------|----------|--------|
| Phase 0 | 100% | Completed |
| Phase 1 | 0% | 📋 Planned |
| Phase 2 | 0% | 📋 Planned |
**Overall Progress**: 100% (Phase 0)
---
## Development Log
### 2025-02-24
- 创建优化提案文档macOS Settings模式
- 创建进度追踪文档
- Phase 0 开发完成
- CSS重构完成新增macOS Settings样式移除折叠相关样式
- HTML重构完成重组为4个Section移除所有折叠按钮
- JavaScript重构完成实现Section切换逻辑更新搜索功能
---
## Notes
### Design Decisions
- 采用macOS Settings模式而非长页面滚动模式
- 左侧仅显示4个Section不显示具体设置项
- 移除折叠/展开功能简化交互
- Section标题使用accent色下划线强调
### Technical Notes
- 优先使用现有CSS变量
- 保持向后兼容不破坏现有设置存储逻辑
- 移动端响应式小屏幕单栏堆叠
### Blockers
None
---
**Next Action**: Start Phase 0 - CSS Updates

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"actions": { "actions": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen", "delete": "Löschen",
"move": "Verschieben", "move": "Verschieben",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -11,7 +14,8 @@
"backToTop": "Nach oben", "backToTop": "Nach oben",
"settings": "Einstellungen", "settings": "Einstellungen",
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen" "add": "Hinzufügen",
"close": "Schließen"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Voreinstellungsname...", "presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Modelltypen",
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
@@ -258,17 +262,27 @@
"contentFiltering": "Inhaltsfilterung", "contentFiltering": "Inhaltsfilterung",
"videoSettings": "Video-Einstellungen", "videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"folderSettings": "Ordner-Einstellungen",
"priorityTags": "Prioritäts-Tags",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"exampleImages": "Beispielbilder",
"updateFlags": "Update-Markierungen",
"autoOrganize": "Auto-organize",
"misc": "Verschiedenes", "misc": "Verschiedenes",
"metadataArchive": "Metadaten-Archiv-Datenbank", "folderSettings": "Standard-Roots",
"storageLocation": "Einstellungsort", "extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags",
"updateFlags": "Update-Markierungen",
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
"proxySettings": "Proxy-Einstellungen" "proxySettings": "Proxy-Einstellungen"
}, },
"nav": {
"general": "Allgemein",
"interface": "Oberfläche",
"library": "Bibliothek"
},
"search": {
"placeholder": "Einstellungen durchsuchen...",
"clear": "Suche löschen",
"noResults": "Keine Einstellungen gefunden für \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Portabler Modus", "locationLabel": "Portabler Modus",
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern." "locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."
@@ -277,7 +291,15 @@
"blurNsfwContent": "NSFW-Inhalte unscharf stellen", "blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen", "blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen", "showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern" "showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
"matureBlurThreshold": "Schwelle für Unschärfe bei jugendgefährdenden Inhalten",
"matureBlurThresholdHelp": "Legen Sie fest, ab welcher Altersfreigabe die Unschärfe beginnt, wenn NSFW-Unschärfe aktiviert ist.",
"matureBlurThresholdOptions": {
"pg13": "PG13 und höher",
"r": "R und höher (Standard)",
"x": "X und höher",
"xxx": "Nur XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Videos bei Hover automatisch abspielen", "autoplayOnHover": "Videos bei Hover automatisch abspielen",
@@ -301,6 +323,24 @@
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}" "saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Downloads für Basismodelle überspringen",
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
"searchPlaceholder": "Basismodelle filtern...",
"empty": "Keine Basismodelle entsprechen der aktuellen Suche.",
"summary": {
"none": "Nichts ausgewählt",
"count": "{count} ausgewählt"
},
"actions": {
"edit": "Bearbeiten",
"collapse": "Einklappen",
"clear": "Löschen"
},
"validation": {
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.", "activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.",
"loadingLibraries": "Bibliotheken werden geladen...", "loadingLibraries": "Bibliotheken werden geladen...",
"noLibraries": "Keine Bibliotheken konfiguriert", "noLibraries": "Keine Bibliotheken konfiguriert",
"defaultLoraRoot": "Standard-LoRA-Stammordner", "defaultLoraRoot": "LoRA-Stammordner",
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner", "defaultCheckpointRoot": "Checkpoint-Stammordner",
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultUnetRoot": "Standard-Diffusion-Modell-Stammordner", "defaultUnetRoot": "Diffusion-Modell-Stammordner",
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Standard-Embedding-Stammordner", "defaultEmbeddingRoot": "Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard" "noDefault": "Kein Standard"
}, },
"extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade",
"description": "Zusätzliche Modellstammverzeichnisse, die ausschließlich für LoRA Manager gelten. Laden Sie Modelle von Speicherorten außerhalb der Standardordner von ComfyUI ideal für große Bibliotheken, die ComfyUI sonst verlangsamen würden.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embedding": "Embedding-Pfade"
},
"pathPlaceholder": "/pfad/zu/extra/modellen",
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
}
},
"priorityTags": { "priorityTags": {
"title": "Prioritäts-Tags", "title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))", "description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen", "deleteAll": "Alle Modelle löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -614,6 +672,8 @@
"root": "Stammverzeichnis", "root": "Stammverzeichnis",
"browseFolders": "Ordner durchsuchen:", "browseFolders": "Ordner durchsuchen:",
"downloadAndSaveRecipe": "Herunterladen & Rezept speichern", "downloadAndSaveRecipe": "Herunterladen & Rezept speichern",
"importRecipeOnly": "Nur Rezept importieren",
"importAndDownload": "Importieren & Herunterladen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"saveRecipe": "Rezept speichern", "saveRecipe": "Rezept speichern",
"loraCountInfo": "({existing}/{total} in Bibliothek)", "loraCountInfo": "({existing}/{total} in Bibliothek)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "Wenigste" "lorasCountAsc": "Wenigste"
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren" "title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
}, },
"filteredByLora": "Gefiltert nach LoRA", "filteredByLora": "Gefiltert nach LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "Rezept-Reparatur fehlgeschlagen: {message}", "failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID" "missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben" "moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "Sidebar lösen", "unpinSidebar": "Sidebar lösen",
"switchToListView": "Zur Listenansicht wechseln", "switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln", "switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner durchsuchen", "recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktuellen Ordner durchsuchen", "recursiveOff": "Nur aktueller Ordner",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "Basis-Modell aktualisieren", "save": "Basis-Modell aktualisieren",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"bulkDownloadMissingLoras": {
"title": "Fehlende LoRAs herunterladen",
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
"previewTitle": "Zu herunterladende LoRAs:",
"moreItems": "...und {count} weitere",
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"exampleAccess": { "exampleAccess": {
"title": "Lokale Beispielbilder", "title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen" "openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet", "success": "Dateispeicherort erfolgreich geöffnet",
@@ -937,6 +1080,9 @@
"copied": "Pfad in die Zwischenablage kopiert: {{path}}", "copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}" "clipboardFallback": "Pfad: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Dateiname", "fileName": "Dateiname",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt" "noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
@@ -1315,7 +1463,14 @@
"showWechatQR": "WeChat QR-Code anzeigen", "showWechatQR": "WeChat QR-Code anzeigen",
"hideWechatQR": "WeChat QR-Code ausblenden" "hideWechatQR": "WeChat QR-Code ausblenden"
}, },
"footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️" "footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️",
"supporters": {
"title": "Danke an alle Unterstützer",
"subtitle": "Danke an {count} Unterstützer, die dieses Projekt möglich gemacht haben",
"specialThanks": "Besonderer Dank",
"allSupporters": "Alle Unterstützer",
"totalCount": "{count} Unterstützer insgesamt"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus", "pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", "autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "Fehler beim Laden der {modelType}s: {message}", "loadFailed": "Fehler beim Laden der {modelType}s: {message}",
"refreshComplete": "Aktualisierung abgeschlossen", "refreshComplete": "Aktualisierung abgeschlossen",
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}", "refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
"syncComplete": "Synchronisation abgeschlossen",
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}", "updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}", "updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert", "nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
"nameUpdated": "Rezeptname erfolgreich aktualisiert", "nameUpdated": "Rezeptname erfolgreich aktualisiert",
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert", "tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
"sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert", "sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert",
"promptUpdated": "Prompt erfolgreich aktualisiert",
"negativePromptUpdated": "Negativer Prompt erfolgreich aktualisiert",
"promptEditorHint": "Drücken Sie Enter zum Speichern, Shift+Enter für neue Zeile",
"noRecipeId": "Keine Rezept-ID verfügbar", "noRecipeId": "Keine Rezept-ID verfügbar",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -1383,9 +1545,20 @@
"processingError": "Verarbeitungsfehler: {message}", "processingError": "Verarbeitungsfehler: {message}",
"folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}", "folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}",
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}", "recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import fehlgeschlagen: {message}", "importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums" "folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Cancel",
"confirm": "Confirm",
"actions": { "actions": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete", "delete": "Delete",
"move": "Move", "move": "Move",
"refresh": "Refresh", "refresh": "Refresh",
@@ -11,7 +14,8 @@
"backToTop": "Back to top", "backToTop": "Back to top",
"settings": "Settings", "settings": "Settings",
"help": "Help", "help": "Help",
"add": "Add" "add": "Add",
"close": "Close"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -258,17 +262,27 @@
"contentFiltering": "Content Filtering", "contentFiltering": "Content Filtering",
"videoSettings": "Video Settings", "videoSettings": "Video Settings",
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"folderSettings": "Folder Settings", "misc": "Miscellaneous",
"priorityTags": "Priority Tags", "folderSettings": "Default Roots",
"extraFolderPaths": "Extra Folder Paths",
"downloadPathTemplates": "Download Path Templates", "downloadPathTemplates": "Download Path Templates",
"exampleImages": "Example Images", "priorityTags": "Priority Tags",
"updateFlags": "Update Flags", "updateFlags": "Update Flags",
"exampleImages": "Example Images",
"autoOrganize": "Auto-organize", "autoOrganize": "Auto-organize",
"misc": "Misc.", "metadata": "Metadata",
"metadataArchive": "Metadata Archive Database",
"storageLocation": "Settings Location",
"proxySettings": "Proxy Settings" "proxySettings": "Proxy Settings"
}, },
"nav": {
"general": "General",
"interface": "Interface",
"library": "Library"
},
"search": {
"placeholder": "Search settings...",
"clear": "Clear search",
"noResults": "No settings found matching \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Portable mode", "locationLabel": "Portable mode",
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory." "locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."
@@ -277,7 +291,15 @@
"blurNsfwContent": "Blur NSFW Content", "blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images", "blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
"showOnlySfw": "Show Only SFW Results", "showOnlySfw": "Show Only SFW Results",
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching" "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
"matureBlurThreshold": "Mature Blur Threshold",
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "PG13 and above",
"r": "R and above (default)",
"x": "X and above",
"xxx": "XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Autoplay Videos on Hover", "autoplayOnHover": "Autoplay Videos on Hover",
@@ -301,6 +323,24 @@
"saveFailed": "Unable to save skip paths: {message}" "saveFailed": "Unable to save skip paths: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Skip downloads for base models",
"help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.",
"searchPlaceholder": "Filter base models...",
"empty": "No base models match the current search.",
"summary": {
"none": "None selected",
"count": "{count} selected"
},
"actions": {
"edit": "Edit",
"collapse": "Collapse",
"clear": "Clear"
},
"validation": {
"saveFailed": "Unable to save excluded base models: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
"loadingLibraries": "Loading libraries...", "loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured", "noLibraries": "No libraries configured",
"defaultLoraRoot": "Default LoRA Root", "defaultLoraRoot": "LoRA Root",
"defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves", "defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
"defaultCheckpointRoot": "Default Checkpoint Root", "defaultCheckpointRoot": "Checkpoint Root",
"defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves", "defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves",
"defaultUnetRoot": "Default Diffusion Model Root", "defaultUnetRoot": "Diffusion Model Root",
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Default Embedding Root", "defaultEmbeddingRoot": "Embedding Root",
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"noDefault": "No Default" "noDefault": "No Default"
}, },
"extraFolderPaths": {
"title": "Extra Folder Paths",
"description": "Additional model root paths exclusive to LoRA Manager. Load models from locations outside ComfyUI's standard folders—ideal for large libraries that would otherwise slow down ComfyUI.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "LoRA Paths",
"checkpoint": "Checkpoint Paths",
"unet": "Diffusion Model Paths",
"embedding": "Embedding Paths"
},
"pathPlaceholder": "/path/to/extra/models",
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
"saveError": "Failed to update extra folder paths: {message}",
"validation": {
"duplicatePath": "This path is already configured"
}
},
"priorityTags": { "priorityTags": {
"title": "Priority Tags", "title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "Skip Metadata Refresh for Selected", "skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected", "resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models", "deleteAll": "Delete Selected Models",
"downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -614,6 +672,8 @@
"root": "Root", "root": "Root",
"browseFolders": "Browse Folders:", "browseFolders": "Browse Folders:",
"downloadAndSaveRecipe": "Download & Save Recipe", "downloadAndSaveRecipe": "Download & Save Recipe",
"importRecipeOnly": "Import Recipe Only",
"importAndDownload": "Import & Download",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"saveRecipe": "Save Recipe", "saveRecipe": "Save Recipe",
"loraCountInfo": "({existing}/{total} in library)", "loraCountInfo": "({existing}/{total} in library)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "Least" "lorasCountAsc": "Least"
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list" "title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
}, },
"filteredByLora": "Filtered by LoRA", "filteredByLora": "Filtered by LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "Failed to repair recipe: {message}", "failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID" "missingId": "Cannot repair recipe: Missing recipe ID"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder" "moveToOtherTypeFolder": "Move to {otherType} Folder",
"sendToWorkflow": "Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "Unpin Sidebar", "unpinSidebar": "Unpin Sidebar",
"switchToListView": "Switch to List View", "switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View", "switchToTreeView": "Switch to Tree View",
"recursiveOn": "Search subfolders", "recursiveOn": "Include subfolders",
"recursiveOff": "Search current folder only", "recursiveOff": "Current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only", "recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "Update Base Model", "save": "Update Base Model",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"bulkDownloadMissingLoras": {
"title": "Download Missing LoRAs",
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
"previewTitle": "LoRAs to download:",
"moreItems": "...and {count} more",
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Local Example Images", "title": "Local Example Images",
"message": "No local example images found for this model. View options:", "message": "No local example images found for this model. View options:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location" "openFileLocation": "Open File Location",
"sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "File location opened successfully", "success": "File location opened successfully",
@@ -937,6 +1080,9 @@
"copied": "Path copied to clipboard: {{path}}", "copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}" "clipboardFallback": "Path: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Unable to send to ComfyUI: No file path available"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "File Name", "fileName": "File Name",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "Recipe replaced in workflow", "recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow", "recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow", "noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected" "noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1315,7 +1463,14 @@
"showWechatQR": "Show WeChat QR Code", "showWechatQR": "Show WeChat QR Code",
"hideWechatQR": "Hide WeChat QR Code" "hideWechatQR": "Hide WeChat QR Code"
}, },
"footer": "Thank you for using LoRA Manager! ❤️" "footer": "Thank you for using LoRA Manager! ❤️",
"supporters": {
"title": "Thank You To Our Supporters",
"subtitle": "Thanks to {count} supporters who made this project possible",
"specialThanks": "Special Thanks",
"allSupporters": "All Supporters",
"totalCount": "{count} supporters in total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "Please select a version", "pleaseSelectVersion": "Please select a version",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}", "autoOrganizeFailed": "Auto-organize failed: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "Failed to load {modelType}s: {message}", "loadFailed": "Failed to load {modelType}s: {message}",
"refreshComplete": "Refresh complete", "refreshComplete": "Refresh complete",
"refreshFailed": "Failed to refresh recipes: {message}", "refreshFailed": "Failed to refresh recipes: {message}",
"syncComplete": "Sync complete",
"syncFailed": "Failed to sync recipes: {message}",
"updateFailed": "Failed to update recipe: {error}", "updateFailed": "Failed to update recipe: {error}",
"updateError": "Error updating recipe: {message}", "updateError": "Error updating recipe: {message}",
"nameSaved": "Recipe \"{name}\" saved successfully", "nameSaved": "Recipe \"{name}\" saved successfully",
"nameUpdated": "Recipe name updated successfully", "nameUpdated": "Recipe name updated successfully",
"tagsUpdated": "Recipe tags updated successfully", "tagsUpdated": "Recipe tags updated successfully",
"sourceUrlUpdated": "Source URL updated successfully", "sourceUrlUpdated": "Source URL updated successfully",
"promptUpdated": "Prompt updated successfully",
"negativePromptUpdated": "Negative prompt updated successfully",
"promptEditorHint": "Press Enter to save, Shift+Enter for new line",
"noRecipeId": "No recipe ID available", "noRecipeId": "No recipe ID available",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}", "copyFailed": "Error copying recipe syntax: {message}",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -1383,9 +1545,20 @@
"processingError": "Processing error: {message}", "processingError": "Processing error: {message}",
"folderBrowserError": "Error loading folder browser: {message}", "folderBrowserError": "Error loading folder browser: {message}",
"recipeSaveFailed": "Failed to save recipe: {error}", "recipeSaveFailed": "Failed to save recipe: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import failed: {message}", "importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree" "folderTreeError": "Error loading folder tree",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
}, },
"models": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar",
"delete": "Eliminar", "delete": "Eliminar",
"move": "Mover", "move": "Mover",
"refresh": "Actualizar", "refresh": "Actualizar",
@@ -11,7 +14,8 @@
"backToTop": "Volver arriba", "backToTop": "Volver arriba",
"settings": "Configuración", "settings": "Configuración",
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir" "add": "Añadir",
"close": "Cerrar"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Nombre del preajuste...", "presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"modelTags": "Etiquetas (Top 20)", "modelTags": "Etiquetas (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Tipos de modelos",
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
@@ -258,17 +262,27 @@
"contentFiltering": "Filtrado de contenido", "contentFiltering": "Filtrado de contenido",
"videoSettings": "Configuración de video", "videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"folderSettings": "Configuración de carpetas",
"priorityTags": "Etiquetas prioritarias",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"exampleImages": "Imágenes de ejemplo",
"updateFlags": "Indicadores de actualización",
"autoOrganize": "Auto-organize",
"misc": "Varios", "misc": "Varios",
"metadataArchive": "Base de datos de archivo de metadatos", "folderSettings": "Raíces predeterminadas",
"storageLocation": "Ubicación de ajustes", "extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias",
"updateFlags": "Indicadores de actualización",
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
"proxySettings": "Configuración de proxy" "proxySettings": "Configuración de proxy"
}, },
"nav": {
"general": "General",
"interface": "Interfaz",
"library": "Biblioteca"
},
"search": {
"placeholder": "Buscar ajustes...",
"clear": "Limpiar búsqueda",
"noResults": "No se encontraron ajustes que coincidan con \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Modo portátil", "locationLabel": "Modo portátil",
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario." "locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."
@@ -277,7 +291,15 @@
"blurNsfwContent": "Difuminar contenido NSFW", "blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)", "blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
"showOnlySfw": "Mostrar solo resultados SFW", "showOnlySfw": "Mostrar solo resultados SFW",
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar" "showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
"matureBlurThreshold": "Umbral de difuminado para contenido adulto",
"matureBlurThresholdHelp": "Establecer a partir de qué nivel de clasificación comienza el filtrado por difuminado cuando el difuminado NSFW está habilitado.",
"matureBlurThresholdOptions": {
"pg13": "PG13 y superior",
"r": "R y superior (predeterminado)",
"x": "X y superior",
"xxx": "Solo XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
@@ -301,6 +323,24 @@
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}" "saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Omitir descargas para modelos base",
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
"searchPlaceholder": "Filtrar modelos base...",
"empty": "Ningún modelo base coincide con la búsqueda actual.",
"summary": {
"none": "Ninguno seleccionado",
"count": "{count} seleccionados"
},
"actions": {
"edit": "Editar",
"collapse": "Contraer",
"clear": "Limpiar"
},
"validation": {
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.", "activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.",
"loadingLibraries": "Cargando bibliotecas...", "loadingLibraries": "Cargando bibliotecas...",
"noLibraries": "No hay bibliotecas configuradas", "noLibraries": "No hay bibliotecas configuradas",
"defaultLoraRoot": "Raíz predeterminada de LoRA", "defaultLoraRoot": "Raíz de LoRA",
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos", "defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint", "defaultCheckpointRoot": "Raíz de checkpoint",
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos", "defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
"defaultUnetRoot": "Raíz predeterminada de Diffusion Model", "defaultUnetRoot": "Raíz de Diffusion Model",
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos", "defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz predeterminada de embedding", "defaultEmbeddingRoot": "Raíz de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado" "noDefault": "Sin predeterminado"
}, },
"extraFolderPaths": {
"title": "Rutas de carpetas adicionales",
"description": "Rutas raíz de modelos adicionales exclusivas para LoRA Manager. Cargue modelos desde ubicaciones fuera de las carpetas estándar de ComfyUI, ideal para bibliotecas grandes que de otro modo ralentizarían ComfyUI.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embedding": "Rutas de Embedding"
},
"pathPlaceholder": "/ruta/a/modelos/extra",
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": {
"duplicatePath": "Esta ruta ya está configurada"
}
},
"priorityTags": { "priorityTags": {
"title": "Etiquetas prioritarias", "title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))", "description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos", "deleteAll": "Eliminar todos los modelos",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -614,6 +672,8 @@
"root": "Raíz", "root": "Raíz",
"browseFolders": "Explorar carpetas:", "browseFolders": "Explorar carpetas:",
"downloadAndSaveRecipe": "Descargar y guardar receta", "downloadAndSaveRecipe": "Descargar y guardar receta",
"importRecipeOnly": "Importar solo la receta",
"importAndDownload": "Importar y descargar",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"saveRecipe": "Guardar receta", "saveRecipe": "Guardar receta",
"loraCountInfo": "({existing}/{total} en la biblioteca)", "loraCountInfo": "({existing}/{total} en la biblioteca)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "Menos" "lorasCountAsc": "Menos"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas" "title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
}, },
"filteredByLora": "Filtrado por LoRA", "filteredByLora": "Filtrado por LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "Error al reparar la receta: {message}", "failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta" "missingId": "No se puede reparar la receta: falta el ID de la receta"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}" "moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "Desfijar barra lateral", "unpinSidebar": "Desfijar barra lateral",
"switchToListView": "Cambiar a vista de lista", "switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol", "switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Buscar en subcarpetas", "recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Buscar solo en la carpeta actual", "recursiveOff": "Solo carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "Actualizar modelo base", "save": "Actualizar modelo base",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"bulkDownloadMissingLoras": {
"title": "Descargar LoRAs faltantes",
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
"previewTitle": "LoRAs para descargar:",
"moreItems": "...y {count} más",
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Imágenes de ejemplo locales", "title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo" "openFileLocation": "Abrir ubicación del archivo",
"sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente", "success": "Ubicación del archivo abierta exitosamente",
@@ -937,6 +1080,9 @@
"copied": "Ruta copiada al portapapeles: {{path}}", "copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}" "clipboardFallback": "Ruta: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
},
"metadata": { "metadata": {
"version": "Versión", "version": "Versión",
"fileName": "Nombre de archivo", "fileName": "Nombre de archivo",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino" "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
@@ -1315,7 +1463,14 @@
"showWechatQR": "Mostrar código QR de WeChat", "showWechatQR": "Mostrar código QR de WeChat",
"hideWechatQR": "Ocultar código QR de WeChat" "hideWechatQR": "Ocultar código QR de WeChat"
}, },
"footer": "¡Gracias por usar el gestor de LoRA! ❤️" "footer": "¡Gracias por usar el gestor de LoRA! ❤️",
"supporters": {
"title": "Gracias a todos los seguidores",
"subtitle": "Gracias a {count} seguidores que hicieron este proyecto posible",
"specialThanks": "Agradecimientos especiales",
"allSupporters": "Todos los seguidores",
"totalCount": "{count} seguidores en total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "Por favor selecciona una versión", "pleaseSelectVersion": "Por favor selecciona una versión",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}", "autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos", "autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}", "autoOrganizeFailed": "Auto-organización fallida: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "Error al cargar {modelType}s: {message}", "loadFailed": "Error al cargar {modelType}s: {message}",
"refreshComplete": "Actualización completa", "refreshComplete": "Actualización completa",
"refreshFailed": "Error al actualizar recetas: {message}", "refreshFailed": "Error al actualizar recetas: {message}",
"syncComplete": "Sincronización completa",
"syncFailed": "Error al sincronizar recetas: {message}",
"updateFailed": "Error al actualizar receta: {error}", "updateFailed": "Error al actualizar receta: {error}",
"updateError": "Error actualizando receta: {message}", "updateError": "Error actualizando receta: {message}",
"nameSaved": "Receta \"{name}\" guardada exitosamente", "nameSaved": "Receta \"{name}\" guardada exitosamente",
"nameUpdated": "Nombre de receta actualizado exitosamente", "nameUpdated": "Nombre de receta actualizado exitosamente",
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente", "tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
"sourceUrlUpdated": "URL de origen actualizada exitosamente", "sourceUrlUpdated": "URL de origen actualizada exitosamente",
"promptUpdated": "Prompt actualizado exitosamente",
"negativePromptUpdated": "Prompt negativo actualizado exitosamente",
"promptEditorHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
"noRecipeId": "No hay ID de receta disponible", "noRecipeId": "No hay ID de receta disponible",
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
"copyFailed": "Error copiando sintaxis de receta: {message}", "copyFailed": "Error copiando sintaxis de receta: {message}",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -1383,9 +1545,20 @@
"processingError": "Error de procesamiento: {message}", "processingError": "Error de procesamiento: {message}",
"folderBrowserError": "Error cargando explorador de carpetas: {message}", "folderBrowserError": "Error cargando explorador de carpetas: {message}",
"recipeSaveFailed": "Error al guardar receta: {error}", "recipeSaveFailed": "Error al guardar receta: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Importación falló: {message}", "importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas" "folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer",
"delete": "Supprimer", "delete": "Supprimer",
"move": "Déplacer", "move": "Déplacer",
"refresh": "Actualiser", "refresh": "Actualiser",
@@ -11,7 +14,8 @@
"backToTop": "Retour en haut", "backToTop": "Retour en haut",
"settings": "Paramètres", "settings": "Paramètres",
"help": "Aide", "help": "Aide",
"add": "Ajouter" "add": "Ajouter",
"close": "Fermer"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Nom du préréglage...", "presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Types de modèles",
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
@@ -258,17 +262,27 @@
"contentFiltering": "Filtrage du contenu", "contentFiltering": "Filtrage du contenu",
"videoSettings": "Paramètres vidéo", "videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"folderSettings": "Paramètres des dossiers",
"priorityTags": "Étiquettes prioritaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"exampleImages": "Images d'exemple",
"updateFlags": "Indicateurs de mise à jour",
"autoOrganize": "Auto-organize",
"misc": "Divers", "misc": "Divers",
"metadataArchive": "Base de données d'archive des métadonnées", "folderSettings": "Racines par défaut",
"storageLocation": "Emplacement des paramètres", "extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires",
"updateFlags": "Indicateurs de mise à jour",
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
"proxySettings": "Paramètres du proxy" "proxySettings": "Paramètres du proxy"
}, },
"nav": {
"general": "Général",
"interface": "Interface",
"library": "Bibliothèque"
},
"search": {
"placeholder": "Rechercher dans les paramètres...",
"clear": "Effacer la recherche",
"noResults": "Aucun paramètre trouvé correspondant à \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Mode portable", "locationLabel": "Mode portable",
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur." "locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."
@@ -277,7 +291,15 @@
"blurNsfwContent": "Flouter le contenu NSFW", "blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)", "blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
"showOnlySfw": "Afficher uniquement les résultats SFW", "showOnlySfw": "Afficher uniquement les résultats SFW",
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche" "showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
"matureBlurThreshold": "Seuil de floutage pour contenu adulte",
"matureBlurThresholdHelp": "Définir à partir de quel niveau de classification le floutage s'applique lorsque le floutage NSFW est activé.",
"matureBlurThresholdOptions": {
"pg13": "PG13 et plus",
"r": "R et plus (par défaut)",
"x": "X et plus",
"xxx": "XXX uniquement"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Lecture automatique vidéo au survol", "autoplayOnHover": "Lecture automatique vidéo au survol",
@@ -301,6 +323,24 @@
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}" "saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Ignorer les téléchargements pour certains modèles de base",
"help": "Sapplique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
"searchPlaceholder": "Filtrer les modèles de base...",
"empty": "Aucun modèle de base ne correspond à la recherche actuelle.",
"summary": {
"none": "Aucune sélection",
"count": "{count} sélectionnés"
},
"actions": {
"edit": "Modifier",
"collapse": "Réduire",
"clear": "Effacer"
},
"validation": {
"saveFailed": "Impossible denregistrer les modèles de base exclus : {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.", "activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.",
"loadingLibraries": "Chargement des bibliothèques...", "loadingLibraries": "Chargement des bibliothèques...",
"noLibraries": "Aucune bibliothèque configurée", "noLibraries": "Aucune bibliothèque configurée",
"defaultLoraRoot": "Racine LoRA par défaut", "defaultLoraRoot": "Racine LoRA",
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements", "defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
"defaultCheckpointRoot": "Racine Checkpoint par défaut", "defaultCheckpointRoot": "Racine Checkpoint",
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements", "defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
"defaultUnetRoot": "Racine Diffusion Model par défaut", "defaultUnetRoot": "Racine Diffusion Model",
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements", "defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding par défaut", "defaultEmbeddingRoot": "Racine Embedding",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"noDefault": "Aucun par défaut" "noDefault": "Aucun par défaut"
}, },
"extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires",
"description": "Chemins racine de modèles supplémentaires exclusifs à LoRA Manager. Chargez des modèles depuis des emplacements en dehors des dossiers standard de ComfyUI, idéal pour les grandes bibliothèques qui ralentiraient autrement ComfyUI.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embedding": "Chemins Embedding"
},
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": {
"duplicatePath": "Ce chemin est déjà configuré"
}
},
"priorityTags": { "priorityTags": {
"title": "Étiquettes prioritaires", "title": "Étiquettes prioritaires",
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles", "deleteAll": "Supprimer tous les modèles",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -614,6 +672,8 @@
"root": "Racine", "root": "Racine",
"browseFolders": "Parcourir les dossiers :", "browseFolders": "Parcourir les dossiers :",
"downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe", "downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe",
"importRecipeOnly": "Importer uniquement la recette",
"importAndDownload": "Importer et télécharger",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"saveRecipe": "Sauvegarder la recipe", "saveRecipe": "Sauvegarder la recipe",
"loraCountInfo": "({existing}/{total} dans la bibliothèque)", "loraCountInfo": "({existing}/{total} dans la bibliothèque)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "Moins" "lorasCountAsc": "Moins"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes" "title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
}, },
"filteredByLora": "Filtré par LoRA", "filteredByLora": "Filtré par LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "Échec de la réparation de la recette : {message}", "failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant" "missingId": "Impossible de réparer la recette : ID de recette manquant"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}" "moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "Désépingler la barre latérale", "unpinSidebar": "Désépingler la barre latérale",
"switchToListView": "Passer en vue liste", "switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence", "switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Rechercher dans les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Rechercher uniquement dans le dossier actuel", "recursiveOff": "Dossier actuel uniquement",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "Mettre à jour le modèle de base", "save": "Mettre à jour le modèle de base",
"cancel": "Annuler" "cancel": "Annuler"
}, },
"bulkDownloadMissingLoras": {
"title": "Télécharger les LoRAs manquants",
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
"previewTitle": "LoRAs à télécharger :",
"moreItems": "...et {count} de plus",
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Images d'exemple locales", "title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier" "openFileLocation": "Ouvrir l'emplacement du fichier",
"sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès", "success": "Emplacement du fichier ouvert avec succès",
@@ -937,6 +1080,9 @@
"copied": "Chemin copié dans le presse-papiers: {{path}}", "copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}" "clipboardFallback": "Chemin: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Nom de fichier", "fileName": "Nom de fichier",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné" "noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1315,7 +1463,14 @@
"showWechatQR": "Afficher le QR Code WeChat", "showWechatQR": "Afficher le QR Code WeChat",
"hideWechatQR": "Masquer le QR Code WeChat" "hideWechatQR": "Masquer le QR Code WeChat"
}, },
"footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️" "footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️",
"supporters": {
"title": "Merci à tous les supporters",
"subtitle": "Merci aux {count} supporters qui ont rendu ce projet possible",
"specialThanks": "Remerciements spéciaux",
"allSupporters": "Tous les supporters",
"totalCount": "{count} supporters au total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "Veuillez sélectionner une version", "pleaseSelectVersion": "Veuillez sélectionner une version",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}", "autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles", "autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}", "autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "Échec du chargement des {modelType}s : {message}", "loadFailed": "Échec du chargement des {modelType}s : {message}",
"refreshComplete": "Actualisation terminée", "refreshComplete": "Actualisation terminée",
"refreshFailed": "Échec de l'actualisation des recipes : {message}", "refreshFailed": "Échec de l'actualisation des recipes : {message}",
"syncComplete": "Synchronisation terminée",
"syncFailed": "Échec de la synchronisation des recipes : {message}",
"updateFailed": "Échec de la mise à jour de la recipe : {error}", "updateFailed": "Échec de la mise à jour de la recipe : {error}",
"updateError": "Erreur lors de la mise à jour de la recipe : {message}", "updateError": "Erreur lors de la mise à jour de la recipe : {message}",
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès", "nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
"nameUpdated": "Nom de la recipe mis à jour avec succès", "nameUpdated": "Nom de la recipe mis à jour avec succès",
"tagsUpdated": "Tags de la recipe mis à jour avec succès", "tagsUpdated": "Tags de la recipe mis à jour avec succès",
"sourceUrlUpdated": "URL source mise à jour avec succès", "sourceUrlUpdated": "URL source mise à jour avec succès",
"promptUpdated": "Prompt mis à jour avec succès",
"negativePromptUpdated": "Prompt négatif mis à jour avec succès",
"promptEditorHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
"noRecipeId": "Aucun ID de recipe disponible", "noRecipeId": "Aucun ID de recipe disponible",
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -1383,9 +1545,20 @@
"processingError": "Erreur de traitement : {message}", "processingError": "Erreur de traitement : {message}",
"folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}", "folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}",
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}", "recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Échec de l'importation : {message}", "importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers" "folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -1,17 +1,21 @@
{ {
"common": { "common": {
"cancel": "ביטול",
"confirm": "אישור",
"actions": { "actions": {
"save": "שמור", "save": "שמירה",
"cancel": "ביטול", "cancel": "ביטול",
"delete": "מחק", "confirm": "אישור",
"move": עבר", "delete": "מחיקה",
"refresh": "רענן", "move": "העברה",
"back": "חזור", "refresh": ענון",
"back": "חזרה",
"next": "הבא", "next": "הבא",
"backToTop": "חזור למעלה", "backToTop": "חזרה למעלה",
"settings": "הגדרות", "settings": "הגדרות",
"help": "עזרה", "help": "עזרה",
"add": "הוסף" "add": "הוספה",
"close": "סגור"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "שם קביעה מראש...", "presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"modelTags": "תגיות (20 המובילות)", "modelTags": "תגיות (20 המובילות)",
"modelTypes": "Model Types", "modelTypes": "סוגי מודלים",
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
@@ -258,17 +262,27 @@
"contentFiltering": "סינון תוכן", "contentFiltering": "סינון תוכן",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"folderSettings": "הגדרות תיקייה",
"priorityTags": "תגיות עדיפות",
"downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון",
"autoOrganize": "Auto-organize",
"misc": "שונות", "misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "folderSettings": "תיקיות ברירת מחדל",
"storageLocation": "מיקום ההגדרות", "extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון",
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
"proxySettings": "הגדרות פרוקסי" "proxySettings": "הגדרות פרוקסי"
}, },
"nav": {
"general": "כללי",
"interface": "ממשק",
"library": "ספרייה"
},
"search": {
"placeholder": "חיפוש בהגדרות...",
"clear": "נקה חיפוש",
"noResults": "לא נמצאו הגדרות תואמות ל-\"{query}\""
},
"storage": { "storage": {
"locationLabel": "מצב נייד", "locationLabel": "מצב נייד",
"locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש." "locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש."
@@ -277,7 +291,15 @@
"blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
"showOnlySfw": "הצג רק תוצאות SFW", "showOnlySfw": "הצג רק תוצאות SFW",
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש" "showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
"matureBlurThreshold": "סף טשטוש תוכן מבוגרים",
"matureBlurThresholdHelp": "הגדר מאיזו רמת דירוג מתחיל סינון הטשטוש כאשר טשטוש NSFW מופעל.",
"matureBlurThresholdOptions": {
"pg13": "PG13 ומעלה",
"r": "R ומעלה (ברירת מחדל)",
"x": "X ומעלה",
"xxx": "XXX בלבד"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף", "autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
@@ -301,6 +323,24 @@
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}" "saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "דלג על הורדות עבור מודלי בסיס",
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
"searchPlaceholder": "סנן מודלי בסיס...",
"empty": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"summary": {
"none": "לא נבחר דבר",
"count": "{count} נבחרו"
},
"actions": {
"edit": "עריכה",
"collapse": "כווץ",
"clear": "נקה"
},
"validation": {
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.", "activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
"loadingLibraries": "טוען ספריות...", "loadingLibraries": "טוען ספריות...",
"noLibraries": "לא הוגדרו ספריות", "noLibraries": "לא הוגדרו ספריות",
"defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA", "defaultLoraRoot": "תיקיית שורש LoRA",
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות", "defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
"defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint", "defaultCheckpointRoot": "תיקיית שורש Checkpoint",
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות", "defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
"defaultUnetRoot": "תיקיית שורש ברירת מחדל של Diffusion Model", "defaultUnetRoot": "תיקיית שורש Diffusion Model",
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות", "defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding", "defaultEmbeddingRoot": "תיקיית שורש Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"extraFolderPaths": {
"title": "נתיבי תיקיות נוספים",
"description": "נתיבי שורש מודלים נוספים בלעדיים ל-LoRA Manager. טען מודלים ממיקומים מחוץ לתיקיות הסטנדרטיות של ComfyUI - אידיאלי לספריות גדולות שאחרת יאטו את ComfyUI.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embedding": "נתיבי Embedding"
},
"pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": {
"duplicatePath": "נתיב זה כבר מוגדר"
}
},
"priorityTags": { "priorityTags": {
"title": "תגיות עדיפות", "title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים", "deleteAll": "מחק את כל המודלים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -614,6 +672,8 @@
"root": "שורש", "root": "שורש",
"browseFolders": "דפדף בתיקיות:", "browseFolders": "דפדף בתיקיות:",
"downloadAndSaveRecipe": "הורד ושמור מתכון", "downloadAndSaveRecipe": "הורד ושמור מתכון",
"importRecipeOnly": "יבא רק מתכון",
"importAndDownload": "יבא והורד",
"downloadMissingLoras": "הורד LoRAs חסרים", "downloadMissingLoras": "הורד LoRAs חסרים",
"saveRecipe": "שמור מתכון", "saveRecipe": "שמור מתכון",
"loraCountInfo": "({existing}/{total} בספרייה)", "loraCountInfo": "({existing}/{total} בספרייה)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "הכי פחות" "lorasCountAsc": "הכי פחות"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים" "title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA", "filteredByLora": "מסונן לפי LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "תיקון המתכון נכשל: {message}", "failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון" "missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}" "moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "שחרר סרגל צד", "unpinSidebar": "שחרר סרגל צד",
"switchToListView": "עבור לתצוגת רשימה", "switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ", "switchToTreeView": "תצוגת עץ",
"recursiveOn": "חיפוש בתיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "חיפוש רק בתיקייה הנוכחית", "recursiveOff": "רק התיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "עדכן מודל בסיס", "save": "עדכן מודל בסיס",
"cancel": "ביטול" "cancel": "ביטול"
}, },
"bulkDownloadMissingLoras": {
"title": "הורדת LoRAs חסרים",
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
"previewTitle": "LoRAs להורדה:",
"moreItems": "...ועוד {count}",
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "תמונות דוגמה מקומיות", "title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ" "openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה", "success": "מיקום הקובץ נפתח בהצלחה",
@@ -937,6 +1080,9 @@
"copied": "הנתיב הועתק ללוח העריכה: {{path}}", "copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}" "clipboardFallback": "נתיב: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
},
"metadata": { "metadata": {
"version": "גרסה", "version": "גרסה",
"fileName": "שם קובץ", "fileName": "שם קובץ",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד" "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
@@ -1315,7 +1463,14 @@
"showWechatQR": "הצג קוד QR של WeChat", "showWechatQR": "הצג קוד QR של WeChat",
"hideWechatQR": "הסתר קוד QR של WeChat" "hideWechatQR": "הסתר קוד QR של WeChat"
}, },
"footer": "תודה על השימוש במנהל LoRA! ❤️" "footer": "תודה על השימוש במנהל LoRA! ❤️",
"supporters": {
"title": "תודה לכל התומכים",
"subtitle": "תודה ל־{count} תומכים שהפכו את הפרויקט הזה לאפשרי",
"specialThanks": "תודה מיוחדת",
"allSupporters": "כל התומכים",
"totalCount": "{count} תומכים בסך הכל"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "טעינת {modelType}s נכשלה: {message}", "loadFailed": "טעינת {modelType}s נכשלה: {message}",
"refreshComplete": "הרענון הושלם", "refreshComplete": "הרענון הושלם",
"refreshFailed": "רענון המתכונים נכשל: {message}", "refreshFailed": "רענון המתכונים נכשל: {message}",
"syncComplete": "הסנכרון הושלם",
"syncFailed": "סנכרון המתכונים נכשל: {message}",
"updateFailed": "עדכון המתכון נכשל: {error}", "updateFailed": "עדכון המתכון נכשל: {error}",
"updateError": "שגיאה בעדכון המתכון: {message}", "updateError": "שגיאה בעדכון המתכון: {message}",
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה", "nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
"nameUpdated": "שם המתכון עודכן בהצלחה", "nameUpdated": "שם המתכון עודכן בהצלחה",
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה", "tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה", "sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
"promptUpdated": "הפרומפט עודכן בהצלחה",
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"noRecipeId": "אין מזהה מתכון זמין", "noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -1383,9 +1545,20 @@
"processingError": "שגיאת עיבוד: {message}", "processingError": "שגיאת עיבוד: {message}",
"folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}", "folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}",
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}", "recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "הייבוא נכשל: {message}", "importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות" "folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",

View File

@@ -1,17 +1,21 @@
{ {
"common": { "common": {
"cancel": "キャンセル",
"confirm": "確認",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "確認",
"delete": "削除", "delete": "削除",
"move": "移動", "move": "移動",
"refresh": "更新", "refresh": "更新",
"back": "戻る", "back": "戻る",
"next": "次へ", "next": "次へ",
"backToTop": "トップ戻る", "backToTop": "トップ戻る",
"settings": "設定", "settings": "設定",
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加" "add": "追加",
"close": "閉じる"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "プリセット名...", "presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"modelTags": "タグ上位20", "modelTags": "タグ上位20",
"modelTypes": "Model Types", "modelTypes": "モデルタイプ",
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
@@ -258,17 +262,27 @@
"contentFiltering": "コンテンツフィルタリング", "contentFiltering": "コンテンツフィルタリング",
"videoSettings": "動画設定", "videoSettings": "動画設定",
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"folderSettings": "フォルダ設定",
"priorityTags": "優先タグ",
"downloadPathTemplates": "ダウンロードパステンプレート",
"exampleImages": "例画像",
"updateFlags": "アップデートフラグ",
"autoOrganize": "Auto-organize",
"misc": "その他", "misc": "その他",
"metadataArchive": "メタデータアーカイブデータベース", "folderSettings": "デフォルトルート",
"storageLocation": "設定の場所", "extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ",
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
"proxySettings": "プロキシ設定" "proxySettings": "プロキシ設定"
}, },
"nav": {
"general": "一般",
"interface": "インターフェース",
"library": "ライブラリ"
},
"search": {
"placeholder": "設定を検索...",
"clear": "検索をクリア",
"noResults": "\"{query}\" に一致する設定が見つかりません"
},
"storage": { "storage": {
"locationLabel": "ポータブルモード", "locationLabel": "ポータブルモード",
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。" "locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"
@@ -277,7 +291,15 @@
"blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします", "blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",
"showOnlySfw": "SFWコンテンツのみ表示", "showOnlySfw": "SFWコンテンツのみ表示",
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します" "showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
"matureBlurThreshold": "成人コンテンツぼかし閾値",
"matureBlurThresholdHelp": "NSFWぼかしが有効な場合、どのレーティングレベルからぼかしフィルタリングを開始するかを設定します。",
"matureBlurThresholdOptions": {
"pg13": "PG13 以上",
"r": "R 以上(デフォルト)",
"x": "X 以上",
"xxx": "XXX のみ"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "ホバー時に動画を自動再生", "autoplayOnHover": "ホバー時に動画を自動再生",
@@ -301,6 +323,24 @@
"saveFailed": "スキップパスの保存に失敗しました:{message}" "saveFailed": "スキップパスの保存に失敗しました:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "ベースモデルのダウンロードをスキップ",
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
"searchPlaceholder": "ベースモデルを絞り込む...",
"empty": "現在の検索に一致するベースモデルはありません。",
"summary": {
"none": "未選択",
"count": "{count} 件を選択"
},
"actions": {
"edit": "編集",
"collapse": "折りたたむ",
"clear": "クリア"
},
"validation": {
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。", "activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。",
"loadingLibraries": "ライブラリを読み込み中...", "loadingLibraries": "ライブラリを読み込み中...",
"noLibraries": "ライブラリが設定されていません", "noLibraries": "ライブラリが設定されていません",
"defaultLoraRoot": "デフォルトLoRAルート", "defaultLoraRoot": "LoRAルート",
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定", "defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "デフォルトCheckpointルート", "defaultCheckpointRoot": "Checkpointルート",
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定", "defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
"defaultUnetRoot": "デフォルトDiffusion Modelルート", "defaultUnetRoot": "Diffusion Modelルート",
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定", "defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "デフォルトEmbeddingルート", "defaultEmbeddingRoot": "Embeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"noDefault": "デフォルトなし" "noDefault": "デフォルトなし"
}, },
"extraFolderPaths": {
"title": "追加フォルダーパス",
"description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "LoRAパス",
"checkpoint": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embedding": "Embeddingパス"
},
"pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています"
}
},
"priorityTags": { "priorityTags": {
"title": "優先タグ", "title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除", "deleteAll": "すべてのモデルを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -614,6 +672,8 @@
"root": "ルート", "root": "ルート",
"browseFolders": "フォルダを参照:", "browseFolders": "フォルダを参照:",
"downloadAndSaveRecipe": "ダウンロード & レシピ保存", "downloadAndSaveRecipe": "ダウンロード & レシピ保存",
"importRecipeOnly": "レシピのみインポート",
"importAndDownload": "インポートとダウンロード",
"downloadMissingLoras": "不足しているLoRAをダウンロード", "downloadMissingLoras": "不足しているLoRAをダウンロード",
"saveRecipe": "レシピを保存", "saveRecipe": "レシピを保存",
"loraCountInfo": "{existing}/{total} ライブラリ内)", "loraCountInfo": "{existing}/{total} ライブラリ内)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "少ない順" "lorasCountAsc": "少ない順"
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新" "title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
}, },
"filteredByLora": "LoRAでフィルタ済み", "filteredByLora": "LoRAでフィルタ済み",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "レシピの修復に失敗しました: {message}", "failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません" "missingId": "レシピを修復できません: レシピIDがありません"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} フォルダに移動" "moveToOtherTypeFolder": "{otherType} フォルダに移動",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "サイドバーの固定を解除", "unpinSidebar": "サイドバーの固定を解除",
"switchToListView": "リストビューに切り替え", "switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え", "switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを検索", "recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみを検索", "recursiveOff": "現在のフォルダーのみ",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "ベースモデルを更新", "save": "ベースモデルを更新",
"cancel": "キャンセル" "cancel": "キャンセル"
}, },
"bulkDownloadMissingLoras": {
"title": "不足している LoRA をダウンロード",
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
"previewTitle": "ダウンロードする LoRA:",
"moreItems": "...あと {count} 個",
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"exampleAccess": { "exampleAccess": {
"title": "ローカル例画像", "title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く" "openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信"
}, },
"openFileLocation": { "openFileLocation": {
"success": "ファイルの場所を正常に開きました", "success": "ファイルの場所を正常に開きました",
@@ -937,6 +1080,9 @@
"copied": "パスをクリップボードにコピーしました: {{path}}", "copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}" "clipboardFallback": "パス: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
},
"metadata": { "metadata": {
"version": "バージョン", "version": "バージョン",
"fileName": "ファイル名", "fileName": "ファイル名",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません" "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
@@ -1315,7 +1463,14 @@
"showWechatQR": "WeChat QRコードを表示", "showWechatQR": "WeChat QRコードを表示",
"hideWechatQR": "WeChat QRコードを非表示" "hideWechatQR": "WeChat QRコードを非表示"
}, },
"footer": "LoRA Managerをご利用いただきありがとうございます ❤️" "footer": "LoRA Managerをご利用いただきありがとうございます ❤️",
"supporters": {
"title": "サポーターの皆様に感謝",
"subtitle": "{count} 名のサポーターの皆様に、このプロジェクトを実現していただきありがとうございます",
"specialThanks": "特別感謝",
"allSupporters": "全サポーター",
"totalCount": "サポーター {count} 名"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}", "autoOrganizeFailed": "自動整理に失敗しました:{error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "{modelType}の読み込みに失敗しました:{message}", "loadFailed": "{modelType}の読み込みに失敗しました:{message}",
"refreshComplete": "更新完了", "refreshComplete": "更新完了",
"refreshFailed": "レシピの更新に失敗しました:{message}", "refreshFailed": "レシピの更新に失敗しました:{message}",
"syncComplete": "同期完了",
"syncFailed": "レシピの同期に失敗しました:{message}",
"updateFailed": "レシピの更新に失敗しました:{error}", "updateFailed": "レシピの更新に失敗しました:{error}",
"updateError": "レシピ更新エラー:{message}", "updateError": "レシピ更新エラー:{message}",
"nameSaved": "レシピ\"{name}\"が正常に保存されました", "nameSaved": "レシピ\"{name}\"が正常に保存されました",
"nameUpdated": "レシピ名が正常に更新されました", "nameUpdated": "レシピ名が正常に更新されました",
"tagsUpdated": "レシピタグが正常に更新されました", "tagsUpdated": "レシピタグが正常に更新されました",
"sourceUrlUpdated": "ソースURLが正常に更新されました", "sourceUrlUpdated": "ソースURLが正常に更新されました",
"promptUpdated": "プロンプトが正常に更新されました",
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
"noRecipeId": "レシピIDが利用できません", "noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -1383,9 +1545,20 @@
"processingError": "処理エラー:{message}", "processingError": "処理エラー:{message}",
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}", "folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
"recipeSaveFailed": "レシピの保存に失敗しました:{error}", "recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "インポートに失敗しました:{message}", "importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー" "folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "취소",
"confirm": "확인",
"actions": { "actions": {
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"confirm": "확인",
"delete": "삭제", "delete": "삭제",
"move": "이동", "move": "이동",
"refresh": "새로고침", "refresh": "새로고침",
@@ -11,7 +14,8 @@
"backToTop": "맨 위로", "backToTop": "맨 위로",
"settings": "설정", "settings": "설정",
"help": "도움말", "help": "도움말",
"add": "추가" "add": "추가",
"close": "닫기"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "프리셋 이름...", "presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"modelTags": "태그 (상위 20개)", "modelTags": "태그 (상위 20개)",
"modelTypes": "Model Types", "modelTypes": "모델 유형",
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
@@ -258,17 +262,27 @@
"contentFiltering": "콘텐츠 필터링", "contentFiltering": "콘텐츠 필터링",
"videoSettings": "비디오 설정", "videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"folderSettings": "폴더 설정",
"priorityTags": "우선순위 태그",
"downloadPathTemplates": "다운로드 경로 템플릿",
"exampleImages": "예시 이미지",
"updateFlags": "업데이트 표시",
"autoOrganize": "Auto-organize",
"misc": "기타", "misc": "기타",
"metadataArchive": "메타데이터 아카이브 데이터베이스", "folderSettings": "기본 루트",
"storageLocation": "설정 위치", "extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시",
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
"proxySettings": "프록시 설정" "proxySettings": "프록시 설정"
}, },
"nav": {
"general": "일반",
"interface": "인터페이스",
"library": "라이브러리"
},
"search": {
"placeholder": "설정 검색...",
"clear": "검색 지우기",
"noResults": "\"{query}\"와 일치하는 설정을 찾을 수 없습니다"
},
"storage": { "storage": {
"locationLabel": "휴대용 모드", "locationLabel": "휴대용 모드",
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다." "locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."
@@ -277,7 +291,15 @@
"blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
"showOnlySfw": "SFW 결과만 표시", "showOnlySfw": "SFW 결과만 표시",
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다" "showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
"matureBlurThreshold": "성인 콘텐츠 블러 임계값",
"matureBlurThresholdHelp": "NSFW 블러가 활성화될 때 어떤 등급 레벨부터 블러 필터링을 시작할지 설정합니다.",
"matureBlurThresholdOptions": {
"pg13": "PG13 이상",
"r": "R 이상(기본값)",
"x": "X 이상",
"xxx": "XXX만"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "호버 시 비디오 자동 재생", "autoplayOnHover": "호버 시 비디오 자동 재생",
@@ -301,6 +323,24 @@
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}" "saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "기본 모델 다운로드 건너뛰기",
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
"searchPlaceholder": "기본 모델 필터링...",
"empty": "현재 검색과 일치하는 기본 모델이 없습니다.",
"summary": {
"none": "선택 없음",
"count": "{count}개 선택됨"
},
"actions": {
"edit": "편집",
"collapse": "접기",
"clear": "지우기"
},
"validation": {
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.", "activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.",
"loadingLibraries": "라이브러리를 불러오는 중...", "loadingLibraries": "라이브러리를 불러오는 중...",
"noLibraries": "구성된 라이브러리가 없습니다", "noLibraries": "구성된 라이브러리가 없습니다",
"defaultLoraRoot": "기본 LoRA 루트", "defaultLoraRoot": "LoRA 루트",
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다", "defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
"defaultCheckpointRoot": "기본 Checkpoint 루트", "defaultCheckpointRoot": "Checkpoint 루트",
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다", "defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
"defaultUnetRoot": "기본 Diffusion Model 루트", "defaultUnetRoot": "Diffusion Model 루트",
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다", "defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "기본 Embedding 루트", "defaultEmbeddingRoot": "Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"noDefault": "기본값 없음" "noDefault": "기본값 없음"
}, },
"extraFolderPaths": {
"title": "추가 폴다 경로",
"description": "LoRA Manager 전용 추가 모델 루트 경로입니다. ComfyUI의 표준 폴더 외부 위치에서 모델을 로드하여 대규모 라이브러리로 인한 성능 저하를 방지합니다.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로",
"unet": "Diffusion 모델 경로",
"embedding": "Embedding 경로"
},
"pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
}
},
"priorityTags": { "priorityTags": {
"title": "우선순위 태그", "title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제", "deleteAll": "모든 모델 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -614,6 +672,8 @@
"root": "루트", "root": "루트",
"browseFolders": "폴더 탐색:", "browseFolders": "폴더 탐색:",
"downloadAndSaveRecipe": "다운로드 및 레시피 저장", "downloadAndSaveRecipe": "다운로드 및 레시피 저장",
"importRecipeOnly": "레시피만 가져오기",
"importAndDownload": "가져오기 및 다운로드",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"saveRecipe": "레시피 저장", "saveRecipe": "레시피 저장",
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)", "loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "적은순" "lorasCountAsc": "적은순"
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침" "title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
}, },
"filteredByLora": "LoRA로 필터링됨", "filteredByLora": "LoRA로 필터링됨",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "레시피 복구 실패: {message}", "failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락" "missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} 폴더로 이동" "moveToOtherTypeFolder": "{otherType} 폴더로 이동",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "사이드바 고정 해제", "unpinSidebar": "사이드바 고정 해제",
"switchToListView": "목록 보기로 전환", "switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환", "switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 검색", "recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더만 검색", "recursiveOff": "현재 폴더만",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "베이스 모델 업데이트", "save": "베이스 모델 업데이트",
"cancel": "취소" "cancel": "취소"
}, },
"bulkDownloadMissingLoras": {
"title": "누락된 LoRA 다운로드",
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
"previewTitle": "다운로드할 LoRA:",
"moreItems": "...그리고 {count}개 더",
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"exampleAccess": { "exampleAccess": {
"title": "로컬 예시 이미지", "title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기" "openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기"
}, },
"openFileLocation": { "openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다", "success": "파일 위치가 성공적으로 열렸습니다",
@@ -937,6 +1080,9 @@
"copied": "경로가 클립보드에 복사되었습니다: {{path}}", "copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}" "clipboardFallback": "경로: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
},
"metadata": { "metadata": {
"version": "버전", "version": "버전",
"fileName": "파일명", "fileName": "파일명",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다" "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
@@ -1315,7 +1463,14 @@
"showWechatQR": "WeChat QR 코드 표시", "showWechatQR": "WeChat QR 코드 표시",
"hideWechatQR": "WeChat QR 코드 숨기기" "hideWechatQR": "WeChat QR 코드 숨기기"
}, },
"footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️" "footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️",
"supporters": {
"title": "후원자 분들께 감사드립니다",
"subtitle": "이 프로젝트를 가능하게 해준 {count}명의 후원자분들께 감사드립니다",
"specialThanks": "특별 감사",
"allSupporters": "모든 후원자",
"totalCount": "총 {count}명의 후원자"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}", "autoOrganizeFailed": "자동 정리 실패: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "{modelType} 로딩 실패: {message}", "loadFailed": "{modelType} 로딩 실패: {message}",
"refreshComplete": "새로고침 완료", "refreshComplete": "새로고침 완료",
"refreshFailed": "레시피 새로고침 실패: {message}", "refreshFailed": "레시피 새로고침 실패: {message}",
"syncComplete": "동기화 완료",
"syncFailed": "레시피 동기화 실패: {message}",
"updateFailed": "레시피 업데이트 실패: {error}", "updateFailed": "레시피 업데이트 실패: {error}",
"updateError": "레시피 업데이트 오류: {message}", "updateError": "레시피 업데이트 오류: {message}",
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다", "nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다", "nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다", "tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다", "sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
"noRecipeId": "사용 가능한 레시피 ID가 없습니다", "noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}", "copyFailed": "레시피 문법 복사 오류: {message}",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -1383,9 +1545,20 @@
"processingError": "처리 오류: {message}", "processingError": "처리 오류: {message}",
"folderBrowserError": "폴더 브라우저 로딩 오류: {message}", "folderBrowserError": "폴더 브라우저 로딩 오류: {message}",
"recipeSaveFailed": "레시피 저장 실패: {error}", "recipeSaveFailed": "레시피 저장 실패: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "가져오기 실패: {message}", "importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류" "folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"actions": { "actions": {
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить",
"delete": "Удалить", "delete": "Удалить",
"move": "Переместить", "move": "Переместить",
"refresh": "Обновить", "refresh": "Обновить",
@@ -11,7 +14,8 @@
"backToTop": "Наверх", "backToTop": "Наверх",
"settings": "Настройки", "settings": "Настройки",
"help": "Справка", "help": "Справка",
"add": "Добавить" "add": "Добавить",
"close": "Закрыть"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Имя пресета...", "presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"modelTags": "Теги (Топ 20)", "modelTags": "Теги (Топ 20)",
"modelTypes": "Model Types", "modelTypes": "Типы моделей",
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
@@ -258,17 +262,27 @@
"contentFiltering": "Фильтрация контента", "contentFiltering": "Фильтрация контента",
"videoSettings": "Настройки видео", "videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"folderSettings": "Настройки папок",
"priorityTags": "Приоритетные теги",
"downloadPathTemplates": "Шаблоны путей загрузки",
"exampleImages": "Примеры изображений",
"updateFlags": "Метки обновлений",
"autoOrganize": "Auto-organize",
"misc": "Разное", "misc": "Разное",
"metadataArchive": "Архив метаданных", "folderSettings": "Корневые папки",
"storageLocation": "Расположение настроек", "extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений",
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
"proxySettings": "Настройки прокси" "proxySettings": "Настройки прокси"
}, },
"nav": {
"general": "Общее",
"interface": "Интерфейс",
"library": "Библиотека"
},
"search": {
"placeholder": "Поиск в настройках...",
"clear": "Очистить поиск",
"noResults": "Настройки, соответствующие \"{query}\", не найдены"
},
"storage": { "storage": {
"locationLabel": "Портативный режим", "locationLabel": "Портативный режим",
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя." "locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."
@@ -277,7 +291,15 @@
"blurNsfwContent": "Размывать NSFW контент", "blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
"showOnlySfw": "Показывать только SFW результаты", "showOnlySfw": "Показывать только SFW результаты",
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске" "showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
"matureBlurThreshold": "Порог размытия взрослого контента",
"matureBlurThresholdHelp": "Установить, с какого уровня рейтинга начинается размытие при включенном размытии NSFW.",
"matureBlurThresholdOptions": {
"pg13": "PG13 и выше",
"r": "R и выше (по умолчанию)",
"x": "X и выше",
"xxx": "Только XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Автовоспроизведение видео при наведении", "autoplayOnHover": "Автовоспроизведение видео при наведении",
@@ -301,6 +323,24 @@
"saveFailed": "Не удалось сохранить пути для пропуска: {message}" "saveFailed": "Не удалось сохранить пути для пропуска: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Пропускать загрузки для базовых моделей",
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
"searchPlaceholder": "Фильтровать базовые модели...",
"empty": "Нет базовых моделей, соответствующих текущему поиску.",
"summary": {
"none": "Ничего не выбрано",
"count": "Выбрано: {count}"
},
"actions": {
"edit": "Изменить",
"collapse": "Свернуть",
"clear": "Очистить"
},
"validation": {
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.", "activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.",
"loadingLibraries": "Загрузка библиотек...", "loadingLibraries": "Загрузка библиотек...",
"noLibraries": "Библиотеки не настроены", "noLibraries": "Библиотеки не настроены",
"defaultLoraRoot": "Корневая папка LoRA по умолчанию", "defaultLoraRoot": "Корневая папка LoRA",
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений", "defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию", "defaultCheckpointRoot": "Корневая папка Checkpoint",
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений", "defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
"defaultUnetRoot": "Корневая папка Diffusion Model по умолчанию", "defaultUnetRoot": "Корневая папка Diffusion Model",
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений", "defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию", "defaultEmbeddingRoot": "Корневая папка Embedding",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"noDefault": "Не задано" "noDefault": "Не задано"
}, },
"extraFolderPaths": {
"title": "Дополнительные пути к папкам",
"description": "Дополнительные корневые пути моделей, эксклюзивные для LoRA Manager. Загружайте модели из расположений за пределами стандартных папок ComfyUI — идеально подходит для больших библиотек, которые иначе замедлили бы ComfyUI.",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embedding": "Пути Embedding"
},
"pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": {
"duplicatePath": "Этот путь уже настроен"
}
},
"priorityTags": { "priorityTags": {
"title": "Приоритетные теги", "title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели", "deleteAll": "Удалить все модели",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -614,6 +672,8 @@
"root": "Корень", "root": "Корень",
"browseFolders": "Обзор папок:", "browseFolders": "Обзор папок:",
"downloadAndSaveRecipe": "Скачать и сохранить рецепт", "downloadAndSaveRecipe": "Скачать и сохранить рецепт",
"importRecipeOnly": "Импортировать только рецепт",
"importAndDownload": "Импорт и скачивание",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"saveRecipe": "Сохранить рецепт", "saveRecipe": "Сохранить рецепт",
"loraCountInfo": "({existing}/{total} в библиотеке)", "loraCountInfo": "({existing}/{total} в библиотеке)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "Меньше всего" "lorasCountAsc": "Меньше всего"
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов" "title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
}, },
"filteredByLora": "Фильтр по LoRA", "filteredByLora": "Фильтр по LoRA",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "Не удалось восстановить рецепт: {message}", "failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта" "missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Переместить в папку {otherType}" "moveToOtherTypeFolder": "Переместить в папку {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "Открепить боковую панель", "unpinSidebar": "Открепить боковую панель",
"switchToListView": "Переключить на вид списка", "switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид", "switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Искать во вложенных папках", "recursiveOn": "Включать вложенные папки",
"recursiveOff": "Искать только в текущей папке", "recursiveOff": "Только текущая папка",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "Обновить базовую модель", "save": "Обновить базовую модель",
"cancel": "Отмена" "cancel": "Отмена"
}, },
"bulkDownloadMissingLoras": {
"title": "Скачать отсутствующие LoRAs",
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
"previewTitle": "LoRAs для скачивания:",
"moreItems": "...и еще {count}",
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Локальные примеры изображений", "title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла" "openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Расположение файла успешно открыто", "success": "Расположение файла успешно открыто",
@@ -937,6 +1080,9 @@
"copied": "Путь скопирован в буфер обмена: {{path}}", "copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}" "clipboardFallback": "Путь: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
},
"metadata": { "metadata": {
"version": "Версия", "version": "Версия",
"fileName": "Имя файла", "fileName": "Имя файла",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран" "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
@@ -1315,7 +1463,14 @@
"showWechatQR": "Показать QR-код WeChat", "showWechatQR": "Показать QR-код WeChat",
"hideWechatQR": "Скрыть QR-код WeChat" "hideWechatQR": "Скрыть QR-код WeChat"
}, },
"footer": "Спасибо за использование LoRA Manager! ❤️" "footer": "Спасибо за использование LoRA Manager! ❤️",
"supporters": {
"title": "Спасибо всем сторонникам",
"subtitle": "Спасибо {count} сторонникам, которые сделали этот проект возможным",
"specialThanks": "Особая благодарность",
"allSupporters": "Все сторонники",
"totalCount": "Всего {count} сторонников"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}", "autoOrganizeFailed": "Ошибка автоматической организации: {error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "Не удалось загрузить {modelType}s: {message}", "loadFailed": "Не удалось загрузить {modelType}s: {message}",
"refreshComplete": "Обновление завершено", "refreshComplete": "Обновление завершено",
"refreshFailed": "Не удалось обновить рецепты: {message}", "refreshFailed": "Не удалось обновить рецепты: {message}",
"syncComplete": "Синхронизация завершена",
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
"updateFailed": "Не удалось обновить рецепт: {error}", "updateFailed": "Не удалось обновить рецепт: {error}",
"updateError": "Ошибка обновления рецепта: {message}", "updateError": "Ошибка обновления рецепта: {message}",
"nameSaved": "Рецепт \"{name}\" успешно сохранен", "nameSaved": "Рецепт \"{name}\" успешно сохранен",
"nameUpdated": "Название рецепта успешно обновлено", "nameUpdated": "Название рецепта успешно обновлено",
"tagsUpdated": "Теги рецепта успешно обновлены", "tagsUpdated": "Теги рецепта успешно обновлены",
"sourceUrlUpdated": "Исходный URL успешно обновлен", "sourceUrlUpdated": "Исходный URL успешно обновлен",
"promptUpdated": "Промпт успешно обновлён",
"negativePromptUpdated": "Негативный промпт успешно обновлён",
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"noRecipeId": "ID рецепта недоступен", "noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -1383,9 +1545,20 @@
"processingError": "Ошибка обработки: {message}", "processingError": "Ошибка обработки: {message}",
"folderBrowserError": "Ошибка загрузки браузера папок: {message}", "folderBrowserError": "Ошибка загрузки браузера папок: {message}",
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}", "recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Импорт не удался: {message}", "importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок" "folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "确认",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"confirm": "确认",
"delete": "删除", "delete": "删除",
"move": "移动", "move": "移动",
"refresh": "刷新", "refresh": "刷新",
@@ -11,7 +14,8 @@
"backToTop": "返回顶部", "backToTop": "返回顶部",
"settings": "设置", "settings": "设置",
"help": "帮助", "help": "帮助",
"add": "添加" "add": "添加",
"close": "关闭"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -159,11 +163,11 @@
"error": "清理示例图片文件夹失败:{message}" "error": "清理示例图片文件夹失败:{message}"
}, },
"fetchMissingLicenses": { "fetchMissingLicenses": {
"label": "Refresh license metadata", "label": "刷新许可证元数据",
"loading": "Refreshing license metadata for {typePlural}...", "loading": "正在刷新 {typePlural} 的许可证元数据...",
"success": "Updated license metadata for {count} {typePlural}", "success": "已更新 {count} {typePlural} 的许可证元数据",
"none": "All {typePlural} already have license metadata", "none": "所有 {typePlural} 都已具备许可证元数据",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "刷新 {typePlural} 的许可证元数据失败:{message}"
}, },
"repairRecipes": { "repairRecipes": {
"label": "修复配方数据", "label": "修复配方数据",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "预设名称...", "presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型", "baseModel": "基础模型",
"modelTags": "标签前20", "modelTags": "标签前20",
"modelTypes": "Model Types", "modelTypes": "模型类型",
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
@@ -258,17 +262,27 @@
"contentFiltering": "内容过滤", "contentFiltering": "内容过滤",
"videoSettings": "视频设置", "videoSettings": "视频设置",
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"folderSettings": "文件夹设置",
"priorityTags": "优先标签",
"downloadPathTemplates": "下载路径模板",
"exampleImages": "示例图片",
"updateFlags": "更新标记",
"autoOrganize": "Auto-organize",
"misc": "其他", "misc": "其他",
"metadataArchive": "元数据归档数据库", "folderSettings": "默认根目录",
"storageLocation": "设置位置", "extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签",
"updateFlags": "更新标记",
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
"proxySettings": "代理设置" "proxySettings": "代理设置"
}, },
"nav": {
"general": "通用",
"interface": "界面",
"library": "库"
},
"search": {
"placeholder": "搜索设置...",
"clear": "清除搜索",
"noResults": "未找到匹配 \"{query}\" 的设置"
},
"storage": { "storage": {
"locationLabel": "便携模式", "locationLabel": "便携模式",
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。" "locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"
@@ -277,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片", "blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",
"showOnlySfw": "仅显示 SFW 结果", "showOnlySfw": "仅显示 SFW 结果",
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容" "showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
"matureBlurThreshold": "成人内容模糊阈值",
"matureBlurThresholdHelp": "设置当启用 NSFW 模糊时,从哪个评级级别开始模糊过滤。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(默认)",
"x": "X 及以上",
"xxx": "仅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "悬停时自动播放视频", "autoplayOnHover": "悬停时自动播放视频",
@@ -301,6 +323,24 @@
"saveFailed": "无法保存跳过路径:{message}" "saveFailed": "无法保存跳过路径:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳过这些基础模型的下载",
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
"searchPlaceholder": "筛选基础模型...",
"empty": "没有与当前搜索匹配的基础模型。",
"summary": {
"none": "未选择",
"count": "已选择 {count} 项"
},
"actions": {
"edit": "编辑",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "无法保存已排除的基础模型:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。", "activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。",
"loadingLibraries": "正在加载库...", "loadingLibraries": "正在加载库...",
"noLibraries": "尚未配置库", "noLibraries": "尚未配置库",
"defaultLoraRoot": "默认 LoRA 根目录", "defaultLoraRoot": "LoRA 根目录",
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录", "defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
"defaultCheckpointRoot": "默认 Checkpoint 根目录", "defaultCheckpointRoot": "Checkpoint 根目录",
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录", "defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
"defaultUnetRoot": "默认 Diffusion Model 根目录", "defaultUnetRoot": "Diffusion Model 根目录",
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录", "defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "默认 Embedding 根目录", "defaultEmbeddingRoot": "Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"noDefault": "无默认" "noDefault": "无默认"
}, },
"extraFolderPaths": {
"title": "额外文件夹路径",
"description": "LoRA Manager 专属的额外模型根目录。从 ComfyUI 标准文件夹之外的位置加载模型,特别适合管理大型模型库,避免影响 ComfyUI 性能。",
"restartRequired": "需要重启才能生效",
"modelTypes": {
"lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embedding": "Embedding 路径"
},
"pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
"saveError": "更新额外文件夹路径失败:{message}",
"validation": {
"duplicatePath": "此路径已配置"
}
},
"priorityTags": { "priorityTags": {
"title": "优先标签", "title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "跳过所选模型的元数据刷新", "skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型", "deleteAll": "删除选中模型",
"downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -614,6 +672,8 @@
"root": "根目录", "root": "根目录",
"browseFolders": "浏览文件夹:", "browseFolders": "浏览文件夹:",
"downloadAndSaveRecipe": "下载并保存配方", "downloadAndSaveRecipe": "下载并保存配方",
"importRecipeOnly": "仅导入配方",
"importAndDownload": "导入并下载",
"downloadMissingLoras": "下载缺失的 LoRA", "downloadMissingLoras": "下载缺失的 LoRA",
"saveRecipe": "保存配方", "saveRecipe": "保存配方",
"loraCountInfo": "({existing}/{total} in library)", "loraCountInfo": "({existing}/{total} in library)",
@@ -655,7 +715,11 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "刷新配方列表" "title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
}, },
"filteredByLora": "按 LoRA 筛选", "filteredByLora": "按 LoRA 筛选",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "修复配方失败:{message}", "failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID" "missingId": "无法修复配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlList": "URL 列表",
"directory": "目录",
"urlDescription": "输入图像 URL 或本地文件路径(每行一个)。每个都将作为配方导入。",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "每行输入一个 URL 或路径",
"directoryPath": "目录路径",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "浏览",
"recursive": "包含子目录",
"tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "输入以逗号分隔的标签",
"tagsHint": "标签将被添加到所有导入的配方中",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "开始导入",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "总计",
"success": "成功",
"failed": "失败",
"skipped": "跳过",
"current": "当前",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "取消",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "导入完成但有错误",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "查看详情",
"newImport": "新建导入",
"manualPathEntry": "请手动输入目录路径。此浏览器中文件浏览器不可用。",
"batchImportDirectorySelected": "已选择目录:{path}",
"batchImportManualEntryRequired": "文件浏览器不可用。请手动输入目录路径。",
"backToParent": "返回上级目录",
"folders": "文件夹",
"folderCount": "{count} 个文件夹",
"imageFiles": "图像文件",
"images": "图像",
"imageCount": "{count} 个图像",
"selectFolder": "选择此文件夹",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
"startFailed": "启动导入失败:{message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹" "moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "取消固定侧边栏", "unpinSidebar": "取消固定侧边栏",
"switchToListView": "切换到列表视图", "switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图", "switchToTreeView": "切换到树状视图",
"recursiveOn": "搜索子文件夹", "recursiveOn": "包含子文件夹",
"recursiveOff": "仅搜索当前文件夹", "recursiveOff": "仅当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "更新基础模型", "save": "更新基础模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下载缺失的 LoRAs",
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs从选定配方中的 {totalCount} 个总数)。",
"previewTitle": "要下载的 LoRAs",
"moreItems": "...还有 {count} 个",
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本地示例图片", "title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:", "message": "未找到此模型的本地示例图片。可选操作:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置" "openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "文件位置已成功打开", "success": "文件位置已成功打开",
@@ -937,6 +1080,9 @@
"copied": "路径已复制到剪贴板:{{path}}", "copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}" "clipboardFallback": "路径:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "无法发送到 ComfyUI没有可用的文件路径"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "文件名", "fileName": "文件名",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "配方已替换到工作流", "recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败", "recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点" "noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1315,7 +1463,14 @@
"showWechatQR": "显示微信二维码", "showWechatQR": "显示微信二维码",
"hideWechatQR": "隐藏微信二维码" "hideWechatQR": "隐藏微信二维码"
}, },
"footer": "感谢使用 LoRA 管理器!❤️" "footer": "感谢使用 LoRA 管理器!❤️",
"supporters": {
"title": "感谢所有支持者",
"subtitle": "感谢 {count} 位支持者让这个项目成为可能",
"specialThanks": "特别感谢",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}", "autoOrganizeFailed": "自动整理失败:{error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "加载 {modelType} 失败:{message}", "loadFailed": "加载 {modelType} 失败:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失败:{message}", "refreshFailed": "刷新配方失败:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失败:{message}",
"updateFailed": "更新配方失败:{error}", "updateFailed": "更新配方失败:{error}",
"updateError": "更新配方出错:{message}", "updateError": "更新配方出错:{message}",
"nameSaved": "配方“{name}”保存成功", "nameSaved": "配方“{name}”保存成功",
"nameUpdated": "配方名称更新成功", "nameUpdated": "配方名称更新成功",
"tagsUpdated": "配方标签更新成功", "tagsUpdated": "配方标签更新成功",
"sourceUrlUpdated": "来源 URL 更新成功", "sourceUrlUpdated": "来源 URL 更新成功",
"promptUpdated": "提示词更新成功",
"negativePromptUpdated": "负面提示词更新成功",
"promptEditorHint": "按 Enter 保存Shift+Enter 换行",
"noRecipeId": "无配方 ID", "noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}", "copyFailed": "复制配方语法出错:{message}",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -1383,9 +1545,20 @@
"processingError": "处理出错:{message}", "processingError": "处理出错:{message}",
"folderBrowserError": "加载文件夹浏览器出错:{message}", "folderBrowserError": "加载文件夹浏览器出错:{message}",
"recipeSaveFailed": "保存配方失败:{error}", "recipeSaveFailed": "保存配方失败:{error}",
"recipeSaved": "配方保存成功",
"importFailed": "导入失败:{message}", "importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错" "folderTreeError": "加载文件夹树出错",
"batchImportFailed": "启动批量导入失败:{message}",
"batchImportCancelling": "正在取消批量导入...",
"batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径",
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "確認",
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
"confirm": "確認",
"delete": "刪除", "delete": "刪除",
"move": "移動", "move": "移動",
"refresh": "重新整理", "refresh": "重新整理",
@@ -11,7 +14,8 @@
"backToTop": "回到頂部", "backToTop": "回到頂部",
"settings": "設定", "settings": "設定",
"help": "說明", "help": "說明",
"add": "新增" "add": "新增",
"close": "關閉"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "預設名稱...", "presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"modelTags": "標籤(前 20", "modelTags": "標籤(前 20",
"modelTypes": "Model Types", "modelTypes": "模型類型",
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
@@ -258,17 +262,27 @@
"contentFiltering": "內容過濾", "contentFiltering": "內容過濾",
"videoSettings": "影片設定", "videoSettings": "影片設定",
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"folderSettings": "資料夾設定",
"priorityTags": "優先標籤",
"downloadPathTemplates": "下載路徑範本",
"exampleImages": "範例圖片",
"updateFlags": "更新標記",
"autoOrganize": "Auto-organize",
"misc": "其他", "misc": "其他",
"metadataArchive": "中繼資料封存資料庫", "folderSettings": "預設根目錄",
"storageLocation": "設定位置", "extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤",
"updateFlags": "更新標記",
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
"proxySettings": "代理設定" "proxySettings": "代理設定"
}, },
"nav": {
"general": "通用",
"interface": "介面",
"library": "模型庫"
},
"search": {
"placeholder": "搜尋設定...",
"clear": "清除搜尋",
"noResults": "未找到符合 \"{query}\" 的設定"
},
"storage": { "storage": {
"locationLabel": "可攜式模式", "locationLabel": "可攜式模式",
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。" "locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"
@@ -277,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片", "blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",
"showOnlySfw": "僅顯示 SFW 結果", "showOnlySfw": "僅顯示 SFW 結果",
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容" "showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
"matureBlurThreshold": "成人內容模糊閾值",
"matureBlurThresholdHelp": "設定當啟用 NSFW 模糊時,從哪個評級級別開始模糊過濾。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(預設)",
"x": "X 及以上",
"xxx": "僅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "滑鼠懸停自動播放影片", "autoplayOnHover": "滑鼠懸停自動播放影片",
@@ -301,6 +323,24 @@
"saveFailed": "無法儲存跳過路徑:{message}" "saveFailed": "無法儲存跳過路徑:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳過這些基礎模型的下載",
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
"searchPlaceholder": "篩選基礎模型...",
"empty": "沒有符合目前搜尋條件的基礎模型。",
"summary": {
"none": "未選擇",
"count": "已選擇 {count} 項"
},
"actions": {
"edit": "編輯",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "無法儲存已排除的基礎模型:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -341,16 +381,33 @@
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。", "activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。",
"loadingLibraries": "正在載入資料庫...", "loadingLibraries": "正在載入資料庫...",
"noLibraries": "尚未設定任何資料庫", "noLibraries": "尚未設定任何資料庫",
"defaultLoraRoot": "預設 LoRA 根目錄", "defaultLoraRoot": "LoRA 根目錄",
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄", "defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
"defaultCheckpointRoot": "預設 Checkpoint 根目錄", "defaultCheckpointRoot": "Checkpoint 根目錄",
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄", "defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
"defaultUnetRoot": "預設 Diffusion Model 根目錄", "defaultUnetRoot": "Diffusion Model 根目錄",
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄", "defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "預設 Embedding 根目錄", "defaultEmbeddingRoot": "Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"noDefault": "未設定預設" "noDefault": "未設定預設"
}, },
"extraFolderPaths": {
"title": "額外資料夾路徑",
"description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
"restartRequired": "Requires restart to take effect",
"modelTypes": {
"lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embedding": "Embedding 路徑"
},
"pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
"saveError": "更新額外資料夾路徑失敗:{message}",
"validation": {
"duplicatePath": "此路徑已設定"
}
},
"priorityTags": { "priorityTags": {
"title": "優先標籤", "title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -544,6 +601,7 @@
"skipMetadataRefresh": "跳過所選模型的元數據更新", "skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型", "deleteAll": "刪除全部模型",
"downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -614,6 +672,8 @@
"root": "根目錄", "root": "根目錄",
"browseFolders": "瀏覽資料夾:", "browseFolders": "瀏覽資料夾:",
"downloadAndSaveRecipe": "下載並儲存配方", "downloadAndSaveRecipe": "下載並儲存配方",
"importRecipeOnly": "僅匯入配方",
"importAndDownload": "匯入並下載",
"downloadMissingLoras": "下載缺少的 LoRA", "downloadMissingLoras": "下載缺少的 LoRA",
"saveRecipe": "儲存配方", "saveRecipe": "儲存配方",
"loraCountInfo": "(庫存 {existing}/{total}", "loraCountInfo": "(庫存 {existing}/{total}",
@@ -655,7 +715,11 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表" "title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
}, },
"filteredByLora": "已依 LoRA 篩選", "filteredByLora": "已依 LoRA 篩選",
"favorites": { "favorites": {
@@ -695,6 +759,64 @@
"failed": "修復配方失敗:{message}", "failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID" "missingId": "無法修復配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "批量匯入配方",
"action": "批量匯入",
"urlList": "URL 列表",
"directory": "目錄",
"urlDescription": "輸入圖像 URL 或本地檔案路徑(每行一個)。每個都將作為配方匯入。",
"directoryDescription": "輸入目錄路徑以匯入該資料夾中的所有圖像。",
"urlsLabel": "圖像 URL 或本地路徑",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "每行輸入一個 URL 或路徑",
"directoryPath": "目錄路徑",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "瀏覽",
"recursive": "包含子目錄",
"tagsOptional": "標籤(可選,應用於所有配方)",
"tagsPlaceholder": "輸入以逗號分隔的標籤",
"tagsHint": "標籤將被添加到所有匯入的配方中",
"skipNoMetadata": "跳過無元資料的圖像",
"skipNoMetadataHelp": "沒有 LoRA 元資料的圖像將被自動跳過。",
"start": "開始匯入",
"startImport": "開始匯入",
"importing": "匯入中...",
"progress": "進度",
"total": "總計",
"success": "成功",
"failed": "失敗",
"skipped": "跳過",
"current": "當前",
"currentItem": "當前項目",
"preparing": "準備中...",
"cancel": "取消",
"cancelImport": "取消匯入",
"cancelled": "匯入已取消",
"completed": "匯入完成",
"completedWithErrors": "匯入完成但有錯誤",
"completedSuccess": "成功匯入 {count} 個配方",
"successCount": "成功",
"failedCount": "失敗",
"skippedCount": "跳過",
"totalProcessed": "總計處理",
"viewDetails": "查看詳情",
"newImport": "新建匯入",
"manualPathEntry": "請手動輸入目錄路徑。此瀏覽器中檔案瀏覽器不可用。",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"batchImportManualEntryRequired": "檔案瀏覽器不可用。請手動輸入目錄路徑。",
"backToParent": "返回上級目錄",
"folders": "資料夾",
"folderCount": "{count} 個資料夾",
"imageFiles": "圖像檔案",
"images": "圖像",
"imageCount": "{count} 個圖像",
"selectFolder": "選擇此資料夾",
"errors": {
"enterUrls": "請輸入至少一個 URL 或路徑",
"enterDirectory": "請輸入目錄路徑",
"startFailed": "啟動匯入失敗:{message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -704,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾" "moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -717,13 +840,23 @@
"unpinSidebar": "取消固定側邊欄", "unpinSidebar": "取消固定側邊欄",
"switchToListView": "切換至列表檢視", "switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視", "switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "搜尋子資料夾", "recursiveOn": "包含子資料夾",
"recursiveOff": "僅搜尋目前資料夾", "recursiveOff": "僅目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
} }
}, },
"statistics": { "statistics": {
@@ -878,6 +1011,14 @@
"save": "更新基礎模型", "save": "更新基礎模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下載缺失的 LoRAs",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs從選取食譜中的 {totalCount} 個總數)。",
"previewTitle": "要下載的 LoRAs",
"moreItems": "...還有 {count} 個",
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本機範例圖片", "title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:", "message": "此模型未找到本機範例圖片。可選擇:",
@@ -929,7 +1070,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置" "openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "檔案位置已成功開啟", "success": "檔案位置已成功開啟",
@@ -937,6 +1080,9 @@
"copied": "路徑已複製到剪貼簿:{{path}}", "copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}" "clipboardFallback": "路徑:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "無法傳送到 ComfyUI沒有可用的檔案路徑"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "檔案名稱", "fileName": "檔案名稱",
@@ -1194,7 +1340,9 @@
"recipeReplaced": "配方已取代於工作流", "recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗", "recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點" "noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1315,7 +1463,14 @@
"showWechatQR": "顯示微信二維碼", "showWechatQR": "顯示微信二維碼",
"hideWechatQR": "隱藏微信二維碼" "hideWechatQR": "隱藏微信二維碼"
}, },
"footer": "感謝您使用 LoRA 管理器!❤️" "footer": "感謝您使用 LoRA 管理器!❤️",
"supporters": {
"title": "感謝所有支持者",
"subtitle": "感謝 {count} 位支持者讓這個專案成為可能",
"specialThanks": "特別感謝",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1338,6 +1493,7 @@
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}", "autoOrganizeFailed": "自動整理失敗:{error}",
@@ -1349,13 +1505,19 @@
"loadFailed": "載入 {modelType} 失敗:{message}", "loadFailed": "載入 {modelType} 失敗:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失敗:{message}", "refreshFailed": "刷新配方失敗:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失敗:{message}",
"updateFailed": "更新配方失敗:{error}", "updateFailed": "更新配方失敗:{error}",
"updateError": "更新配方錯誤:{message}", "updateError": "更新配方錯誤:{message}",
"nameSaved": "配方「{name}」已成功儲存", "nameSaved": "配方「{name}」已成功儲存",
"nameUpdated": "配方名稱已更新", "nameUpdated": "配方名稱已更新",
"tagsUpdated": "配方標籤已更新", "tagsUpdated": "配方標籤已更新",
"sourceUrlUpdated": "來源網址已更新", "sourceUrlUpdated": "來源網址已更新",
"promptUpdated": "提示詞更新成功",
"negativePromptUpdated": "負面提示詞更新成功",
"promptEditorHint": "按 Enter 儲存Shift+Enter 換行",
"noRecipeId": "無配方 ID", "noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}", "copyFailed": "複製配方語法錯誤:{message}",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -1383,9 +1545,20 @@
"processingError": "處理錯誤:{message}", "processingError": "處理錯誤:{message}",
"folderBrowserError": "載入資料夾瀏覽器錯誤:{message}", "folderBrowserError": "載入資料夾瀏覽器錯誤:{message}",
"recipeSaveFailed": "儲存配方失敗:{error}", "recipeSaveFailed": "儲存配方失敗:{error}",
"recipeSaved": "配方儲存成功",
"importFailed": "匯入失敗:{message}", "importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤" "folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "啟動批量匯入失敗:{message}",
"batchImportCancelling": "正在取消批量匯入...",
"batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑",
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",

3
package-lock.json generated
View File

@@ -114,7 +114,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -138,7 +137,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1613,7 +1611,6 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.0.1", "cssstyle": "^4.0.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",

View File

@@ -2,7 +2,7 @@ import os
import platform import platform
import threading import threading
from pathlib import Path from pathlib import Path
import folder_paths # type: ignore import folder_paths # type: ignore
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
import logging import logging
import json import json
@@ -10,16 +10,48 @@ import urllib.parse
import time import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template from .utils.settings_paths import (
ensure_settings_file,
get_settings_dir,
load_settings_template,
)
# Use an environment variable to control standalone mode # Use an environment variable to control standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = (
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolve_valid_default_root(
current: str, primary_paths: List[str], name: str
) -> str:
"""Return a valid default root from the current primary path set."""
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
if not valid_paths:
return ""
if current in valid_paths:
return current
if current:
logger.info(
"Repaired stale %s from '%s' to '%s'",
name,
current,
valid_paths[0],
)
else:
logger.info("Auto-setting %s to '%s'", name, valid_paths[0])
return valid_paths[0]
def _normalize_folder_paths_for_comparison( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]] folder_paths: Mapping[str, Iterable[str]],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Normalize folder paths for comparison across libraries.""" """Normalize folder paths for comparison across libraries."""
@@ -49,7 +81,7 @@ def _normalize_folder_paths_for_comparison(
def _normalize_library_folder_paths( def _normalize_library_folder_paths(
library_payload: Mapping[str, Any] library_payload: Mapping[str, Any],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Return normalized folder paths extracted from a library payload.""" """Return normalized folder paths extracted from a library payload."""
@@ -76,9 +108,15 @@ class Config:
"""Global configuration for LoRA Manager""" """Global configuration for LoRA Manager"""
def __init__(self): def __init__(self):
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates') self.templates_path = os.path.join(
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static') os.path.dirname(os.path.dirname(__file__)), "templates"
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales') )
self.static_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "static"
)
self.i18n_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "locales"
)
# Path mapping dictionary, target to link mapping # Path mapping dictionary, target to link mapping
self._path_mappings: Dict[str, str] = {} self._path_mappings: Dict[str, str] = {}
# Normalized preview root directories used to validate preview access # Normalized preview root directories used to validate preview access
@@ -91,6 +129,11 @@ class Config:
self.embeddings_roots = None self.embeddings_roots = None
self.base_models_roots = self._init_checkpoint_paths() self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths() self.embeddings_roots = self._init_embedding_paths()
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = []
self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = []
# Scan symbolic links during initialization # Scan symbolic links during initialization
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -147,17 +190,21 @@ class Config:
default_library = libraries.get("default", {}) default_library = libraries.get("default", {})
target_folder_paths = { target_folder_paths = {
'loras': list(self.loras_roots), "loras": list(self.loras_roots),
'checkpoints': list(self.checkpoints_roots or []), "checkpoints": list(self.checkpoints_roots or []),
'unet': list(self.unet_roots or []), "unet": list(self.unet_roots or []),
'embeddings': list(self.embeddings_roots or []), "embeddings": list(self.embeddings_roots or []),
} }
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths) normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
)
normalized_default_paths: Optional[Dict[str, Set[str]]] = None normalized_default_paths: Optional[Dict[str, Set[str]]] = None
if isinstance(default_library, Mapping): if isinstance(default_library, Mapping):
normalized_default_paths = _normalize_library_folder_paths(default_library) normalized_default_paths = _normalize_library_folder_paths(
default_library
)
if ( if (
not comfy_library not comfy_library
@@ -175,19 +222,23 @@ class Config:
"Failed to rename legacy 'default' library: %s", rename_error "Failed to rename legacy 'default' library: %s", rename_error
) )
default_lora_root = comfy_library.get("default_lora_root", "") default_lora_root = _resolve_valid_default_root(
if not default_lora_root and len(self.loras_roots) == 1: comfy_library.get("default_lora_root", ""),
default_lora_root = self.loras_roots[0] list(self.loras_roots or []),
"default_lora_root",
)
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "") default_checkpoint_root = _resolve_valid_default_root(
if (not default_checkpoint_root and self.checkpoints_roots and comfy_library.get("default_checkpoint_root", ""),
len(self.checkpoints_roots) == 1): list(self.checkpoints_roots or []),
default_checkpoint_root = self.checkpoints_roots[0] "default_checkpoint_root",
)
default_embedding_root = comfy_library.get("default_embedding_root", "") default_embedding_root = _resolve_valid_default_root(
if (not default_embedding_root and self.embeddings_roots and comfy_library.get("default_embedding_root", ""),
len(self.embeddings_roots) == 1): list(self.embeddings_roots or []),
default_embedding_root = self.embeddings_roots[0] "default_embedding_root",
)
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI") metadata.setdefault("display_name", "ComfyUI")
@@ -211,11 +262,12 @@ class Config:
try: try:
if os.path.islink(path): if os.path.islink(path):
return True return True
if platform.system() == 'Windows': if platform.system() == "Windows":
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # type: ignore[attr-defined]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception as e: except Exception as e:
logger.error(f"Error checking Windows reparse point: {e}") logger.error(f"Error checking Windows reparse point: {e}")
@@ -228,18 +280,19 @@ class Config:
"""Check if a directory entry is a symlink, including Windows junctions.""" """Check if a directory entry is a symlink, including Windows junctions."""
if entry.is_symlink(): if entry.is_symlink():
return True return True
if platform.system() == 'Windows': if platform.system() == "Windows":
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # type: ignore[attr-defined]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception: except Exception:
pass pass
return False return False
def _normalize_path(self, path: str) -> str: def _normalize_path(self, path: str) -> str:
return os.path.normpath(path).replace(os.sep, '/') return os.path.normpath(path).replace(os.sep, "/")
def _get_symlink_cache_path(self) -> Path: def _get_symlink_cache_path(self) -> Path:
canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True) canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
@@ -250,6 +303,11 @@ class Config:
roots.extend(self.loras_roots or []) roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or []) roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or []) roots.extend(self.embeddings_roots or [])
# Include extra paths for scanning symlinks
roots.extend(self.extra_loras_roots or [])
roots.extend(self.extra_checkpoints_roots or [])
roots.extend(self.extra_unet_roots or [])
roots.extend(self.extra_embeddings_roots or [])
return roots return roots
def _build_symlink_fingerprint(self) -> Dict[str, object]: def _build_symlink_fingerprint(self) -> Dict[str, object]:
@@ -268,19 +326,18 @@ class Config:
if self._entry_is_symlink(entry): if self._entry_is_symlink(entry):
try: try:
target = os.path.realpath(entry.path) target = os.path.realpath(entry.path)
direct_symlinks.append([ direct_symlinks.append(
self._normalize_path(entry.path), [
self._normalize_path(target) self._normalize_path(entry.path),
]) self._normalize_path(target),
]
)
except OSError: except OSError:
pass pass
except (OSError, PermissionError): except (OSError, PermissionError):
pass pass
return { return {"roots": unique_roots, "direct_symlinks": sorted(direct_symlinks)}
"roots": unique_roots,
"direct_symlinks": sorted(direct_symlinks)
}
def _initialize_symlink_mappings(self) -> None: def _initialize_symlink_mappings(self) -> None:
start = time.perf_counter() start = time.perf_counter()
@@ -297,10 +354,14 @@ class Config:
cached_fingerprint = self._cached_fingerprint cached_fingerprint = self._cached_fingerprint
# Check 1: First-level symlinks unchanged (catches new symlinks at root) # Check 1: First-level symlinks unchanged (catches new symlinks at root)
fingerprint_valid = cached_fingerprint and current_fingerprint == cached_fingerprint fingerprint_valid = (
cached_fingerprint and current_fingerprint == cached_fingerprint
)
# Check 2: All cached mappings still valid (catches changes at any depth) # Check 2: All cached mappings still valid (catches changes at any depth)
mappings_valid = self._validate_cached_mappings() if fingerprint_valid else False mappings_valid = (
self._validate_cached_mappings() if fingerprint_valid else False
)
if fingerprint_valid and mappings_valid: if fingerprint_valid and mappings_valid:
return return
@@ -360,7 +421,9 @@ class Config:
for target, link in cached_mappings.items(): for target, link in cached_mappings.items():
if not isinstance(target, str) or not isinstance(link, str): if not isinstance(target, str) or not isinstance(link, str):
continue continue
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link) normalized_mappings[self._normalize_path(target)] = self._normalize_path(
link
)
self._path_mappings = normalized_mappings self._path_mappings = normalized_mappings
@@ -381,7 +444,9 @@ class Config:
parent_dir = loaded_path.parent parent_dir = loaded_path.parent
if parent_dir.name == "cache" and not any(parent_dir.iterdir()): if parent_dir.name == "cache" and not any(parent_dir.iterdir()):
parent_dir.rmdir() parent_dir.rmdir()
logger.info("Removed empty legacy cache directory: %s", parent_dir) logger.info(
"Removed empty legacy cache directory: %s", parent_dir
)
except Exception: except Exception:
pass pass
@@ -392,7 +457,9 @@ class Config:
exc, exc,
) )
else: else:
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings)) logger.info(
"Symlink cache loaded with %d mappings", len(self._path_mappings)
)
return True return True
@@ -404,7 +471,7 @@ class Config:
""" """
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
# Convert normalized paths back to OS paths # Convert normalized paths back to OS paths
link_path = link.replace('/', os.sep) link_path = link.replace("/", os.sep)
# Check if symlink still exists # Check if symlink still exists
if not self._is_link(link_path): if not self._is_link(link_path):
@@ -417,7 +484,9 @@ class Config:
if actual_target != target: if actual_target != target:
logger.debug( logger.debug(
"Symlink target changed: %s -> %s (cached: %s)", "Symlink target changed: %s -> %s (cached: %s)",
link_path, actual_target, target link_path,
actual_target,
target,
) )
return False return False
except OSError: except OSError:
@@ -436,7 +505,11 @@ class Config:
try: try:
with cache_path.open("w", encoding="utf-8") as handle: with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2) json.dump(payload, handle, ensure_ascii=False, indent=2)
logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings)) logger.debug(
"Symlink cache saved to %s with %d mappings",
cache_path,
len(self._path_mappings),
)
except Exception as exc: except Exception as exc:
logger.info("Failed to write symlink cache %s: %s", cache_path, exc) logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
@@ -484,13 +557,13 @@ class Config:
self.add_path_mapping(entry.path, target_path) self.add_path_mapping(entry.path, target_path)
except Exception as inner_exc: except Exception as inner_exc:
logger.debug( logger.debug(
"Error processing directory entry %s: %s", entry.path, inner_exc "Error processing directory entry %s: %s",
entry.path,
inner_exc,
) )
except Exception as e: except Exception as e:
logger.error(f"Error scanning links in {root}: {e}") logger.error(f"Error scanning links in {root}: {e}")
def add_path_mapping(self, link_path: str, target_path: str): def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping """Add a symbolic link path mapping
target_path: actual target path target_path: actual target path
@@ -570,31 +643,45 @@ class Config:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []: for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
# Include extra paths for preview access
for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_checkpoints_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_unet_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root))
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target)) preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link)) preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()} self._preview_root_paths = {
path for path in preview_roots if path.is_absolute()
}
logger.debug( logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings", "Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
len(self._preview_root_paths), len(self._preview_root_paths),
len(self.loras_roots or []), len(self.loras_roots or []),
len(self.extra_loras_roots or []),
len(self.base_models_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []), len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
len(self._path_mappings), len(self._path_mappings),
) )
def map_path_to_link(self, path: str) -> str: def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path""" """Map a target path back to its symbolic link path"""
normalized_path = os.path.normpath(path).replace(os.sep, '/') normalized_path = os.path.normpath(path).replace(os.sep, "/")
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items(): for target_path, link_path in self._path_mappings.items():
# Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc) # Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
if normalized_path == target_path: if normalized_path == target_path:
return link_path return link_path
if normalized_path.startswith(target_path + '/'): if normalized_path.startswith(target_path + "/"):
# If the path starts with the target path, replace with link path # If the path starts with the target path, replace with link path
mapped_path = normalized_path.replace(target_path, link_path, 1) mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path return mapped_path
@@ -602,14 +689,14 @@ class Config:
def map_link_to_path(self, link_path: str) -> str: def map_link_to_path(self, link_path: str) -> str:
"""Map a symbolic link path back to the actual path""" """Map a symbolic link path back to the actual path"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/') normalized_link = os.path.normpath(link_path).replace(os.sep, "/")
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path_mapped in self._path_mappings.items(): for target_path, link_path_mapped in self._path_mappings.items():
# Match whole path components # Match whole path components
if normalized_link == link_path_mapped: if normalized_link == link_path_mapped:
return target_path return target_path
if normalized_link.startswith(link_path_mapped + '/'): if normalized_link.startswith(link_path_mapped + "/"):
# If the path starts with the link path, replace with actual path # If the path starts with the link path, replace with actual path
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1) mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
return mapped_path return mapped_path
@@ -622,8 +709,8 @@ class Config:
continue continue
if not os.path.exists(path): if not os.path.exists(path):
continue continue
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, "/")
normalized = os.path.normpath(path).replace(os.sep, '/') normalized = os.path.normpath(path).replace(os.sep, "/")
if real_path not in dedup: if real_path not in dedup:
dedup[real_path] = normalized dedup[real_path] = normalized
return dedup return dedup
@@ -633,15 +720,139 @@ class Config:
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
return unique_paths return unique_paths
@staticmethod
def _normalize_path_for_comparison(
path: str, *, resolve_realpath: bool = False
) -> str:
"""Normalize a path for equality checks across platforms."""
candidate = os.path.realpath(path) if resolve_realpath else path
return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/")
def _filter_overlapping_extra_lora_paths(
self,
primary_paths: Iterable[str],
extra_paths: Iterable[str],
) -> List[str]:
"""Drop extra LoRA paths that resolve to the same physical location as primary roots."""
primary_map = {
self._normalize_path_for_comparison(path, resolve_realpath=True): path
for path in primary_paths
if isinstance(path, str) and path.strip() and os.path.exists(path)
}
primary_symlink_map = self._collect_first_level_symlink_targets(primary_paths)
filtered: List[str] = []
for original_path in extra_paths:
if not isinstance(original_path, str):
continue
stripped = original_path.strip()
if not stripped:
continue
if not os.path.exists(stripped):
continue
real_path = self._normalize_path_for_comparison(
stripped,
resolve_realpath=True,
)
normalized_path = os.path.normpath(stripped).replace(os.sep, "/")
primary_path = primary_map.get(real_path)
if primary_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
symlink_path = primary_symlink_map.get(real_path)
if symlink_path:
# Config loading should stay tolerant of existing invalid state and warn.
logger.warning(
"Detected the same LoRA folder in both ComfyUI model paths and "
"LoRA Manager Extra Folder Paths. This can cause duplicate items or "
"other unexpected behavior, and it usually means the path setup is "
"not doing what you intended. LoRA Manager will keep the ComfyUI "
"path and ignore this Extra Folder Paths entry: '%s'. Please review "
"your path settings and remove the duplicate entry.",
normalized_path,
)
continue
filtered.append(stripped)
return filtered
def _collect_first_level_symlink_targets(
self, roots: Iterable[str]
) -> Dict[str, str]:
"""Return real-path -> link-path mappings for first-level symlinks under the given roots."""
targets: Dict[str, str] = {}
for root in roots:
if not isinstance(root, str):
continue
stripped_root = root.strip()
if not stripped_root or not os.path.isdir(stripped_root):
continue
try:
with os.scandir(stripped_root) as iterator:
for entry in iterator:
try:
if not self._entry_is_symlink(entry):
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
normalized_target = self._normalize_path_for_comparison(
target_path,
resolve_realpath=True,
)
normalized_link = os.path.normpath(entry.path).replace(
os.sep, "/"
)
targets.setdefault(normalized_target, normalized_link)
except Exception as inner_exc:
logger.debug(
"Error collecting LoRA symlink target for %s: %s",
entry.path,
inner_exc,
)
except Exception as exc:
logger.debug(
"Error scanning first-level LoRA symlinks in %s: %s",
stripped_root,
exc,
)
return targets
def _prepare_checkpoint_paths( def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str] self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> List[str]: ) -> Tuple[List[str], List[str], List[str]]:
"""Prepare checkpoint paths and return (all_roots, checkpoint_roots, unet_roots).
Returns:
Tuple of (all_unique_paths, checkpoint_only_paths, unet_only_paths)
This method does NOT modify instance variables - callers must set them.
"""
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths) checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
unet_map = self._dedupe_existing_paths(unet_paths) unet_map = self._dedupe_existing_paths(unet_paths)
@@ -655,7 +866,7 @@ class Config:
"Please fix your ComfyUI path configuration to separate these folders. " "Please fix your ComfyUI path configuration to separate these folders. "
"Falling back to 'checkpoints' for backward compatibility. " "Falling back to 'checkpoints' for backward compatibility. "
"Overlapping real paths: %s", "Overlapping real paths: %s",
[checkpoint_map.get(rp, rp) for rp in overlapping_real_paths] [checkpoint_map.get(rp, rp) for rp in overlapping_real_paths],
) )
# Remove overlapping paths from unet_map to prioritize checkpoints # Remove overlapping paths from unet_map to prioritize checkpoints
for rp in overlapping_real_paths: for rp in overlapping_real_paths:
@@ -671,40 +882,99 @@ class Config:
checkpoint_values = set(checkpoint_map.values()) checkpoint_values = set(checkpoint_map.values())
unet_values = set(unet_map.values()) unet_values = set(unet_map.values())
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values] checkpoint_roots = [p for p in unique_paths if p in checkpoint_values]
self.unet_roots = [p for p in unique_paths if p in unet_values] unet_roots = [p for p in unique_paths if p in unet_values]
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
return unique_paths return unique_paths, checkpoint_roots, unet_roots
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]: def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
path_map = self._dedupe_existing_paths(raw_paths) path_map = self._dedupe_existing_paths(raw_paths)
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
return unique_paths return unique_paths
def _apply_library_paths(self, folder_paths: Mapping[str, Iterable[str]]) -> None: def _apply_library_paths(
self,
folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
) -> None:
self._path_mappings.clear() self._path_mappings.clear()
self._preview_root_paths = set() self._preview_root_paths = set()
lora_paths = folder_paths.get('loras', []) or [] lora_paths = folder_paths.get("loras", []) or []
checkpoint_paths = folder_paths.get('checkpoints', []) or [] checkpoint_paths = folder_paths.get("checkpoints", []) or []
unet_paths = folder_paths.get('unet', []) or [] unet_paths = folder_paths.get("unet", []) or []
embedding_paths = folder_paths.get('embeddings', []) or [] embedding_paths = folder_paths.get("embeddings", []) or []
self.loras_roots = self._prepare_lora_paths(lora_paths) self.loras_roots = self._prepare_lora_paths(lora_paths)
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths) (
self.base_models_roots,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or []
extra_checkpoint_paths = extra_paths.get("checkpoints", []) or []
extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get("embeddings", []) or []
filtered_extra_lora_paths = self._filter_overlapping_extra_lora_paths(
self.loras_roots,
extra_lora_paths,
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora_paths)
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding_paths
)
# Log extra folder paths
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
def _init_lora_paths(self) -> List[str]: def _init_lora_paths(self) -> List[str]:
@@ -712,7 +982,10 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("loras") raw_paths = folder_paths.get_folder_paths("loras")
unique_paths = self._prepare_lora_paths(raw_paths) unique_paths = self._prepare_lora_paths(raw_paths)
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found LoRA roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid loras folders found in ComfyUI configuration") logger.warning("No valid loras folders found in ComfyUI configuration")
@@ -728,12 +1001,21 @@ class Config:
try: try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints") raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet") raw_unet_paths = folder_paths.get_folder_paths("unet")
unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths) (
unique_paths,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found checkpoint roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid checkpoint folders found in ComfyUI configuration") logger.warning(
"No valid checkpoint folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -746,10 +1028,15 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("embeddings") raw_paths = folder_paths.get_folder_paths("embeddings")
unique_paths = self._prepare_embedding_paths(raw_paths) unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found embedding roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid embeddings folders found in ComfyUI configuration") logger.warning(
"No valid embeddings folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -761,9 +1048,9 @@ class Config:
if not preview_path: if not preview_path:
return "" return ""
normalized = os.path.normpath(preview_path).replace(os.sep, '/') normalized = os.path.normpath(preview_path).replace(os.sep, "/")
encoded_path = urllib.parse.quote(normalized, safe='') encoded_path = urllib.parse.quote(normalized, safe="")
return f'/api/lm/previews?path={encoded_path}' return f"/api/lm/previews?path={encoded_path}"
def is_preview_path_allowed(self, preview_path: str) -> bool: def is_preview_path_allowed(self, preview_path: str) -> bool:
"""Return ``True`` if ``preview_path`` is within an allowed directory. """Return ``True`` if ``preview_path`` is within an allowed directory.
@@ -838,14 +1125,18 @@ class Config:
normalized_link = self._normalize_path(str(current)) normalized_link = self._normalize_path(str(current))
self._path_mappings[normalized_target] = normalized_link self._path_mappings[normalized_target] = normalized_link
self._preview_root_paths.update(self._expand_preview_root(normalized_target)) self._preview_root_paths.update(
self._preview_root_paths.update(self._expand_preview_root(normalized_link)) self._expand_preview_root(normalized_target)
)
self._preview_root_paths.update(
self._expand_preview_root(normalized_link)
)
logger.debug( logger.debug(
"Discovered deep symlink: %s -> %s (preview path: %s)", "Discovered deep symlink: %s -> %s (preview path: %s)",
normalized_link, normalized_link,
normalized_target, normalized_target,
preview_path preview_path,
) )
return True return True
@@ -863,17 +1154,31 @@ class Config:
def apply_library_settings(self, library_config: Mapping[str, object]) -> None: def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
"""Update runtime paths to match the provided library configuration.""" """Update runtime paths to match the provided library configuration."""
folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {} folder_paths = (
library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else {}
)
extra_folder_paths = (
library_config.get("extra_folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths, Mapping): if not isinstance(folder_paths, Mapping):
folder_paths = {} folder_paths = {}
if not isinstance(extra_folder_paths, Mapping):
extra_folder_paths = None
self._apply_library_paths(folder_paths) self._apply_library_paths(folder_paths, extra_folder_paths)
logger.info( logger.info(
"Applied library settings with %d lora roots, %d checkpoint roots, and %d embedding roots", "Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
len(self.loras_roots or []), len(self.loras_roots or []),
len(self.extra_loras_roots or []),
len(self.base_models_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []), len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
) )
def get_library_registry_snapshot(self) -> Dict[str, object]: def get_library_registry_snapshot(self) -> Dict[str, object]:
@@ -893,5 +1198,6 @@ class Config:
logger.debug("Failed to collect library registry snapshot: %s", exc) logger.debug("Failed to collect library registry snapshot: %s", exc)
return {"active_library": "", "libraries": {}} return {"active_library": "", "libraries": {}}
# Global config instance # Global config instance
config = Config() config = Config()

View File

@@ -5,16 +5,22 @@ import logging
from .utils.logging_config import setup_logging from .utils.logging_config import setup_logging
# Check if we're in standalone mode # Check if we're in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = (
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
# Only setup logging prefix if not in standalone mode # Only setup logging prefix if not in standalone mode
if not standalone_mode: if not standalone_mode:
setup_logging() setup_logging()
from server import PromptServer # type: ignore from server import PromptServer # type: ignore
from .config import config from .config import config
from .services.model_service_factory import ModelServiceFactory, register_default_model_types from .services.model_service_factory import (
ModelServiceFactory,
register_default_model_types,
)
from .routes.recipe_routes import RecipeRoutes from .routes.recipe_routes import RecipeRoutes
from .routes.stats_routes import StatsRoutes from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes from .routes.update_routes import UpdateRoutes
@@ -61,6 +67,7 @@ class _SettingsProxy:
settings = _SettingsProxy() settings = _SettingsProxy()
class LoraManager: class LoraManager:
"""Main entry point for LoRA Manager plugin""" """Main entry point for LoRA Manager plugin"""
@@ -76,7 +83,8 @@ class LoraManager:
( (
idx idx
for idx, middleware in enumerate(app.middlewares) for idx, middleware in enumerate(app.middlewares)
if getattr(middleware, "__name__", "") == "block_external_middleware" if getattr(middleware, "__name__", "")
== "block_external_middleware"
), ),
None, None,
) )
@@ -84,7 +92,9 @@ class LoraManager:
if block_middleware_index is None: if block_middleware_index is None:
app.middlewares.append(relax_csp_for_remote_media) app.middlewares.append(relax_csp_for_remote_media)
else: else:
app.middlewares.insert(block_middleware_index, relax_csp_for_remote_media) app.middlewares.insert(
block_middleware_index, relax_csp_for_remote_media
)
# Increase allowed header sizes so browsers with large localhost cookie # Increase allowed header sizes so browsers with large localhost cookie
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default # jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
@@ -105,7 +115,7 @@ class LoraManager:
app._handler_args = updated_handler_args app._handler_args = updated_handler_args
# Configure aiohttp access logger to be less verbose # Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
# Add specific suppression for connection reset errors # Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter): class ConnectionResetFilter(logging.Filter):
@@ -124,19 +134,23 @@ class LoraManager:
asyncio_logger.addFilter(ConnectionResetFilter()) asyncio_logger.addFilter(ConnectionResetFilter())
# Add static route for example images if the path exists in settings # Add static route for example images if the path exists in settings
example_images_path = settings.get('example_images_path') example_images_path = settings.get("example_images_path")
logger.info(f"Example images path: {example_images_path}") logger.info(f"Example images path: {example_images_path}")
if example_images_path and os.path.exists(example_images_path): if example_images_path and os.path.exists(example_images_path):
app.router.add_static('/example_images_static', example_images_path) app.router.add_static("/example_images_static", example_images_path)
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}") logger.info(
f"Added static route for example images: /example_images_static -> {example_images_path}"
)
# Add static route for locales JSON files # Add static route for locales JSON files
if os.path.exists(config.i18n_path): if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path) app.router.add_static("/locales", config.i18n_path)
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}") logger.info(
f"Added static route for locales: /locales -> {config.i18n_path}"
)
# Add static route for plugin assets # Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path) app.router.add_static("/loras_static", config.static_path)
# Register default model types with the factory # Register default model types with the factory
register_default_model_types() register_default_model_types()
@@ -154,9 +168,11 @@ class LoraManager:
PreviewRoutes.setup_routes(app) PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types # Setup WebSocket routes that are shared across all model types
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection) app.router.add_get("/ws/fetch-progress", ws_manager.handle_connection)
app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection) app.router.add_get(
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection) "/ws/download-progress", ws_manager.handle_download_connection
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())
@@ -168,6 +184,39 @@ class LoraManager:
async def _initialize_services(cls): async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry""" """Initialize all services using the ServiceRegistry"""
try: try:
# Apply library settings to load extra folder paths before scanning
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if library_name and library_name in libraries:
library_config = libraries[library_name]
# Only apply settings if extra paths are not already configured
# This preserves values set by tests via monkeypatch
extra_paths = library_config.get("extra_folder_paths", {})
has_extra_paths = (
config.extra_loras_roots
or config.extra_checkpoints_roots
or config.extra_unet_roots
or config.extra_embeddings_roots
)
if not has_extra_paths and any(extra_paths.values()):
config.apply_library_settings(library_config)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
library_name,
extra_paths.get("loras", []),
extra_paths.get("checkpoints", []),
extra_paths.get("embeddings", []),
)
except Exception as exc:
logger.warning(
"Failed to apply library settings during initialization: %s", exc
)
# Initialize CivitaiClient first to ensure it's ready for other services # Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client() await ServiceRegistry.get_civitai_client()
@@ -175,6 +224,7 @@ class LoraManager:
await ServiceRegistry.get_download_manager() await ServiceRegistry.get_download_manager()
from .services.metadata_service import initialize_metadata_providers from .services.metadata_service import initialize_metadata_providers
await initialize_metadata_providers() await initialize_metadata_providers()
# Initialize WebSocket manager # Initialize WebSocket manager
@@ -190,39 +240,58 @@ class LoraManager:
# Create low-priority initialization tasks # Create low-priority initialization tasks
init_tasks = [ init_tasks = [
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'), asyncio.create_task(
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'), lora_scanner.initialize_in_background(), name="lora_cache_init"
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'), ),
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init') asyncio.create_task(
checkpoint_scanner.initialize_in_background(),
name="checkpoint_cache_init",
),
asyncio.create_task(
embedding_scanner.initialize_in_background(),
name="embedding_cache_init",
),
asyncio.create_task(
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
),
] ]
await ExampleImagesMigration.check_and_run_migrations() await ExampleImagesMigration.check_and_run_migrations()
# Schedule post-initialization tasks to run after scanners complete # Schedule post-initialization tasks to run after scanners complete
asyncio.create_task( asyncio.create_task(
cls._run_post_initialization_tasks(init_tasks), cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
name='post_init_tasks'
) )
logger.debug("LoRA Manager: All services initialized and background tasks scheduled") logger.debug(
"LoRA Manager: All services initialized and background tasks scheduled"
)
except Exception as e: except Exception as e:
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True) logger.error(
f"LoRA Manager: Error initializing services: {e}", exc_info=True
)
@classmethod @classmethod
async def _run_post_initialization_tasks(cls, init_tasks): async def _run_post_initialization_tasks(cls, init_tasks):
"""Run post-initialization tasks after all scanners complete""" """Run post-initialization tasks after all scanners complete"""
try: try:
logger.debug("LoRA Manager: Waiting for scanner initialization to complete...") logger.debug(
"LoRA Manager: Waiting for scanner initialization to complete..."
)
# Wait for all scanner initialization tasks to complete # Wait for all scanner initialization tasks to complete
await asyncio.gather(*init_tasks, return_exceptions=True) await asyncio.gather(*init_tasks, return_exceptions=True)
logger.debug("LoRA Manager: Scanner initialization completed, starting post-initialization tasks...") logger.debug(
"LoRA Manager: Scanner initialization completed, starting post-initialization tasks..."
)
# Run post-initialization tasks # Run post-initialization tasks
post_tasks = [ post_tasks = [
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'), asyncio.create_task(
cls._cleanup_backup_files(), name="cleanup_bak_files"
),
# Add more post-initialization tasks here as needed # Add more post-initialization tasks here as needed
# asyncio.create_task(cls._another_post_task(), name='another_task'), # asyncio.create_task(cls._another_post_task(), name='another_task'),
] ]
@@ -234,14 +303,20 @@ class LoraManager:
for i, result in enumerate(results): for i, result in enumerate(results):
task_name = post_tasks[i].get_name() task_name = post_tasks[i].get_name()
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"Post-initialization task '{task_name}' failed: {result}") logger.error(
f"Post-initialization task '{task_name}' failed: {result}"
)
else: else:
logger.debug(f"Post-initialization task '{task_name}' completed successfully") logger.debug(
f"Post-initialization task '{task_name}' completed successfully"
)
logger.debug("LoRA Manager: All post-initialization tasks completed") logger.debug("LoRA Manager: All post-initialization tasks completed")
except Exception as e: except Exception as e:
logger.error(f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True) logger.error(
f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True
)
@classmethod @classmethod
async def _cleanup_backup_files(cls): async def _cleanup_backup_files(cls):
@@ -252,8 +327,8 @@ class LoraManager:
# Collect all model roots # Collect all model roots
all_roots = set() all_roots = set()
all_roots.update(config.loras_roots) all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots) all_roots.update(config.base_models_roots or [])
all_roots.update(config.embeddings_roots) all_roots.update(config.embeddings_roots or [])
total_deleted = 0 total_deleted = 0
total_size_freed = 0 total_size_freed = 0
@@ -263,12 +338,17 @@ class LoraManager:
continue continue
try: try:
deleted_count, size_freed = await cls._cleanup_backup_files_in_directory(root_path) (
deleted_count,
size_freed,
) = await cls._cleanup_backup_files_in_directory(root_path)
total_deleted += deleted_count total_deleted += deleted_count
total_size_freed += size_freed total_size_freed += size_freed
if deleted_count > 0: if deleted_count > 0:
logger.debug(f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024*1024):.2f} MB)") logger.debug(
f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024 * 1024):.2f} MB)"
)
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up .bak files in {root_path}: {e}") logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
@@ -277,7 +357,9 @@ class LoraManager:
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
if total_deleted > 0: if total_deleted > 0:
logger.debug(f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024*1024):.2f} MB total") logger.debug(
f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024 * 1024):.2f} MB total"
)
else: else:
logger.debug("Backup cleanup completed: no .bak files found") logger.debug("Backup cleanup completed: no .bak files found")
@@ -310,7 +392,9 @@ class LoraManager:
with os.scandir(path) as it: with os.scandir(path) as it:
for entry in it: for entry in it:
try: try:
if entry.is_file(follow_symlinks=True) and entry.name.endswith('.bak'): if entry.is_file(
follow_symlinks=True
) and entry.name.endswith(".bak"):
file_size = entry.stat().st_size file_size = entry.stat().st_size
os.remove(entry.path) os.remove(entry.path)
deleted_count += 1 deleted_count += 1
@@ -321,7 +405,9 @@ class LoraManager:
cleanup_recursive(entry.path) cleanup_recursive(entry.path)
except Exception as e: except Exception as e:
logger.warning(f"Could not delete .bak file {entry.path}: {e}") logger.warning(
f"Could not delete .bak file {entry.path}: {e}"
)
except Exception as e: except Exception as e:
logger.error(f"Error scanning directory {path} for .bak files: {e}") logger.error(f"Error scanning directory {path} for .bak files: {e}")
@@ -339,21 +425,21 @@ class LoraManager:
service = ExampleImagesCleanupService() service = ExampleImagesCleanupService()
result = await service.cleanup_example_image_folders() result = await service.cleanup_example_image_folders()
if result.get('success'): if result.get("success"):
logger.debug( logger.debug(
"Manual example images cleanup completed: moved=%s", "Manual example images cleanup completed: moved=%s",
result.get('moved_total'), result.get("moved_total"),
) )
elif result.get('partial_success'): elif result.get("partial_success"):
logger.warning( logger.warning(
"Manual example images cleanup partially succeeded: moved=%s failures=%s", "Manual example images cleanup partially succeeded: moved=%s failures=%s",
result.get('moved_total'), result.get("moved_total"),
result.get('move_failures'), result.get("move_failures"),
) )
else: else:
logger.debug( logger.debug(
"Manual example images cleanup skipped or failed: %s", "Manual example images cleanup skipped or failed: %s",
result.get('error', 'no changes'), result.get("error", "no changes"),
) )
return result return result
@@ -361,9 +447,9 @@ class LoraManager:
except Exception as e: # pragma: no cover - defensive guard except Exception as e: # pragma: no cover - defensive guard
logger.error(f"Error during example images cleanup: {e}", exc_info=True) logger.error(f"Error during example images cleanup: {e}", exc_info=True)
return { return {
'success': False, "success": False,
'error': str(e), "error": str(e),
'error_code': 'unexpected_error', "error_code": "unexpected_error",
} }
@classmethod @classmethod

View File

@@ -4,7 +4,10 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = (
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
if not standalone_mode: if not standalone_mode:
from .metadata_hook import MetadataHook from .metadata_hook import MetadataHook
@@ -19,7 +22,7 @@ if not standalone_mode:
logger.info("ComfyUI Metadata Collector initialized") logger.info("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None): def get_metadata(prompt_id=None): # type: ignore[no-redef]
"""Helper function to get metadata from the registry""" """Helper function to get metadata from the registry"""
registry = MetadataRegistry() registry = MetadataRegistry()
return registry.get_metadata(prompt_id) return registry.get_metadata(prompt_id)
@@ -28,6 +31,6 @@ else:
def init(): def init():
logger.info("ComfyUI Metadata Collector disabled in standalone mode") logger.info("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None): def get_metadata(prompt_id=None): # type: ignore[no-redef]
"""Dummy implementation for standalone mode""" """Dummy implementation for standalone mode"""
return {} return {}

View File

@@ -149,9 +149,12 @@ class MetadataHook:
# Store the original _async_map_node_over_list function # Store the original _async_map_node_over_list function
original_map_node_over_list = getattr(execution, map_node_func_name) original_map_node_over_list = getattr(execution, map_node_func_name)
# Wrapped async function, compatible with both stable and nightly # Wrapped async function - signature must exactly match _async_map_node_over_list
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs): async def async_map_node_over_list_with_metadata(
hidden_inputs = kwargs.get('hidden_inputs', None) prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt=False, execution_block_cb=None,
pre_execute_cb=None, v3_data=None
):
# Only collect metadata when calling the main function of nodes # Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
try: try:
@@ -164,10 +167,10 @@ class MetadataHook:
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
# Call original function with all args/kwargs # Call original function with exact parameters
results = await original_map_node_over_list( results = await original_map_node_over_list(
prompt_id, unique_id, obj, input_data_all, func, prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs allow_interrupt, execution_block_cb, pre_execute_cb, v3_data=v3_data
) )
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):

View File

@@ -1,10 +1,12 @@
import time import time
from nodes import NODE_CLASS_MAPPINGS from nodes import NODE_CLASS_MAPPINGS # type: ignore
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
from .constants import METADATA_CATEGORIES, IMAGES from .constants import METADATA_CATEGORIES, IMAGES
class MetadataRegistry: class MetadataRegistry:
"""A singleton registry to store and retrieve workflow metadata""" """A singleton registry to store and retrieve workflow metadata"""
_instance = None _instance = None
def __new__(cls): def __new__(cls):
@@ -37,11 +39,13 @@ class MetadataRegistry:
# Sort all prompt_ids by timestamp # Sort all prompt_ids by timestamp
sorted_prompts = sorted( sorted_prompts = sorted(
self.prompt_metadata.keys(), self.prompt_metadata.keys(),
key=lambda pid: self.prompt_metadata[pid].get("timestamp", 0) key=lambda pid: self.prompt_metadata[pid].get("timestamp", 0),
) )
# Remove oldest records # Remove oldest records
prompts_to_remove = sorted_prompts[:len(sorted_prompts) - self.max_prompt_history] prompts_to_remove = sorted_prompts[
: len(sorted_prompts) - self.max_prompt_history
]
for pid in prompts_to_remove: for pid in prompts_to_remove:
del self.prompt_metadata[pid] del self.prompt_metadata[pid]
@@ -53,11 +57,13 @@ class MetadataRegistry:
category: {} for category in METADATA_CATEGORIES category: {} for category in METADATA_CATEGORIES
} }
# Add additional metadata fields # Add additional metadata fields
self.prompt_metadata[prompt_id].update({ self.prompt_metadata[prompt_id].update(
"execution_order": [], {
"current_prompt": None, # Will store the prompt object "execution_order": [],
"timestamp": time.time() "current_prompt": None, # Will store the prompt object
}) "timestamp": time.time(),
}
)
# Clean up old prompt data # Clean up old prompt data
self._clean_old_prompts() self._clean_old_prompts()
@@ -125,7 +131,9 @@ class MetadataRegistry:
for category in self.metadata_categories: for category in self.metadata_categories:
if category in cached_data and node_id in cached_data[category]: if category in cached_data and node_id in cached_data[category]:
if node_id not in metadata[category]: if node_id not in metadata[category]:
metadata[category][node_id] = cached_data[category][node_id] metadata[category][node_id] = cached_data[category][
node_id
]
def record_node_execution(self, node_id, class_type, inputs, outputs): def record_node_execution(self, node_id, class_type, inputs, outputs):
"""Record information about a node's execution""" """Record information about a node's execution"""
@@ -135,7 +143,9 @@ class MetadataRegistry:
# Add to execution order and mark as executed # Add to execution order and mark as executed
if node_id not in self.executed_nodes: if node_id not in self.executed_nodes:
self.executed_nodes.add(node_id) self.executed_nodes.add(node_id)
self.prompt_metadata[self.current_prompt_id]["execution_order"].append(node_id) self.prompt_metadata[self.current_prompt_id]["execution_order"].append(
node_id
)
# Process inputs to simplify working with them # Process inputs to simplify working with them
processed_inputs = {} processed_inputs = {}
@@ -152,7 +162,7 @@ class MetadataRegistry:
node_id, node_id,
processed_inputs, processed_inputs,
outputs, outputs,
self.prompt_metadata[self.current_prompt_id] self.prompt_metadata[self.current_prompt_id],
) )
# Cache this node's metadata # Cache this node's metadata
@@ -168,11 +178,9 @@ class MetadataRegistry:
# Use the same extractor to update with outputs # Use the same extractor to update with outputs
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor) extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
if hasattr(extractor, 'update'): if hasattr(extractor, "update"):
extractor.update( extractor.update(
node_id, node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id]
processed_outputs,
self.prompt_metadata[self.current_prompt_id]
) )
# Update the cached metadata for this node # Update the cached metadata for this node
@@ -214,7 +222,7 @@ class MetadataRegistry:
# Find cache keys that are no longer needed # Find cache keys that are no longer needed
keys_to_remove = [] keys_to_remove = []
for cache_key in self.node_cache: for cache_key in self.node_cache:
node_id = cache_key.split(':')[0] node_id = cache_key.split(":")[0]
if node_id not in active_node_ids: if node_id not in active_node_ids:
keys_to_remove.append(cache_key) keys_to_remove.append(cache_key)
@@ -270,7 +278,10 @@ class MetadataRegistry:
if IMAGES in cached_data and node_id in cached_data[IMAGES]: if IMAGES in cached_data and node_id in cached_data[IMAGES]:
image_data = cached_data[IMAGES][node_id]["image"] image_data = cached_data[IMAGES][node_id]["image"]
# Handle different image formats # Handle different image formats
if isinstance(image_data, (list, tuple)) and len(image_data) > 0: if (
isinstance(image_data, (list, tuple))
and len(image_data) > 0
):
return image_data[0] return image_data[0]
return image_data return image_data

View File

@@ -0,0 +1,118 @@
import logging
from typing import List, Tuple
import comfy.sd # type: ignore
import folder_paths # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class CheckpointLoaderLM:
"""Checkpoint Loader with support for extra folder paths
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading.
"""
NAME = "Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = s._get_checkpoint_names()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
RETURN_NAMES = ("MODEL", "CLIP", "VAE")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
)
FUNCTION = "load_checkpoint"
@classmethod
def _get_checkpoint_names(cls) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
def load_checkpoint(self, ckpt_name: str) -> Tuple:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
Returns:
Tuple of (MODEL, CLIP, VAE)
"""
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3]

View File

@@ -0,0 +1,161 @@
"""
Helper module to safely import ComfyUI-GGUF modules.
This module provides a robust way to import ComfyUI-GGUF functionality
regardless of how ComfyUI loaded it.
"""
import sys
import os
import importlib.util
import logging
from typing import Optional, Tuple, Any
logger = logging.getLogger(__name__)
def _get_gguf_path() -> str:
"""Get the path to ComfyUI-GGUF based on this file's location.
Since ComfyUI-Lora-Manager and ComfyUI-GGUF are both in custom_nodes/,
we can derive the GGUF path from our own location.
"""
# This file is at: custom_nodes/ComfyUI-Lora-Manager/py/nodes/gguf_import_helper.py
# ComfyUI-GGUF is at: custom_nodes/ComfyUI-GGUF
current_file = os.path.abspath(__file__)
# Go up 4 levels: nodes -> py -> ComfyUI-Lora-Manager -> custom_nodes
custom_nodes_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(custom_nodes_dir, "ComfyUI-GGUF")
def _find_gguf_module() -> Optional[Any]:
"""Find ComfyUI-GGUF module in sys.modules.
ComfyUI registers modules using the full path with dots replaced by _x_.
"""
gguf_path = _get_gguf_path()
sys_module_name = gguf_path.replace(".", "_x_")
logger.debug(f"[GGUF Import] Looking for module '{sys_module_name}' in sys.modules")
if sys_module_name in sys.modules:
logger.info(f"[GGUF Import] Found module: '{sys_module_name}'")
return sys.modules[sys_module_name]
logger.debug(f"[GGUF Import] Module not found: '{sys_module_name}'")
return None
def _load_gguf_modules_directly() -> Optional[Any]:
"""Load ComfyUI-GGUF modules directly from file paths."""
gguf_path = _get_gguf_path()
logger.info(f"[GGUF Import] Direct Load: Attempting to load from '{gguf_path}'")
if not os.path.exists(gguf_path):
logger.warning(f"[GGUF Import] Path does not exist: {gguf_path}")
return None
try:
namespace = "ComfyUI_GGUF_Dynamic"
init_path = os.path.join(gguf_path, "__init__.py")
if not os.path.exists(init_path):
logger.warning(f"[GGUF Import] __init__.py not found at '{init_path}'")
return None
logger.debug(f"[GGUF Import] Loading from '{init_path}'")
spec = importlib.util.spec_from_file_location(namespace, init_path)
if not spec or not spec.loader:
logger.error(f"[GGUF Import] Failed to create spec for '{init_path}'")
return None
package = importlib.util.module_from_spec(spec)
package.__path__ = [gguf_path]
sys.modules[namespace] = package
spec.loader.exec_module(package)
logger.debug(f"[GGUF Import] Loaded main package '{namespace}'")
# Load submodules
loaded = []
for submod_name in ["loader", "ops", "nodes"]:
submod_path = os.path.join(gguf_path, f"{submod_name}.py")
if os.path.exists(submod_path):
submod_spec = importlib.util.spec_from_file_location(
f"{namespace}.{submod_name}", submod_path
)
if submod_spec and submod_spec.loader:
submod = importlib.util.module_from_spec(submod_spec)
submod.__package__ = namespace
sys.modules[f"{namespace}.{submod_name}"] = submod
submod_spec.loader.exec_module(submod)
setattr(package, submod_name, submod)
loaded.append(submod_name)
logger.debug(f"[GGUF Import] Loaded submodule '{submod_name}'")
logger.info(f"[GGUF Import] Direct Load success: {loaded}")
return package
except Exception as e:
logger.error(f"[GGUF Import] Direct Load failed: {e}", exc_info=True)
return None
def get_gguf_modules() -> Tuple[Any, Any, Any]:
"""Get ComfyUI-GGUF modules (loader, ops, nodes).
Returns:
Tuple of (loader_module, ops_module, nodes_module)
Raises:
RuntimeError: If ComfyUI-GGUF cannot be found or loaded.
"""
logger.debug("[GGUF Import] Starting module search...")
# Try to find already loaded module first
gguf_module = _find_gguf_module()
if gguf_module is None:
logger.info("[GGUF Import] Not found in sys.modules, trying direct load...")
gguf_module = _load_gguf_modules_directly()
if gguf_module is None:
raise RuntimeError(
"ComfyUI-GGUF is not installed. "
"Please install from https://github.com/city96/ComfyUI-GGUF"
)
# Extract submodules
loader = getattr(gguf_module, "loader", None)
ops = getattr(gguf_module, "ops", None)
nodes = getattr(gguf_module, "nodes", None)
if loader is None or ops is None or nodes is None:
missing = [
name
for name, mod in [("loader", loader), ("ops", ops), ("nodes", nodes)]
if mod is None
]
raise RuntimeError(f"ComfyUI-GGUF missing submodules: {missing}")
logger.debug("[GGUF Import] All modules loaded successfully")
return loader, ops, nodes
def get_gguf_sd_loader():
"""Get the gguf_sd_loader function from ComfyUI-GGUF."""
loader, _, _ = get_gguf_modules()
return getattr(loader, "gguf_sd_loader")
def get_ggml_ops():
"""Get the GGMLOps class from ComfyUI-GGUF."""
_, ops, _ = get_gguf_modules()
return getattr(ops, "GGMLOps")
def get_gguf_model_patcher():
"""Get the GGUFModelPatcher class from ComfyUI-GGUF."""
_, _, nodes = get_gguf_modules()
return getattr(nodes, "GGUFModelPatcher")

View File

@@ -8,6 +8,7 @@ and tracks the cycle progress which persists across workflow save/load.
import logging import logging
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,8 +55,14 @@ class LoraCyclerLM:
current_index = cycler_config.get("current_index", 1) # 1-based current_index = cycler_config.get("current_index", 1) # 1-based
model_strength = float(cycler_config.get("model_strength", 1.0)) model_strength = float(cycler_config.get("model_strength", 1.0))
clip_strength = float(cycler_config.get("clip_strength", 1.0)) clip_strength = float(cycler_config.get("clip_strength", 1.0))
use_same_clip_strength = cycler_config.get("use_same_clip_strength", True)
use_preset_strength = cycler_config.get("use_preset_strength", False)
preset_strength_scale = float(cycler_config.get("preset_strength_scale", 1.0))
sort_by = "filename" sort_by = "filename"
# Include "no lora" option
include_no_lora = cycler_config.get("include_no_lora", False)
# Dual-index mechanism for batch queue synchronization # Dual-index mechanism for batch queue synchronization
execution_index = cycler_config.get("execution_index") # Can be None execution_index = cycler_config.get("execution_index") # Can be None
# next_index_from_config = cycler_config.get("next_index") # Not used on backend # next_index_from_config = cycler_config.get("next_index") # Not used on backend
@@ -71,7 +78,10 @@ class LoraCyclerLM:
total_count = len(lora_list) total_count = len(lora_list)
if total_count == 0: # Calculate effective total count (includes no lora option if enabled)
effective_total_count = total_count + 1 if include_no_lora else total_count
if total_count == 0 and not include_no_lora:
logger.warning("[LoraCyclerLM] No LoRAs available in pool") logger.warning("[LoraCyclerLM] No LoRAs available in pool")
return { return {
"result": ([],), "result": ([],),
@@ -93,42 +103,99 @@ class LoraCyclerLM:
else: else:
actual_index = current_index actual_index = current_index
# Clamp index to valid range (1-based) # Clamp index to valid range (1-based, includes no lora if enabled)
clamped_index = max(1, min(actual_index, total_count)) clamped_index = max(1, min(actual_index, effective_total_count))
# Get LoRA at current index (convert to 0-based for list access) # Check if current index is the "no lora" option (last position when include_no_lora is True)
current_lora = lora_list[clamped_index - 1] is_no_lora = include_no_lora and clamped_index == effective_total_count
# Build LORA_STACK with single LoRA if is_no_lora:
lora_path, _ = get_lora_info(current_lora["file_name"]) # "No LoRA" option - return empty stack
if not lora_path:
logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
)
lora_stack = [] lora_stack = []
current_lora_name = "No LoRA"
current_lora_filename = "No LoRA"
else: else:
# Normalize path separators # Get LoRA at current index (convert to 0-based for list access)
lora_path = lora_path.replace("/", os.sep) current_lora = lora_list[clamped_index - 1]
lora_stack = [(lora_path, model_strength, clip_strength)] current_lora_name = current_lora["file_name"]
current_lora_filename = current_lora["file_name"]
# Build LORA_STACK with single LoRA
if current_lora["file_name"] == "None":
lora_path = None
else:
lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path:
if current_lora["file_name"] != "None":
logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
)
lora_stack = []
else:
# Normalize path separators
lora_path = lora_path.replace("/", os.sep)
if use_preset_strength:
lora_metadata = await lora_service.get_lora_metadata_by_filename(
current_lora["file_name"]
)
if lora_metadata:
recommended_strength = (
lora_service.get_recommended_strength_from_lora_data(
lora_metadata
)
)
if recommended_strength is not None:
model_strength = round(
recommended_strength * preset_strength_scale, 2
)
if use_same_clip_strength:
clip_strength = model_strength
else:
recommended_clip_strength = (
lora_service.get_recommended_clip_strength_from_lora_data(
lora_metadata
)
)
if recommended_clip_strength is not None:
clip_strength = round(
recommended_clip_strength * preset_strength_scale, 2
)
elif use_same_clip_strength:
clip_strength = model_strength
elif use_same_clip_strength:
clip_strength = model_strength
lora_stack = [(lora_path, model_strength, clip_strength)]
# Calculate next index (wrap to 1 if at end) # Calculate next index (wrap to 1 if at end)
next_index = clamped_index + 1 next_index = clamped_index + 1
if next_index > total_count: if next_index > effective_total_count:
next_index = 1 next_index = 1
# Get next LoRA for UI display (what will be used next generation) # Get next LoRA for UI display (what will be used next generation)
next_lora = lora_list[next_index - 1] is_next_no_lora = include_no_lora and next_index == effective_total_count
next_display_name = next_lora["file_name"] if is_next_no_lora:
next_display_name = "No LoRA"
next_lora_filename = "No LoRA"
else:
next_lora = lora_list[next_index - 1]
next_display_name = next_lora["file_name"]
next_lora_filename = next_lora["file_name"]
return { return {
"result": (lora_stack,), "result": (lora_stack,),
"ui": { "ui": {
"current_index": [clamped_index], "current_index": [clamped_index],
"next_index": [next_index], "next_index": [next_index],
"total_count": [total_count], "total_count": [
"current_lora_name": [current_lora["file_name"]], total_count
"current_lora_filename": [current_lora["file_name"]], ], # Return actual LoRA count, not effective_total_count
"current_lora_name": [current_lora_name],
"current_lora_filename": [current_lora_filename],
"next_lora_name": [next_display_name], "next_lora_name": [next_display_name],
"next_lora_filename": [next_lora["file_name"]], "next_lora_filename": [next_lora_filename],
}, },
} }

View File

@@ -1,11 +1,129 @@
import importlib
import logging import logging
import re import re
from nodes import LoraLoader
from ..utils.utils import get_lora_info import comfy.sd # type: ignore
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora import comfy.utils # type: ignore
from ..utils.utils import get_lora_info_absolute
from .utils import (
FlexibleOptionalInputType,
any_type,
detect_nunchaku_model_kind,
extract_lora_name,
get_loras_list,
nunchaku_load_lora,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_nunchaku_load_qwen_loras():
try:
module = importlib.import_module(".nunchaku_qwen", __package__)
except ImportError as exc:
raise RuntimeError(
"Qwen-Image LoRA loading requires the ComfyUI runtime with its torch dependency available."
) from exc
return module.nunchaku_load_qwen_loras
def _collect_stack_entries(lora_stack):
entries = []
if not lora_stack:
return entries
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)
entries.append({
"name": lora_name,
"absolute_path": absolute_lora_path,
"input_path": lora_path,
"model_strength": float(model_strength),
"clip_strength": float(clip_strength),
"trigger_words": trigger_words,
})
return entries
def _collect_widget_entries(kwargs):
entries = []
for lora in get_loras_list(kwargs):
if not lora.get("active", False):
continue
lora_name = lora["name"]
model_strength = float(lora["strength"])
clip_strength = float(lora.get("clipStrength", model_strength))
lora_path, trigger_words = get_lora_info_absolute(lora_name)
entries.append({
"name": lora_name,
"absolute_path": lora_path,
"input_path": lora_path,
"model_strength": model_strength,
"clip_strength": clip_strength,
"trigger_words": trigger_words,
})
return entries
def _format_loaded_loras(loaded_loras):
formatted_loras = []
for item in loaded_loras:
if item["include_clip_strength"]:
formatted_loras.append(
f"<lora:{item['name']}:{item['model_strength']}:{item['clip_strength']}>"
)
else:
formatted_loras.append(f"<lora:{item['name']}:{item['model_strength']}>")
return " ".join(formatted_loras)
def _apply_entries(model, clip, lora_entries, nunchaku_model_kind):
loaded_loras = []
all_trigger_words = []
if nunchaku_model_kind == "qwen_image":
nunchaku_load_qwen_loras = _get_nunchaku_load_qwen_loras()
qwen_lora_configs = []
for entry in lora_entries:
qwen_lora_configs.append((entry["absolute_path"], entry["model_strength"]))
loaded_loras.append({
"name": entry["name"],
"model_strength": entry["model_strength"],
"clip_strength": entry["model_strength"],
"include_clip_strength": False,
})
all_trigger_words.extend(entry["trigger_words"])
if qwen_lora_configs:
model = nunchaku_load_qwen_loras(model, qwen_lora_configs)
return model, clip, loaded_loras, all_trigger_words
for entry in lora_entries:
if nunchaku_model_kind == "flux":
model = nunchaku_load_lora(model, entry["input_path"], entry["model_strength"])
else:
lora = comfy.utils.load_torch_file(entry["absolute_path"], safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model,
clip,
lora,
entry["model_strength"],
entry["clip_strength"],
)
include_clip_strength = nunchaku_model_kind is None and abs(entry["model_strength"] - entry["clip_strength"]) > 0.001
loaded_loras.append({
"name": entry["name"],
"model_strength": entry["model_strength"],
"clip_strength": entry["clip_strength"],
"include_clip_strength": include_clip_strength,
})
all_trigger_words.extend(entry["trigger_words"])
return model, clip, loaded_loras, all_trigger_words
class LoraLoaderLM: class LoraLoaderLM:
NAME = "Lora Loader (LoraManager)" NAME = "Lora Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@@ -15,7 +133,6 @@ class LoraLoaderLM:
return { return {
"required": { "required": {
"model": ("MODEL",), "model": ("MODEL",),
# "clip": ("CLIP",),
"text": ("AUTOCOMPLETE_TEXT_LORAS", { "text": ("AUTOCOMPLETE_TEXT_LORAS", {
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
@@ -30,104 +147,23 @@ class LoraLoaderLM:
def load_loras(self, model, text, **kwargs): def load_loras(self, model, text, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack.""" """Loads multiple LoRAs based on the kwargs input and lora_stack."""
loaded_loras = [] del text
all_trigger_words = [] clip = kwargs.get("clip", None)
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
lora_entries.extend(_collect_widget_entries(kwargs))
clip = kwargs.get('clip', None) nunchaku_model_kind = detect_nunchaku_model_kind(model)
lora_stack = kwargs.get('lora_stack', None) if nunchaku_model_kind == "flux":
logger.info("Detected Nunchaku Flux model")
elif nunchaku_model_kind == "qwen_image":
logger.info("Detected Nunchaku Qwen-Image model")
# Check if model is a Nunchaku Flux model - simplified approach model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
is_nunchaku_model = False
try:
model_wrapper = model.model.diffusion_model
# Check if model is a Nunchaku Flux model using only class name
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
is_nunchaku_model = True
logger.info("Detected Nunchaku Flux model")
except (AttributeError, TypeError):
# Not a model with the expected structure
pass
# First process lora_stack if available
if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack:
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Extract lora name for trigger words lookup
lora_name = extract_lora_name(lora_path)
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models)
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Then process loras from kwargs with support for both old and new formats
loras_list = get_loras_list(kwargs)
for lora in loras_list:
if not lora.get('active', False):
continue
lora_name = lora['name']
model_strength = float(lora['strength'])
# Get clip strength - use model strength as default if not specified
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# For Nunchaku models, use our custom function
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
formatted_loras_text = _format_loaded_loras(loaded_loras)
# Format loaded_loras with support for both formats
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
lora_name = parts[0]
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
# Different model and clip strengths
model_str = strength_parts[0].strip()
clip_str = strength_parts[1].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
else:
# Same strength for both
model_str = strength_parts[0].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
formatted_loras_text = " ".join(formatted_loras)
return (model, clip, trigger_words_text, formatted_loras_text) return (model, clip, trigger_words_text, formatted_loras_text)
class LoraTextLoaderLM: class LoraTextLoaderLM:
NAME = "LoRA Text Loader (LoraManager)" NAME = "LoRA Text Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders" CATEGORY = "Lora Manager/loaders"
@@ -139,13 +175,13 @@ class LoraTextLoaderLM:
"model": ("MODEL",), "model": ("MODEL",),
"lora_syntax": ("STRING", { "lora_syntax": ("STRING", {
"forceInput": True, "forceInput": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation" "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
}, },
"optional": { "optional": {
"clip": ("CLIP",), "clip": ("CLIP",),
"lora_stack": ("LORA_STACK",), "lora_stack": ("LORA_STACK",),
} },
} }
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING") RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
@@ -154,113 +190,40 @@ class LoraTextLoaderLM:
def parse_lora_syntax(self, text): def parse_lora_syntax(self, text):
"""Parse LoRA syntax from text input.""" """Parse LoRA syntax from text input."""
# Pattern to match <lora:name:strength> or <lora:name:model_strength:clip_strength> pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
pattern = r'<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>'
matches = re.findall(pattern, text, re.IGNORECASE) matches = re.findall(pattern, text, re.IGNORECASE)
loras = [] loras = []
for match in matches: for match in matches:
lora_name = match[0]
model_strength = float(match[1]) model_strength = float(match[1])
clip_strength = float(match[2]) if match[2] else model_strength
loras.append({ loras.append({
'name': lora_name, "name": match[0],
'model_strength': model_strength, "model_strength": model_strength,
'clip_strength': clip_strength "clip_strength": float(match[2]) if match[2] else model_strength,
}) })
return loras return loras
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None): def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
"""Load LoRAs based on text syntax input.""" """Load LoRAs based on text syntax input."""
loaded_loras = [] lora_entries = _collect_stack_entries(lora_stack)
all_trigger_words = [] for lora in self.parse_lora_syntax(lora_syntax):
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
lora_entries.append({
"name": lora["name"],
"absolute_path": lora_path,
"input_path": lora_path,
"model_strength": lora["model_strength"],
"clip_strength": lora["clip_strength"],
"trigger_words": trigger_words,
})
# Check if model is a Nunchaku Flux model - simplified approach nunchaku_model_kind = detect_nunchaku_model_kind(model)
is_nunchaku_model = False if nunchaku_model_kind == "flux":
logger.info("Detected Nunchaku Flux model")
elif nunchaku_model_kind == "qwen_image":
logger.info("Detected Nunchaku Qwen-Image model")
try: model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
model_wrapper = model.model.diffusion_model
# Check if model is a Nunchaku Flux model using only class name
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
is_nunchaku_model = True
logger.info("Detected Nunchaku Flux model")
except (AttributeError, TypeError):
# Not a model with the expected structure
pass
# First process lora_stack if available
if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack:
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Extract lora name for trigger words lookup
lora_name = extract_lora_name(lora_path)
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models)
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Parse and process LoRAs from text syntax
parsed_loras = self.parse_lora_syntax(lora_syntax)
for lora in parsed_loras:
lora_name = lora['name']
model_strength = lora['model_strength']
clip_strength = lora['clip_strength']
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# For Nunchaku models, use our custom function
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
else:
loaded_loras.append(f"{lora_name}: {model_strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
formatted_loras_text = _format_loaded_loras(loaded_loras)
# Format loaded_loras with support for both formats
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
lora_name = parts[0].strip()
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
# Different model and clip strengths
model_str = strength_parts[0].strip()
clip_str = strength_parts[1].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
else:
# Same strength for both
model_str = strength_parts[0].strip()
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
formatted_loras_text = " ".join(formatted_loras)
return (model, clip, trigger_words_text, formatted_loras_text) return (model, clip, trigger_words_text, formatted_loras_text)

View File

@@ -82,6 +82,7 @@ class LoraPoolLM:
"folders": {"include": [], "exclude": []}, "folders": {"include": [], "exclude": []},
"favoritesOnly": False, "favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False}, "license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
}, },
"preview": {"matchCount": 0, "lastUpdated": 0}, "preview": {"matchCount": 0, "lastUpdated": 0},
} }

View File

@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
""" """
import logging import logging
import random
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
from .utils import extract_lora_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,26 @@
class LoraStackCombinerLM:
NAME = "Lora Stack Combiner (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack_a": ("LORA_STACK",),
"lora_stack_b": ("LORA_STACK",),
},
}
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("LORA_STACK",)
FUNCTION = "combine_stacks"
def combine_stacks(self, lora_stack_a, lora_stack_b):
combined_stack = []
if lora_stack_a:
combined_stack.extend(lora_stack_a)
if lora_stack_b:
combined_stack.extend(lora_stack_b)
return (combined_stack,)

570
py/nodes/nunchaku_qwen.py Normal file
View File

@@ -0,0 +1,570 @@
from __future__ import annotations
"""Qwen-Image LoRA support for Nunchaku models.
Portions of the LoRA mapping/application logic in this file are adapted from
ComfyUI-QwenImageLoraLoader by GitHub user ussoewwin:
https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader
The upstream project is licensed under Apache License 2.0.
"""
import copy
import logging
import os
import re
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import comfy.utils # type: ignore
import folder_paths # type: ignore
import torch
import torch.nn as nn
from safetensors import safe_open
from nunchaku.lora.flux.nunchaku_converter import (
pack_lowrank_weight,
unpack_lowrank_weight,
)
logger = logging.getLogger(__name__)
KEY_MAPPING = [
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]([qkv])$"), r"\1.\2.attention.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"), r"\1.\2.feed_forward.net.0.proj", "glu", lambda m: m.group(3)),
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"), r"\1.\2.feed_forward.net.2", "regular", None),
(re.compile(r"^(layers)[._](\d+)[._](.*)$"), r"\1.\2.\3", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._](q|k|v)[._]proj$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]add[._](q|k|v)[._]proj$"), r"\1.\2.attn.add_qkv_proj", "add_qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj[._]context$"), r"\1.\2.attn.to_add_out", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj$"), r"\1.\2.attn.to_out.0", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out.0", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_fc1", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]2$"), r"\1.\2.mlp_fc2", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_context_fc1", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]2$"), r"\1.\2.mlp_context_fc2", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]out$"), r"\1.\2.proj_out", "single_proj_out", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]mlp$"), r"\1.\2.mlp_fc1", "regular", None),
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]norm[._]linear$"), r"\1.\2.norm.linear", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1[._]linear$"), r"\1.\2.norm1.linear", "regular", None),
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1_context[._]linear$"), r"\1.\2.norm1_context.linear", "regular", None),
(re.compile(r"^(img_in)$"), r"\1", "regular", None),
(re.compile(r"^(txt_in)$"), r"\1", "regular", None),
(re.compile(r"^(proj_out)$"), r"\1", "regular", None),
(re.compile(r"^(norm_out)[._](linear)$"), r"\1.\2", "regular", None),
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_1)$"), r"\1.\2.\3", "regular", None),
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_2)$"), r"\1.\2.\3", "regular", None),
]
_RE_LORA_SUFFIX = re.compile(r"\.(?P<tag>lora(?:[._](?:A|B|down|up)))(?:\.[^.]+)*\.weight$")
_RE_ALPHA_SUFFIX = re.compile(r"\.(?:alpha|lora_alpha)(?:\.[^.]+)*$")
def _rename_layer_underscore_layer_name(old_name: str) -> str:
rules = [
(r"_(\d+)_attn_to_out_(\d+)", r".\1.attn.to_out.\2"),
(r"_(\d+)_img_mlp_net_(\d+)_proj", r".\1.img_mlp.net.\2.proj"),
(r"_(\d+)_txt_mlp_net_(\d+)_proj", r".\1.txt_mlp.net.\2.proj"),
(r"_(\d+)_img_mlp_net_(\d+)", r".\1.img_mlp.net.\2"),
(r"_(\d+)_txt_mlp_net_(\d+)", r".\1.txt_mlp.net.\2"),
(r"_(\d+)_img_mod_(\d+)", r".\1.img_mod.\2"),
(r"_(\d+)_txt_mod_(\d+)", r".\1.txt_mod.\2"),
(r"_(\d+)_attn_", r".\1.attn."),
]
new_name = old_name
for pattern, replacement in rules:
new_name = re.sub(pattern, replacement, new_name)
return new_name
def _is_indexable_module(module):
return isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple))
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
if not name:
return model
module = model
for part in name.split("."):
if not part:
continue
if hasattr(module, part):
module = getattr(module, part)
elif part.isdigit() and _is_indexable_module(module):
try:
module = module[int(part)]
except (IndexError, TypeError):
return None
else:
return None
return module
def _resolve_module_name(model: nn.Module, name: str) -> Tuple[str, Optional[nn.Module]]:
module = _get_module_by_name(model, name)
if module is not None:
return name, module
replacements = [
(".attn.to_out.0", ".attn.to_out"),
(".attention.to_qkv", ".attention.qkv"),
(".attention.to_out.0", ".attention.out"),
(".feed_forward.net.0.proj", ".feed_forward.w13"),
(".feed_forward.net.2", ".feed_forward.w2"),
(".ff.net.0.proj", ".mlp_fc1"),
(".ff.net.2", ".mlp_fc2"),
(".ff_context.net.0.proj", ".mlp_context_fc1"),
(".ff_context.net.2", ".mlp_context_fc2"),
]
for src, dst in replacements:
if src in name:
alt = name.replace(src, dst)
module = _get_module_by_name(model, alt)
if module is not None:
return alt, module
return name, None
def _classify_and_map_key(key: str) -> Optional[Tuple[str, str, Optional[str], str]]:
normalized = key
if normalized.startswith("transformer."):
normalized = normalized[len("transformer."):]
if normalized.startswith("diffusion_model."):
normalized = normalized[len("diffusion_model."):]
if normalized.startswith("lora_unet_"):
normalized = _rename_layer_underscore_layer_name(normalized[len("lora_unet_"):])
match = _RE_LORA_SUFFIX.search(normalized)
if match:
tag = match.group("tag")
base = normalized[:match.start()]
ab = "A" if ("lora_A" in tag or tag.endswith(".A") or "down" in tag) else "B"
else:
match = _RE_ALPHA_SUFFIX.search(normalized)
if not match:
return None
base = normalized[:match.start()]
ab = "alpha"
for pattern, template, group, comp_fn in KEY_MAPPING:
key_match = pattern.match(base)
if key_match:
return group, key_match.expand(template), comp_fn(key_match) if comp_fn else None, ab
return None
def _detect_lora_format(lora_state_dict: Dict[str, torch.Tensor]) -> bool:
standard_patterns = (
".lora_up.",
".lora_down.",
".lora_A.",
".lora_B.",
".lora.up.",
".lora.down.",
".lora.A.",
".lora.B.",
)
return any(pattern in key for key in lora_state_dict for pattern in standard_patterns)
def _load_lora_state_dict(path_or_dict: Union[str, Path, Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
if isinstance(path_or_dict, dict):
return path_or_dict
path = Path(path_or_dict)
if path.suffix == ".safetensors":
state_dict: Dict[str, torch.Tensor] = {}
with safe_open(path, framework="pt", device="cpu") as handle:
for key in handle.keys():
state_dict[key] = handle.get_tensor(key)
return state_dict
return comfy.utils.load_torch_file(str(path), safe_load=True)
def _fuse_glu_lora(glu_weights: Dict[str, torch.Tensor]) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
if "w1_A" not in glu_weights or "w3_A" not in glu_weights:
return None, None, None
a_w1, b_w1 = glu_weights["w1_A"], glu_weights["w1_B"]
a_w3, b_w3 = glu_weights["w3_A"], glu_weights["w3_B"]
if a_w1.shape[1] != a_w3.shape[1]:
return None, None, None
a_fused = torch.cat([a_w1, a_w3], dim=0)
out1, out3 = b_w1.shape[0], b_w3.shape[0]
rank1, rank3 = b_w1.shape[1], b_w3.shape[1]
b_fused = torch.zeros(out1 + out3, rank1 + rank3, dtype=b_w1.dtype, device=b_w1.device)
b_fused[:out1, :rank1] = b_w1
b_fused[out1:, rank1:] = b_w3
return a_fused, b_fused, glu_weights.get("w1_alpha")
def _fuse_qkv_lora(qkv_weights: Dict[str, torch.Tensor], model: Optional[nn.Module] = None, base_key: Optional[str] = None) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
required_keys = ["Q_A", "Q_B", "K_A", "K_B", "V_A", "V_B"]
if not all(key in qkv_weights for key in required_keys):
return None, None, None
a_q, a_k, a_v = qkv_weights["Q_A"], qkv_weights["K_A"], qkv_weights["V_A"]
b_q, b_k, b_v = qkv_weights["Q_B"], qkv_weights["K_B"], qkv_weights["V_B"]
if not (a_q.shape == a_k.shape == a_v.shape):
return None, None, None
if not (b_q.shape[1] == b_k.shape[1] == b_v.shape[1]):
return None, None, None
out_features = None
if model is not None and base_key is not None:
_, module = _resolve_module_name(model, base_key)
out_features = getattr(module, "out_features", None) if module is not None else None
alpha_fused = None
alpha_q = qkv_weights.get("Q_alpha")
alpha_k = qkv_weights.get("K_alpha")
alpha_v = qkv_weights.get("V_alpha")
if alpha_q is not None and alpha_k is not None and alpha_v is not None and alpha_q.item() == alpha_k.item() == alpha_v.item():
alpha_fused = alpha_q
a_fused = torch.cat([a_q, a_k, a_v], dim=0)
rank = b_q.shape[1]
out_q, out_k, out_v = b_q.shape[0], b_k.shape[0], b_v.shape[0]
total_out = out_features if out_features is not None else out_q + out_k + out_v
b_fused = torch.zeros(total_out, 3 * rank, dtype=b_q.dtype, device=b_q.device)
b_fused[:out_q, :rank] = b_q
b_fused[out_q:out_q + out_k, rank:2 * rank] = b_k
b_fused[out_q + out_k:out_q + out_k + out_v, 2 * rank:] = b_v
return a_fused, b_fused, alpha_fused
def _handle_proj_out_split(lora_dict: Dict[str, Dict[str, torch.Tensor]], base_key: str, model: nn.Module) -> Tuple[Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]], List[str]]:
result: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
consumed: List[str] = []
match = re.search(r"single_transformer_blocks\.(\d+)", base_key)
if not match or base_key not in lora_dict:
return result, consumed
block_idx = match.group(1)
block = _get_module_by_name(model, f"single_transformer_blocks.{block_idx}")
if block is None:
return result, consumed
a_full = lora_dict[base_key].get("A")
b_full = lora_dict[base_key].get("B")
alpha = lora_dict[base_key].get("alpha")
attn_to_out = getattr(getattr(block, "attn", None), "to_out", None)
mlp_fc2 = getattr(block, "mlp_fc2", None)
if a_full is None or b_full is None or attn_to_out is None or mlp_fc2 is None:
return result, consumed
attn_in = getattr(attn_to_out, "in_features", None)
mlp_in = getattr(mlp_fc2, "in_features", None)
if attn_in is None or mlp_in is None or a_full.shape[1] != attn_in + mlp_in:
return result, consumed
result[f"single_transformer_blocks.{block_idx}.attn.to_out"] = (a_full[:, :attn_in], b_full.clone(), alpha)
result[f"single_transformer_blocks.{block_idx}.mlp_fc2"] = (a_full[:, attn_in:], b_full.clone(), alpha)
consumed.append(base_key)
return result, consumed
def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: nn.Module) -> None:
if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
raise ValueError(f"{module_name}: unsupported module without in/out features")
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
raise ValueError(f"{module_name}: LoRA shape mismatch")
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
if not hasattr(module, "_lora_original_forward"):
module._lora_original_forward = module.forward
if not hasattr(module, "_nunchaku_lora_bundle"):
module._nunchaku_lora_bundle = []
module._nunchaku_lora_bundle.append((a_tensor, b_tensor))
def _awq_lora_forward(x, *args, **kwargs):
out = module._lora_original_forward(x, *args, **kwargs)
x_flat = x.reshape(-1, module.in_features)
for local_a, local_b in module._nunchaku_lora_bundle:
local_a = local_a.to(device=out.device, dtype=out.dtype)
local_b = local_b.to(device=out.device, dtype=out.dtype)
lora_term = (x_flat @ local_a.transpose(0, 1)) @ local_b.transpose(0, 1)
try:
out = out + lora_term.reshape(out.shape)
except Exception:
pass
return out
module.forward = _awq_lora_forward
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
model._lora_slots[module_name] = {"type": "awq_w4a16"}
return
if hasattr(module, "proj_down") and hasattr(module, "proj_up"):
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
base_rank = proj_down.shape[0] if proj_down.shape[1] == module.in_features else proj_down.shape[1]
if proj_down.shape[1] == module.in_features:
updated_down = torch.cat([proj_down, a_tensor], dim=0)
axis_down = 0
else:
updated_down = torch.cat([proj_down, a_tensor.T], dim=1)
axis_down = 1
updated_up = torch.cat([proj_up, b_tensor], dim=1)
module.proj_down.data = pack_lowrank_weight(updated_down, down=True)
module.proj_up.data = pack_lowrank_weight(updated_up, down=False)
module.rank = base_rank + a_tensor.shape[0]
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
model._lora_slots[module_name] = {
"type": "nunchaku",
"base_rank": base_rank,
"axis_down": axis_down,
}
return
if isinstance(module, nn.Linear):
if not hasattr(model, "_lora_slots"):
model._lora_slots = {}
if module_name not in model._lora_slots:
model._lora_slots[module_name] = {
"type": "linear",
"original_weight": module.weight.detach().cpu().clone(),
}
module.weight.data.add_((b_tensor @ a_tensor).to(dtype=module.weight.dtype, device=module.weight.device))
return
raise ValueError(f"{module_name}: unsupported module type {type(module)}")
def reset_lora_v2(model: nn.Module) -> None:
slots = getattr(model, "_lora_slots", None)
if not slots:
return
for name, info in list(slots.items()):
module = _get_module_by_name(model, name)
if module is None:
continue
module_type = info.get("type", "nunchaku")
if module_type == "nunchaku":
base_rank = info["base_rank"]
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
if info.get("axis_down", 0) == 0:
proj_down = proj_down[:base_rank, :].clone()
else:
proj_down = proj_down[:, :base_rank].clone()
proj_up = proj_up[:, :base_rank].clone()
module.proj_down.data = pack_lowrank_weight(proj_down, down=True)
module.proj_up.data = pack_lowrank_weight(proj_up, down=False)
module.rank = base_rank
elif module_type == "linear" and "original_weight" in info:
module.weight.data.copy_(info["original_weight"].to(device=module.weight.device, dtype=module.weight.dtype))
elif module_type == "awq_w4a16":
if hasattr(module, "_lora_original_forward"):
module.forward = module._lora_original_forward
for attr in ("_lora_original_forward", "_nunchaku_lora_bundle"):
if hasattr(module, attr):
delattr(module, attr)
model._lora_slots = {}
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
del apply_awq_mod # retained for interface compatibility
reset_lora_v2(model)
aggregated_weights: Dict[str, List[Dict[str, object]]] = defaultdict(list)
saw_supported_format = False
unresolved_targets = 0
for index, (path_or_dict, strength) in enumerate(lora_configs):
if abs(strength) < 1e-5:
continue
lora_name = str(path_or_dict) if not isinstance(path_or_dict, dict) else f"lora_{index}"
lora_state_dict = _load_lora_state_dict(path_or_dict)
if not lora_state_dict or not _detect_lora_format(lora_state_dict):
logger.warning("Skipping unsupported Qwen LoRA: %s", lora_name)
continue
saw_supported_format = True
grouped_weights: Dict[str, Dict[str, torch.Tensor]] = defaultdict(dict)
for key, value in lora_state_dict.items():
parsed = _classify_and_map_key(key)
if parsed is None:
continue
group, base_key, component, ab = parsed
if component and ab:
grouped_weights[base_key][f"{component}_{ab}"] = value
else:
grouped_weights[base_key][ab] = value
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
handled: set[str] = set()
for base_key, weights in grouped_weights.items():
if base_key in handled:
continue
a_tensor = b_tensor = alpha = None
if "qkv" in base_key or "add_qkv_proj" in base_key:
a_tensor, b_tensor, alpha = _fuse_qkv_lora(weights, model=model, base_key=base_key)
elif "w1_A" in weights or "w3_A" in weights:
a_tensor, b_tensor, alpha = _fuse_glu_lora(weights)
elif ".proj_out" in base_key and "single_transformer_blocks" in base_key:
split_map, consumed = _handle_proj_out_split(grouped_weights, base_key, model)
processed_groups.update(split_map)
handled.update(consumed)
continue
else:
a_tensor, b_tensor, alpha = weights.get("A"), weights.get("B"), weights.get("alpha")
if a_tensor is not None and b_tensor is not None:
processed_groups[base_key] = (a_tensor, b_tensor, alpha)
for module_name, (a_tensor, b_tensor, alpha) in processed_groups.items():
aggregated_weights[module_name].append({
"A": a_tensor,
"B": b_tensor,
"alpha": alpha,
"strength": strength,
})
for module_name, weight_list in aggregated_weights.items():
resolved_name, module = _resolve_module_name(model, module_name)
if module is None:
logger.warning("Skipping unresolved Qwen LoRA target: %s", module_name)
unresolved_targets += 1
continue
all_a = []
all_b_scaled = []
for item in weight_list:
a_tensor = item["A"]
b_tensor = item["B"]
alpha = item["alpha"]
strength = float(item["strength"])
rank = a_tensor.shape[0]
scale = strength * ((alpha / rank) if alpha is not None else 1.0)
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
target_dtype = torch.float16
target_device = module.qweight.device
elif hasattr(module, "proj_down"):
target_dtype = module.proj_down.dtype
target_device = module.proj_down.device
elif hasattr(module, "weight"):
target_dtype = module.weight.dtype
target_device = module.weight.device
else:
target_dtype = torch.float16
target_device = "cuda" if torch.cuda.is_available() else "cpu"
all_a.append(a_tensor.to(dtype=target_dtype, device=target_device))
all_b_scaled.append((b_tensor * scale).to(dtype=target_dtype, device=target_device))
if not all_a:
continue
_apply_lora_to_module(module, torch.cat(all_a, dim=0), torch.cat(all_b_scaled, dim=1), resolved_name, model)
slot_count = len(getattr(model, "_lora_slots", {}) or {})
logger.info(
"Qwen LoRA composition finished: requested=%d supported=%s applied_targets=%d unresolved=%d",
len(lora_configs),
saw_supported_format,
slot_count,
unresolved_targets,
)
return saw_supported_format
class ComfyQwenImageWrapperLM(nn.Module):
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
super().__init__()
self.model = model
self.config = {} if config is None else config
self.dtype = next(model.parameters()).dtype
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
self._applied_loras: Optional[List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]]] = None
self.apply_awq_mod = apply_awq_mod
def __getattr__(self, name):
try:
inner = object.__getattribute__(self, "_modules").get("model")
except (AttributeError, KeyError):
inner = None
if inner is None:
raise AttributeError(f"{type(self).__name__!s} has no attribute {name}")
if name == "model":
return inner
return getattr(inner, name)
def process_img(self, *args, **kwargs):
return self.model.process_img(*args, **kwargs)
def _ensure_composed(self):
if self._applied_loras != self.loras or (not self.loras and getattr(self.model, "_lora_slots", None)):
is_supported_format = compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
self._applied_loras = self.loras.copy()
has_slots = bool(getattr(self.model, "_lora_slots", None))
if self.loras and is_supported_format and not has_slots:
logger.warning("Qwen LoRA compose produced 0 target modules. Resetting and retrying once.")
reset_lora_v2(self.model)
compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
has_slots = bool(getattr(self.model, "_lora_slots", None))
logger.info("Qwen LoRA retry result: applied_targets=%d", len(getattr(self.model, "_lora_slots", {}) or {}))
offload_manager = getattr(self.model, "offload_manager", None)
if offload_manager is not None:
offload_settings = {
"num_blocks_on_gpu": getattr(offload_manager, "num_blocks_on_gpu", 1),
"use_pin_memory": getattr(offload_manager, "use_pin_memory", False),
}
logger.info(
"Rebuilding Qwen offload manager after LoRA compose: num_blocks_on_gpu=%s use_pin_memory=%s",
offload_settings["num_blocks_on_gpu"],
offload_settings["use_pin_memory"],
)
self.model.set_offload(False)
self.model.set_offload(True, **offload_settings)
def forward(self, *args, **kwargs):
self._ensure_composed()
return self.model(*args, **kwargs)
def _get_qwen_wrapper_and_transformer(model):
model_wrapper = model.model.diffusion_model
if hasattr(model_wrapper, "model") and hasattr(model_wrapper, "loras"):
transformer = model_wrapper.model
if transformer.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
return model_wrapper, transformer
if model_wrapper.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
wrapped_model = ComfyQwenImageWrapperLM(model_wrapper, getattr(model_wrapper, "config", {}))
model.model.diffusion_model = wrapped_model
return wrapped_model, wrapped_model.model
raise TypeError(f"This LoRA loader only works with Nunchaku Qwen Image models, but got {type(model_wrapper).__name__}.")
def nunchaku_load_qwen_loras(model, lora_configs: List[Tuple[str, float]], apply_awq_mod: bool = True):
model_wrapper, transformer = _get_qwen_wrapper_and_transformer(model)
model_wrapper.apply_awq_mod = apply_awq_mod
saved_config = None
if hasattr(model, "model") and hasattr(model.model, "model_config"):
saved_config = model.model.model_config
model.model.model_config = None
model_wrapper.model = None
try:
ret_model = copy.deepcopy(model)
finally:
if saved_config is not None:
model.model.model_config = saved_config
model_wrapper.model = transformer
ret_model_wrapper = ret_model.model.diffusion_model
if saved_config is not None:
ret_model.model.model_config = saved_config
ret_model_wrapper.model = transformer
ret_model_wrapper.apply_awq_mod = apply_awq_mod
ret_model_wrapper.loras = list(getattr(model_wrapper, "loras", []))
for lora_name, lora_strength in lora_configs:
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping Qwen LoRA '%s' because it could not be found", lora_name)
continue
ret_model_wrapper.loras.append((lora_path, lora_strength))
return ret_model

View File

@@ -1,8 +1,9 @@
import json import json
import os import os
import re import re
from typing import Any, Dict, Optional
import numpy as np import numpy as np
import folder_paths # type: ignore import folder_paths # type: ignore
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..metadata_collector.metadata_processor import MetadataProcessor from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
@@ -12,6 +13,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SaveImageLM: class SaveImageLM:
NAME = "Save Image (LoraManager)" NAME = "Save Image (LoraManager)"
CATEGORY = "Lora Manager/utils" CATEGORY = "Lora Manager/utils"
@@ -32,33 +34,58 @@ class SaveImageLM:
return { return {
"required": { "required": {
"images": ("IMAGE",), "images": ("IMAGE",),
"filename_prefix": ("STRING", { "filename_prefix": (
"default": "ComfyUI", "STRING",
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc." {
}), "default": "ComfyUI",
"file_format": (["png", "jpeg", "webp"], { "tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc.",
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality." },
}), ),
"file_format": (
["png", "jpeg", "webp"],
{
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
},
),
}, },
"optional": { "optional": {
"lossless_webp": ("BOOLEAN", { "lossless_webp": (
"default": False, "BOOLEAN",
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss." {
}), "default": False,
"quality": ("INT", { "tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss.",
"default": 100, },
"min": 1, ),
"max": 100, "quality": (
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files." "INT",
}), {
"embed_workflow": ("BOOLEAN", { "default": 100,
"default": False, "min": 1,
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats." "max": 100,
}), "tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
"add_counter_to_filename": ("BOOLEAN", { },
"default": True, ),
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images." "embed_workflow": (
}), "BOOLEAN",
{
"default": False,
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
},
),
"save_with_metadata": (
"BOOLEAN",
{
"default": True,
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
},
),
"add_counter_to_filename": (
"BOOLEAN",
{
"default": True,
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
},
),
}, },
"hidden": { "hidden": {
"id": "UNIQUE_ID", "id": "UNIQUE_ID",
@@ -77,9 +104,10 @@ class SaveImageLM:
scanner = ServiceRegistry.get_service_sync("lora_scanner") scanner = ServiceRegistry.get_service_sync("lora_scanner")
# Use the new direct filename lookup method # Use the new direct filename lookup method
hash_value = scanner.get_hash_by_filename(lora_name) if scanner is not None:
if hash_value: hash_value = scanner.get_hash_by_filename(lora_name)
return hash_value if hash_value:
return hash_value
return None return None
@@ -95,9 +123,10 @@ class SaveImageLM:
checkpoint_name = os.path.splitext(checkpoint_name)[0] checkpoint_name = os.path.splitext(checkpoint_name)[0]
# Try direct filename lookup first # Try direct filename lookup first
hash_value = scanner.get_hash_by_filename(checkpoint_name) if scanner is not None:
if hash_value: hash_value = scanner.get_hash_by_filename(checkpoint_name)
return hash_value if hash_value:
return hash_value
return None return None
@@ -112,11 +141,11 @@ class SaveImageLM:
param_list.append(f"{label}: {value}") param_list.append(f"{label}: {value}")
# Extract the prompt and negative prompt # Extract the prompt and negative prompt
prompt = metadata_dict.get('prompt', '') prompt = metadata_dict.get("prompt", "")
negative_prompt = metadata_dict.get('negative_prompt', '') negative_prompt = metadata_dict.get("negative_prompt", "")
# Extract loras from the prompt if present # Extract loras from the prompt if present
loras_text = metadata_dict.get('loras', '') loras_text = metadata_dict.get("loras", "")
lora_hashes = {} lora_hashes = {}
# If loras are found, add them on a new line after the prompt # If loras are found, add them on a new line after the prompt
@@ -124,7 +153,7 @@ class SaveImageLM:
prompt_with_loras = f"{prompt}\n{loras_text}" prompt_with_loras = f"{prompt}\n{loras_text}"
# Extract lora names from the format <lora:name:strength> # Extract lora names from the format <lora:name:strength>
lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', loras_text) lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", loras_text)
# Get hash for each lora # Get hash for each lora
for lora_name, strength in lora_matches: for lora_name, strength in lora_matches:
@@ -145,43 +174,43 @@ class SaveImageLM:
params = [] params = []
# Add standard parameters in the correct order # Add standard parameters in the correct order
if 'steps' in metadata_dict: if "steps" in metadata_dict:
add_param_if_not_none(params, "Steps", metadata_dict.get('steps')) add_param_if_not_none(params, "Steps", metadata_dict.get("steps"))
# Combine sampler and scheduler information # Combine sampler and scheduler information
sampler_name = None sampler_name = None
scheduler_name = None scheduler_name = None
if 'sampler' in metadata_dict: if "sampler" in metadata_dict:
sampler = metadata_dict.get('sampler') sampler = metadata_dict.get("sampler")
# Convert ComfyUI sampler names to user-friendly names # Convert ComfyUI sampler names to user-friendly names
sampler_mapping = { sampler_mapping = {
'euler': 'Euler', "euler": "Euler",
'euler_ancestral': 'Euler a', "euler_ancestral": "Euler a",
'dpm_2': 'DPM2', "dpm_2": "DPM2",
'dpm_2_ancestral': 'DPM2 a', "dpm_2_ancestral": "DPM2 a",
'heun': 'Heun', "heun": "Heun",
'dpm_fast': 'DPM fast', "dpm_fast": "DPM fast",
'dpm_adaptive': 'DPM adaptive', "dpm_adaptive": "DPM adaptive",
'lms': 'LMS', "lms": "LMS",
'dpmpp_2s_ancestral': 'DPM++ 2S a', "dpmpp_2s_ancestral": "DPM++ 2S a",
'dpmpp_sde': 'DPM++ SDE', "dpmpp_sde": "DPM++ SDE",
'dpmpp_sde_gpu': 'DPM++ SDE', "dpmpp_sde_gpu": "DPM++ SDE",
'dpmpp_2m': 'DPM++ 2M', "dpmpp_2m": "DPM++ 2M",
'dpmpp_2m_sde': 'DPM++ 2M SDE', "dpmpp_2m_sde": "DPM++ 2M SDE",
'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE', "dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
'ddim': 'DDIM' "ddim": "DDIM",
} }
sampler_name = sampler_mapping.get(sampler, sampler) sampler_name = sampler_mapping.get(sampler, sampler)
if 'scheduler' in metadata_dict: if "scheduler" in metadata_dict:
scheduler = metadata_dict.get('scheduler') scheduler = metadata_dict.get("scheduler")
scheduler_mapping = { scheduler_mapping = {
'normal': 'Simple', "normal": "Simple",
'karras': 'Karras', "karras": "Karras",
'exponential': 'Exponential', "exponential": "Exponential",
'sgm_uniform': 'SGM Uniform', "sgm_uniform": "SGM Uniform",
'sgm_quadratic': 'SGM Quadratic' "sgm_quadratic": "SGM Quadratic",
} }
scheduler_name = scheduler_mapping.get(scheduler, scheduler) scheduler_name = scheduler_mapping.get(scheduler, scheduler)
@@ -193,25 +222,25 @@ class SaveImageLM:
params.append(f"Sampler: {sampler_name}") params.append(f"Sampler: {sampler_name}")
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg) # CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg)
if 'guidance' in metadata_dict: if "guidance" in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get('guidance')) add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance"))
elif 'cfg_scale' in metadata_dict: elif "cfg_scale" in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg_scale')) add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale"))
elif 'cfg' in metadata_dict: elif "cfg" in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg')) add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg"))
# Seed # Seed
if 'seed' in metadata_dict: if "seed" in metadata_dict:
add_param_if_not_none(params, "Seed", metadata_dict.get('seed')) add_param_if_not_none(params, "Seed", metadata_dict.get("seed"))
# Size # Size
if 'size' in metadata_dict: if "size" in metadata_dict:
add_param_if_not_none(params, "Size", metadata_dict.get('size')) add_param_if_not_none(params, "Size", metadata_dict.get("size"))
# Model info # Model info
if 'checkpoint' in metadata_dict: if "checkpoint" in metadata_dict:
# Ensure checkpoint is a string before processing # Ensure checkpoint is a string before processing
checkpoint = metadata_dict.get('checkpoint') checkpoint = metadata_dict.get("checkpoint")
if checkpoint is not None: if checkpoint is not None:
# Get model hash # Get model hash
model_hash = self.get_checkpoint_hash(checkpoint) model_hash = self.get_checkpoint_hash(checkpoint)
@@ -223,7 +252,9 @@ class SaveImageLM:
# Add model hash if available # Add model hash if available
if model_hash: if model_hash:
params.append(f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}") params.append(
f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}"
)
else: else:
params.append(f"Model: {checkpoint_name}") params.append(f"Model: {checkpoint_name}")
@@ -234,7 +265,7 @@ class SaveImageLM:
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}") lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
if lora_hash_parts: if lora_hash_parts:
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"") params.append(f'Lora hashes: "{", ".join(lora_hash_parts)}"')
# Combine all parameters with commas # Combine all parameters with commas
metadata_parts.append(", ".join(params)) metadata_parts.append(", ".join(params))
@@ -254,30 +285,30 @@ class SaveImageLM:
parts = segment.replace("%", "").split(":") parts = segment.replace("%", "").split(":")
key = parts[0] key = parts[0]
if key == "seed" and 'seed' in metadata_dict: if key == "seed" and "seed" in metadata_dict:
filename = filename.replace(segment, str(metadata_dict.get('seed', ''))) filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
elif key == "width" and 'size' in metadata_dict: elif key == "width" and "size" in metadata_dict:
size = metadata_dict.get('size', 'x') size = metadata_dict.get("size", "x")
w = size.split('x')[0] if isinstance(size, str) else size[0] w = size.split("x")[0] if isinstance(size, str) else size[0]
filename = filename.replace(segment, str(w)) filename = filename.replace(segment, str(w))
elif key == "height" and 'size' in metadata_dict: elif key == "height" and "size" in metadata_dict:
size = metadata_dict.get('size', 'x') size = metadata_dict.get("size", "x")
h = size.split('x')[1] if isinstance(size, str) else size[1] h = size.split("x")[1] if isinstance(size, str) else size[1]
filename = filename.replace(segment, str(h)) filename = filename.replace(segment, str(h))
elif key == "pprompt" and 'prompt' in metadata_dict: elif key == "pprompt" and "prompt" in metadata_dict:
prompt = metadata_dict.get('prompt', '').replace("\n", " ") prompt = metadata_dict.get("prompt", "").replace("\n", " ")
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip()) filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and 'negative_prompt' in metadata_dict: elif key == "nprompt" and "negative_prompt" in metadata_dict:
prompt = metadata_dict.get('negative_prompt', '').replace("\n", " ") prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip()) filename = filename.replace(segment, prompt.strip())
elif key == "model": elif key == "model":
model_value = metadata_dict.get('checkpoint') model_value = metadata_dict.get("checkpoint")
if isinstance(model_value, (bytes, os.PathLike)): if isinstance(model_value, (bytes, os.PathLike)):
model_value = str(model_value) model_value = str(model_value)
@@ -291,6 +322,7 @@ class SaveImageLM:
filename = filename.replace(segment, model) filename = filename.replace(segment, model)
elif key == "date": elif key == "date":
from datetime import datetime from datetime import datetime
now = datetime.now() now = datetime.now()
date_table = { date_table = {
"yyyy": f"{now.year:04d}", "yyyy": f"{now.year:04d}",
@@ -314,8 +346,20 @@ class SaveImageLM:
return filename return filename
def save_images(self, images, filename_prefix, file_format, id, prompt=None, extra_pnginfo=None, def save_images(
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True): self,
images,
filename_prefix,
file_format,
id,
prompt=None,
extra_pnginfo=None,
lossless_webp=True,
quality=100,
embed_workflow=False,
save_with_metadata=True,
add_counter_to_filename=True,
):
"""Save images with metadata""" """Save images with metadata"""
results = [] results = []
@@ -329,8 +373,10 @@ class SaveImageLM:
filename_prefix = self.format_filename(filename_prefix, metadata_dict) filename_prefix = self.format_filename(filename_prefix, metadata_dict)
# Get initial save path info once for the batch # Get initial save path info once for the batch
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path( full_output_folder, filename, counter, subfolder, processed_prefix = (
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0] folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
) )
# Create directory if it doesn't exist # Create directory if it doesn't exist
@@ -340,7 +386,7 @@ class SaveImageLM:
# Process each image with incrementing counter # Process each image with incrementing counter
for i, image in enumerate(images): for i, image in enumerate(images):
# Convert the tensor image to numpy array # Convert the tensor image to numpy array
img = 255. * image.cpu().numpy() img = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed # Generate filename with counter if needed
@@ -351,6 +397,9 @@ class SaveImageLM:
base_filename += f"_{current_counter:05}_" base_filename += f"_{current_counter:05}_"
# Set file extension and prepare saving parameters # Set file extension and prepare saving parameters
file: str
save_kwargs: Dict[str, Any]
pnginfo: Optional[PngImagePlugin.PngInfo] = None
if file_format == "png": if file_format == "png":
file = base_filename + ".png" file = base_filename + ".png"
file_extension = ".png" file_extension = ".png"
@@ -365,7 +414,13 @@ class SaveImageLM:
file = base_filename + ".webp" file = base_filename + ".webp"
file_extension = ".webp" file_extension = ".webp"
# Add optimization param to control performance # Add optimization param to control performance
save_kwargs = {"quality": quality, "lossless": lossless_webp, "method": 0} save_kwargs = {
"quality": quality,
"lossless": lossless_webp,
"method": 0,
}
else:
raise ValueError(f"Unsupported file format: {file_format}")
# Full save path # Full save path
file_path = os.path.join(full_output_folder, file) file_path = os.path.join(full_output_folder, file)
@@ -373,7 +428,8 @@ class SaveImageLM:
# Save the image with metadata # Save the image with metadata
try: try:
if file_format == "png": if file_format == "png":
if metadata: assert pnginfo is not None
if save_with_metadata and metadata:
pnginfo.add_text("parameters", metadata) pnginfo.add_text("parameters", metadata)
if embed_workflow and extra_pnginfo is not None: if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"]) workflow_json = json.dumps(extra_pnginfo["workflow"])
@@ -382,9 +438,14 @@ class SaveImageLM:
img.save(file_path, format="PNG", **save_kwargs) img.save(file_path, format="PNG", **save_kwargs)
elif file_format == "jpeg": elif file_format == "jpeg":
# For JPEG, use piexif # For JPEG, use piexif
if metadata: if save_with_metadata and metadata:
try: try:
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}} exif_dict = {
"Exif": {
piexif.ExifIFD.UserComment: b"UNICODE\0"
+ metadata.encode("utf-16be")
}
}
exif_bytes = piexif.dump(exif_dict) exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes save_kwargs["exif"] = exif_bytes
except Exception as e: except Exception as e:
@@ -395,13 +456,19 @@ class SaveImageLM:
# For WebP, use piexif for metadata # For WebP, use piexif for metadata
exif_dict = {} exif_dict = {}
if metadata: if save_with_metadata and metadata:
exif_dict['Exif'] = {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')} exif_dict["Exif"] = {
piexif.ExifIFD.UserComment: b"UNICODE\0"
+ metadata.encode("utf-16be")
}
# Add workflow if needed # Add workflow if needed
if embed_workflow and extra_pnginfo is not None: if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"]) workflow_json = json.dumps(extra_pnginfo["workflow"])
exif_dict['0th'] = {piexif.ImageIFD.ImageDescription: "Workflow:" + workflow_json} exif_dict["0th"] = {
piexif.ImageIFD.ImageDescription: "Workflow:"
+ workflow_json
}
exif_bytes = piexif.dump(exif_dict) exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes save_kwargs["exif"] = exif_bytes
@@ -410,19 +477,29 @@ class SaveImageLM:
img.save(file_path, format="WEBP", **save_kwargs) img.save(file_path, format="WEBP", **save_kwargs)
results.append({ results.append(
"filename": file, {"filename": file, "subfolder": subfolder, "type": self.type}
"subfolder": subfolder, )
"type": self.type
})
except Exception as e: except Exception as e:
logger.error(f"Error saving image: {e}") logger.error(f"Error saving image: {e}")
return results return results
def process_image(self, images, id, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None, def process_image(
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True): self,
images,
id,
filename_prefix="ComfyUI",
file_format="png",
prompt=None,
extra_pnginfo=None,
lossless_webp=True,
quality=100,
embed_workflow=False,
save_with_metadata=True,
add_counter_to_filename=True,
):
"""Process and save image with metadata""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
@@ -448,7 +525,11 @@ class SaveImageLM:
lossless_webp, lossless_webp,
quality, quality,
embed_workflow, embed_workflow,
add_counter_to_filename save_with_metadata,
add_counter_to_filename,
) )
return (images,) return {
"result": (images,),
"ui": {"images": results},
}

205
py/nodes/unet_loader.py Normal file
View File

@@ -0,0 +1,205 @@
import logging
import os
from typing import List, Tuple
import comfy.sd # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class UNETLoaderLM:
"""UNET Loader with support for extra folder paths
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models.
"""
NAME = "Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = s._get_unet_names()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("MODEL",)
OUTPUT_TOOLTIPS = ("The model used for denoising latents.",)
FUNCTION = "load_unet"
@classmethod
def _get_unet_names(cls) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model,)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
return (model,)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)

View File

@@ -1,33 +1,35 @@
class AnyType(str): class AnyType(str):
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss""" """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
def __ne__(self, __value: object) -> bool:
return False
def __ne__(self, __value: object) -> bool:
return False
# Credit to Regis Gaughan, III (rgthree) # Credit to Regis Gaughan, III (rgthree)
class FlexibleOptionalInputType(dict): class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers. """A special class to make flexible nodes that pass data to our python handlers.
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc). (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
Note, for ComfyUI, all that's needed is the `__contains__` override below, which tells ComfyUI Note, for ComfyUI, all that's needed is the `__contains__` override below, which tells ComfyUI
that our node will handle the input, regardless of what it is. that our node will handle the input, regardless of what it is.
However, with https://github.com/comfyanonymous/ComfyUI/pull/2666 a large change would occur However, with https://github.com/comfyanonymous/ComfyUI/pull/2666 a large change would occur
requiring more details on the input itself. There, we need to return a list/tuple where the first requiring more details on the input itself. There, we need to return a list/tuple where the first
item is the type. This can be a real type, or use the AnyType for additional flexibility. item is the type. This can be a real type, or use the AnyType for additional flexibility.
This should be forwards compatible unless more changes occur in the PR. This should be forwards compatible unless more changes occur in the PR.
""" """
def __init__(self, type):
self.type = type
def __getitem__(self, key): def __init__(self, type):
return (self.type, ) self.type = type
def __contains__(self, key): def __getitem__(self, key):
return True return (self.type,)
def __contains__(self, key):
return True
any_type = AnyType("*") any_type = AnyType("*")
@@ -37,25 +39,27 @@ import os
import logging import logging
import copy import copy
import sys import sys
import folder_paths import folder_paths # type: ignore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def extract_lora_name(lora_path): def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" """Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
# Get the basename without extension # Get the basename without extension
basename = os.path.basename(lora_path) basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0] return os.path.splitext(basename)[0]
def get_loras_list(kwargs): def get_loras_list(kwargs):
"""Helper to extract loras list from either old or new kwargs format""" """Helper to extract loras list from either old or new kwargs format"""
if 'loras' not in kwargs: if "loras" not in kwargs:
return [] return []
loras_data = kwargs['loras'] loras_data = kwargs["loras"]
# Handle new format: {'loras': {'__value__': [...]}} # Handle new format: {'loras': {'__value__': [...]}}
if isinstance(loras_data, dict) and '__value__' in loras_data: if isinstance(loras_data, dict) and "__value__" in loras_data:
return loras_data['__value__'] return loras_data["__value__"]
# Handle old format: {'loras': [...]} # Handle old format: {'loras': [...]}
elif isinstance(loras_data, list): elif isinstance(loras_data, list):
return loras_data return loras_data
@@ -64,23 +68,25 @@ def get_loras_list(kwargs):
logger.warning(f"Unexpected loras format: {type(loras_data)}") logger.warning(f"Unexpected loras format: {type(loras_data)}")
return [] return []
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""): def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path""" """Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
import safetensors.torch import safetensors.torch
state_dict = {} state_dict = {}
with safetensors.torch.safe_open(path, framework="pt", device=device) as f: with safetensors.torch.safe_open(path, framework="pt", device=device) as f: # type: ignore[attr-defined]
for k in f.keys(): for k in f.keys():
if filter_prefix and not k.startswith(filter_prefix): if filter_prefix and not k.startswith(filter_prefix):
continue continue
state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k) state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k)
return state_dict return state_dict
def to_diffusers(input_lora): def to_diffusers(input_lora):
"""Simplified version of to_diffusers for Flux LoRA conversion""" """Simplified version of to_diffusers for Flux LoRA conversion"""
import torch import torch
from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft
from diffusers.loaders import FluxLoraLoaderMixin from diffusers.loaders import FluxLoraLoaderMixin # type: ignore[attr-defined]
if isinstance(input_lora, str): if isinstance(input_lora, str):
tensors = load_state_dict_in_safetensors(input_lora, device="cpu") tensors = load_state_dict_in_safetensors(input_lora, device="cpu")
@@ -97,10 +103,15 @@ def to_diffusers(input_lora):
return new_tensors return new_tensors
def nunchaku_load_lora(model, lora_name, lora_strength): def nunchaku_load_lora(model, lora_name, lora_strength):
"""Load a Flux LoRA for Nunchaku model""" """Load a Flux LoRA for Nunchaku model"""
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names. # Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name) lora_path = (
lora_name
if os.path.isfile(lora_name)
else folder_paths.get_full_path("loras", lora_name)
)
if not lora_path or not os.path.isfile(lora_path): if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name) logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
return model return model
@@ -118,7 +129,9 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)] ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
else: else:
# Fallback to legacy logic # Fallback to legacy logic
logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.") logger.warning(
"Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic."
)
transformer = model_wrapper.model transformer = model_wrapper.model
# Save the transformer temporarily # Save the transformer temporarily
@@ -145,3 +158,24 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
ret_model.model.model_config.unet_config["in_channels"] = new_in_channels ret_model.model.model_config.unet_config["in_channels"] = new_in_channels
return ret_model return ret_model
def detect_nunchaku_model_kind(model):
"""Return the supported Nunchaku model kind for a Comfy model, if any."""
try:
model_wrapper = model.model.diffusion_model
except (AttributeError, TypeError):
return None
wrapper_name = model_wrapper.__class__.__name__
if wrapper_name == "ComfyFluxWrapper":
return "flux"
inner_model = getattr(model_wrapper, "model", None)
inner_name = inner_model.__class__.__name__ if inner_model is not None else ""
if wrapper_name.endswith("NunchakuQwenImageTransformer2DModel"):
return "qwen_image"
if inner_name.endswith("NunchakuQwenImageTransformer2DModel"):
return "qwen_image"
return None

View File

@@ -6,17 +6,19 @@ from .parsers import (
ComfyMetadataParser, ComfyMetadataParser,
MetaFormatParser, MetaFormatParser,
AutomaticMetadataParser, AutomaticMetadataParser,
CivitaiApiMetadataParser CivitaiApiMetadataParser,
SuiImageParamsParser,
) )
from .base import RecipeMetadataParser from .base import RecipeMetadataParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RecipeParserFactory: class RecipeParserFactory:
"""Factory for creating recipe metadata parsers""" """Factory for creating recipe metadata parsers"""
@staticmethod @staticmethod
def create_parser(metadata) -> RecipeMetadataParser: def create_parser(metadata) -> RecipeMetadataParser | None:
""" """
Create appropriate parser based on the metadata content Create appropriate parser based on the metadata content
@@ -38,6 +40,7 @@ class RecipeParserFactory:
# Convert dict to string for other parsers that expect string input # Convert dict to string for other parsers that expect string input
try: try:
import json import json
metadata_str = json.dumps(metadata) metadata_str = json.dumps(metadata)
except Exception as e: except Exception as e:
logger.debug(f"Failed to convert dict to JSON string: {e}") logger.debug(f"Failed to convert dict to JSON string: {e}")
@@ -53,6 +56,13 @@ class RecipeParserFactory:
# If JSON parsing fails, move on to other parsers # If JSON parsing fails, move on to other parsers
pass pass
# Try SuiImageParamsParser for SuiImage metadata format
try:
if SuiImageParamsParser().is_metadata_matching(metadata_str):
return SuiImageParamsParser()
except Exception:
pass
# Check other parsers that expect string input # Check other parsers that expect string input
if RecipeFormatParser().is_metadata_matching(metadata_str): if RecipeFormatParser().is_metadata_matching(metadata_str):
return RecipeFormatParser() return RecipeFormatParser()

View File

@@ -5,6 +5,7 @@ from .comfy import ComfyMetadataParser
from .meta_format import MetaFormatParser from .meta_format import MetaFormatParser
from .automatic import AutomaticMetadataParser from .automatic import AutomaticMetadataParser
from .civitai_image import CivitaiApiMetadataParser from .civitai_image import CivitaiApiMetadataParser
from .sui_image_params import SuiImageParamsParser
__all__ = [ __all__ = [
'RecipeFormatParser', 'RecipeFormatParser',
@@ -12,4 +13,5 @@ __all__ = [
'MetaFormatParser', 'MetaFormatParser',
'AutomaticMetadataParser', 'AutomaticMetadataParser',
'CivitaiApiMetadataParser', 'CivitaiApiMetadataParser',
'SuiImageParamsParser',
] ]

View File

@@ -9,6 +9,7 @@ from ...services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CivitaiApiMetadataParser(RecipeMetadataParser): class CivitaiApiMetadataParser(RecipeMetadataParser):
"""Parser for Civitai image metadata format""" """Parser for Civitai image metadata format"""
@@ -40,7 +41,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
"width", "width",
"height", "height",
"Model", "Model",
"Model hash" "Model hash",
"modelVersionIds",
) )
return any(key in payload for key in civitai_image_fields) return any(key in payload for key in civitai_image_fields)
@@ -50,7 +52,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Check for LoRA hash patterns # Check for LoRA hash patterns
hashes = metadata.get("hashes") hashes = metadata.get("hashes")
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes): if isinstance(hashes, dict) and any(
str(key).lower().startswith("lora:") for key in hashes
):
return True return True
# Check nested meta object (common in CivitAI image responses) # Check nested meta object (common in CivitAI image responses)
@@ -61,22 +65,28 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Also check for LoRA hash patterns in nested meta # Also check for LoRA hash patterns in nested meta
hashes = nested_meta.get("hashes") hashes = nested_meta.get("hashes")
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes): if isinstance(hashes, dict) and any(
str(key).lower().startswith("lora:") for key in hashes
):
return True return True
return False return False
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]: async def parse_metadata( # type: ignore[override]
self, user_comment, recipe_scanner=None, civitai_client=None
) -> Dict[str, Any]:
"""Parse metadata from Civitai image format """Parse metadata from Civitai image format
Args: Args:
metadata: The metadata from the image (dict) user_comment: The metadata from the image (dict)
recipe_scanner: Optional recipe scanner service recipe_scanner: Optional recipe scanner service
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead) civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
Returns: Returns:
Dict containing parsed recipe data Dict containing parsed recipe data
""" """
metadata: Dict[str, Any] = user_comment # type: ignore[assignment]
metadata = user_comment
try: try:
# Get metadata provider instead of using civitai_client directly # Get metadata provider instead of using civitai_client directly
metadata_provider = await get_default_metadata_provider() metadata_provider = await get_default_metadata_provider()
@@ -103,11 +113,11 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Initialize result structure # Initialize result structure
result = { result = {
'base_model': None, "base_model": None,
'loras': [], "loras": [],
'model': None, "model": None,
'gen_params': {}, "gen_params": {},
'from_civitai_image': True "from_civitai_image": True,
} }
# Track already added LoRAs to prevent duplicates # Track already added LoRAs to prevent duplicates
@@ -148,16 +158,25 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["base_model"] = metadata["baseModel"] result["base_model"] = metadata["baseModel"]
elif "Model hash" in metadata and metadata_provider: elif "Model hash" in metadata and metadata_provider:
model_hash = metadata["Model hash"] model_hash = metadata["Model hash"]
model_info, error = await metadata_provider.get_model_by_hash(model_hash) model_info, error = await metadata_provider.get_model_by_hash(
model_hash
)
if model_info: if model_info:
result["base_model"] = model_info.get("baseModel", "") result["base_model"] = model_info.get("baseModel", "")
elif "Model" in metadata and isinstance(metadata.get("resources"), list): elif "Model" in metadata and isinstance(metadata.get("resources"), list):
# Try to find base model in resources # Try to find base model in resources
for resource in metadata.get("resources", []): for resource in metadata.get("resources", []):
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"): if resource.get("type") == "model" and resource.get(
"name"
) == metadata.get("Model"):
# This is likely the checkpoint model # This is likely the checkpoint model
if metadata_provider and resource.get("hash"): if metadata_provider and resource.get("hash"):
model_info, error = await metadata_provider.get_model_by_hash(resource.get("hash")) (
model_info,
error,
) = await metadata_provider.get_model_by_hash(
resource.get("hash")
)
if model_info: if model_info:
result["base_model"] = model_info.get("baseModel", "") result["base_model"] = model_info.get("baseModel", "")
@@ -176,7 +195,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Skip LoRAs without proper identification (hash or modelVersionId) # Skip LoRAs without proper identification (hash or modelVersionId)
if not lora_hash and not resource.get("modelVersionId"): if not lora_hash and not resource.get("modelVersionId"):
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId") logger.debug(
f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId"
)
continue continue
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
@@ -184,31 +205,33 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
'name': resource.get("name", "Unknown LoRA"), "name": resource.get("name", "Unknown LoRA"),
'type': "lora", "type": "lora",
'weight': float(resource.get("weight", 1.0)), "weight": float(resource.get("weight", 1.0)),
'hash': lora_hash, "hash": lora_hash,
'existsLocally': False, "existsLocally": False,
'localPath': None, "localPath": None,
'file_name': resource.get("name", "Unknown"), "file_name": resource.get("name", "Unknown"),
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry['hash'] and metadata_provider: if lora_entry["hash"] and metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = (
await metadata_provider.get_model_by_hash(lora_hash)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash lora_hash,
) )
if populated_entry is None: if populated_entry is None:
@@ -217,10 +240,14 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication # If we have a version ID from Civitai, track it for deduplication
if 'id' in lora_entry and lora_entry['id']: if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry['id'])] = len(result["loras"]) added_loras[str(lora_entry["id"])] = len(
result["loras"]
)
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}") logger.error(
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
)
# Track by hash if we have it # Track by hash if we have it
if lora_hash: if lora_hash:
@@ -229,7 +256,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process civitaiResources array # Process civitaiResources array
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list): if "civitaiResources" in metadata and isinstance(
metadata["civitaiResources"], list
):
for resource in metadata["civitaiResources"]: for resource in metadata["civitaiResources"]:
# Get resource type and identifier # Get resource type and identifier
resource_type = str(resource.get("type") or "").lower() resource_type = str(resource.get("type") or "").lower()
@@ -237,32 +266,39 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource_type == "checkpoint": if resource_type == "checkpoint":
checkpoint_entry = { checkpoint_entry = {
'id': resource.get("modelVersionId", 0), "id": resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0), "modelId": resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown Checkpoint"), "name": resource.get("modelName", "Unknown Checkpoint"),
'version': resource.get("modelVersionName", ""), "version": resource.get("modelVersionName", ""),
'type': resource.get("type", "checkpoint"), "type": resource.get("type", "checkpoint"),
'existsLocally': False, "existsLocally": False,
'localPath': None, "localPath": None,
'file_name': resource.get("modelName", ""), "file_name": resource.get("modelName", ""),
'hash': resource.get("hash", "") or "", "hash": resource.get("hash", "") or "",
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_version_info(version_id) civitai_info = (
await metadata_provider.get_model_version_info(
version_id
)
)
checkpoint_entry = await self.populate_checkpoint_from_civitai( checkpoint_entry = (
checkpoint_entry, await self.populate_checkpoint_from_civitai(
civitai_info checkpoint_entry, civitai_info
)
) )
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint version {version_id}: {e}") logger.error(
f"Error fetching Civitai info for checkpoint version {version_id}: {e}"
)
if result["model"] is None: if result["model"] is None:
result["model"] = checkpoint_entry result["model"] = checkpoint_entry
@@ -275,31 +311,35 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Initialize lora entry # Initialize lora entry
lora_entry = { lora_entry = {
'id': resource.get("modelVersionId", 0), "id": resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0), "modelId": resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"), "name": resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""), "version": resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"), "type": resource.get("type", "lora"),
'weight': round(float(resource.get("weight", 1.0)), 2), "weight": round(float(resource.get("weight", 1.0)), 2),
'existsLocally': False, "existsLocally": False,
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
# Try to get info from Civitai if modelVersionId is available # Try to get info from Civitai if modelVersionId is available
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
# Use get_model_version_info instead of get_model_version # Use get_model_version_info instead of get_model_version
civitai_info = await metadata_provider.get_model_version_info(version_id) civitai_info = (
await metadata_provider.get_model_version_info(
version_id
)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts base_model_counts,
) )
if populated_entry is None: if populated_entry is None:
@@ -307,7 +347,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for model version {version_id}: {e}") logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}"
)
# Track this LoRA in our deduplication dict # Track this LoRA in our deduplication dict
if version_id: if version_id:
@@ -316,10 +358,15 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process additionalResources array # Process additionalResources array
if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list): if "additionalResources" in metadata and isinstance(
metadata["additionalResources"], list
):
for resource in metadata["additionalResources"]: for resource in metadata["additionalResources"]:
# Skip resources that aren't LoRAs or LyCORIS # Skip resources that aren't LoRAs or LyCORIS
if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource: if (
resource.get("type") not in ["lora", "lycoris"]
and "type" not in resource
):
continue continue
lora_type = resource.get("type", "lora") lora_type = resource.get("type", "lora")
@@ -337,31 +384,35 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
'name': name, "name": name,
'type': lora_type, "type": lora_type,
'weight': float(resource.get("strength", 1.0)), "weight": float(resource.get("strength", 1.0)),
'hash': "", "hash": "",
'existsLocally': False, "existsLocally": False,
'localPath': None, "localPath": None,
'file_name': name, "file_name": name,
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
# If we have a version ID and metadata provider, try to get more info # If we have a version ID and metadata provider, try to get more info
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
# Use get_model_version_info with the version ID # Use get_model_version_info with the version ID
civitai_info = await metadata_provider.get_model_version_info(version_id) civitai_info = (
await metadata_provider.get_model_version_info(
version_id
)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts base_model_counts,
) )
if populated_entry is None: if populated_entry is None:
@@ -373,10 +424,71 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id: if version_id:
added_loras[version_id] = len(result["loras"]) added_loras[version_id] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}") logger.error(
f"Error fetching Civitai info for model ID {version_id}: {e}"
)
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process modelVersionIds from Civitai image API
# These are model version IDs returned at root level when meta doesn't contain resources
if "modelVersionIds" in metadata and isinstance(
metadata["modelVersionIds"], list
):
for version_id in metadata["modelVersionIds"]:
version_id_str = str(version_id)
# Skip if we've already added this LoRA by version ID
if version_id_str in added_loras:
continue
# Initialize lora entry with version ID
lora_entry = {
"id": version_id,
"modelId": 0,
"name": "Unknown LoRA",
"version": "",
"type": "lora",
"weight": 1.0,
"existsLocally": False,
"thumbnailUrl": "/loras_static/images/no-preview.png",
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
# Fetch model info from Civitai
if metadata_provider and version_id_str:
try:
civitai_info = (
await metadata_provider.get_model_version_info(
version_id_str
)
)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}"
)
# Track this LoRA for deduplication
if version_id_str:
added_loras[version_id_str] = len(result["loras"])
result["loras"].append(lora_entry)
# If we found LoRA hashes in the metadata but haven't already # If we found LoRA hashes in the metadata but haven't already
# populated entries for them, fall back to creating LoRAs from # populated entries for them, fall back to creating LoRAs from
# the hashes section. Some Civitai image responses only include # the hashes section. Some Civitai image responses only include
@@ -390,30 +502,32 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
'name': lora_name, "name": lora_name,
'type': "lora", "type": "lora",
'weight': 1.0, "weight": 1.0,
'hash': lora_hash, "hash": lora_hash,
'existsLocally': False, "existsLocally": False,
'localPath': None, "localPath": None,
'file_name': lora_name, "file_name": lora_name,
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
if metadata_provider: if metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = await metadata_provider.get_model_by_hash(
lora_hash
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash lora_hash,
) )
if populated_entry is None: if populated_entry is None:
@@ -421,20 +535,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
if 'id' in lora_entry and lora_entry['id']: if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry['id'])] = len(result["loras"]) added_loras[str(lora_entry["id"])] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}") logger.error(
f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}"
)
added_loras[lora_hash] = len(result["loras"]) added_loras[lora_hash] = len(result["loras"])
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc. # Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
lora_index = 0 lora_index = 0
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata: while (
f"Lora_{lora_index} Model hash" in metadata
and f"Lora_{lora_index} Model name" in metadata
):
lora_hash = metadata[f"Lora_{lora_index} Model hash"] lora_hash = metadata[f"Lora_{lora_index} Model hash"]
lora_name = metadata[f"Lora_{lora_index} Model name"] lora_name = metadata[f"Lora_{lora_index} Model name"]
lora_strength_model = float(metadata.get(f"Lora_{lora_index} Strength model", 1.0)) lora_strength_model = float(
metadata.get(f"Lora_{lora_index} Strength model", 1.0)
)
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
if lora_hash and lora_hash in added_loras: if lora_hash and lora_hash in added_loras:
@@ -442,31 +563,33 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
'name': lora_name, "name": lora_name,
'type': "lora", "type": "lora",
'weight': lora_strength_model, "weight": lora_strength_model,
'hash': lora_hash, "hash": lora_hash,
'existsLocally': False, "existsLocally": False,
'localPath': None, "localPath": None,
'file_name': lora_name, "file_name": lora_name,
'thumbnailUrl': '/loras_static/images/no-preview.png', "thumbnailUrl": "/loras_static/images/no-preview.png",
'baseModel': '', "baseModel": "",
'size': 0, "size": 0,
'downloadUrl': '', "downloadUrl": "",
'isDeleted': False "isDeleted": False,
} }
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry['hash'] and metadata_provider: if lora_entry["hash"] and metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash) civitai_info = await metadata_provider.get_model_by_hash(
lora_hash
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash lora_hash,
) )
if populated_entry is None: if populated_entry is None:
@@ -476,10 +599,12 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication # If we have a version ID from Civitai, track it for deduplication
if 'id' in lora_entry and lora_entry['id']: if "id" in lora_entry and lora_entry["id"]:
added_loras[str(lora_entry['id'])] = len(result["loras"]) added_loras[str(lora_entry["id"])] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}") logger.error(
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
)
# Track by hash if we have it # Track by hash if we have it
if lora_hash: if lora_hash:
@@ -491,7 +616,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# If base model wasn't found earlier, use the most common one from LoRAs # If base model wasn't found earlier, use the most common one from LoRAs
if not result["base_model"] and base_model_counts: if not result["base_model"] and base_model_counts:
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0] result["base_model"] = max(
base_model_counts.items(), key=lambda x: x[1]
)[0]
return result return result

View File

@@ -0,0 +1,188 @@
"""Parser for SuiImage (Stable Diffusion WebUI) metadata format."""
import json
import logging
from typing import Dict, Any, Optional, List
from ..base import RecipeMetadataParser
from ...services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__)
class SuiImageParamsParser(RecipeMetadataParser):
"""Parser for SuiImage metadata JSON format.
This format is used by some Stable Diffusion WebUI variants.
Structure:
{
"sui_image_params": {
"prompt": "...",
"negativeprompt": "...",
"model": "...",
"seed": ...,
"steps": ...,
...
},
"sui_models": [
{"name": "...", "param": "model", "hash": "..."},
...
],
"sui_extra_data": {...}
}
"""
def is_metadata_matching(self, user_comment: str) -> bool:
"""Check if the user comment matches the SuiImage metadata format"""
try:
data = json.loads(user_comment)
return isinstance(data, dict) and 'sui_image_params' in data
except (json.JSONDecodeError, TypeError):
return False
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from SuiImage metadata format"""
try:
metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment)
params = data.get('sui_image_params', {})
models = data.get('sui_models', [])
# Extract prompt and negative prompt
prompt = params.get('prompt', '')
negative_prompt = params.get('negativeprompt', '') or params.get('negative_prompt', '')
# Extract generation parameters
gen_params = {}
if prompt:
gen_params['prompt'] = prompt
if negative_prompt:
gen_params['negative_prompt'] = negative_prompt
# Map standard parameters
param_mapping = {
'steps': 'steps',
'seed': 'seed',
'cfgscale': 'cfg_scale',
'cfg_scale': 'cfg_scale',
'width': 'width',
'height': 'height',
'sampler': 'sampler',
'scheduler': 'scheduler',
'model': 'model',
'vae': 'vae',
}
for src_key, dest_key in param_mapping.items():
if src_key in params and params[src_key] is not None:
gen_params[dest_key] = params[src_key]
# Add size info if available
if 'width' in gen_params and 'height' in gen_params:
gen_params['size'] = f"{gen_params['width']}x{gen_params['height']}"
# Process models - extract checkpoint and loras
loras: List[Dict[str, Any]] = []
checkpoint: Optional[Dict[str, Any]] = None
for model in models:
model_name = model.get('name', '')
param_type = model.get('param', '')
model_hash = model.get('hash', '')
# Remove .safetensors extension for cleaner name
clean_name = model_name.replace('.safetensors', '') if model_name else ''
# Check if this is a LoRA by looking at the name or param type
is_lora = 'lora' in model_name.lower() or param_type.lower().startswith('lora')
if is_lora:
lora_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'lora',
'weight': 1.0,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
lora_entry = await self.populate_lora_from_civitai(
lora_entry, civitai_info, recipe_scanner
)
except Exception as e:
logger.debug(f"Error fetching info for LoRA {clean_name}: {e}")
if lora_entry:
loras.append(lora_entry)
elif param_type == 'model' or 'lora' not in model_name.lower():
# This is likely a checkpoint
checkpoint_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'checkpoint',
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
except Exception as e:
logger.debug(f"Error fetching info for checkpoint {clean_name}: {e}")
checkpoint = checkpoint_entry
# Determine base model from loras or checkpoint
base_model = None
if loras:
base_models = [lora.get('baseModel') for lora in loras if lora.get('baseModel')]
if base_models:
from collections import Counter
base_model_counts = Counter(base_models)
base_model = base_model_counts.most_common(1)[0][0]
elif checkpoint and checkpoint.get('baseModel'):
base_model = checkpoint['baseModel']
return {
'base_model': base_model,
'loras': loras,
'checkpoint': checkpoint,
'gen_params': gen_params,
'from_sui_image_params': True
}
except Exception as e:
logger.error(f"Error parsing SuiImage metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}

View File

@@ -1,4 +1,5 @@
"""Base infrastructure shared across recipe routes.""" """Base infrastructure shared across recipe routes."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -16,12 +17,14 @@ from ..services.recipes import (
RecipePersistenceService, RecipePersistenceService,
RecipeSharingService, RecipeSharingService,
) )
from ..services.batch_import_service import BatchImportService
from ..services.server_i18n import server_i18n from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..utils.constants import CARD_PREVIEW_WIDTH from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from .handlers.recipe_handlers import ( from .handlers.recipe_handlers import (
BatchImportHandler,
RecipeAnalysisHandler, RecipeAnalysisHandler,
RecipeHandlerSet, RecipeHandlerSet,
RecipeListingHandler, RecipeListingHandler,
@@ -116,7 +119,10 @@ class BaseRecipeRoutes:
recipe_scanner_getter = lambda: self.recipe_scanner recipe_scanner_getter = lambda: self.recipe_scanner
civitai_client_getter = lambda: self.civitai_client civitai_client_getter = lambda: self.civitai_client
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = (
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
if not standalone_mode: if not standalone_mode:
from ..metadata_collector import get_metadata # type: ignore[import-not-found] from ..metadata_collector import get_metadata # type: ignore[import-not-found]
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found] from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
@@ -190,6 +196,22 @@ class BaseRecipeRoutes:
sharing_service=sharing_service, sharing_service=sharing_service,
) )
from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
batch_import = BatchImportHandler(
ensure_dependencies_ready=self.ensure_dependencies_ready,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
logger=logger,
batch_import_service=batch_import_service,
)
return RecipeHandlerSet( return RecipeHandlerSet(
page_view=page_view, page_view=page_view,
listing=listing, listing=listing,
@@ -197,4 +219,5 @@ class BaseRecipeRoutes:
management=management, management=management,
analysis=analysis, analysis=analysis,
sharing=sharing, sharing=sharing,
batch_import=batch_import,
) )

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import Dict from typing import Dict, List, Set
from aiohttp import web from aiohttp import web
from .base_model_routes import BaseModelRoutes from .base_model_routes import BaseModelRoutes
@@ -82,12 +82,22 @@ class CheckpointRoutes(BaseModelRoutes):
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
async def get_checkpoints_roots(self, request: web.Request) -> web.Response: async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
"""Return the list of checkpoint roots from config""" """Return the list of checkpoint roots from config (including extra paths)"""
try: try:
roots = config.checkpoints_roots # Merge checkpoints_roots with extra_checkpoints_roots, preserving order and removing duplicates
roots: List[str] = []
roots.extend(config.checkpoints_roots or [])
roots.extend(config.extra_checkpoints_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"roots": roots "roots": unique_roots
}) })
except Exception as e: except Exception as e:
logger.error(f"Error getting checkpoint roots: {e}", exc_info=True) logger.error(f"Error getting checkpoint roots: {e}", exc_info=True)
@@ -97,12 +107,22 @@ class CheckpointRoutes(BaseModelRoutes):
}, status=500) }, status=500)
async def get_unet_roots(self, request: web.Request) -> web.Response: async def get_unet_roots(self, request: web.Request) -> web.Response:
"""Return the list of unet roots from config""" """Return the list of unet roots from config (including extra paths)"""
try: try:
roots = config.unet_roots # Merge unet_roots with extra_unet_roots, preserving order and removing duplicates
roots: List[str] = []
roots.extend(config.unet_roots or [])
roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"roots": roots "roots": unique_roots
}) })
except Exception as e: except Exception as e:
logger.error(f"Error getting unet roots: {e}", exc_info=True) logger.error(f"Error getting unet roots: {e}", exc_info=True)

View File

@@ -0,0 +1,141 @@
"""Handlers for base model related endpoints."""
from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable, Dict
from aiohttp import web
from ...services.civitai_base_model_service import get_civitai_base_model_service
logger = logging.getLogger(__name__)
class BaseModelHandlerSet:
"""Collection of handlers for base model operations."""
def __init__(
self,
base_model_service_factory: Callable[[], Any] = get_civitai_base_model_service,
) -> None:
self._base_model_service_factory = base_model_service_factory
def to_route_mapping(
self,
) -> Dict[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Return mapping of route names to handler methods."""
return {
"get_base_models": self.get_base_models,
"refresh_base_models": self.refresh_base_models,
"get_base_model_categories": self.get_base_model_categories,
"get_base_model_cache_status": self.get_base_model_cache_status,
}
async def get_base_models(self, request: web.Request) -> web.Response:
"""Get merged base models (hardcoded + remote from Civitai).
Query Parameters:
refresh: If 'true', force refresh from API
Returns:
JSON response with:
- models: List of base model names
- source: 'cache', 'api', or 'fallback'
- last_updated: ISO timestamp
- counts: hardcoded_count, remote_count, merged_count
"""
try:
service = await self._base_model_service_factory()
# Check for refresh parameter
force_refresh = request.query.get("refresh", "").lower() == "true"
result = await service.get_base_models(force_refresh=force_refresh)
return web.json_response(
{
"success": True,
"data": result,
}
)
except Exception as e:
logger.error(f"Error in get_base_models: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def refresh_base_models(self, request: web.Request) -> web.Response:
"""Force refresh base models from Civitai API.
Returns:
JSON response with refreshed data
"""
try:
service = await self._base_model_service_factory()
result = await service.refresh_cache()
return web.json_response(
{
"success": True,
"data": result,
"message": "Base models cache refreshed successfully",
}
)
except Exception as e:
logger.error(f"Error in refresh_base_models: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def get_base_model_categories(self, request: web.Request) -> web.Response:
"""Get categorized base models.
Returns:
JSON response with categorized models
"""
try:
service = await self._base_model_service_factory()
categories = service.get_model_categories()
return web.json_response(
{
"success": True,
"data": categories,
}
)
except Exception as e:
logger.error(f"Error in get_base_model_categories: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)
async def get_base_model_cache_status(self, request: web.Request) -> web.Response:
"""Get cache status for base models.
Returns:
JSON response with cache status
"""
try:
service = await self._base_model_service_factory()
status = service.get_cache_status()
return web.json_response(
{
"success": True,
"data": status,
}
)
except Exception as e:
logger.error(f"Error in get_base_model_cache_status: {e}")
return web.json_response(
{"success": False, "error": str(e)},
status=500,
)

View File

@@ -9,6 +9,7 @@ objects that can be composed by the route controller.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import subprocess import subprocess
@@ -39,6 +40,7 @@ from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.lora_metadata import extract_trained_words from ...utils.lora_metadata import extract_trained_words
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
from .base_model_handlers import BaseModelHandlerSet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -192,6 +194,7 @@ class NodeRegistry:
"comfy_class": comfy_class, "comfy_class": comfy_class,
"capabilities": capabilities, "capabilities": capabilities,
"widget_names": widget_names, "widget_names": widget_names,
"mode": node.get("mode"),
} }
logger.debug("Registered %s nodes in registry", len(nodes)) logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set() self._registry_updated.set()
@@ -217,20 +220,149 @@ class HealthCheckHandler:
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
class SupportersHandler:
"""Handler for supporters data."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
async def get_supporters(self, request: web.Request) -> web.Response:
"""Return supporters data as JSON."""
try:
supporters = self._load_supporters()
return web.json_response({"success": True, "supporters": supporters})
except Exception as exc:
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ExampleWorkflowsHandler:
"""Handler for example workflow templates."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _get_workflows_dir(self) -> str:
"""Get the example workflows directory path."""
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(root_dir, "example_workflows")
def _format_workflow_name(self, filename: str) -> str:
"""Convert filename to human-readable name."""
name = os.path.splitext(filename)[0]
name = name.replace("_", " ")
return name
async def get_example_workflows(self, request: web.Request) -> web.Response:
"""Return list of available example workflows."""
try:
workflows_dir = self._get_workflows_dir()
workflows = [
{
"value": "Default",
"label": "Default (Blank)",
"path": None,
}
]
if os.path.exists(workflows_dir):
for filename in sorted(os.listdir(workflows_dir)):
if filename.endswith(".json"):
workflows.append(
{
"value": filename,
"label": self._format_workflow_name(filename),
"path": f"example_workflows/{filename}",
}
)
return web.json_response({"success": True, "workflows": workflows})
except Exception as exc:
self._logger.error(
"Error listing example workflows: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_example_workflow(self, request: web.Request) -> web.Response:
"""Return a specific example workflow JSON content."""
try:
filename = request.match_info.get("filename")
if not filename:
return web.json_response(
{"success": False, "error": "Filename not provided"}, status=400
)
if filename == "Default":
return web.json_response(
{
"success": True,
"workflow": {
"last_node_id": 0,
"last_link_id": 0,
"nodes": [],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
},
}
)
workflows_dir = self._get_workflows_dir()
filepath = os.path.join(workflows_dir, filename)
if not os.path.exists(filepath):
return web.json_response(
{"success": False, "error": f"Workflow not found: {filename}"},
status=404,
)
with open(filepath, "r", encoding="utf-8") as f:
workflow = json.load(f)
return web.json_response({"success": True, "workflow": workflow})
except Exception as exc:
self._logger.error("Error loading example workflow: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class SettingsHandler: class SettingsHandler:
"""Sync settings between backend and frontend.""" """Sync settings between backend and frontend."""
# Settings keys that should NOT be synced to frontend. # Settings keys that should NOT be synced to frontend.
# All other settings are synced by default. # All other settings are synced by default.
_NO_SYNC_KEYS = frozenset({ _NO_SYNC_KEYS = frozenset(
# Internal/performance settings (not used by frontend) {
"hash_chunk_size_mb", # Internal/performance settings (not used by frontend)
"download_stall_timeout_seconds", "hash_chunk_size_mb",
# Complex internal structures retrieved via separate endpoints "download_stall_timeout_seconds",
"folder_paths", # Complex internal structures retrieved via separate endpoints
"libraries", "folder_paths",
"active_library", "libraries",
}) "active_library",
}
)
_PROXY_KEYS = { _PROXY_KEYS = {
"proxy_enabled", "proxy_enabled",
@@ -1185,6 +1317,7 @@ class CustomWordsHandler:
def __init__(self) -> None: def __init__(self) -> None:
from ...services.custom_words_service import get_custom_words_service from ...services.custom_words_service import get_custom_words_service
self._service = get_custom_words_service() self._service = get_custom_words_service()
async def search_custom_words(self, request: web.Request) -> web.Response: async def search_custom_words(self, request: web.Request) -> web.Response:
@@ -1193,6 +1326,7 @@ class CustomWordsHandler:
Query parameters: Query parameters:
search: The search term to match against. search: The search term to match against.
limit: Maximum number of results to return (default: 20). limit: Maximum number of results to return (default: 20).
offset: Number of results to skip (default: 0).
category: Optional category filter. Can be: category: Optional category filter. Can be:
- A category name (e.g., "character", "artist", "general") - A category name (e.g., "character", "artist", "general")
- Comma-separated category IDs (e.g., "4,11" for character) - Comma-separated category IDs (e.g., "4,11" for character)
@@ -1202,6 +1336,7 @@ class CustomWordsHandler:
try: try:
search_term = request.query.get("search", "") search_term = request.query.get("search", "")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
offset = max(0, int(request.query.get("offset", "0")))
category_param = request.query.get("category", "") category_param = request.query.get("category", "")
enriched_param = request.query.get("enriched", "").lower() == "true" enriched_param = request.query.get("enriched", "").lower() == "true"
@@ -1211,13 +1346,14 @@ class CustomWordsHandler:
categories = self._parse_category_param(category_param) categories = self._parse_category_param(category_param)
results = self._service.search_words( results = self._service.search_words(
search_term, limit, categories=categories, enriched=enriched_param search_term,
limit,
offset=offset,
categories=categories,
enriched=enriched_param,
) )
return web.json_response({ return web.json_response({"success": True, "words": results})
"success": True,
"words": results
})
except Exception as exc: except Exception as exc:
logger.error("Error searching custom words: %s", exc, exc_info=True) logger.error("Error searching custom words: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -1481,6 +1617,9 @@ class MiscHandlerSet:
metadata_archive: MetadataArchiveHandler, metadata_archive: MetadataArchiveHandler,
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
supporters: SupportersHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -1493,6 +1632,9 @@ class MiscHandlerSet:
self.metadata_archive = metadata_archive self.metadata_archive = metadata_archive
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.supporters = supporters
self.example_workflows = example_workflows
self.base_model = base_model
def to_route_mapping( def to_route_mapping(
self, self,
@@ -1521,6 +1663,14 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
"get_base_model_categories": self.base_model.get_base_model_categories,
"get_base_model_cache_status": self.base_model.get_base_model_cache_status,
} }

View File

@@ -66,6 +66,23 @@ class ModelPageView:
self._logger = logger self._logger = logger
self._app_version = self._get_app_version() self._app_version = self._get_app_version()
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
def _get_app_version(self) -> str: def _get_app_version(self) -> str:
version = "1.0.0" version = "1.0.0"
short_hash = "stable" short_hash = "stable"
@@ -292,6 +309,13 @@ class ModelListingHandler:
else: else:
allow_selling_generated_content = None # None means no filter applied allow_selling_generated_content = None # None means no filter applied
# Name pattern filters for LoRA Pool
name_pattern_include = request.query.getall("name_pattern_include", [])
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
name_pattern_use_regex = (
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
return { return {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -311,6 +335,9 @@ class ModelListingHandler:
"credit_required": credit_required, "credit_required": credit_required,
"allow_selling_generated_content": allow_selling_generated_content, "allow_selling_generated_content": allow_selling_generated_content,
"model_types": model_types, "model_types": model_types,
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
**self._parse_specific_params(request), **self._parse_specific_params(request),
} }
@@ -383,10 +410,34 @@ class ModelManagementHandler:
return web.json_response( return web.json_response(
{"success": False, "error": "Model not found in cache"}, status=404 {"success": False, "error": "Model not found in cache"}, status=404
) )
if not model_data.get("sha256"):
return web.json_response( # Check if hash needs to be calculated (lazy hash for checkpoints)
{"success": False, "error": "No SHA256 hash found"}, status=400 sha256 = model_data.get("sha256")
) hash_status = model_data.get("hash_status", "completed")
if not sha256 or hash_status != "completed":
# For checkpoints, calculate hash on-demand
scanner = self._service.scanner
if hasattr(scanner, "calculate_hash_for_model"):
self._logger.info(
f"Lazy hash calculation triggered for {file_path}"
)
sha256 = await scanner.calculate_hash_for_model(file_path)
if not sha256:
return web.json_response(
{
"success": False,
"error": "Failed to calculate SHA256 hash",
},
status=500,
)
# Update model_data with new hash
model_data["sha256"] = sha256
model_data["hash_status"] = "completed"
else:
return web.json_response(
{"success": False, "error": "No SHA256 hash found"}, status=400
)
await MetadataManager.hydrate_model_data(model_data) await MetadataManager.hydrate_model_data(model_data)
@@ -506,6 +557,153 @@ class ModelManagementHandler:
self._logger.error("Error replacing preview: %s", exc, exc_info=True) self._logger.error("Error replacing preview: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
async def set_preview_from_url(self, request: web.Request) -> web.Response:
"""Set a preview image from a remote URL (e.g., CivitAI)."""
try:
from ...utils.civitai_utils import rewrite_preview_url
from ...services.downloader import get_downloader
data = await request.json()
model_path = data.get("model_path")
image_url = data.get("image_url")
nsfw_level = data.get("nsfw_level", 0)
if not model_path:
return web.json_response(
{"success": False, "error": "Model path is required"}, status=400
)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Rewrite URL to use optimized rendition if it's a Civitai URL
optimized_url, was_rewritten = rewrite_preview_url(
image_url, media_type="image"
)
if was_rewritten and optimized_url:
self._logger.info(
f"Rewritten preview URL to optimized version: {optimized_url}"
)
else:
optimized_url = image_url
# Download the image using the Downloader service
self._logger.info(
f"Downloading preview from {optimized_url} for {model_path}"
)
downloader = await get_downloader()
success, preview_data, headers = await downloader.download_to_memory(
optimized_url, use_auth=False, return_headers=True
)
if not success:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: {preview_data}",
},
status=502,
)
# preview_data is bytes when success is True
preview_bytes = (
preview_data
if isinstance(preview_data, bytes)
else preview_data.encode("utf-8")
)
# Determine content type from response headers
content_type = (
headers.get("Content-Type", "image/jpeg") if headers else "image/jpeg"
)
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = url_path.split("/")[-1] if "/" in url_path else None
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_data,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Download the image from the remote URL
self._logger.info(f"Downloading preview from {image_url} for {model_path}")
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: HTTP {response.status}",
},
status=502,
)
content_type = response.headers.get("Content-Type", "image/jpeg")
preview_data = await response.read()
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = (
url_path.split("/")[-1] if "/" in url_path else None
)
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_bytes,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def save_metadata(self, request: web.Request) -> web.Response: async def save_metadata(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -796,9 +994,7 @@ class ModelQueryHandler:
# Format response # Format response
group = {"hash": sha256, "models": []} group = {"hash": sha256, "models": []}
for model in sorted_models: for model in sorted_models:
group["models"].append( group["models"].append(await self._service.format_response(model))
await self._service.format_response(model)
)
# Only include groups with 2+ models after filtering # Only include groups with 2+ models after filtering
if len(group["models"]) > 1: if len(group["models"]) > 1:
@@ -827,7 +1023,9 @@ class ModelQueryHandler:
"favorites_only": request.query.get("favorites_only", "").lower() == "true", "favorites_only": request.query.get("favorites_only", "").lower() == "true",
} }
def _apply_duplicate_filters(self, models: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]: def _apply_duplicate_filters(
self, models: List[Dict[str, Any]], filters: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Apply filters to a list of models within a duplicate group.""" """Apply filters to a list of models within a duplicate group."""
result = models result = models
@@ -868,7 +1066,9 @@ class ModelQueryHandler:
return result return result
def _sort_duplicate_group(self, models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _sort_duplicate_group(
self, models: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Sort models: originals first (left), copies (with -????. pattern) last (right).""" """Sort models: originals first (left), copies (with -????. pattern) last (right)."""
if len(models) <= 1: if len(models) <= 1:
return models return models
@@ -1078,8 +1278,11 @@ class ModelQueryHandler:
async def get_relative_paths(self, request: web.Request) -> web.Response: async def get_relative_paths(self, request: web.Request) -> web.Response:
try: try:
search = request.query.get("search", "").strip() search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 50) limit = min(int(request.query.get("limit", "15")), 100)
matching_paths = await self._service.search_relative_paths(search, limit) offset = max(0, int(request.query.get("offset", "0")))
matching_paths = await self._service.search_relative_paths(
search, limit, offset
)
return web.json_response( return web.json_response(
{"success": True, "relative_paths": matching_paths} {"success": True, "relative_paths": matching_paths}
) )
@@ -1153,10 +1356,13 @@ class ModelDownloadHandler:
data["source"] = source data["source"] = source
if file_params_json: if file_params_json:
import json import json
try: try:
data["file_params"] = json.loads(file_params_json) data["file_params"] = json.loads(file_params_json)
except json.JSONDecodeError: except json.JSONDecodeError:
self._logger.warning("Invalid file_params JSON: %s", file_params_json) self._logger.warning(
"Invalid file_params JSON: %s", file_params_json
)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
future = loop.create_future() future = loop.create_future()
@@ -1887,7 +2093,8 @@ class ModelUpdateHandler:
from dataclasses import replace from dataclasses import replace
new_record = replace( new_record = replace(
record, versions=list(version_map.values()), record,
versions=list(version_map.values()),
) )
# Optionally persist to database for caching # Optionally persist to database for caching
@@ -2102,6 +2309,7 @@ class ModelUpdateHandler:
if version.early_access_ends_at: if version.early_access_ends_at:
try: try:
from datetime import datetime, timezone from datetime import datetime, timezone
ea_date = datetime.fromisoformat( ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00") version.early_access_ends_at.replace("Z", "+00:00")
) )
@@ -2109,7 +2317,7 @@ class ModelUpdateHandler:
except (ValueError, AttributeError): except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative) # If date parsing fails, treat as active EA (conservative)
is_early_access = True is_early_access = True
elif getattr(version, 'is_early_access', False): elif getattr(version, "is_early_access", False):
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True is_early_access = True
@@ -2189,6 +2397,7 @@ class ModelHandlerSet:
"fetch_all_civitai": self.civitai.fetch_all_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai, "relink_civitai": self.management.relink_civitai,
"replace_preview": self.management.replace_preview, "replace_preview": self.management.replace_preview,
"set_preview_from_url": self.management.set_preview_from_url,
"save_metadata": self.management.save_metadata, "save_metadata": self.management.save_metadata,
"add_tags": self.management.add_tags, "add_tags": self.management.add_tags,
"rename_model": self.management.rename_model, "rename_model": self.management.rename_model,

View File

@@ -1,4 +1,5 @@
"""Dedicated handler objects for recipe-related routes.""" """Dedicated handler objects for recipe-related routes."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -8,6 +9,7 @@ import re
import asyncio import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from aiohttp import web from aiohttp import web
@@ -29,6 +31,7 @@ from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager from ...services.websocket_manager import ws_manager as default_ws_manager
from ...services.batch_import_service import BatchImportService
Logger = logging.Logger Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]] EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -46,8 +49,11 @@ class RecipeHandlerSet:
management: "RecipeManagementHandler" management: "RecipeManagementHandler"
analysis: "RecipeAnalysisHandler" analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler" sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler"
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: def to_route_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Expose handler coroutines keyed by registrar handler names.""" """Expose handler coroutines keyed by registrar handler names."""
return { return {
@@ -75,12 +81,18 @@ class RecipeHandlerSet:
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget, "save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora, "get_recipes_for_lora": self.query.get_recipes_for_lora,
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe, "move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes, "repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair, "cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"get_repair_progress": self.management.get_repair_progress, "get_repair_progress": self.management.get_repair_progress,
"start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
} }
@@ -170,8 +182,10 @@ class RecipeListingHandler:
search_options = { search_options = {
"title": request.query.get("search_title", "true").lower() == "true", "title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "true").lower() == "true", "tags": request.query.get("search_tags", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower() == "true", "lora_name": request.query.get("search_lora_name", "true").lower()
"lora_model": request.query.get("search_lora_model", "true").lower() == "true", == "true",
"lora_model": request.query.get("search_lora_model", "true").lower()
== "true",
"prompt": request.query.get("search_prompt", "true").lower() == "true", "prompt": request.query.get("search_prompt", "true").lower() == "true",
} }
@@ -205,6 +219,7 @@ class RecipeListingHandler:
filters["tags"] = tag_filters filters["tags"] = tag_filters
lora_hash = request.query.get("lora_hash") lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash")
result = await recipe_scanner.get_paginated_data( result = await recipe_scanner.get_paginated_data(
page=page, page=page,
@@ -214,6 +229,7 @@ class RecipeListingHandler:
filters=filters, filters=filters,
search_options=search_options, search_options=search_options,
lora_hash=lora_hash, lora_hash=lora_hash,
checkpoint_hash=checkpoint_hash,
folder=folder, folder=folder,
recursive=recursive, recursive=recursive,
) )
@@ -246,7 +262,9 @@ class RecipeListingHandler:
return web.json_response({"error": "Recipe not found"}, status=404) return web.json_response({"error": "Recipe not found"}, status=404)
return web.json_response(recipe) return web.json_response(recipe)
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving recipe details: %s", exc, exc_info=True) self._logger.error(
"Error retrieving recipe details: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
def format_recipe_file_url(self, file_path: str) -> str: def format_recipe_file_url(self, file_path: str) -> str:
@@ -256,7 +274,9 @@ class RecipeListingHandler:
if static_url: if static_url:
return static_url return static_url
except Exception as exc: # pragma: no cover - logging path except Exception as exc: # pragma: no cover - logging path
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True) self._logger.error(
"Error formatting recipe file URL: %s", exc, exc_info=True
)
return "/loras_static/images/no-preview.png" return "/loras_static/images/no-preview.png"
return "/loras_static/images/no-preview.png" return "/loras_static/images/no-preview.png"
@@ -293,7 +313,9 @@ class RecipeQueryHandler:
for tag in recipe.get("tags", []) or []: for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1 tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [{"tag": tag, "count": count} for tag, count in tag_counts.items()] sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True) sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "tags": sorted_tags[:limit]}) return web.json_response({"success": True, "tags": sorted_tags[:limit]})
except Exception as exc: except Exception as exc:
@@ -313,9 +335,14 @@ class RecipeQueryHandler:
for recipe in getattr(cache, "raw_data", []): for recipe in getattr(cache, "raw_data", []):
base_model = recipe.get("base_model") base_model = recipe.get("base_model")
if base_model: if base_model:
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1 base_model_counts[base_model] = (
base_model_counts.get(base_model, 0) + 1
)
sorted_models = [{"name": model, "count": count} for model, count in base_model_counts.items()] sorted_models = [
{"name": model, "count": count}
for model, count in base_model_counts.items()
]
sorted_models.sort(key=lambda entry: entry["count"], reverse=True) sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "base_models": sorted_models}) return web.json_response({"success": True, "base_models": sorted_models})
except Exception as exc: except Exception as exc:
@@ -345,7 +372,9 @@ class RecipeQueryHandler:
folders = await recipe_scanner.get_folders() folders = await recipe_scanner.get_folders()
return web.json_response({"success": True, "folders": folders}) return web.json_response({"success": True, "folders": folders})
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True) self._logger.error(
"Error retrieving recipe folders: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response: async def get_folder_tree(self, request: web.Request) -> web.Response:
@@ -358,7 +387,9 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree() folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree}) return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True) self._logger.error(
"Error retrieving recipe folder tree: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response: async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
@@ -371,7 +402,9 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree() folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree}) return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True) self._logger.error(
"Error retrieving unified recipe folder tree: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_lora(self, request: web.Request) -> web.Response: async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
@@ -383,7 +416,9 @@ class RecipeQueryHandler:
lora_hash = request.query.get("hash") lora_hash = request.query.get("hash")
if not lora_hash: if not lora_hash:
return web.json_response({"success": False, "error": "Lora hash is required"}, status=400) return web.json_response(
{"success": False, "error": "Lora hash is required"}, status=400
)
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash) matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
return web.json_response({"success": True, "recipes": matching_recipes}) return web.json_response({"success": True, "recipes": matching_recipes})
@@ -391,6 +426,28 @@ class RecipeQueryHandler:
self._logger.error("Error getting recipes for Lora: %s", exc) self._logger.error("Error getting recipes for Lora: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_checkpoint(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
checkpoint_hash = request.query.get("hash")
if not checkpoint_hash:
return web.json_response(
{"success": False, "error": "Checkpoint hash is required"},
status=400,
)
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
checkpoint_hash
)
return web.json_response({"success": True, "recipes": matching_recipes})
except Exception as exc:
self._logger.error("Error getting recipes for checkpoint: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def scan_recipes(self, request: web.Request) -> web.Response: async def scan_recipes(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -400,7 +457,9 @@ class RecipeQueryHandler:
self._logger.info("Manually triggering recipe cache rebuild") self._logger.info("Manually triggering recipe cache rebuild")
await recipe_scanner.get_cached_data(force_refresh=True) await recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response({"success": True, "message": "Recipe cache refreshed successfully"}) return web.json_response(
{"success": True, "message": "Recipe cache refreshed successfully"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True) self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -429,7 +488,9 @@ class RecipeQueryHandler:
"id": recipe.get("id"), "id": recipe.get("id"),
"title": recipe.get("title"), "title": recipe.get("title"),
"file_url": recipe.get("file_url") "file_url": recipe.get("file_url")
or self._format_recipe_file_url(recipe.get("file_path", "")), or self._format_recipe_file_url(
recipe.get("file_path", "")
),
"modified": recipe.get("modified"), "modified": recipe.get("modified"),
"created_date": recipe.get("created_date"), "created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])), "lora_count": len(recipe.get("loras", [])),
@@ -437,7 +498,9 @@ class RecipeQueryHandler:
) )
if len(recipes) >= 2: if len(recipes) >= 2:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True) recipes.sort(
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append( response_data.append(
{ {
"type": "fingerprint", "type": "fingerprint",
@@ -460,7 +523,9 @@ class RecipeQueryHandler:
"id": recipe.get("id"), "id": recipe.get("id"),
"title": recipe.get("title"), "title": recipe.get("title"),
"file_url": recipe.get("file_url") "file_url": recipe.get("file_url")
or self._format_recipe_file_url(recipe.get("file_path", "")), or self._format_recipe_file_url(
recipe.get("file_path", "")
),
"modified": recipe.get("modified"), "modified": recipe.get("modified"),
"created_date": recipe.get("created_date"), "created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])), "lora_count": len(recipe.get("loras", [])),
@@ -468,7 +533,9 @@ class RecipeQueryHandler:
) )
if len(recipes) >= 2: if len(recipes) >= 2:
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True) recipes.sort(
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append( response_data.append(
{ {
"type": "source_url", "type": "source_url",
@@ -479,9 +546,13 @@ class RecipeQueryHandler:
) )
response_data.sort(key=lambda entry: entry["count"], reverse=True) response_data.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "duplicate_groups": response_data}) return web.json_response(
{"success": True, "duplicate_groups": response_data}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error finding duplicate recipes: %s", exc, exc_info=True) self._logger.error(
"Error finding duplicate recipes: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipe_syntax(self, request: web.Request) -> web.Response: async def get_recipe_syntax(self, request: web.Request) -> web.Response:
@@ -498,9 +569,13 @@ class RecipeQueryHandler:
return web.json_response({"error": "Recipe not found"}, status=404) return web.json_response({"error": "Recipe not found"}, status=404)
if not syntax_parts: if not syntax_parts:
return web.json_response({"error": "No LoRAs found in this recipe"}, status=400) return web.json_response(
{"error": "No LoRAs found in this recipe"}, status=400
)
return web.json_response({"success": True, "syntax": " ".join(syntax_parts)}) return web.json_response(
{"success": True, "syntax": " ".join(syntax_parts)}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True) self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -561,11 +636,17 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503) return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Check if already running # Check if already running
if self._ws_manager.is_recipe_repair_running(): if self._ws_manager.is_recipe_repair_running():
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409) return web.json_response(
{"success": False, "error": "Recipe repair already in progress"},
status=409,
)
recipe_scanner.reset_cancellation() recipe_scanner.reset_cancellation()
@@ -579,11 +660,12 @@ class RecipeManagementHandler:
progress_callback=progress_callback progress_callback=progress_callback
) )
except Exception as e: except Exception as e:
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True) self._logger.error(
await self._ws_manager.broadcast_recipe_repair_progress({ f"Error in recipe repair task: {e}", exc_info=True
"status": "error", )
"error": str(e) await self._ws_manager.broadcast_recipe_repair_progress(
}) {"status": "error", "error": str(e)}
)
finally: finally:
# Keep the final status for a while so the UI can see it # Keep the final status for a while so the UI can see it
await asyncio.sleep(5) await asyncio.sleep(5)
@@ -593,7 +675,9 @@ class RecipeManagementHandler:
asyncio.create_task(run_repair()) asyncio.create_task(run_repair())
return web.json_response({"success": True, "message": "Recipe repair started"}) return web.json_response(
{"success": True, "message": "Recipe repair started"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True) self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -603,10 +687,15 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503) return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task() recipe_scanner.cancel_task()
return web.json_response({"success": True, "message": "Cancellation requested"}) return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True) self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -616,7 +705,10 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503) return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_id = request.match_info["recipe_id"] recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id) result = await recipe_scanner.repair_recipe_by_id(recipe_id)
@@ -632,12 +724,13 @@ class RecipeManagementHandler:
progress = self._ws_manager.get_recipe_repair_progress() progress = self._ws_manager.get_recipe_repair_progress()
if progress: if progress:
return web.json_response({"success": True, "progress": progress}) return web.json_response({"success": True, "progress": progress})
return web.json_response({"success": False, "message": "No repair in progress"}, status=404) return web.json_response(
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc: except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True) self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response: async def import_remote_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -658,7 +751,9 @@ class RecipeManagementHandler:
if not resources_raw: if not resources_raw:
raise RecipeValidationError("Missing required field: resources") raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw) checkpoint_entry, lora_entries = self._parse_resources_payload(
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params")) gen_params_request = self._parse_gen_params(params.get("gen_params"))
# 2. Initial Metadata Construction # 2. Initial Metadata Construction
@@ -666,7 +761,7 @@ class RecipeManagementHandler:
"base_model": params.get("base_model", "") or "", "base_model": params.get("base_model", "") or "",
"loras": lora_entries, "loras": lora_entries,
"gen_params": gen_params_request or {}, "gen_params": gen_params_request or {},
"source_url": image_url "source_url": image_url,
} }
source_path = params.get("source_path") source_path = params.get("source_path")
@@ -681,14 +776,20 @@ class RecipeManagementHandler:
# Try to resolve base model from checkpoint if not explicitly provided # Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]: if not metadata["base_model"]:
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry) base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
)
if base_model_from_metadata: if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) tags = self._parse_tags(params.get("tags"))
# 3. Download Image # 3. Download Image
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url) (
image_bytes,
extension,
civitai_meta_from_download,
) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata # 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated # Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
@@ -706,16 +807,24 @@ class RecipeManagementHandler:
# Let's extract embedded metadata first # Let's extract embedded metadata first
embedded_gen_params = {} embedded_gen_params = {}
try: try:
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img: with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes) temp_img.write(image_bytes)
temp_img_path = temp_img.name temp_img_path = temp_img.name
try: try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path) raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded: if raw_embedded:
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded) parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser: if parser:
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner) parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded: if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"] embedded_gen_params = parsed_embedded["gen_params"]
else: else:
@@ -724,7 +833,9 @@ class RecipeManagementHandler:
if os.path.exists(temp_img_path): if os.path.exists(temp_img_path):
os.unlink(temp_img_path) os.unlink(temp_img_path)
except Exception as exc: except Exception as exc:
self._logger.warning("Failed to extract embedded metadata during import: %s", exc) self._logger.warning(
"Failed to extract embedded metadata during import: %s", exc
)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer # Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
if embedded_gen_params: if embedded_gen_params:
@@ -739,7 +850,7 @@ class RecipeManagementHandler:
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=gen_params_request # Pass explicit request params here to override request_params=gen_params_request, # Pass explicit request params here to override
) )
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed), # If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
@@ -762,7 +873,9 @@ class RecipeManagementHandler:
except RecipeDownloadError as exc: except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400) return web.json_response({"error": str(exc)}, status=400)
except Exception as exc: except Exception as exc:
self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True) self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def delete_recipe(self, request: web.Request) -> web.Response: async def delete_recipe(self, request: web.Request) -> web.Response:
@@ -816,7 +929,11 @@ class RecipeManagementHandler:
target_path = data.get("target_path") target_path = data.get("target_path")
if not recipe_id or not target_path: if not recipe_id or not target_path:
return web.json_response( return web.json_response(
{"success": False, "error": "recipe_id and target_path are required"}, status=400 {
"success": False,
"error": "recipe_id and target_path are required",
},
status=400,
) )
result = await self._persistence_service.move_recipe( result = await self._persistence_service.move_recipe(
@@ -845,7 +962,11 @@ class RecipeManagementHandler:
target_path = data.get("target_path") target_path = data.get("target_path")
if not recipe_ids or not target_path: if not recipe_ids or not target_path:
return web.json_response( return web.json_response(
{"success": False, "error": "recipe_ids and target_path are required"}, status=400 {
"success": False,
"error": "recipe_ids and target_path are required",
},
status=400,
) )
result = await self._persistence_service.move_recipes_bulk( result = await self._persistence_service.move_recipes_bulk(
@@ -934,7 +1055,9 @@ class RecipeManagementHandler:
except RecipeValidationError as exc: except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400) return web.json_response({"error": str(exc)}, status=400)
except Exception as exc: except Exception as exc:
self._logger.error("Error saving recipe from widget: %s", exc, exc_info=True) self._logger.error(
"Error saving recipe from widget: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def _parse_save_payload(self, reader) -> dict[str, Any]: async def _parse_save_payload(self, reader) -> dict[str, Any]:
@@ -1006,7 +1129,9 @@ class RecipeManagementHandler:
raise RecipeValidationError("gen_params payload must be an object") raise RecipeValidationError("gen_params payload must be an object")
return parsed return parsed
def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: def _parse_resources_payload(
self, payload_raw: str
) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
try: try:
payload = json.loads(payload_raw) payload = json.loads(payload_raw)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
@@ -1066,10 +1191,14 @@ class RecipeManagementHandler:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url) civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match: if civitai_match:
if civitai_client is None: if civitai_client is None:
raise RecipeDownloadError("Civitai client unavailable for image download") raise RecipeDownloadError(
"Civitai client unavailable for image download"
)
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
)
media_url = image_info.get("url") media_url = image_info.get("url")
if not media_url: if not media_url:
@@ -1083,18 +1212,24 @@ class RecipeManagementHandler:
else: else:
download_url = media_url download_url = media_url
success, result = await downloader.download_file(download_url, temp_path, use_auth=False) success, result = await downloader.download_file(
download_url, temp_path, use_auth=False
)
if not success: if not success:
raise RecipeDownloadError(f"Failed to download image: {result}") raise RecipeDownloadError(f"Failed to download image: {result}")
# Extract extension from URL # Extract extension from URL
url_path = download_url.split('?')[0].split('#')[0] url_path = download_url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower() extension = os.path.splitext(url_path)[1].lower()
if not extension: if not extension:
extension = ".webp" # Default to webp if unknown extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_match and image_info else None,
)
except RecipeDownloadError: except RecipeDownloadError:
raise raise
except RecipeValidationError: except RecipeValidationError:
@@ -1108,14 +1243,15 @@ class RecipeManagementHandler:
except FileNotFoundError: except FileNotFoundError:
pass pass
def _safe_int(self, value: Any) -> int: def _safe_int(self, value: Any) -> int:
try: try:
return int(value) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 return 0
async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str: async def _resolve_base_model_from_checkpoint(
self, checkpoint_entry: Dict[str, Any]
) -> str:
version_id = self._safe_int(checkpoint_entry.get("modelVersionId")) version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
if not version_id: if not version_id:
@@ -1134,7 +1270,9 @@ class RecipeManagementHandler:
base_model = version_info.get("baseModel") or "" base_model = version_info.get("baseModel") or ""
return str(base_model) if base_model is not None else "" return str(base_model) if base_model is not None else ""
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc) self._logger.warning(
"Failed to resolve base model from checkpoint metadata: %s", exc
)
return "" return ""
@@ -1279,5 +1417,311 @@ class RecipeSharingHandler:
except RecipeNotFoundError as exc: except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404) return web.json_response({"error": str(exc)}, status=404)
except Exception as exc: except Exception as exc:
self._logger.error("Error downloading shared recipe: %s", exc, exc_info=True) self._logger.error(
"Error downloading shared recipe: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
class BatchImportHandler:
"""Handle batch import operations for recipes."""
def __init__(
self,
*,
ensure_dependencies_ready: EnsureDependenciesCallable,
recipe_scanner_getter: RecipeScannerGetter,
civitai_client_getter: CivitaiClientGetter,
logger: Logger,
batch_import_service: BatchImportService,
) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter
self._civitai_client_getter = civitai_client_getter
self._logger = logger
self._batch_import_service = batch_import_service
async def start_batch_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
items = data.get("items", [])
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", False)
if not items:
return web.json_response(
{"success": False, "error": "No items provided"},
status=400,
)
for item in items:
if not item.get("source"):
return web.json_response(
{
"success": False,
"error": "Each item must have a 'source' field",
},
status=400,
)
operation_id = await self._batch_import_service.start_batch_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error starting batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def start_directory_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
directory = data.get("directory")
recursive = data.get("recursive", True)
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", True)
if not directory:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
operation_id = await self._batch_import_service.start_directory_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
directory=directory,
recursive=recursive,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error starting directory import: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_batch_import_progress(self, request: web.Request) -> web.Response:
try:
operation_id = request.query.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
progress = self._batch_import_service.get_progress(operation_id)
if not progress:
return web.json_response(
{"success": False, "error": "Operation not found"},
status=404,
)
return web.json_response(
{
"success": True,
"progress": progress.to_dict(),
}
)
except Exception as exc:
self._logger.error(
"Error getting batch import progress: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_batch_import(self, request: web.Request) -> web.Response:
try:
data = await request.json()
operation_id = data.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
cancelled = self._batch_import_service.cancel_import(operation_id)
if not cancelled:
return web.json_response(
{
"success": False,
"error": "Operation not found or already completed",
},
status=404,
)
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling batch import: %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 and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
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())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
status=400,
)
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)

View File

@@ -26,6 +26,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"), RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
@@ -37,12 +38,33 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"), RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"), RouteDefinition(
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"), "POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"), ),
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"), RouteDefinition(
"POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"
),
RouteDefinition(
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
),
RouteDefinition(
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
),
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"), RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"), RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"),
RouteDefinition("GET", "/api/lm/example-workflows", "get_example_workflows"),
RouteDefinition(
"GET", "/api/lm/example-workflows/{filename}", "get_example_workflow"
),
# Base model management routes
RouteDefinition("GET", "/api/lm/base-models", "get_base_models"),
RouteDefinition("POST", "/api/lm/base-models/refresh", "refresh_base_models"),
RouteDefinition(
"GET", "/api/lm/base-models/categories", "get_base_model_categories"
),
RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
),
) )
@@ -66,7 +88,11 @@ class MiscRouteRegistrar:
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS, definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
) -> None: ) -> None:
for definition in definitions: for definition in definitions:
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name]) self._bind(
definition.method,
definition.path,
handler_lookup[definition.handler_name],
)
def _bind(self, method: str, path: str, handler: Callable) -> None: def _bind(self, method: str, path: str, handler: Callable) -> None:
add_method_name = self._METHOD_MAP[method.upper()] add_method_name = self._METHOD_MAP[method.upper()]

View File

@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
from .handlers.misc_handlers import ( from .handlers.misc_handlers import (
CustomWordsHandler, CustomWordsHandler,
ExampleWorkflowsHandler,
FileSystemHandler, FileSystemHandler,
HealthCheckHandler, HealthCheckHandler,
LoraCodeHandler, LoraCodeHandler,
@@ -29,17 +30,20 @@ from .handlers.misc_handlers import (
NodeRegistry, NodeRegistry,
NodeRegistryHandler, NodeRegistryHandler,
SettingsHandler, SettingsHandler,
SupportersHandler,
TrainedWordsHandler, TrainedWordsHandler,
UsageStatsHandler, UsageStatsHandler,
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet
from .misc_route_registrar import MiscRouteRegistrar from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get( standalone_mode = (
"HF_HUB_DISABLE_TELEMETRY", "0" os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
) == "0" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
class MiscRoutes: class MiscRoutes:
@@ -74,7 +78,9 @@ class MiscRoutes:
self._node_registry = node_registry or NodeRegistry() self._node_registry = node_registry or NodeRegistry()
self._standalone_mode = standalone_mode_flag self._standalone_mode = standalone_mode_flag
self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None self._handler_mapping: (
Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None
) = None
@staticmethod @staticmethod
def setup_routes(app: web.Application) -> None: def setup_routes(app: web.Application) -> None:
@@ -86,7 +92,9 @@ class MiscRoutes:
registrar = self._registrar_factory(app) registrar = self._registrar_factory(app)
registrar.register_routes(self._ensure_handler_mapping()) registrar.register_routes(self._ensure_handler_mapping())
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: def _ensure_handler_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
if self._handler_mapping is None: if self._handler_mapping is None:
handler_set = self._create_handler_set() handler_set = self._create_handler_set()
self._handler_mapping = handler_set.to_route_mapping() self._handler_mapping = handler_set.to_route_mapping()
@@ -119,6 +127,9 @@ class MiscRoutes:
metadata_provider_factory=self._metadata_provider_factory, metadata_provider_factory=self._metadata_provider_factory,
) )
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
supporters = SupportersHandler()
example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
return self._handler_set_factory( return self._handler_set_factory(
health=health, health=health,
@@ -132,6 +143,9 @@ class MiscRoutes:
metadata_archive=metadata_archive, metadata_archive=metadata_archive,
filesystem=filesystem, filesystem=filesystem,
custom_words=custom_words, custom_words=custom_words,
supporters=supporters,
example_workflows=example_workflows,
base_model=base_model,
) )

View File

@@ -1,4 +1,5 @@
"""Route registrar for model endpoints.""" """Route registrar for model endpoints."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -27,6 +28,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"), RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"),
RouteDefinition(
"POST", "/api/lm/{prefix}/set-preview-from-url", "set_preview_from_url"
),
RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"), RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"),
RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"), RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"),
RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"), RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"),
@@ -36,7 +40,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"), RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"), RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
@@ -44,30 +50,60 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"), RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"), RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"), RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
RouteDefinition("GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"), RouteDefinition(
"GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"), RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"),
RouteDefinition("GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"), RouteDefinition(
"GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"
),
RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"), RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"),
RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"), RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"), RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"),
RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"), RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"),
RouteDefinition("GET", "/api/lm/{prefix}/model-description", "get_model_description"), RouteDefinition(
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"), RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"), RouteDefinition(
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"), "GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"), ),
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"), RouteDefinition(
RouteDefinition("POST", "/api/lm/{prefix}/updates/fetch-missing-license", "fetch_missing_civitai_license_data"), "GET",
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"), "/api/lm/{prefix}/civitai/model/version/{modelVersionId}",
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"), "get_civitai_model_by_version",
RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"), ),
RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"), RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"
),
RouteDefinition(
"POST",
"/api/lm/{prefix}/updates/fetch-missing-license",
"fetch_missing_civitai_license_data",
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"
),
RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"), RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"), RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"), RouteDefinition(
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"), RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )
@@ -94,12 +130,18 @@ class ModelRouteRegistrar:
definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS, definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS,
) -> None: ) -> None:
for definition in definitions: for definition in definitions:
self._bind_route(definition.method, definition.build_path(prefix), handler_lookup[definition.handler_name]) self._bind_route(
definition.method,
definition.build_path(prefix),
handler_lookup[definition.handler_name],
)
def add_route(self, method: str, path: str, handler: Callable) -> None: def add_route(self, method: str, path: str, handler: Callable) -> None:
self._bind_route(method, path, handler) self._bind_route(method, path, handler)
def add_prefixed_route(self, method: str, path_template: str, prefix: str, handler: Callable) -> None: def add_prefixed_route(
self, method: str, path_template: str, prefix: str, handler: Callable
) -> None:
self._bind_route(method, path_template.replace("{prefix}", prefix), handler) self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
def _bind_route(self, method: str, path: str, handler: Callable) -> None: def _bind_route(self, method: str, path: str, handler: Callable) -> None:

View File

@@ -1,4 +1,5 @@
"""Route registrar for recipe endpoints.""" """Route registrar for recipe endpoints."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -22,7 +23,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"), RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"),
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"), RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"), RouteDefinition(
"POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"
),
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
@@ -30,9 +33,13 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"), RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"), RouteDefinition(
"GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), RouteDefinition(
"GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
@@ -40,13 +47,29 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"), RouteDefinition(
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition(
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/cancel", "cancel_batch_import"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
) )
@@ -63,7 +86,9 @@ class RecipeRouteRegistrar:
def __init__(self, app: web.Application) -> None: def __init__(self, app: web.Application) -> None:
self._app = app self._app = app
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None: def register_routes(
self, handler_lookup: Mapping[str, Callable[[web.Request], object]]
) -> None:
for definition in ROUTE_DEFINITIONS: for definition in ROUTE_DEFINITIONS:
handler = handler_lookup[definition.handler_name] handler = handler_lookup[definition.handler_name]
self._bind_route(definition.method, definition.path, handler) self._bind_route(definition.method, definition.path, handler)

View File

@@ -209,6 +209,80 @@ class StatsRoutes:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
async def get_model_usage_list(self, request: web.Request) -> web.Response:
"""Get paginated model usage list for infinite scrolling"""
try:
await self.init_services()
model_type = request.query.get('type', 'lora')
sort_order = request.query.get('sort', 'desc')
try:
limit = int(request.query.get('limit', '50'))
offset = int(request.query.get('offset', '0'))
except ValueError:
limit = 50
offset = 0
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Select proper cache and usage dict based on type
if model_type == 'lora':
cache = await self.lora_scanner.get_cached_data()
type_usage_data = usage_data.get('loras', {})
elif model_type == 'checkpoint':
cache = await self.checkpoint_scanner.get_cached_data()
type_usage_data = usage_data.get('checkpoints', {})
elif model_type == 'embedding':
cache = await self.embedding_scanner.get_cached_data()
type_usage_data = usage_data.get('embeddings', {})
else:
return web.json_response({'success': False, 'error': f"Invalid model type: {model_type}"}, status=400)
# Create list of all models
all_models = []
for item in cache.raw_data:
sha256 = item.get('sha256')
usage_info = type_usage_data.get(sha256, {}) if sha256 else {}
usage_count = usage_info.get('total', 0) if isinstance(usage_info, dict) else 0
all_models.append({
'name': item.get('model_name', 'Unknown'),
'usage_count': usage_count,
'base_model': item.get('base_model', 'Unknown'),
'preview_url': config.get_preview_static_url(item.get('preview_url', '')),
'folder': item.get('folder', '')
})
# Sort the models
reverse = (sort_order == 'desc')
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()), reverse=reverse)
if not reverse:
# If asc, sort by usage_count ascending, but keep name ascending
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()))
else:
all_models.sort(key=lambda x: (-x['usage_count'], x['name'].lower()))
# Slice for pagination
paginated_models = all_models[offset:offset + limit]
return web.json_response({
'success': True,
'data': {
'items': paginated_models,
'total': len(all_models),
'type': model_type
}
})
except Exception as e:
logger.error(f"Error getting model usage list: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_base_model_distribution(self, request: web.Request) -> web.Response: async def get_base_model_distribution(self, request: web.Request) -> web.Response:
"""Get base model distribution statistics""" """Get base model distribution statistics"""
try: try:
@@ -530,6 +604,7 @@ class StatsRoutes:
# Register API routes # Register API routes
app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview) app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview)
app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics) app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics)
app.router.add_get('/api/lm/stats/model-usage-list', self.get_model_usage_list)
app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution) app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution)
app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics) app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics)
app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics) app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import re
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
import logging import logging
import os import os
@@ -207,7 +208,11 @@ class BaseModelService(ABC):
reverse = sort_params.order == "desc" reverse = sort_params.order == "desc"
annotated.sort( annotated.sort(
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()), key=lambda x: (
x.get("usage_count", 0),
x.get("model_name", "").lower(),
x.get("file_path", "").lower()
),
reverse=reverse, reverse=reverse,
) )
return annotated return annotated
@@ -383,7 +388,9 @@ class BaseModelService(ABC):
# Check user setting for hiding early access updates # Check user setting for hiding early access updates
hide_early_access = False hide_early_access = False
try: try:
hide_early_access = bool(self.settings.get("hide_early_access_updates", False)) hide_early_access = bool(
self.settings.get("hide_early_access_updates", False)
)
except Exception: except Exception:
hide_early_access = False hide_early_access = False
@@ -413,7 +420,11 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None) bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method): if callable(bulk_method):
try: try:
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access) resolved = await bulk_method(
self.model_type,
ordered_ids,
hide_early_access=hide_early_access,
)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s", "Failed to resolve update status in bulk for %s models (%s): %s",
@@ -426,7 +437,9 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ tasks = [
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access) self.update_service.has_update(
self.model_type, model_id, hide_early_access=hide_early_access
)
for model_id in ordered_ids for model_id in ordered_ids
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -590,9 +603,15 @@ class BaseModelService(ABC):
continue continue
# Filter by valid sub-types based on scanner type # Filter by valid sub-types based on scanner type
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES: if (
self.model_type == "lora"
and normalized_type not in VALID_LORA_SUB_TYPES
):
continue continue
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES: if (
self.model_type == "checkpoint"
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
):
continue continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
@@ -807,38 +826,61 @@ class BaseModelService(ABC):
return include_terms, exclude_terms return include_terms, exclude_terms
@staticmethod
def _remove_model_extension(path: str) -> str:
"""Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching."""
return re.sub(r"\.(safetensors|ckpt|pt|bin)$", "", path, flags=re.IGNORECASE)
@staticmethod @staticmethod
def _relative_path_matches_tokens( def _relative_path_matches_tokens(
path_lower: str, include_terms: List[str], exclude_terms: List[str] path_lower: str, include_terms: List[str], exclude_terms: List[str]
) -> bool: ) -> bool:
"""Determine whether a relative path string satisfies include/exclude tokens.""" """Determine whether a relative path string satisfies include/exclude tokens.
if any(term and term in path_lower for term in exclude_terms):
Matches against the path without extension to avoid matching .safetensors
when searching for 's'.
"""
# Use path without extension for matching
path_for_matching = BaseModelService._remove_model_extension(path_lower)
if any(term and term in path_for_matching for term in exclude_terms):
return False return False
for term in include_terms: for term in include_terms:
if term and term not in path_lower: if term and term not in path_for_matching:
return False return False
return True return True
@staticmethod @staticmethod
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple: def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
"""Sort paths by how well they satisfy the include tokens.""" """Sort paths by how well they satisfy the include tokens.
path_lower = relative_path.lower()
Sorts based on path without extension for consistent ordering.
"""
# Use path without extension for sorting
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
prefix_hits = sum( prefix_hits = sum(
1 for term in include_terms if term and path_lower.startswith(term) 1 for term in include_terms if term and path_for_sorting.startswith(term)
) )
match_positions = [ match_positions = [
path_lower.find(term) path_for_sorting.find(term)
for term in include_terms for term in include_terms
if term and term in path_lower if term and term in path_for_sorting
] ]
first_match_index = min(match_positions) if match_positions else 0 first_match_index = min(match_positions) if match_positions else 0
return (-prefix_hits, first_match_index, len(relative_path), path_lower) return (
-prefix_hits,
first_match_index,
len(path_for_sorting),
path_for_sorting,
)
async def search_relative_paths( async def search_relative_paths(
self, search_term: str, limit: int = 15 self, search_term: str, limit: int = 15, offset: int = 0
) -> List[str]: ) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -849,6 +891,7 @@ class BaseModelService(ABC):
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data: for model in cache.raw_data:
file_path = model.get("file_path", "") file_path = model.get("file_path", "")
if not file_path: if not file_path:
@@ -877,12 +920,12 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_sort_key(relative, include_terms)
) )
return matching_paths[:limit] # Apply offset and limit
start = min(offset, len(matching_paths))
end = min(start + limit, len(matching_paths))
return matching_paths[start:end]

View File

@@ -0,0 +1,593 @@
"""Batch import service for importing multiple images as recipes."""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
from aiohttp import web
from .recipes import (
RecipeAnalysisService,
RecipePersistenceService,
RecipeValidationError,
RecipeDownloadError,
RecipeNotFoundError,
)
class ImportItemType(Enum):
"""Type of import item."""
URL = "url"
LOCAL_PATH = "local_path"
class ImportStatus(Enum):
"""Status of an individual import item."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchImportItem:
"""Represents a single item to import."""
id: str
source: str
item_type: ImportItemType
status: ImportStatus = ImportStatus.PENDING
error_message: Optional[str] = None
recipe_name: Optional[str] = None
recipe_id: Optional[str] = None
duration: float = 0.0
@dataclass
class BatchImportProgress:
"""Tracks progress of a batch import operation."""
operation_id: str
total: int
completed: int = 0
success: int = 0
failed: int = 0
skipped: int = 0
current_item: str = ""
status: str = "pending"
started_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
items: List[BatchImportItem] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = False
skip_duplicates: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"operation_id": self.operation_id,
"total": self.total,
"completed": self.completed,
"success": self.success,
"failed": self.failed,
"skipped": self.skipped,
"current_item": self.current_item,
"status": self.status,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress_percent": round((self.completed / self.total) * 100, 1)
if self.total > 0
else 0,
"items": [
{
"id": item.id,
"source": item.source,
"item_type": item.item_type.value,
"status": item.status.value,
"error_message": item.error_message,
"recipe_name": item.recipe_name,
"recipe_id": item.recipe_id,
"duration": item.duration,
}
for item in self.items
],
}
class AdaptiveConcurrencyController:
"""Adjusts concurrency based on task performance."""
def __init__(
self,
min_concurrency: int = 1,
max_concurrency: int = 5,
initial_concurrency: int = 3,
) -> None:
self.min_concurrency = min_concurrency
self.max_concurrency = max_concurrency
self.current_concurrency = initial_concurrency
self._task_durations: List[float] = []
self._recent_errors = 0
self._recent_successes = 0
def record_result(self, duration: float, success: bool) -> None:
self._task_durations.append(duration)
if len(self._task_durations) > 10:
self._task_durations.pop(0)
if success:
self._recent_successes += 1
if duration < 1.0 and self.current_concurrency < self.max_concurrency:
self.current_concurrency = min(
self.current_concurrency + 1, self.max_concurrency
)
elif duration > 10.0 and self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
else:
self._recent_errors += 1
if self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
def reset_counters(self) -> None:
self._recent_errors = 0
self._recent_successes = 0
def get_semaphore(self) -> asyncio.Semaphore:
return asyncio.Semaphore(self.current_concurrency)
class BatchImportService:
"""Service for batch importing images as recipes."""
SUPPORTED_EXTENSIONS: Set[str] = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def __init__(
self,
*,
analysis_service: RecipeAnalysisService,
persistence_service: RecipePersistenceService,
ws_manager: Any,
logger: logging.Logger,
) -> None:
self._analysis_service = analysis_service
self._persistence_service = persistence_service
self._ws_manager = ws_manager
self._logger = logger
self._active_operations: Dict[str, BatchImportProgress] = {}
self._cancellation_flags: Dict[str, bool] = {}
self._concurrency_controller = AdaptiveConcurrencyController()
def is_import_running(self, operation_id: Optional[str] = None) -> bool:
if operation_id:
progress = self._active_operations.get(operation_id)
return progress is not None and progress.status in ("pending", "running")
return any(
p.status in ("pending", "running") for p in self._active_operations.values()
)
def get_progress(self, operation_id: str) -> Optional[BatchImportProgress]:
return self._active_operations.get(operation_id)
def cancel_import(self, operation_id: str) -> bool:
if operation_id in self._active_operations:
self._cancellation_flags[operation_id] = True
return True
return False
def _validate_url(self, url: str) -> bool:
import re
url_pattern = re.compile(
r"^https?://"
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
return url_pattern.match(url) is not None
def _validate_local_path(self, path: str) -> bool:
try:
normalized = os.path.normpath(path)
if not os.path.isabs(normalized):
return False
if ".." in normalized:
return False
return True
except Exception:
return False
def _is_duplicate_source(
self,
source: str,
item_type: ImportItemType,
recipe_scanner: Any,
) -> bool:
try:
cache = recipe_scanner.get_cached_data_sync()
if not cache:
return False
for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url")
if source_path and source_path == source:
return True
return False
except Exception:
self._logger.warning("Failed to check for duplicates", exc_info=True)
return False
async def start_batch_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
items: List[Dict[str, str]],
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
operation_id = str(uuid.uuid4())
import_items = []
for idx, item in enumerate(items):
source = item.get("source", "")
item_type_str = item.get("type", "url")
if item_type_str == "url" or source.startswith(("http://", "https://")):
item_type = ImportItemType.URL
else:
item_type = ImportItemType.LOCAL_PATH
batch_import_item = BatchImportItem(
id=f"{operation_id}_{idx}",
source=source,
item_type=item_type,
)
import_items.append(batch_import_item)
progress = BatchImportProgress(
operation_id=operation_id,
total=len(import_items),
items=import_items,
tags=tags or [],
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
self._active_operations[operation_id] = progress
self._cancellation_flags[operation_id] = False
asyncio.create_task(
self._run_batch_import(
operation_id=operation_id,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
)
)
return operation_id
async def start_directory_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
directory: str,
recursive: bool = True,
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
image_paths = await self._discover_images(directory, recursive)
items = [{"source": path, "type": "local_path"} for path in image_paths]
return await self.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
async def _discover_images(
self,
directory: str,
recursive: bool = True,
) -> List[str]:
if not os.path.isdir(directory):
raise RecipeValidationError(f"Directory not found: {directory}")
image_paths: List[str] = []
if recursive:
for root, _, files in os.walk(directory):
for filename in files:
if self._is_supported_image(filename):
image_paths.append(os.path.join(root, filename))
else:
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath) and self._is_supported_image(filename):
image_paths.append(filepath)
return sorted(image_paths)
def _is_supported_image(self, filename: str) -> bool:
ext = os.path.splitext(filename)[1].lower()
return ext in self.SUPPORTED_EXTENSIONS
async def _run_batch_import(
self,
*,
operation_id: str,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
) -> None:
progress = self._active_operations.get(operation_id)
if not progress:
return
progress.status = "running"
await self._broadcast_progress(progress)
self._concurrency_controller = AdaptiveConcurrencyController()
async def process_item(item: BatchImportItem) -> None:
if self._cancellation_flags.get(operation_id, False):
return
progress.current_item = (
os.path.basename(item.source)
if item.item_type == ImportItemType.LOCAL_PATH
else item.source[:50]
)
item.status = ImportStatus.PROCESSING
await self._broadcast_progress(progress)
start_time = time.time()
try:
result = await self._import_single_item(
item=item,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
tags=progress.tags,
skip_no_metadata=progress.skip_no_metadata,
skip_duplicates=progress.skip_duplicates,
semaphore=self._concurrency_controller.get_semaphore(),
)
duration = time.time() - start_time
item.duration = duration
self._concurrency_controller.record_result(
duration, result.get("success", False)
)
if result.get("success"):
item.status = ImportStatus.SUCCESS
item.recipe_name = result.get("recipe_name")
item.recipe_id = result.get("recipe_id")
progress.success += 1
elif result.get("skipped"):
item.status = ImportStatus.SKIPPED
item.error_message = result.get("error")
progress.skipped += 1
else:
item.status = ImportStatus.FAILED
item.error_message = result.get("error")
progress.failed += 1
except Exception as e:
self._logger.error(f"Error importing {item.source}: {e}")
item.status = ImportStatus.FAILED
item.error_message = str(e)
item.duration = time.time() - start_time
progress.failed += 1
self._concurrency_controller.record_result(item.duration, False)
progress.completed += 1
await self._broadcast_progress(progress)
tasks = [process_item(item) for item in progress.items]
await asyncio.gather(*tasks, return_exceptions=True)
if self._cancellation_flags.get(operation_id, False):
progress.status = "cancelled"
else:
progress.status = "completed"
progress.finished_at = time.time()
progress.current_item = ""
await self._broadcast_progress(progress)
await asyncio.sleep(5)
self._cleanup_operation(operation_id)
async def _import_single_item(
self,
*,
item: BatchImportItem,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
tags: List[str],
skip_no_metadata: bool,
skip_duplicates: bool,
semaphore: asyncio.Semaphore,
) -> Dict[str, Any]:
async with semaphore:
recipe_scanner = recipe_scanner_getter()
if recipe_scanner is None:
return {"success": False, "error": "Recipe scanner unavailable"}
try:
if item.item_type == ImportItemType.URL:
if not self._validate_url(item.source):
return {
"success": False,
"error": f"Invalid URL format: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source URL",
}
civitai_client = civitai_client_getter()
analysis_result = await self._analysis_service.analyze_remote_image(
url=item.source,
recipe_scanner=recipe_scanner,
civitai_client=civitai_client,
)
else:
if not self._validate_local_path(item.source):
return {
"success": False,
"error": f"Invalid or unsafe path: {item.source}",
}
if not os.path.exists(item.source):
return {
"success": False,
"error": f"File not found: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source path",
}
analysis_result = await self._analysis_service.analyze_local_image(
file_path=item.source,
recipe_scanner=recipe_scanner,
)
payload = analysis_result.payload
if payload.get("error"):
if skip_no_metadata and "No metadata" in payload.get("error", ""):
return {
"success": False,
"skipped": True,
"error": payload["error"],
}
return {"success": False, "error": payload["error"]}
loras = payload.get("loras", [])
if not loras:
if skip_no_metadata:
return {
"success": False,
"skipped": True,
"error": "No LoRAs found in image",
}
# When skip_no_metadata is False, allow importing images without LoRAs
# Continue with empty loras list
recipe_name = self._generate_recipe_name(item, payload)
all_tags = list(set(tags + (payload.get("tags", []) or [])))
metadata = {
"base_model": payload.get("base_model", ""),
"loras": loras,
"gen_params": payload.get("gen_params", {}),
"source_path": item.source,
}
if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"]
image_bytes = None
image_base64 = payload.get("image_base64")
if item.item_type == ImportItemType.LOCAL_PATH:
with open(item.source, "rb") as f:
image_bytes = f.read()
image_base64 = None
save_result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=image_base64,
name=recipe_name,
tags=all_tags,
metadata=metadata,
extension=payload.get("extension"),
)
if save_result.status == 200:
return {
"success": True,
"recipe_name": recipe_name,
"recipe_id": save_result.payload.get("id"),
}
else:
return {
"success": False,
"error": save_result.payload.get(
"error", "Failed to save recipe"
),
}
except RecipeValidationError as e:
return {"success": False, "error": str(e)}
except RecipeDownloadError as e:
return {"success": False, "error": str(e)}
except RecipeNotFoundError as e:
return {"success": False, "skipped": True, "error": str(e)}
except Exception as e:
self._logger.error(
f"Unexpected error importing {item.source}: {e}", exc_info=True
)
return {"success": False, "error": str(e)}
def _generate_recipe_name(
self, item: BatchImportItem, payload: Dict[str, Any]
) -> str:
if item.item_type == ImportItemType.LOCAL_PATH:
base_name = os.path.splitext(os.path.basename(item.source))[0]
return base_name[:100]
else:
loras = payload.get("loras", [])
if loras:
first_lora = loras[0].get("name", "Recipe")
return f"Import - {first_lora}"[:100]
return f"Imported Recipe {item.id[:8]}"
async def _broadcast_progress(self, progress: BatchImportProgress) -> None:
await self._ws_manager.broadcast(
{
"type": "batch_import_progress",
**progress.to_dict(),
}
)
def _cleanup_operation(self, operation_id: str) -> None:
if operation_id in self._cancellation_flags:
del self._cancellation_flags[operation_id]

View File

@@ -58,6 +58,7 @@ class CacheEntryValidator:
'preview_nsfw_level': (0, False), 'preview_nsfw_level': (0, False),
'notes': ('', False), 'notes': ('', False),
'usage_tips': ('', False), 'usage_tips': ('', False),
'hash_status': ('completed', False),
} }
@classmethod @classmethod
@@ -90,13 +91,31 @@ class CacheEntryValidator:
errors: List[str] = [] errors: List[str] = []
repaired = False repaired = False
# If auto_repair is on, we work on a copy. If not, we still need a safe way to check fields.
working_entry = dict(entry) if auto_repair else entry working_entry = dict(entry) if auto_repair else entry
# Determine effective hash_status for validation logic
hash_status = entry.get('hash_status')
if hash_status is None:
if auto_repair:
working_entry['hash_status'] = 'completed'
repaired = True
hash_status = 'completed'
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items(): for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
value = working_entry.get(field_name) # Get current value from the original entry to avoid side effects during validation
value = entry.get(field_name)
# Check if field is missing or None # Check if field is missing or None
if value is None: if value is None:
# Special case: sha256 can be None/empty if hash_status is pending
if field_name == 'sha256' and hash_status == 'pending':
if auto_repair:
working_entry[field_name] = ''
repaired = True
continue
if is_required: if is_required:
errors.append(f"Required field '{field_name}' is missing or None") errors.append(f"Required field '{field_name}' is missing or None")
if auto_repair: if auto_repair:
@@ -107,6 +126,10 @@ class CacheEntryValidator:
# Validate field type and value # Validate field type and value
field_error = cls._validate_field(field_name, value, default_value) field_error = cls._validate_field(field_name, value, default_value)
if field_error: if field_error:
# Special case: allow empty string for sha256 if pending
if field_name == 'sha256' and hash_status == 'pending' and value == '':
continue
errors.append(field_error) errors.append(field_error)
if auto_repair: if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value) working_entry[field_name] = cls._get_default_copy(default_value)
@@ -125,23 +148,32 @@ class CacheEntryValidator:
) )
# Special validation: sha256 must not be empty for required field # Special validation: sha256 must not be empty for required field
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
sha256 = working_entry.get('sha256', '') sha256 = working_entry.get('sha256', '')
# Use the effective hash_status we determined earlier
if not sha256 or (isinstance(sha256, str) and not sha256.strip()): if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
errors.append("Required field 'sha256' is empty") # Allow empty sha256 for lazy hash calculation (checkpoints)
# Cannot repair empty sha256 - entry is invalid if hash_status != 'pending':
return ValidationResult( errors.append("Required field 'sha256' is empty")
is_valid=False, # Cannot repair empty sha256 - entry is invalid
repaired=repaired, return ValidationResult(
errors=errors, is_valid=False,
entry=working_entry if auto_repair else None repaired=repaired,
) errors=errors,
entry=working_entry if auto_repair else None
)
# Normalize sha256 to lowercase if needed # Normalize sha256 to lowercase if needed
if isinstance(sha256, str): if isinstance(sha256, str):
normalized_sha = sha256.lower().strip() normalized_sha = sha256.lower().strip()
if normalized_sha != sha256: if normalized_sha != sha256:
working_entry['sha256'] = normalized_sha if auto_repair:
repaired = True working_entry['sha256'] = normalized_sha
repaired = True
else:
# If not auto-repairing, we don't consider case difference as a "critical error"
# that invalidates the entry, but we also don't mark it repaired.
pass
# Determine if entry is valid # Determine if entry is valid
# Entry is valid if no critical required field errors remain after repair # Entry is valid if no critical required field errors remain after repair

View File

@@ -1,37 +1,299 @@
import json
import logging import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..utils.file_utils import find_preview_file, normalize_path
from ..utils.metadata_manager import MetadataManager
from ..config import config from ..config import config
from .model_scanner import ModelScanner from .model_scanner import ModelScanner
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CheckpointScanner(ModelScanner): class CheckpointScanner(ModelScanner):
"""Service for scanning and managing checkpoint files""" """Service for scanning and managing checkpoint files"""
def __init__(self): def __init__(self):
# Define supported file extensions # Define supported file extensions
file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'} file_extensions = {
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
super().__init__( super().__init__(
model_type="checkpoint", model_type="checkpoint",
model_class=CheckpointMetadata, model_class=CheckpointMetadata,
file_extensions=file_extensions, file_extensions=file_extensions,
hash_index=ModelHashIndex() hash_index=ModelHashIndex(),
) )
async def _create_default_metadata(
self, file_path: str
) -> Optional[CheckpointMetadata]:
"""Create default metadata for checkpoint without calculating hash (lazy hash).
Checkpoints are typically large (10GB+), so we skip hash calculation during initial
scanning to improve startup performance. Hash will be calculated on-demand when
fetching metadata from Civitai.
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
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)
# Create metadata WITHOUT calculating hash
metadata = CheckpointMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256="", # Empty hash - will be calculated on-demand
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="checkpoint",
from_civitai=False, # Mark as local model since no hash yet
hash_status="pending", # Mark hash as pending
)
# Save the created metadata
logger.info(f"Creating checkpoint metadata (hash pending) for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default checkpoint metadata for {file_path}: {e}"
)
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a checkpoint on-demand.
Args:
file_path: Path to the model file
Returns:
SHA256 hash string, or None if calculation failed
"""
from ..utils.file_utils import calculate_sha256
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
# Load current metadata
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
logger.error(f"No metadata found for {file_path}")
return None
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
return metadata.sha256
# Update status to calculating
metadata.hash_status = "calculating"
await MetadataManager.save_metadata(file_path, metadata)
# Calculate hash
logger.info(f"Calculating hash for checkpoint: {file_path}")
sha256 = await calculate_sha256(real_path)
# Update metadata with hash
metadata.sha256 = sha256
metadata.hash_status = "completed"
await MetadataManager.save_metadata(file_path, metadata)
# Update hash index
self._hash_index.add_entry(sha256.lower(), file_path)
logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
# Update status to failed
try:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata:
metadata.hash_status = "failed"
await MetadataManager.save_metadata(file_path, metadata)
except Exception:
pass
return None
async def calculate_all_pending_hashes(
self, progress_callback=None
) -> Dict[str, int]:
"""Calculate hashes for all checkpoints with pending hash status.
If cache is not initialized, scans filesystem directly for metadata files
with hash_status != 'completed'.
Args:
progress_callback: Optional callback(progress, total, current_file)
Returns:
Dict with 'completed', 'failed', 'total' counts
"""
# Try to get from cache first
cache = await self.get_cached_data()
if cache and cache.raw_data:
# Use cache if available
pending_models = [
item
for item in cache.raw_data
if item.get("hash_status") != "completed" or not item.get("sha256")
]
else:
# Cache not initialized, scan filesystem directly
pending_models = await self._find_pending_models_from_filesystem()
if not pending_models:
return {"completed": 0, "failed": 0, "total": 0}
total = len(pending_models)
completed = 0
failed = 0
for i, model_data in enumerate(pending_models):
file_path = model_data.get("file_path")
if not file_path:
continue
try:
sha256 = await self.calculate_hash_for_model(file_path)
if sha256:
completed += 1
else:
failed += 1
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
failed += 1
if progress_callback:
try:
await progress_callback(i + 1, total, file_path)
except Exception:
pass
return {"completed": completed, "failed": failed, "total": total}
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for checkpoint metadata files with pending hash status."""
pending_models = []
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for dirpath, _dirnames, filenames in os.walk(root_path):
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
model_name = filename.replace(".metadata.json", "")
model_path = None
# Look for model file with matching name
for ext in self.file_extensions:
potential_path = os.path.join(dirpath, 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 _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]: def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub-type based on the root path.""" """Resolve the sub-type based on the root path.
Checks both standard ComfyUI paths and LoRA Manager's extra folder paths.
"""
if not root_path: if not root_path:
return None return None
# Check standard ComfyUI checkpoint paths
if config.checkpoints_roots and root_path in config.checkpoints_roots: if config.checkpoints_roots and root_path in config.checkpoints_roots:
return "checkpoint" return "checkpoint"
# Check extra checkpoint paths
if (
config.extra_checkpoints_roots
and root_path in config.extra_checkpoints_roots
):
return "checkpoint"
# Check standard ComfyUI unet paths
if config.unet_roots and root_path in config.unet_roots: if config.unet_roots and root_path in config.unet_roots:
return "diffusion_model" return "diffusion_model"
# Check extra unet paths
if config.extra_unet_roots and root_path in config.extra_unet_roots:
return "diffusion_model"
return None return None
def adjust_metadata(self, metadata, file_path, root_path): def adjust_metadata(self, metadata, file_path, root_path):
@@ -51,5 +313,16 @@ class CheckpointScanner(ModelScanner):
return entry return entry
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get checkpoint root directories""" """Get checkpoint root directories (including extra paths)"""
return config.base_models_roots roots: List[str] = []
roots.extend(config.base_models_roots or [])
roots.extend(config.extra_checkpoints_roots or [])
roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.constants import SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
from .downloader import get_downloader
logger = logging.getLogger(__name__)
class CivitaiBaseModelService:
"""Service for fetching and managing Civitai base models.
This service provides:
- Fetching base models from Civitai API
- Caching with TTL (7 days default)
- Merging hardcoded and remote base models
- Generating abbreviations for new/unknown models
"""
_instance: Optional[CivitaiBaseModelService] = None
_lock = asyncio.Lock()
# Default TTL for cache in seconds (7 days)
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
# Civitai API endpoint for enums
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
@classmethod
async def get_instance(cls) -> CivitaiBaseModelService:
"""Get singleton instance of the service."""
async with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
"""Initialize the service."""
if hasattr(self, "_initialized"):
return
self._initialized = True
# Cache storage
self._cache: Optional[Dict[str, Any]] = None
self._cache_timestamp: Optional[datetime] = None
self._cache_ttl = self.DEFAULT_CACHE_TTL
# Hardcoded models for fallback
self._hardcoded_models = set(SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS)
logger.info("CivitaiBaseModelService initialized")
async def get_base_models(self, force_refresh: bool = False) -> Dict[str, Any]:
"""Get merged base models (hardcoded + remote).
Args:
force_refresh: If True, fetch from API regardless of cache state.
Returns:
Dictionary containing:
- models: List of merged base model names
- source: 'cache', 'api', or 'fallback'
- last_updated: ISO timestamp of last successful API fetch
- hardcoded_count: Number of hardcoded models
- remote_count: Number of remote models
- merged_count: Total unique models
"""
# Check if cache is valid
if not force_refresh and self._is_cache_valid():
logger.debug("Returning cached base models")
return self._build_response("cache")
# Try to fetch from API
try:
remote_models = await self._fetch_from_civitai()
if remote_models:
self._update_cache(remote_models)
return self._build_response("api")
except Exception as e:
logger.error(f"Failed to fetch base models from Civitai: {e}")
# Fallback to hardcoded models
return self._build_response("fallback")
async def refresh_cache(self) -> Dict[str, Any]:
"""Force refresh the cache from Civitai API.
Returns:
Response dict same as get_base_models()
"""
return await self.get_base_models(force_refresh=True)
def get_cache_status(self) -> Dict[str, Any]:
"""Get current cache status.
Returns:
Dictionary containing:
- has_cache: Whether cache exists
- last_updated: ISO timestamp or None
- is_expired: Whether cache is expired
- ttl_seconds: TTL in seconds
- age_seconds: Age of cache in seconds (if exists)
"""
if self._cache is None or self._cache_timestamp is None:
return {
"has_cache": False,
"last_updated": None,
"is_expired": True,
"ttl_seconds": self._cache_ttl,
"age_seconds": None,
}
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
return {
"has_cache": True,
"last_updated": self._cache_timestamp.isoformat(),
"is_expired": age > self._cache_ttl,
"ttl_seconds": self._cache_ttl,
"age_seconds": int(age),
}
def generate_abbreviation(self, model_name: str) -> str:
"""Generate abbreviation for a base model name.
Algorithm:
1. Extract version patterns (e.g., "2.5" from "Wan Video 2.5")
2. Extract main acronym (e.g., "SD" from "SD 1.5")
3. Handle special cases (Flux, Wan, etc.)
4. Fallback to first letters of words (max 4 chars)
Args:
model_name: Full base model name
Returns:
Generated abbreviation (max 4 characters)
"""
if not model_name or not isinstance(model_name, str):
return "OTH"
name = model_name.strip()
if not name:
return "OTH"
# Check if it's already in hardcoded abbreviations
# This is a simplified check - in practice you'd have a mapping
lower_name = name.lower()
# Special cases
special_cases = {
"sd 1.4": "SD1",
"sd 1.5": "SD1",
"sd 1.5 lcm": "SD1",
"sd 1.5 hyper": "SD1",
"sd 2.0": "SD2",
"sd 2.1": "SD2",
"sd 3": "SD3",
"sd 3.5": "SD3",
"sd 3.5 medium": "SD3",
"sd 3.5 large": "SD3",
"sd 3.5 large turbo": "SD3",
"sdxl 1.0": "XL",
"sdxl lightning": "XL",
"sdxl hyper": "XL",
"flux.1 d": "F1D",
"flux.1 s": "F1S",
"flux.1 krea": "F1KR",
"flux.1 kontext": "F1KX",
"flux.2 d": "F2D",
"flux.2 klein 9b": "FK9",
"flux.2 klein 9b-base": "FK9B",
"flux.2 klein 4b": "FK4",
"flux.2 klein 4b-base": "FK4B",
"auraflow": "AF",
"chroma": "CHR",
"pixart a": "PXA",
"pixart e": "PXE",
"hunyuan 1": "HY",
"hunyuan video": "HYV",
"lumina": "L",
"kolors": "KLR",
"noobai": "NAI",
"illustrious": "IL",
"pony": "PONY",
"pony v7": "PNY7",
"hidream": "HID",
"qwen": "QWEN",
"zimageturbo": "ZIT",
"zimagebase": "ZIB",
"anima": "ANI",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
"ltxv 2.3": "LTX",
"cogvideox": "CVX",
"mochi": "MCHI",
"wan video": "WAN",
"wan video 1.3b t2v": "WAN",
"wan video 14b t2v": "WAN",
"wan video 14b i2v 480p": "WAN",
"wan video 14b i2v 720p": "WAN",
"wan video 2.2 ti2v-5b": "WAN",
"wan video 2.2 t2v-a14b": "WAN",
"wan video 2.2 i2v-a14b": "WAN",
"wan video 2.5 t2v": "WAN",
"wan video 2.5 i2v": "WAN",
}
if lower_name in special_cases:
return special_cases[lower_name]
# Try to extract acronym from version pattern
# e.g., "Model Name 2.5" -> "MN25"
version_match = re.search(r"(\d+(?:\.\d+)?)", name)
version = version_match.group(1) if version_match else ""
# Remove version and common words
words = re.sub(r"\d+(?:\.\d+)?", "", name)
words = re.sub(
r"\b(model|video|diffusion|checkpoint|textualinversion)\b",
"",
words,
flags=re.I,
)
words = words.strip()
# Get first letters of remaining words
tokens = re.findall(r"[A-Za-z]+", words)
if tokens:
# Build abbreviation from first letters
abbrev = "".join(token[0].upper() for token in tokens)
# Add version if present
if version:
# Clean version (remove dots for abbreviation)
version_clean = version.replace(".", "")
abbrev = abbrev[: 4 - len(version_clean)] + version_clean
return abbrev[:4]
# Final fallback: just take first 4 alphanumeric chars
alphanumeric = re.sub(r"[^A-Za-z0-9]", "", name)
if alphanumeric:
return alphanumeric[:4].upper()
return "OTH"
async def _fetch_from_civitai(self) -> Optional[Set[str]]:
"""Fetch base models from Civitai API.
Returns:
Set of base model names, or None if failed
"""
try:
downloader = await get_downloader()
success, result = await downloader.make_request(
"GET",
self.CIVITAI_ENUMS_URL,
use_auth=False, # enums endpoint doesn't require auth
)
if not success:
logger.warning(f"Failed to fetch enums from Civitai: {result}")
return None
if isinstance(result, str):
data = json.loads(result)
else:
data = result
# Extract base models from response
base_models = set()
# Use ActiveBaseModel if available (recommended active models)
if "ActiveBaseModel" in data:
base_models.update(data["ActiveBaseModel"])
logger.info(f"Fetched {len(base_models)} models from ActiveBaseModel")
# Fallback to full BaseModel list
elif "BaseModel" in data:
base_models.update(data["BaseModel"])
logger.info(f"Fetched {len(base_models)} models from BaseModel")
else:
logger.warning("No base model data found in Civitai response")
return None
return base_models
except Exception as e:
logger.error(f"Error fetching from Civitai: {e}")
return None
def _update_cache(self, remote_models: Set[str]) -> None:
"""Update internal cache with fetched models.
Args:
remote_models: Set of base model names from API
"""
self._cache = {
"remote_models": sorted(remote_models),
"hardcoded_models": sorted(self._hardcoded_models),
}
self._cache_timestamp = datetime.now(timezone.utc)
logger.info(f"Cache updated with {len(remote_models)} remote models")
def _is_cache_valid(self) -> bool:
"""Check if current cache is valid (not expired).
Returns:
True if cache exists and is not expired
"""
if self._cache is None or self._cache_timestamp is None:
return False
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
return age <= self._cache_ttl
def _build_response(self, source: str) -> Dict[str, Any]:
"""Build response dictionary.
Args:
source: 'cache', 'api', or 'fallback'
Returns:
Response dictionary
"""
if source == "fallback" or self._cache is None:
# Use only hardcoded models
merged = sorted(self._hardcoded_models)
return {
"models": merged,
"source": source,
"last_updated": None,
"hardcoded_count": len(self._hardcoded_models),
"remote_count": 0,
"merged_count": len(merged),
}
# Merge hardcoded and remote models
remote_set = set(self._cache.get("remote_models", []))
merged = sorted(self._hardcoded_models | remote_set)
return {
"models": merged,
"source": source,
"last_updated": self._cache_timestamp.isoformat()
if self._cache_timestamp
else None,
"hardcoded_count": len(self._hardcoded_models),
"remote_count": len(remote_set),
"merged_count": len(merged),
}
def get_model_categories(self) -> Dict[str, List[str]]:
"""Get categorized base models.
Returns:
Dictionary mapping category names to lists of model names
"""
# Define category patterns
categories = {
"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.1"],
"Stable Diffusion 3.x": [
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
],
"SDXL": ["SDXL 1.0", "SDXL Lightning", "SDXL Hyper"],
"Flux Models": [
"Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
],
"Video Models": [
"SVD",
"LTXV",
"LTXV2",
"LTXV 2.3",
"CogVideoX",
"Mochi",
"Hunyuan Video",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
],
"Other Models": [
"Illustrious",
"Pony",
"Pony V7",
"HiDream",
"Qwen",
"AuraFlow",
"Chroma",
"ZImageTurbo",
"ZImageBase",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Anima",
],
}
return categories
# Convenience function for getting the singleton instance
async def get_civitai_base_model_service() -> CivitaiBaseModelService:
"""Get the singleton instance of CivitaiBaseModelService."""
return await CivitaiBaseModelService.get_instance()

View File

@@ -3,13 +3,17 @@ import copy
import logging import logging
import os import os
from typing import Any, Optional, Dict, Tuple, List, Sequence from typing import Any, Optional, Dict, Tuple, List, Sequence
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager from .model_metadata_provider import (
CivitaiModelMetadataProvider,
ModelMetadataProviderManager,
)
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload from ..utils.civitai_utils import resolve_license_payload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CivitaiClient: class CivitaiClient:
_instance = None _instance = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
@@ -23,13 +27,15 @@ class CivitaiClient:
# Register this client as a metadata provider # Register this client as a metadata provider
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
provider_manager.register_provider('civitai', CivitaiModelMetadataProvider(cls._instance), True) provider_manager.register_provider(
"civitai", CivitaiModelMetadataProvider(cls._instance), True
)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
# Check if already initialized for singleton pattern # Check if already initialized for singleton pattern
if hasattr(self, '_initialized'): if hasattr(self, "_initialized"):
return return
self._initialized = True self._initialized = True
@@ -76,7 +82,9 @@ class CivitaiClient:
if isinstance(meta, dict) and "comfy" in meta: if isinstance(meta, dict) and "comfy" in meta:
meta.pop("comfy", None) meta.pop("comfy", None)
async def download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]: async def download_file(
self, url: str, save_dir: str, default_filename: str, progress_callback=None
) -> Tuple[bool, str]:
"""Download file with resumable downloads and retry mechanism """Download file with resumable downloads and retry mechanism
Args: Args:
@@ -97,34 +105,41 @@ class CivitaiClient:
save_path=save_path, save_path=save_path,
progress_callback=progress_callback, progress_callback=progress_callback,
use_auth=True, # Enable CivitAI authentication use_auth=True, # Enable CivitAI authentication
allow_resume=True allow_resume=True,
) )
return success, result return success, result
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]: async def get_model_by_hash(
self, model_hash: str
) -> Tuple[Optional[Dict], Optional[str]]:
try: try:
success, version = await self._make_request( success, version = await self._make_request(
'GET', "GET",
f"{self.base_url}/model-versions/by-hash/{model_hash}", f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True use_auth=True,
) )
if not success: if not success:
message = str(version) message = str(version)
if "not found" in message.lower(): if "not found" in message.lower():
return None, "Model not found" return None, "Model not found"
logger.error("Failed to fetch model info for %s: %s", model_hash[:10], message) logger.error(
"Failed to fetch model info for %s: %s", model_hash[:10], message
)
return None, message return None, message
model_id = version.get('modelId') if isinstance(version, dict):
if model_id: model_id = version.get("modelId")
model_data = await self._fetch_model_data(model_id) if model_id:
if model_data: model_data = await self._fetch_model_data(model_id)
self._enrich_version_with_model_data(version, model_data) if model_data:
self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
return version, None return version, None
else:
return None, "Invalid response format"
except RateLimitError: except RateLimitError:
raise raise
except Exception as exc: except Exception as exc:
@@ -136,12 +151,12 @@ class CivitaiClient:
downloader = await get_downloader() downloader = await get_downloader()
success, content, headers = await downloader.download_to_memory( success, content, headers = await downloader.download_to_memory(
image_url, image_url,
use_auth=False # Preview images don't need auth use_auth=False, # Preview images don't need auth
) )
if success: if success:
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, 'wb') as f: with open(save_path, "wb") as f:
f.write(content) f.write(content)
return True return True
return False return False
@@ -175,19 +190,17 @@ class CivitaiClient:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
success, result = await self._make_request( success, result = await self._make_request(
'GET', "GET", f"{self.base_url}/models/{model_id}", use_auth=True
f"{self.base_url}/models/{model_id}",
use_auth=True
) )
if success: if success:
# Also return model type along with versions # Also return model type along with versions
return { return {
'modelVersions': result.get('modelVersions', []), "modelVersions": result.get("modelVersions", []),
'type': result.get('type', ''), "type": result.get("type", ""),
'name': result.get('name', '') "name": result.get("name", ""),
} }
message = self._extract_error_message(result) message = self._extract_error_message(result)
if message and 'not found' in message.lower(): if message and "not found" in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}") raise ResourceNotFoundError(f"Resource not found for model {model_id}")
if message: if message:
raise RuntimeError(message) raise RuntimeError(message)
@@ -221,15 +234,15 @@ class CivitaiClient:
try: try:
query = ",".join(normalized_ids) query = ",".join(normalized_ids)
success, result = await self._make_request( success, result = await self._make_request(
'GET', "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={'ids': query}, params={"ids": query},
) )
if not success: if not success:
return None return None
items = result.get('items') if isinstance(result, dict) else None items = result.get("items") if isinstance(result, dict) else None
if not isinstance(items, list): if not isinstance(items, list):
return {} return {}
@@ -237,19 +250,19 @@ class CivitaiClient:
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
model_id = item.get('id') model_id = item.get("id")
try: try:
normalized_id = int(model_id) normalized_id = int(model_id)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
payload[normalized_id] = { payload[normalized_id] = {
'modelVersions': item.get('modelVersions', []), "modelVersions": item.get("modelVersions", []),
'type': item.get('type', ''), "type": item.get("type", ""),
'name': item.get('name', ''), "name": item.get("name", ""),
'allowNoCredit': item.get('allowNoCredit'), "allowNoCredit": item.get("allowNoCredit"),
'allowCommercialUse': item.get('allowCommercialUse'), "allowCommercialUse": item.get("allowCommercialUse"),
'allowDerivatives': item.get('allowDerivatives'), "allowDerivatives": item.get("allowDerivatives"),
'allowDifferentLicense': item.get('allowDifferentLicense'), "allowDifferentLicense": item.get("allowDifferentLicense"),
} }
return payload return payload
except RateLimitError: except RateLimitError:
@@ -258,7 +271,9 @@ class CivitaiClient:
logger.error(f"Error fetching model versions in bulk: {exc}") logger.error(f"Error fetching model versions in bulk: {exc}")
return None return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]: async def get_model_version(
self, model_id: int = None, version_id: int = None
) -> Optional[Dict]:
"""Get specific model version with additional metadata.""" """Get specific model version with additional metadata."""
try: try:
if model_id is None and version_id is not None: if model_id is None and version_id is not None:
@@ -281,7 +296,7 @@ class CivitaiClient:
if version is None: if version is None:
return None return None
model_id = version.get('modelId') model_id = version.get("modelId")
if not model_id: if not model_id:
logger.error(f"No modelId found in version {version_id}") logger.error(f"No modelId found in version {version_id}")
return None return None
@@ -293,7 +308,9 @@ class CivitaiClient:
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
return version return version
async def _get_version_with_model_id(self, model_id: int, version_id: Optional[int]) -> Optional[Dict]: async def _get_version_with_model_id(
self, model_id: int, version_id: Optional[int]
) -> Optional[Dict]:
model_data = await self._fetch_model_data(model_id) model_data = await self._fetch_model_data(model_id)
if not model_data: if not model_data:
return None return None
@@ -302,8 +319,12 @@ class CivitaiClient:
if target_version is None: if target_version is None:
return None return None
target_version_id = target_version.get('id') target_version_id = target_version.get("id")
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None version = (
await self._fetch_version_by_id(target_version_id)
if target_version_id
else None
)
if version is None: if version is None:
model_hash = self._extract_primary_model_hash(target_version) model_hash = self._extract_primary_model_hash(target_version)
@@ -315,7 +336,9 @@ class CivitaiClient:
) )
if version is None: if version is None:
version = self._build_version_from_model_data(target_version, model_id, model_data) version = self._build_version_from_model_data(
target_version, model_id, model_data
)
self._enrich_version_with_model_data(version, model_data) self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
@@ -323,9 +346,7 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]: async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request( success, data = await self._make_request(
'GET', "GET", f"{self.base_url}/models/{model_id}", use_auth=True
f"{self.base_url}/models/{model_id}",
use_auth=True
) )
if success: if success:
return data return data
@@ -337,9 +358,7 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
'GET', "GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
f"{self.base_url}/model-versions/{version_id}",
use_auth=True
) )
if success: if success:
return version return version
@@ -352,9 +371,7 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
'GET', "GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
) )
if success: if success:
return version return version
@@ -362,16 +379,17 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by hash {model_hash}") logger.warning(f"Failed to fetch version by hash {model_hash}")
return None return None
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]: def _select_target_version(
model_versions = model_data.get('modelVersions', []) self, model_data: Dict, model_id: int, version_id: Optional[int]
) -> Optional[Dict]:
model_versions = model_data.get("modelVersions", [])
if not model_versions: if not model_versions:
logger.warning(f"No model versions found for model {model_id}") logger.warning(f"No model versions found for model {model_id}")
return None return None
if version_id is not None: if version_id is not None:
target_version = next( target_version = next(
(item for item in model_versions if item.get('id') == version_id), (item for item in model_versions if item.get("id") == version_id), None
None
) )
if target_version is None: if target_version is None:
logger.warning( logger.warning(
@@ -383,41 +401,45 @@ class CivitaiClient:
return model_versions[0] return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]: def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
for file_info in version_entry.get('files', []): for file_info in version_entry.get("files", []):
if file_info.get('type') == 'Model' and file_info.get('primary'): if file_info.get("type") == "Model" and file_info.get("primary"):
hashes = file_info.get('hashes', {}) hashes = file_info.get("hashes", {})
model_hash = hashes.get('SHA256') model_hash = hashes.get("SHA256")
if model_hash: if model_hash:
return model_hash return model_hash
return None return None
def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict: def _build_version_from_model_data(
self, version_entry: Dict, model_id: int, model_data: Dict
) -> Dict:
version = copy.deepcopy(version_entry) version = copy.deepcopy(version_entry)
version.pop('index', None) version.pop("index", None)
version['modelId'] = model_id version["modelId"] = model_id
version['model'] = { version["model"] = {
'name': model_data.get('name'), "name": model_data.get("name"),
'type': model_data.get('type'), "type": model_data.get("type"),
'nsfw': model_data.get('nsfw'), "nsfw": model_data.get("nsfw"),
'poi': model_data.get('poi') "poi": model_data.get("poi"),
} }
return version return version
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None: def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
model_info = version.get('model') model_info = version.get("model")
if not isinstance(model_info, dict): if not isinstance(model_info, dict):
model_info = {} model_info = {}
version['model'] = model_info version["model"] = model_info
model_info['description'] = model_data.get("description") model_info["description"] = model_data.get("description")
model_info['tags'] = model_data.get("tags", []) model_info["tags"] = model_data.get("tags", [])
version['creator'] = model_data.get("creator") version["creator"] = model_data.get("creator")
license_payload = resolve_license_payload(model_data) license_payload = resolve_license_payload(model_data)
for field, value in license_payload.items(): for field, value in license_payload.items():
model_info[field] = value model_info[field] = value
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]: async def get_model_version_info(
self, version_id: str
) -> Tuple[Optional[Dict], Optional[str]]:
"""Fetch model version metadata from Civitai """Fetch model version metadata from Civitai
Args: Args:
@@ -432,14 +454,12 @@ class CivitaiClient:
url = f"{self.base_url}/model-versions/{version_id}" url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}") logger.debug(f"Resolving DNS for model version info: {url}")
success, result = await self._make_request( success, result = await self._make_request("GET", url, use_auth=True)
'GET',
url,
use_auth=True
)
if success: if success:
logger.debug(f"Successfully fetched model version info for: {version_id}") logger.debug(
f"Successfully fetched model version info for: {version_id}"
)
self._remove_comfy_metadata(result) self._remove_comfy_metadata(result)
return result, None return result, None
@@ -470,18 +490,33 @@ class CivitaiClient:
""" """
try: try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X" url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id)
logger.debug(f"Fetching image info for ID: {image_id}") logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request( success, result = await self._make_request("GET", url, use_auth=True)
'GET',
url,
use_auth=True
)
if success: if success:
if result and "items" in result and len(result["items"]) > 0: if result and "items" in result and isinstance(result["items"], list):
logger.debug(f"Successfully fetched image info for ID: {image_id}") items = result["items"]
return result["items"][0]
# First, try to find the item with matching ID
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
)
return None
logger.warning(f"No image found with ID: {image_id}") logger.warning(f"No image found with ID: {image_id}")
return None return None
@@ -489,6 +524,10 @@ class CivitaiClient:
return None return None
except RateLimitError: except RateLimitError:
raise raise
except ValueError as e:
error_msg = f"Invalid image ID format: {image_id}"
logger.error(error_msg)
return None
except Exception as e: except Exception as e:
error_msg = f"Error fetching image info: {e}" error_msg = f"Error fetching image info: {e}"
logger.error(error_msg) logger.error(error_msg)
@@ -501,11 +540,7 @@ class CivitaiClient:
try: try:
url = f"{self.base_url}/models?username={username}" url = f"{self.base_url}/models?username={username}"
success, result = await self._make_request( success, result = await self._make_request("GET", url, use_auth=True)
'GET',
url,
use_auth=True
)
if not success: if not success:
logger.error("Failed to fetch models for %s: %s", username, result) logger.error("Failed to fetch models for %s: %s", username, result)

View File

@@ -49,6 +49,7 @@ class CustomWordsService:
if self._tag_index is None: if self._tag_index is None:
try: try:
from .tag_fts_index import get_tag_fts_index from .tag_fts_index import get_tag_fts_index
self._tag_index = get_tag_fts_index() self._tag_index = get_tag_fts_index()
except Exception as e: except Exception as e:
logger.warning(f"Failed to initialize TagFTSIndex: {e}") logger.warning(f"Failed to initialize TagFTSIndex: {e}")
@@ -59,14 +60,16 @@ class CustomWordsService:
self, self,
search_term: str, search_term: str,
limit: int = 20, limit: int = 20,
offset: int = 0,
categories: Optional[List[int]] = None, categories: Optional[List[int]] = None,
enriched: bool = False enriched: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Search tags using TagFTSIndex with category filtering. """Search tags using TagFTSIndex with category filtering.
Args: Args:
search_term: The search term to match against. search_term: The search term to match against.
limit: Maximum number of results to return. limit: Maximum number of results to return.
offset: Number of results to skip.
categories: Optional list of category IDs to filter by. categories: Optional list of category IDs to filter by.
enriched: If True, always return enriched results with category enriched: If True, always return enriched results with category
and post_count (default behavior now). and post_count (default behavior now).
@@ -76,7 +79,9 @@ class CustomWordsService:
""" """
tag_index = self._get_tag_index() tag_index = self._get_tag_index()
if tag_index is not None: if tag_index is not None:
results = tag_index.search(search_term, categories=categories, limit=limit) results = tag_index.search(
search_term, categories=categories, limit=limit, offset=offset
)
return results return results
logger.debug("TagFTSIndex not available, returning empty results") logger.debug("TagFTSIndex not available, returning empty results")

View File

@@ -10,12 +10,16 @@ import uuid
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.file_utils import calculate_sha256
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
@@ -225,7 +229,9 @@ class DownloadManager:
# Update status based on result # Update status based on result
if task_id in self._active_downloads: if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = ( self._active_downloads[task_id]["status"] = (
"completed" if result["success"] else "failed" result.get("status", "completed")
if result["success"]
else "failed"
) )
if not result["success"]: if not result["success"]:
self._active_downloads[task_id]["error"] = result.get( self._active_downloads[task_id]["error"] = result.get(
@@ -349,13 +355,59 @@ class DownloadManager:
"error": f'Model type "{model_type_from_info}" is not supported for download', "error": f'Model type "{model_type_from_info}" is not supported for download',
} }
excluded_base_models = get_settings_manager().get_download_skip_base_models()
base_model_value = version_info.get("baseModel", "")
if (
isinstance(base_model_value, str)
and base_model_value in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
and base_model_value in excluded_base_models
):
file_name = ""
files = version_info.get("files")
if isinstance(files, list):
primary_file = next(
(
file_info
for file_info in files
if isinstance(file_info, dict) and file_info.get("primary")
),
None,
)
selected_file = primary_file
if selected_file is None:
selected_file = next(
(file_info for file_info in files if isinstance(file_info, dict)),
None,
)
if isinstance(selected_file, dict):
raw_file_name = selected_file.get("name", "")
if isinstance(raw_file_name, str):
file_name = raw_file_name.strip()
message = (
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{model_version_id or model_id}'}' "
f"because base model '{base_model_value}' is excluded in settings"
)
logger.info(message)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": message,
"base_model": base_model_value,
"file_name": file_name,
"download_id": download_id,
}
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
base_model_value = version_info.get('baseModel', '')
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info(f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder") logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Case 2: model_version_id was None, check after getting version_info # Case 2: model_version_id was None, check after getting version_info
if model_version_id is None: if model_version_id is None:
@@ -476,8 +528,13 @@ class DownloadManager:
if is_primary: if is_primary:
# Find primary file # Find primary file
file_info = next( file_info = next(
(f for f in files if f.get("primary") and f.get("type") in ("Model", "Negative")), (
None f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative")
),
None,
) )
else: else:
# Match by metadata # Match by metadata
@@ -836,9 +893,13 @@ class DownloadManager:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
selected_image, nsfw_level = select_preview_media( selected_image, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
preview_url = selected_image.get("url") if selected_image else None preview_url = selected_image.get("url") if selected_image else None
@@ -954,11 +1015,12 @@ class DownloadManager:
for download_url in download_urls: for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/") use_auth = download_url.startswith("https://civitai.com/api/download/")
download_kwargs = { download_kwargs = {
"progress_callback": lambda progress, "progress_callback": lambda progress, snapshot=None: (
snapshot=None: self._handle_download_progress( self._handle_download_progress(
progress, progress,
progress_callback, progress_callback,
snapshot, snapshot,
)
), ),
"use_auth": use_auth, # Only use authentication for Civitai downloads "use_auth": use_auth, # Only use authentication for Civitai downloads
} }
@@ -1220,8 +1282,15 @@ class DownloadManager:
entries: List = [] entries: List = []
for index, file_path in enumerate(file_paths): for index, file_path in enumerate(file_paths):
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata) entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
entry.update_file_info(file_path) # Update file paths without modifying size and modified timestamps
entry.sha256 = await calculate_sha256(file_path) # modified should remain as the download start time (import time)
# size will be updated below to reflect actual downloaded file size
entry.file_path = file_path.replace(os.sep, "/")
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
# Use SHA256 from API metadata (already set in from_civitai_info)
# Do not recalculate to avoid blocking during ComfyUI execution
entries.append(entry) entries.append(entry)
return entries return entries

View File

@@ -44,7 +44,9 @@ class DownloadStreamControl:
self._event.set() self._event.set()
self._reconnect_requested = False self._reconnect_requested = False
self.last_progress_timestamp: Optional[float] = None self.last_progress_timestamp: Optional[float] = None
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0 self.stall_timeout: float = (
float(stall_timeout) if stall_timeout is not None else 120.0
)
def is_set(self) -> bool: def is_set(self) -> bool:
return self._event.is_set() return self._event.is_set()
@@ -85,7 +87,9 @@ class DownloadStreamControl:
self.last_progress_timestamp = timestamp or datetime.now().timestamp() self.last_progress_timestamp = timestamp or datetime.now().timestamp()
self._reconnect_requested = False self._reconnect_requested = False
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]: def time_since_last_progress(
self, *, now: Optional[float] = None
) -> Optional[float]:
if self.last_progress_timestamp is None: if self.last_progress_timestamp is None:
return None return None
reference = now if now is not None else datetime.now().timestamp() reference = now if now is not None else datetime.now().timestamp()
@@ -120,7 +124,7 @@ class Downloader:
def __init__(self): def __init__(self):
"""Initialize the downloader with optimal settings""" """Initialize the downloader with optimal settings"""
# Check if already initialized for singleton pattern # Check if already initialized for singleton pattern
if hasattr(self, '_initialized'): if hasattr(self, "_initialized"):
return return
self._initialized = True self._initialized = True
@@ -131,7 +135,9 @@ class Downloader:
self._session_lock = asyncio.Lock() self._session_lock = asyncio.Lock()
# Configuration # Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput self.chunk_size = (
16 * 1024 * 1024
) # 16MB chunks to balance I/O reduction and memory usage
self.max_retries = 5 self.max_retries = 5
self.base_delay = 2.0 # Base delay for exponential backoff self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes self.session_timeout = 300 # 5 minutes
@@ -139,10 +145,10 @@ class Downloader:
# Default headers # Default headers
self.default_headers = { self.default_headers = {
'User-Agent': 'ComfyUI-LoRA-Manager/1.0', "User-Agent": "ComfyUI-LoRA-Manager/1.0",
# Explicitly request uncompressed payloads so aiohttp doesn't need optional # Explicitly request uncompressed payloads so aiohttp doesn't need optional
# decoders (e.g. zstandard) that may be missing in runtime environments. # decoders (e.g. zstandard) that may be missing in runtime environments.
'Accept-Encoding': 'identity', "Accept-Encoding": "identity",
} }
@property @property
@@ -158,7 +164,7 @@ class Downloader:
@property @property
def proxy_url(self) -> Optional[str]: def proxy_url(self) -> Optional[str]:
"""Get the current proxy URL (initialize if needed)""" """Get the current proxy URL (initialize if needed)"""
if not hasattr(self, '_proxy_url'): if not hasattr(self, "_proxy_url"):
self._proxy_url = None self._proxy_url = None
return self._proxy_url return self._proxy_url
@@ -169,14 +175,14 @@ class Downloader:
try: try:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_timeout = settings_manager.get('download_stall_timeout_seconds') settings_timeout = settings_manager.get("download_stall_timeout_seconds")
except Exception as exc: # pragma: no cover - defensive guard except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to read stall timeout from settings: %s", exc) logger.debug("Failed to read stall timeout from settings: %s", exc)
raw_value = ( raw_value = (
settings_timeout settings_timeout
if settings_timeout not in (None, "") if settings_timeout not in (None, "")
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT') else os.environ.get("COMFYUI_DOWNLOAD_STALL_TIMEOUT")
) )
try: try:
@@ -191,11 +197,13 @@ class Downloader:
if self._session is None: if self._session is None:
return True return True
if not hasattr(self, '_session_created_at') or self._session_created_at is None: if not hasattr(self, "_session_created_at") or self._session_created_at is None:
return True return True
# Refresh if session is older than timeout # Refresh if session is older than timeout
if (datetime.now() - self._session_created_at).total_seconds() > self.session_timeout: if (
datetime.now() - self._session_created_at
).total_seconds() > self.session_timeout:
return True return True
return False return False
@@ -209,7 +217,7 @@ class Downloader:
if self._session is not None: if self._session is not None:
try: try:
await self._session.close() await self._session.close()
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}") logger.warning(f"Error closing previous session: {e}")
finally: finally:
self._session = None self._session = None
@@ -217,12 +225,12 @@ class Downloader:
# Check for app-level proxy settings # Check for app-level proxy settings
proxy_url = None proxy_url = None
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
if settings_manager.get('proxy_enabled', False): if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get('proxy_host', '').strip() proxy_host = settings_manager.get("proxy_host", "").strip()
proxy_port = settings_manager.get('proxy_port', '').strip() proxy_port = settings_manager.get("proxy_port", "").strip()
proxy_type = settings_manager.get('proxy_type', 'http').lower() proxy_type = settings_manager.get("proxy_type", "http").lower()
proxy_username = settings_manager.get('proxy_username', '').strip() proxy_username = settings_manager.get("proxy_username", "").strip()
proxy_password = settings_manager.get('proxy_password', '').strip() proxy_password = settings_manager.get("proxy_password", "").strip()
if proxy_host and proxy_port: if proxy_host and proxy_port:
# Build proxy URL # Build proxy URL
@@ -231,37 +239,46 @@ class Downloader:
else: else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}" proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}") logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
)
logger.debug("Proxy mode: app-level proxy is active.") logger.debug("Proxy mode: app-level proxy is active.")
else: else:
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.") logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
)
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=True,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
enable_cleanup_closed=True enable_cleanup_closed=True,
) )
# Configure timeout parameters # Configure timeout parameters
timeout = aiohttp.ClientTimeout( timeout = aiohttp.ClientTimeout(
total=None, # No total timeout for large downloads total=None, # No total timeout for large downloads
connect=60, # Connection timeout connect=60, # Connection timeout
sock_read=300 # 5 minute socket read timeout sock_read=300, # 5 minute socket read timeout
) )
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=connector, connector=connector,
trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set trust_env=proxy_url
timeout=timeout is None, # Only use system proxy if no app-level proxy is set
timeout=timeout,
) )
# Store proxy URL for use in requests # Store proxy URL for use in requests
self._proxy_url = proxy_url self._proxy_url = proxy_url
self._session_created_at = datetime.now() self._session_created_at = datetime.now()
logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None) logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
proxy_url is None,
)
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]: def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
"""Get headers with optional authentication""" """Get headers with optional authentication"""
@@ -270,10 +287,10 @@ class Downloader:
if use_auth: if use_auth:
# Add CivitAI API key if available # Add CivitAI API key if available
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
api_key = settings_manager.get('civitai_api_key') api_key = settings_manager.get("civitai_api_key")
if api_key: if api_key:
headers['Authorization'] = f'Bearer {api_key}' headers["Authorization"] = f"Bearer {api_key}"
headers['Content-Type'] = 'application/json' headers["Content-Type"] = "application/json"
return headers return headers
@@ -303,7 +320,7 @@ class Downloader:
Tuple[bool, str]: (success, save_path or error message) Tuple[bool, str]: (success, save_path or error message)
""" """
retry_count = 0 retry_count = 0
part_path = save_path + '.part' if allow_resume else save_path part_path = save_path + ".part" if allow_resume else save_path
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
@@ -323,50 +340,71 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[download_file] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[download_file] Using system-level proxy (trust_env) if configured.") logger.debug(
"[download_file] Using system-level proxy (trust_env) if configured."
)
# Add Range header for resume if we have partial data # Add Range header for resume if we have partial data
request_headers = headers.copy() request_headers = headers.copy()
if allow_resume and resume_offset > 0: if allow_resume and resume_offset > 0:
request_headers['Range'] = f'bytes={resume_offset}-' request_headers["Range"] = f"bytes={resume_offset}-"
# Disable compression for better chunked downloads # Disable compression for better chunked downloads
request_headers['Accept-Encoding'] = 'identity' request_headers["Accept-Encoding"] = "identity"
logger.debug(f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}") logger.debug(
f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}"
)
if resume_offset > 0: if resume_offset > 0:
logger.debug(f"Requesting range from byte {resume_offset}") logger.debug(f"Requesting range from byte {resume_offset}")
async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response: async with session.get(
url,
headers=request_headers,
allow_redirects=True,
proxy=self.proxy_url,
) as response:
# Handle different response codes # Handle different response codes
if response.status == 200: if response.status == 200:
# Full content response # Full content response
if resume_offset > 0: if resume_offset > 0:
# Server doesn't support ranges, restart from beginning # Server doesn't support ranges, restart from beginning
logger.warning("Server doesn't support range requests, restarting download") logger.warning(
"Server doesn't support range requests, restarting download"
)
resume_offset = 0 resume_offset = 0
if os.path.exists(part_path): if os.path.exists(part_path):
os.remove(part_path) os.remove(part_path)
elif response.status == 206: elif response.status == 206:
# Partial content response (resume successful) # Partial content response (resume successful)
content_range = response.headers.get('Content-Range') content_range = response.headers.get("Content-Range")
if content_range: if content_range:
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048") # Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
range_parts = content_range.split('/') range_parts = content_range.split("/")
if len(range_parts) == 2: if len(range_parts) == 2:
total_size = int(range_parts[1]) total_size = int(range_parts[1])
logger.info(f"Successfully resumed download from byte {resume_offset}") logger.info(
f"Successfully resumed download from byte {resume_offset}"
)
elif response.status == 416: elif response.status == 416:
# Range not satisfiable - file might be complete or corrupted # Range not satisfiable - file might be complete or corrupted
if allow_resume and os.path.exists(part_path): if allow_resume and os.path.exists(part_path):
part_size = os.path.getsize(part_path) part_size = os.path.getsize(part_path)
logger.warning(f"Range not satisfiable. Part file size: {part_size}") logger.warning(
f"Range not satisfiable. Part file size: {part_size}"
)
# Try to get actual file size # Try to get actual file size
head_response = await session.head(url, headers=headers, proxy=self.proxy_url) head_response = await session.head(
url, headers=headers, proxy=self.proxy_url
)
if head_response.status == 200: if head_response.status == 200:
actual_size = int(head_response.headers.get('content-length', 0)) actual_size = int(
head_response.headers.get("content-length", 0)
)
if part_size == actual_size: if part_size == actual_size:
# File is complete, just rename it # File is complete, just rename it
if allow_resume: if allow_resume:
@@ -388,21 +426,36 @@ class Downloader:
resume_offset = 0 resume_offset = 0
continue continue
elif response.status == 401: elif response.status == 401:
logger.warning(f"Unauthorized access to resource: {url} (Status 401)") logger.warning(
return False, "Invalid or missing API key, or early access restriction." f"Unauthorized access to resource: {url} (Status 401)"
)
return (
False,
"Invalid or missing API key, or early access restriction.",
)
elif response.status == 403: elif response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)") logger.warning(
return False, "Access forbidden: You don't have permission to download this file." f"Forbidden access to resource: {url} (Status 403)"
)
return (
False,
"Access forbidden: You don't have permission to download this file.",
)
elif response.status == 404: elif response.status == 404:
logger.warning(f"Resource not found: {url} (Status 404)") logger.warning(f"Resource not found: {url} (Status 404)")
return False, "File not found - the download link may be invalid or expired." return (
False,
"File not found - the download link may be invalid or expired.",
)
else: else:
logger.error(f"Download failed for {url} with status {response.status}") logger.error(
f"Download failed for {url} with status {response.status}"
)
return False, f"Download failed with status {response.status}" return False, f"Download failed with status {response.status}"
# Get total file size for progress calculation (if not set from Content-Range) # Get total file size for progress calculation (if not set from Content-Range)
if total_size == 0: if total_size == 0:
total_size = int(response.headers.get('content-length', 0)) total_size = int(response.headers.get("content-length", 0))
if response.status == 206: if response.status == 206:
# For partial content, add the offset to get total file size # For partial content, add the offset to get total file size
total_size += resume_offset total_size += resume_offset
@@ -417,7 +470,7 @@ class Downloader:
# Stream download to file with progress updates # Stream download to file with progress updates
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb' mode = "ab" if (allow_resume and resume_offset > 0) else "wb"
control = pause_event control = pause_event
if control is not None: if control is not None:
@@ -425,7 +478,9 @@ class Downloader:
with open(part_path, mode) as f: with open(part_path, mode) as f:
while True: while True:
active_stall_timeout = control.stall_timeout if control else self.stall_timeout active_stall_timeout = (
control.stall_timeout if control else self.stall_timeout
)
if control is not None: if control is not None:
if control.is_paused(): if control.is_paused():
@@ -437,7 +492,9 @@ class Downloader:
"Reconnect requested after resume" "Reconnect requested after resume"
) )
elif control.consume_reconnect_request(): elif control.consume_reconnect_request():
raise DownloadRestartRequested("Reconnect requested") raise DownloadRestartRequested(
"Reconnect requested"
)
try: try:
chunk = await asyncio.wait_for( chunk = await asyncio.wait_for(
@@ -466,22 +523,32 @@ class Downloader:
control.mark_progress(timestamp=now.timestamp()) control.mark_progress(timestamp=now.timestamp())
# Limit progress update frequency to reduce overhead # Limit progress update frequency to reduce overhead
time_diff = (now - last_progress_report_time).total_seconds() time_diff = (
now - last_progress_report_time
).total_seconds()
if progress_callback and time_diff >= 1.0: if progress_callback and time_diff >= 1.0:
progress_samples.append((now, current_size)) progress_samples.append((now, current_size))
cutoff = now - timedelta(seconds=5) cutoff = now - timedelta(seconds=5)
while progress_samples and progress_samples[0][0] < cutoff: while (
progress_samples and progress_samples[0][0] < cutoff
):
progress_samples.popleft() progress_samples.popleft()
percent = (current_size / total_size) * 100 if total_size else 0.0 percent = (
(current_size / total_size) * 100
if total_size
else 0.0
)
bytes_per_second = 0.0 bytes_per_second = 0.0
if len(progress_samples) >= 2: if len(progress_samples) >= 2:
first_time, first_bytes = progress_samples[0] first_time, first_bytes = progress_samples[0]
last_time, last_bytes = progress_samples[-1] last_time, last_bytes = progress_samples[-1]
elapsed = (last_time - first_time).total_seconds() elapsed = (last_time - first_time).total_seconds()
if elapsed > 0: if elapsed > 0:
bytes_per_second = (last_bytes - first_bytes) / elapsed bytes_per_second = (
last_bytes - first_bytes
) / elapsed
progress_snapshot = DownloadProgress( progress_snapshot = DownloadProgress(
percent_complete=percent, percent_complete=percent,
@@ -491,21 +558,23 @@ class Downloader:
timestamp=now.timestamp(), timestamp=now.timestamp(),
) )
await self._dispatch_progress_callback(progress_callback, progress_snapshot) await self._dispatch_progress_callback(
progress_callback, progress_snapshot
)
last_progress_report_time = now last_progress_report_time = now
# Download completed successfully # Download completed successfully
# Verify file size integrity before finalizing # Verify file size integrity before finalizing
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0 final_size = (
os.path.getsize(part_path) if os.path.exists(part_path) else 0
)
expected_size = total_size if total_size > 0 else None expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None integrity_error: Optional[str] = None
if final_size <= 0: if final_size <= 0:
integrity_error = "Downloaded file is empty" integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size: elif expected_size is not None and final_size != expected_size:
integrity_error = ( integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
)
if integrity_error is not None: if integrity_error is not None:
logger.error( logger.error(
@@ -555,7 +624,9 @@ class Downloader:
rename_attempt = 0 rename_attempt = 0
rename_success = False rename_success = False
while rename_attempt < max_rename_attempts and not rename_success: while (
rename_attempt < max_rename_attempts and not rename_success
):
try: try:
# If the destination file exists, remove it first (Windows safe) # If the destination file exists, remove it first (Windows safe)
if os.path.exists(save_path): if os.path.exists(save_path):
@@ -566,11 +637,18 @@ class Downloader:
except PermissionError as e: except PermissionError as e:
rename_attempt += 1 rename_attempt += 1
if rename_attempt < max_rename_attempts: if rename_attempt < max_rename_attempts:
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})") logger.info(
f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})"
)
await asyncio.sleep(2) await asyncio.sleep(2)
else: else:
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}") logger.error(
return False, f"Failed to finalize download: {str(e)}" f"Failed to rename file after {max_rename_attempts} attempts: {e}"
)
return (
False,
f"Failed to finalize download: {str(e)}",
)
final_size = os.path.getsize(save_path) final_size = os.path.getsize(save_path)
@@ -583,8 +661,9 @@ class Downloader:
bytes_per_second=0.0, bytes_per_second=0.0,
timestamp=datetime.now().timestamp(), timestamp=datetime.now().timestamp(),
) )
await self._dispatch_progress_callback(progress_callback, final_snapshot) await self._dispatch_progress_callback(
progress_callback, final_snapshot
)
return True, save_path return True, save_path
@@ -597,7 +676,9 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}") logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
)
if retry_count <= self.max_retries: if retry_count <= self.max_retries:
# Calculate delay with exponential backoff # Calculate delay with exponential backoff
@@ -615,7 +696,10 @@ class Downloader:
continue continue
else: else:
logger.error(f"Max retries exceeded for download: {e}") logger.error(f"Max retries exceeded for download: {e}")
return False, f"Network error after {self.max_retries + 1} attempts: {str(e)}" return (
False,
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
)
except Exception as e: except Exception as e:
logger.error(f"Unexpected download error: {e}") logger.error(f"Unexpected download error: {e}")
@@ -645,7 +729,7 @@ class Downloader:
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False return_headers: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]: ) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
""" """
Download a file to memory (for small files like preview images) Download a file to memory (for small files like preview images)
@@ -663,16 +747,22 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[download_to_memory] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.") logger.debug(
"[download_to_memory] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
if custom_headers: if custom_headers:
headers.update(custom_headers) headers.update(custom_headers)
async with session.get(url, headers=headers, proxy=self.proxy_url) as response: async with session.get(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200: if response.status == 200:
content = await response.read() content = await response.read()
if return_headers: if return_headers:
@@ -700,7 +790,7 @@ class Downloader:
self, self,
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None custom_headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict, str]]:
""" """
Get response headers without downloading the full content Get response headers without downloading the full content
@@ -717,16 +807,22 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[get_response_headers] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.") logger.debug(
"[get_response_headers] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
if custom_headers: if custom_headers:
headers.update(custom_headers) headers.update(custom_headers)
async with session.head(url, headers=headers, proxy=self.proxy_url) as response: async with session.head(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200: if response.status == 200:
return True, dict(response.headers) return True, dict(response.headers)
else: else:
@@ -742,7 +838,7 @@ class Downloader:
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
**kwargs **kwargs,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict, str]]:
""" """
Make a generic HTTP request and return JSON response Make a generic HTTP request and return JSON response
@@ -763,7 +859,9 @@ class Downloader:
if self.proxy_url: if self.proxy_url:
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}") logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
else: else:
logger.debug("[make_request] Using system-level proxy (trust_env) if configured.") logger.debug(
"[make_request] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
@@ -771,10 +869,12 @@ class Downloader:
headers.update(custom_headers) headers.update(custom_headers)
# Add proxy to kwargs if not already present # Add proxy to kwargs if not already present
if 'proxy' not in kwargs: if "proxy" not in kwargs:
kwargs['proxy'] = self.proxy_url kwargs["proxy"] = self.proxy_url
async with session.request(method, url, headers=headers, **kwargs) as response: async with session.request(
method, url, headers=headers, **kwargs
) as response:
if response.status == 200: if response.status == 200:
# Try to parse as JSON, fall back to text # Try to parse as JSON, fall back to text
try: try:

View File

@@ -22,5 +22,15 @@ class EmbeddingScanner(ModelScanner):
) )
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get embedding root directories""" """Get embedding root directories (including extra paths)"""
return config.embeddings_roots roots: List[str] = []
roots.extend(config.embeddings_roots or [])
roots.extend(config.extra_embeddings_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots

View File

@@ -25,8 +25,18 @@ class LoraScanner(ModelScanner):
) )
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get lora root directories""" """Get lora root directories (including extra paths)"""
return config.loras_roots roots: List[str] = []
roots.extend(config.loras_roots or [])
roots.extend(config.extra_loras_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
async def diagnose_hash_index(self): async def diagnose_hash_index(self):
"""Diagnostic method to verify hash index functionality""" """Diagnostic method to verify hash index functionality"""

View File

@@ -1,5 +1,6 @@
import os
import logging import logging
import json
import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
@@ -48,7 +49,9 @@ class LoraService(BaseModelService):
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)
),
"sub_type": sub_type, "sub_type": sub_type,
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
@@ -62,6 +65,68 @@ class LoraService(BaseModelService):
if first_letter: if first_letter:
data = self._filter_by_first_letter(data, first_letter) data = self._filter_by_first_letter(data, first_letter)
# Handle name pattern filters
name_pattern_include = kwargs.get("name_pattern_include", [])
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
if name_pattern_include or name_pattern_exclude:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in data:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if name_pattern_exclude:
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_exclude, name_pattern_use_regex
):
excluded = True
break
if excluded:
continue
# Check include patterns
if name_pattern_include:
included = False
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_include, name_pattern_use_regex
):
included = True
break
if not included:
continue
filtered.append(lora)
data = filtered
return data return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]: def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
@@ -214,6 +279,42 @@ class LoraService(BaseModelService):
return None return None
@staticmethod
def get_recommended_strength_from_lora_data(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended model strength."""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("strength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
@staticmethod
def get_recommended_clip_strength_from_lora_data(
lora_data: Dict,
) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended clip strength."""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("clipStrength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
async def get_lora_metadata_by_filename(self, filename: str) -> Optional[Dict]:
"""Return cached raw metadata for a LoRA matching the given filename."""
cache = await self.scanner.get_cached_data(force_refresh=False)
for lora in cache.raw_data if cache else []:
if lora.get("file_name") == filename:
return lora
return None
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes""" """Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
@@ -264,34 +365,10 @@ class LoraService(BaseModelService):
List of LoRA dicts with randomized strengths List of LoRA dicts with randomized strengths
""" """
import random import random
import json
# Use a local Random instance to avoid affecting global random state # Use a local Random instance to avoid affecting global random state
# This ensures each execution with a different seed produces different results # This ensures each execution with a different seed produces different results
rng = random.Random(seed) rng = random.Random(seed)
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("strength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended clip strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("clipStrength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
if locked_loras is None: if locked_loras is None:
locked_loras = [] locked_loras = []
@@ -339,7 +416,9 @@ class LoraService(BaseModelService):
result_loras = [] result_loras = []
for lora in selected: for lora in selected:
if use_recommended_strength: if use_recommended_strength:
recommended_strength = get_recommended_strength(lora) recommended_strength = self.get_recommended_strength_from_lora_data(
lora
)
if recommended_strength is not None: if recommended_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max
@@ -357,7 +436,9 @@ class LoraService(BaseModelService):
if use_same_clip_strength: if use_same_clip_strength:
clip_str = model_str clip_str = model_str
elif use_recommended_strength: elif use_recommended_strength:
recommended_clip_strength = get_recommended_clip_strength(lora) recommended_clip_strength = (
self.get_recommended_clip_strength_from_lora_data(lora)
)
if recommended_clip_strength is not None: if recommended_clip_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max
@@ -368,9 +449,7 @@ class LoraService(BaseModelService):
rng.uniform(clip_strength_min, clip_strength_max), 2 rng.uniform(clip_strength_min, clip_strength_max), 2
) )
else: else:
clip_str = round( clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
rng.uniform(clip_strength_min, clip_strength_max), 2
)
result_loras.append( result_loras.append(
{ {
@@ -485,12 +564,69 @@ class LoraService(BaseModelService):
if bool(lora.get("license_flags", 127) & (1 << 1)) if bool(lora.get("license_flags", 127) & (1 << 1))
] ]
# Apply name pattern filters
name_patterns = filter_section.get("namePatterns", {})
include_patterns = name_patterns.get("include", [])
exclude_patterns = name_patterns.get("exclude", [])
use_regex = name_patterns.get("useRegex", False)
if include_patterns or exclude_patterns:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in available_loras:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if exclude_patterns:
for name in names_to_check:
if matches_any_pattern(name, exclude_patterns, use_regex):
excluded = True
break
if excluded:
continue
# Check include patterns
if include_patterns:
included = False
for name in names_to_check:
if matches_any_pattern(name, include_patterns, use_regex):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
return available_loras return available_loras
async def get_cycler_list( async def get_cycler_list(
self, self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
) -> List[Dict]: ) -> List[Dict]:
""" """
Get filtered and sorted LoRA list for cycling. Get filtered and sorted LoRA list for cycling.
@@ -516,12 +652,18 @@ class LoraService(BaseModelService):
if sort_by == "model_name": if sort_by == "model_name":
available_loras = sorted( available_loras = sorted(
available_loras, available_loras,
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower() key=lambda x: (
(x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower(),
),
) )
else: # Default to filename else: # Default to filename
available_loras = sorted( available_loras = sorted(
available_loras, available_loras,
key=lambda x: x.get("file_name", "").lower() key=lambda x: (
x.get("file_name", "").lower(),
x.get("file_path", "").lower(),
),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling

View File

@@ -122,11 +122,25 @@ async def get_metadata_provider(provider_name: str = None):
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
provider = ( try:
provider_manager._get_provider(provider_name) provider = (
if provider_name provider_manager._get_provider(provider_name)
else provider_manager._get_provider() if provider_name
) else provider_manager._get_provider()
)
except ValueError as e:
# Provider not initialized, attempt to initialize
if "No default provider set" in str(e) or "not registered" in str(e):
logger.warning(f"Metadata provider not initialized ({e}), initializing now...")
await initialize_metadata_providers()
provider_manager = await ModelMetadataProviderManager.get_instance()
provider = (
provider_manager._get_provider(provider_name)
if provider_name
else provider_manager._get_provider()
)
else:
raise
return _wrap_provider_with_rate_limit(provider_name, provider) return _wrap_provider_with_rate_limit(provider_name, provider)

View File

@@ -221,33 +221,45 @@ class ModelCache:
start_time = time.perf_counter() start_time = time.perf_counter()
reverse = (order == 'desc') reverse = (order == 'desc')
if sort_key == 'name': if sort_key == 'name':
# Natural sort by configured display name, case-insensitive # Natural sort by configured display name, case-insensitive, with file_path as tie-breaker
result = natsorted( result = natsorted(
data, data,
key=lambda x: self._get_display_name(x).lower(), key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'date': elif sort_key == 'date':
# Sort by modified timestamp (use .get() with default to handle missing fields) # Sort by modified timestamp, fallback to name and path for stability
result = sorted( result = sorted(
data, data,
key=lambda x: x.get('modified', 0.0), key=lambda x: (
x.get('modified', 0.0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'size': elif sort_key == 'size':
# Sort by file size (use .get() with default to handle missing fields) # Sort by file size, fallback to name and path for stability
result = sorted( result = sorted(
data, data,
key=lambda x: x.get('size', 0), key=lambda x: (
x.get('size', 0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'usage': elif sort_key == 'usage':
# Sort by usage count, fallback to 0, then name for stability # Sort by usage count, fallback to 0, then name and path for stability
return sorted( return sorted(
data, data,
key=lambda x: ( key=lambda x: (
x.get('usage_count', 0), x.get('usage_count', 0),
self._get_display_name(x).lower() self._get_display_name(x).lower(),
x.get('file_path', '').lower()
), ),
reverse=reverse reverse=reverse
) )

View File

@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
@@ -283,6 +282,11 @@ class ModelScanner:
if sub_type: if sub_type:
entry['sub_type'] = sub_type entry['sub_type'] = sub_type
# Handle hash_status for lazy hash calculation (checkpoints)
hash_status = get_value('hash_status', 'completed')
if hash_status:
entry['hash_status'] = hash_status
return entry return entry
def _ensure_license_flags(self, entry: Dict[str, Any]) -> None: def _ensure_license_flags(self, entry: Dict[str, Any]) -> None:
@@ -728,19 +732,24 @@ class ModelScanner:
# Get current cached file paths # Get current cached file paths
cached_paths = {item['file_path'] for item in self._cache.raw_data} cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data} path_to_item = {item['file_path']: item for item in self._cache.raw_data}
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
# Track found files and new files # Track found files and new files
found_paths = set() found_paths = set()
new_files = [] new_files = []
visited_real_paths = set()
discovered_real_files = set()
# Scan all model roots # Scan all model roots
for root_path in self.get_model_roots(): for root_path in self.get_model_roots():
if not os.path.exists(root_path): if not os.path.exists(root_path):
continue continue
# Track visited real paths to avoid symlink loops
visited_real_paths = set()
# Recursively scan directory # Recursively scan directory
for root, _, files in os.walk(root_path, followlinks=True): for root, _, files in os.walk(root_path, followlinks=True):
real_root = os.path.realpath(root) real_root = os.path.realpath(root)
@@ -753,12 +762,18 @@ class ModelScanner:
if ext in self.file_extensions: if ext in self.file_extensions:
# Construct paths exactly as they would be in cache # Construct paths exactly as they would be in cache
file_path = os.path.join(root, file).replace(os.sep, '/') file_path = os.path.join(root, file).replace(os.sep, '/')
real_file_path = os.path.realpath(os.path.join(root, file))
# Check if this file is already in cache # Check if this file is already in cache
if file_path in cached_paths: if file_path in cached_paths:
found_paths.add(file_path) found_paths.add(file_path)
continue continue
cached_real_match = cached_real_paths.get(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
continue
if file_path in self._excluded_models: if file_path in self._excluded_models:
continue continue
@@ -774,6 +789,10 @@ class ModelScanner:
if matched: if matched:
continue continue
if real_file_path in discovered_real_files:
continue
discovered_real_files.add(real_file_path)
# This is a new file to process # This is a new file to process
new_files.append(file_path) new_files.append(file_path)
@@ -1095,6 +1114,8 @@ class ModelScanner:
tags_count: Dict[str, int] = {} tags_count: Dict[str, int] = {}
excluded_models: List[str] = [] excluded_models: List[str] = []
processed_files = 0 processed_files = 0
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
async def handle_progress() -> None: async def handle_progress() -> None:
if progress_callback is None: if progress_callback is None:
@@ -1111,9 +1132,10 @@ class ModelScanner:
try: try:
real_path = os.path.realpath(current_path) real_path = os.path.realpath(current_path)
if real_path in visited_paths: if real_path in visited_paths or real_path in visited_real_dirs:
return return
visited_paths.add(real_path) visited_paths.add(real_path)
visited_real_dirs.add(real_path)
with os.scandir(current_path) as iterator: with os.scandir(current_path) as iterator:
entries = list(iterator) entries = list(iterator)
@@ -1126,6 +1148,11 @@ class ModelScanner:
continue continue
file_path = entry.path.replace(os.sep, "/") file_path = entry.path.replace(os.sep, "/")
real_file_path = os.path.realpath(entry.path)
if real_file_path in processed_real_files:
continue
processed_real_files.add(real_file_path)
result = await self._process_model_file( result = await self._process_model_file(
file_path, file_path,
root_path, root_path,
@@ -1438,12 +1465,11 @@ class ModelScanner:
if not file_path: if not file_path:
return None return None
base_name = os.path.splitext(file_path)[0] dir_path = os.path.dirname(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
for ext in PREVIEW_EXTENSIONS: preview_path = find_preview_file(base_name, dir_path)
preview_path = f"{base_name}{ext}" if preview_path:
if os.path.exists(preview_path): return config.get_preview_static_url(preview_path)
return config.get_preview_static_url(preview_path)
return None return None

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None return None
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None) settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"): if settings is not None and hasattr(settings, "get"):
try: try:
blur_mature_content = bool(settings.get("blur_mature_content", True)) blur_mature_content = bool(settings.get("blur_mature_content", True))
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings.get("mature_blur_level", "R")}
)
except Exception: # pragma: no cover - defensive guard except Exception: # pragma: no cover - defensive guard
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content) selected, _ = select_preview_media(
candidates,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not selected: if not selected:
return None return None

View File

@@ -56,6 +56,7 @@ class PersistentModelCache:
"exclude", "exclude",
"db_checked", "db_checked",
"last_checked_at", "last_checked_at",
"hash_status",
) )
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:] _MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {} _instances: Dict[str, "PersistentModelCache"] = {}
@@ -186,6 +187,7 @@ class PersistentModelCache:
"civitai_deleted": bool(row["civitai_deleted"]), "civitai_deleted": bool(row["civitai_deleted"]),
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]), "skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value), "license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
} }
raw_data.append(item) raw_data.append(item)
@@ -449,6 +451,7 @@ class PersistentModelCache:
exclude INTEGER, exclude INTEGER,
db_checked INTEGER, db_checked INTEGER,
last_checked_at REAL, last_checked_at REAL,
hash_status TEXT,
PRIMARY KEY (model_type, file_path) PRIMARY KEY (model_type, file_path)
); );
@@ -496,6 +499,7 @@ class PersistentModelCache:
"skip_metadata_refresh": "INTEGER DEFAULT 0", "skip_metadata_refresh": "INTEGER DEFAULT 0",
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57). # Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}", "license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
} }
for column, definition in required_columns.items(): for column, definition in required_columns.items():
@@ -570,6 +574,7 @@ class PersistentModelCache:
1 if item.get("exclude") else 0, 1 if item.get("exclude") else 0,
1 if item.get("db_checked") else 0, 1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0), float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
) )
def _insert_model_sql(self) -> str: def _insert_model_sql(self) -> str:

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
first_preview, nsfw_level = select_preview_media( first_preview, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
if not first_preview: if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type: if "webm" in content_type:
return ".webm" return ".webm"
return ".mp4" return ".mp4"

View File

@@ -4,6 +4,7 @@ from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@dataclass @dataclass
class RecipeCache: class RecipeCache:
"""Cache structure for Recipe data""" """Cache structure for Recipe data"""
@@ -21,11 +22,18 @@ class RecipeCache:
self.folder_tree = self.folder_tree or {} self.folder_tree = self.folder_tree or {}
async def resort(self, name_only: bool = False): async def resort(self, name_only: bool = False):
"""Resort all cached data views""" """Resort all cached data views in a thread pool to avoid blocking the event loop."""
async with self._lock: async with self._lock:
self._resort_locked(name_only=name_only) loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self._resort_locked,
name_only,
)
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict, *, resort: bool = True) -> bool: async def update_recipe_metadata(
self, recipe_id: str, metadata: Dict, *, resort: bool = True
) -> bool:
"""Update metadata for a specific recipe in all cached data """Update metadata for a specific recipe in all cached data
Args: Args:
@@ -37,7 +45,7 @@ class RecipeCache:
""" """
async with self._lock: async with self._lock:
for item in self.raw_data: for item in self.raw_data:
if str(item.get('id')) == str(recipe_id): if str(item.get("id")) == str(recipe_id):
item.update(metadata) item.update(metadata)
if resort: if resort:
self._resort_locked() self._resort_locked()
@@ -52,7 +60,9 @@ class RecipeCache:
if resort: if resort:
self._resort_locked() self._resort_locked()
async def remove_recipe(self, recipe_id: str, *, resort: bool = False) -> Optional[Dict]: async def remove_recipe(
self, recipe_id: str, *, resort: bool = False
) -> Optional[Dict]:
"""Remove a recipe from the cache by ID. """Remove a recipe from the cache by ID.
Args: Args:
@@ -64,14 +74,16 @@ class RecipeCache:
async with self._lock: async with self._lock:
for index, recipe in enumerate(self.raw_data): for index, recipe in enumerate(self.raw_data):
if str(recipe.get('id')) == str(recipe_id): if str(recipe.get("id")) == str(recipe_id):
removed = self.raw_data.pop(index) removed = self.raw_data.pop(index)
if resort: if resort:
self._resort_locked() self._resort_locked()
return removed return removed
return None return None
async def bulk_remove(self, recipe_ids: Iterable[str], *, resort: bool = False) -> List[Dict]: async def bulk_remove(
self, recipe_ids: Iterable[str], *, resort: bool = False
) -> List[Dict]:
"""Remove multiple recipes from the cache.""" """Remove multiple recipes from the cache."""
id_set = {str(recipe_id) for recipe_id in recipe_ids} id_set = {str(recipe_id) for recipe_id in recipe_ids}
@@ -79,21 +91,25 @@ class RecipeCache:
return [] return []
async with self._lock: async with self._lock:
removed = [item for item in self.raw_data if str(item.get('id')) in id_set] removed = [item for item in self.raw_data if str(item.get("id")) in id_set]
if not removed: if not removed:
return [] return []
self.raw_data = [item for item in self.raw_data if str(item.get('id')) not in id_set] self.raw_data = [
item for item in self.raw_data if str(item.get("id")) not in id_set
]
if resort: if resort:
self._resort_locked() self._resort_locked()
return removed return removed
async def replace_recipe(self, recipe_id: str, new_data: Dict, *, resort: bool = False) -> bool: async def replace_recipe(
self, recipe_id: str, new_data: Dict, *, resort: bool = False
) -> bool:
"""Replace cached data for a recipe.""" """Replace cached data for a recipe."""
async with self._lock: async with self._lock:
for index, recipe in enumerate(self.raw_data): for index, recipe in enumerate(self.raw_data):
if str(recipe.get('id')) == str(recipe_id): if str(recipe.get("id")) == str(recipe_id):
self.raw_data[index] = new_data self.raw_data[index] = new_data
if resort: if resort:
self._resort_locked() self._resort_locked()
@@ -105,7 +121,7 @@ class RecipeCache:
async with self._lock: async with self._lock:
for recipe in self.raw_data: for recipe in self.raw_data:
if str(recipe.get('id')) == str(recipe_id): if str(recipe.get("id")) == str(recipe_id):
return dict(recipe) return dict(recipe)
return None return None
@@ -115,16 +131,14 @@ class RecipeCache:
async with self._lock: async with self._lock:
return [dict(item) for item in self.raw_data] return [dict(item) for item in self.raw_data]
def _resort_locked(self, *, name_only: bool = False) -> None: def _resort_locked(self, name_only: bool = False) -> None:
"""Sort cached views. Caller must hold ``_lock``.""" """Sort cached views. Caller must hold ``_lock``."""
self.sorted_by_name = natsorted( self.sorted_by_name = natsorted(
self.raw_data, self.raw_data,
key=lambda x: x.get('title', '').lower() key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()),
) )
if not name_only: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(
self.raw_data, self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
key=itemgetter('created_date', 'file_path'),
reverse=True
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
"""Services responsible for recipe metadata analysis.""" """Services responsible for recipe metadata analysis."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -69,7 +70,9 @@ class RecipeAnalysisService:
try: try:
metadata = self._exif_utils.extract_image_metadata(temp_path) metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata: if not metadata:
return AnalysisResult({"error": "No metadata found in this image", "loras": []}) return AnalysisResult(
{"error": "No metadata found in this image", "loras": []}
)
return await self._parse_metadata( return await self._parse_metadata(
metadata, metadata,
@@ -105,7 +108,9 @@ class RecipeAnalysisService:
if civitai_match: if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
)
image_url = image_info.get("url") image_url = image_info.get("url")
if not image_url: if not image_url:
@@ -114,13 +119,15 @@ class RecipeAnalysisService:
is_video = image_info.get("type") == "video" is_video = image_info.get("type") == "video"
# Use optimized preview URLs if possible # Use optimized preview URLs if possible
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type")) rewritten_url, _ = rewrite_preview_url(
image_url, media_type=image_info.get("type")
)
if rewritten_url: if rewritten_url:
image_url = rewritten_url image_url = rewritten_url
if is_video: if is_video:
# Extract extension from URL # Extract extension from URL
url_path = image_url.split('?')[0].split('#')[0] url_path = image_url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower() or ".mp4" extension = os.path.splitext(url_path)[1].lower() or ".mp4"
else: else:
extension = ".jpg" extension = ".jpg"
@@ -135,9 +142,23 @@ class RecipeAnalysisService:
and isinstance(metadata["meta"], dict) and isinstance(metadata["meta"], dict)
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Include modelVersionIds from root level if available
# Civitai API returns modelVersionIds at root level, not in meta
model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
# Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
self._logger.debug(
"Civitai API metadata lacks recipe fields, will extract from EXIF"
)
metadata = None
else: else:
# Basic extension detection for non-Civitai URLs # Basic extension detection for non-Civitai URLs
url_path = url.split('?')[0].split('#')[0] url_path = url.split("?")[0].split("#")[0]
extension = os.path.splitext(url_path)[1].lower() extension = os.path.splitext(url_path)[1].lower()
if extension in [".mp4", ".webm"]: if extension in [".mp4", ".webm"]:
is_video = True is_video = True
@@ -211,7 +232,9 @@ class RecipeAnalysisService:
image_bytes = self._convert_tensor_to_png_bytes(latest_image) image_bytes = self._convert_tensor_to_png_bytes(latest_image)
if image_bytes is None: if image_bytes is None:
raise RecipeValidationError("Cannot handle this data shape from metadata registry") raise RecipeValidationError(
"Cannot handle this data shape from metadata registry"
)
return AnalysisResult( return AnalysisResult(
{ {
@@ -222,6 +245,22 @@ class RecipeAnalysisService:
# Internal helpers ------------------------------------------------- # Internal helpers -------------------------------------------------
def _has_recipe_fields(self, metadata: dict[str, Any]) -> bool:
"""Check if metadata contains meaningful recipe-related fields."""
recipe_fields = {
"prompt",
"negative_prompt",
"resources",
"hashes",
"params",
"generationData",
"Workflow",
"prompt_type",
"positive",
"negative",
}
return any(field in metadata for field in recipe_fields)
async def _parse_metadata( async def _parse_metadata(
self, self,
metadata: dict[str, Any], metadata: dict[str, Any],
@@ -234,7 +273,12 @@ class RecipeAnalysisService:
) -> AnalysisResult: ) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata) parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None: if parser is None:
payload = {"error": "No parser found for this image", "loras": []} # Provide more specific error message based on metadata source
if not metadata:
error_msg = "This image does not contain any generation metadata (prompt, models, or parameters)"
else:
error_msg = "No parser found for this image"
payload = {"error": error_msg, "loras": []}
if include_image_base64 and image_path: if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path) payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video payload["is_video"] = is_video
@@ -257,7 +301,9 @@ class RecipeAnalysisService:
matching_recipes: list[str] = [] matching_recipes: list[str] = []
if fingerprint: if fingerprint:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(fingerprint) matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
fingerprint
)
result["matching_recipes"] = matching_recipes result["matching_recipes"] = matching_recipes
return AnalysisResult(result) return AnalysisResult(result)
@@ -269,7 +315,10 @@ class RecipeAnalysisService:
raise RecipeDownloadError(f"Failed to download image from URL: {result}") raise RecipeDownloadError(f"Failed to download image from URL: {result}")
def _metadata_not_found_response(self, path: str) -> AnalysisResult: def _metadata_not_found_response(self, path: str) -> AnalysisResult:
payload: dict[str, Any] = {"error": "No metadata found in this image", "loras": []} payload: dict[str, Any] = {
"error": "No metadata found in this image",
"loras": [],
}
if os.path.exists(path): if os.path.exists(path):
payload["image_base64"] = self._encode_file(path) payload["image_base64"] = self._encode_file(path)
return AnalysisResult(payload) return AnalysisResult(payload)
@@ -305,7 +354,9 @@ class RecipeAnalysisService:
if hasattr(tensor_image, "shape"): if hasattr(tensor_image, "shape"):
self._logger.debug( self._logger.debug(
"Tensor shape: %s, dtype: %s", tensor_image.shape, getattr(tensor_image, "dtype", None) "Tensor shape: %s, dtype: %s",
tensor_image.shape,
getattr(tensor_image, "dtype", None),
) )
import torch # type: ignore[import-not-found] import torch # type: ignore[import-not-found]

View File

@@ -173,11 +173,23 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")): allowed_fields = (
"title",
"tags",
"source_path",
"preview_nsfw_level",
"favorite",
"gen_params",
)
if not any(key in updates for key in allowed_fields):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
) )
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
raise RecipeValidationError("gen_params must be an object")
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
if not success: if not success:
raise RecipeNotFoundError("Recipe not found or update failed") raise RecipeNotFoundError("Recipe not found or update failed")

View File

@@ -7,12 +7,31 @@ import logging
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from threading import Lock from threading import Lock
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from typing import (
Any,
Awaitable,
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Tuple,
)
from platformdirs import user_config_dir from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG from ..utils.constants import (
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
APP_NAME,
ensure_settings_file,
get_legacy_settings_path,
)
from ..utils.tag_priorities import ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
collect_canonical_tags, collect_canonical_tags,
@@ -54,10 +73,12 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"folder_paths": {}, "folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "", "example_images_path": "",
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
"blur_mature_content": True, "blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"card_info_display": "always", "card_info_display": "always",
@@ -70,6 +91,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"update_flag_strategy": "same_base", "update_flag_strategy": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
"download_skip_base_models": [],
} }
@@ -86,7 +108,9 @@ class SettingsManager:
self._template_payload_cache_loaded = False self._template_payload_cache_loaded = False
self._original_disk_payload: Optional[Dict[str, Any]] = None self._original_disk_payload: Optional[Dict[str, Any]] = None
self._preserve_disk_template = False self._preserve_disk_template = False
self._template_path = Path(__file__).resolve().parents[2] / "settings.json.example" self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example"
)
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -112,7 +136,7 @@ class SettingsManager:
"""Load settings from file""" """Load settings from file"""
if os.path.exists(self.settings_file): if os.path.exists(self.settings_file):
try: try:
with open(self.settings_file, 'r', encoding='utf-8') as f: with open(self.settings_file, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if isinstance(data, dict): if isinstance(data, dict):
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
@@ -190,7 +214,9 @@ class SettingsManager:
return None return None
if not isinstance(data, dict): if not isinstance(data, dict):
logger.debug("settings.json.example is not a JSON object; ignoring template") logger.debug(
"settings.json.example is not a JSON object; ignoring template"
)
return None return None
self._template_payload_cache = copy.deepcopy(data) self._template_payload_cache = copy.deepcopy(data)
@@ -266,13 +292,38 @@ class SettingsManager:
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths( normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
self.settings.get("metadata_refresh_skip_paths") self.settings.get("metadata_refresh_skip_paths")
) )
if normalized_skip_paths != self.settings.get("metadata_refresh_skip_paths"): if normalized_skip_paths != self.settings.get(
"metadata_refresh_skip_paths"
):
self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
updated_existing = True updated_existing = True
else: else:
self.settings["metadata_refresh_skip_paths"] = [] self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True inserted_defaults = True
if "download_skip_base_models" in self.settings:
normalized_skip_base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if normalized_skip_base_models != self.settings.get(
"download_skip_base_models"
):
self.settings["download_skip_base_models"] = normalized_skip_base_models
updated_existing = True
else:
self.settings["download_skip_base_models"] = []
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
if normalized_mature_level != raw_mature_level:
self.settings["mature_blur_level"] = normalized_mature_level
if had_mature_level:
updated_existing = True
else:
inserted_defaults = True
for key, value in defaults.items(): for key, value in defaults.items():
if key == "priority_tags": if key == "priority_tags":
continue continue
@@ -297,19 +348,19 @@ class SettingsManager:
raw_top_level_paths = self.settings.get("folder_paths", {}) raw_top_level_paths = self.settings.get("folder_paths", {})
normalized_top_level_paths: Dict[str, List[str]] = {} normalized_top_level_paths: Dict[str, List[str]] = {}
if isinstance(raw_top_level_paths, Mapping): if isinstance(raw_top_level_paths, Mapping):
normalized_top_level_paths = self._normalize_folder_paths(raw_top_level_paths) normalized_top_level_paths = self._normalize_folder_paths(
raw_top_level_paths
)
if normalized_top_level_paths != raw_top_level_paths: if normalized_top_level_paths != raw_top_level_paths:
self.settings["folder_paths"] = copy.deepcopy(normalized_top_level_paths) self.settings["folder_paths"] = copy.deepcopy(
normalized_top_level_paths
)
top_level_has_paths = self._has_configured_paths(normalized_top_level_paths) top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
if ( if not needs_library_bootstrap and top_level_has_paths and len(libraries) == 1:
not needs_library_bootstrap
and top_level_has_paths
and len(libraries) == 1
):
only_library_payload = next(iter(libraries.values())) only_library_payload = next(iter(libraries.values()))
if isinstance(only_library_payload, Mapping): if isinstance(only_library_payload, Mapping):
folder_payload = only_library_payload.get("folder_paths") folder_payload = only_library_payload.get("folder_paths")
@@ -321,7 +372,9 @@ class SettingsManager:
library_payload = self._build_library_payload( library_payload = self._build_library_payload(
folder_paths=normalized_top_level_paths, folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_root", ""), default_lora_root=self.settings.get("default_lora_root", ""),
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""), default_checkpoint_root=self.settings.get(
"default_checkpoint_root", ""
),
default_unet_root=self.settings.get("default_unet_root", ""), default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""),
) )
@@ -343,7 +396,11 @@ class SettingsManager:
if target_name: if target_name:
candidate_payload = libraries.get(target_name) candidate_payload = libraries.get(target_name)
if isinstance(candidate_payload, Mapping) and not self._has_configured_paths(candidate_payload.get("folder_paths")): if isinstance(
candidate_payload, Mapping
) and not self._has_configured_paths(
candidate_payload.get("folder_paths")
):
seed_library_name = target_name seed_library_name = target_name
sanitized_libraries: Dict[str, Dict[str, Any]] = {} sanitized_libraries: Dict[str, Dict[str, Any]] = {}
@@ -402,10 +459,17 @@ class SettingsManager:
active_library = libraries.get(active_name, {}) active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {})) folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = folder_paths self.settings["folder_paths"] = folder_paths
self.settings["extra_folder_paths"] = copy.deepcopy(
active_library.get("extra_folder_paths", {})
)
self.settings["default_lora_root"] = active_library.get("default_lora_root", "") self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "") self.settings["default_checkpoint_root"] = active_library.get(
"default_checkpoint_root", ""
)
self.settings["default_unet_root"] = active_library.get("default_unet_root", "") self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "") self.settings["default_embedding_root"] = active_library.get(
"default_embedding_root", ""
)
if save: if save:
self._save_settings() self._save_settings()
@@ -417,6 +481,7 @@ class SettingsManager:
self, self,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -432,6 +497,13 @@ class SettingsManager:
else: else:
payload.setdefault("folder_paths", {}) payload.setdefault("folder_paths", {})
if extra_folder_paths is not None:
payload["extra_folder_paths"] = self._normalize_folder_paths(
extra_folder_paths
)
else:
payload.setdefault("extra_folder_paths", {})
if default_lora_root is not None: if default_lora_root is not None:
payload["default_lora_root"] = default_lora_root payload["default_lora_root"] = default_lora_root
else: else:
@@ -537,7 +609,9 @@ class SettingsManager:
} }
overlap = existing.intersection(new_paths.keys()) overlap = existing.intersection(new_paths.keys())
if overlap: if overlap:
collisions = ", ".join(sorted(new_paths[value] for value in overlap)) collisions = ", ".join(
sorted(new_paths[value] for value in overlap)
)
raise ValueError( raise ValueError(
f"Folder path(s) {collisions} already assigned to library '{other_name}'" f"Folder path(s) {collisions} already assigned to library '{other_name}'"
) )
@@ -546,6 +620,7 @@ class SettingsManager:
self, self,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -565,19 +640,37 @@ class SettingsManager:
library["folder_paths"] = normalized_paths library["folder_paths"] = normalized_paths
changed = True changed = True
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root: if extra_folder_paths is not None:
normalized_extra_paths = self._normalize_folder_paths(extra_folder_paths)
if library.get("extra_folder_paths") != normalized_extra_paths:
library["extra_folder_paths"] = normalized_extra_paths
changed = True
if (
default_lora_root is not None
and library.get("default_lora_root") != default_lora_root
):
library["default_lora_root"] = default_lora_root library["default_lora_root"] = default_lora_root
changed = True changed = True
if default_checkpoint_root is not None and library.get("default_checkpoint_root") != default_checkpoint_root: if (
default_checkpoint_root is not None
and library.get("default_checkpoint_root") != default_checkpoint_root
):
library["default_checkpoint_root"] = default_checkpoint_root library["default_checkpoint_root"] = default_checkpoint_root
changed = True changed = True
if default_unet_root is not None and library.get("default_unet_root") != default_unet_root: if (
default_unet_root is not None
and library.get("default_unet_root") != default_unet_root
):
library["default_unet_root"] = default_unet_root library["default_unet_root"] = default_unet_root
changed = True changed = True
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root: if (
default_embedding_root is not None
and library.get("default_embedding_root") != default_embedding_root
):
library["default_embedding_root"] = default_embedding_root library["default_embedding_root"] = default_embedding_root
changed = True changed = True
@@ -590,15 +683,16 @@ class SettingsManager:
def _migrate_setting_keys(self) -> None: def _migrate_setting_keys(self) -> None:
"""Migrate legacy camelCase setting keys to snake_case""" """Migrate legacy camelCase setting keys to snake_case"""
key_migrations = { key_migrations = {
'optimizeExampleImages': 'optimize_example_images', "optimizeExampleImages": "optimize_example_images",
'autoDownloadExampleImages': 'auto_download_example_images', "autoDownloadExampleImages": "auto_download_example_images",
'blurMatureContent': 'blur_mature_content', "blurMatureContent": "blur_mature_content",
'autoplayOnHover': 'autoplay_on_hover', "matureBlurLevel": "mature_blur_level",
'displayDensity': 'display_density', "autoplayOnHover": "autoplay_on_hover",
'cardInfoDisplay': 'card_info_display', "displayDensity": "display_density",
'includeTriggerWords': 'include_trigger_words', "cardInfoDisplay": "card_info_display",
'compactMode': 'compact_mode', "includeTriggerWords": "include_trigger_words",
'modelCardFooterAction': 'model_card_footer_action', "compactMode": "compact_mode",
"modelCardFooterAction": "model_card_footer_action",
} }
updated = False updated = False
@@ -615,65 +709,77 @@ class SettingsManager:
def _migrate_download_path_template(self): def _migrate_download_path_template(self):
"""Migrate old download_path_template to new download_path_templates""" """Migrate old download_path_template to new download_path_templates"""
old_template = self.settings.get('download_path_template') old_template = self.settings.get("download_path_template")
templates = self.settings.get('download_path_templates') templates = self.settings.get("download_path_templates")
# If old template exists and new templates don't exist, migrate # If old template exists and new templates don't exist, migrate
if old_template is not None and not templates: if old_template is not None and not templates:
logger.info("Migrating download_path_template to download_path_templates") logger.info("Migrating download_path_template to download_path_templates")
self.settings['download_path_templates'] = { self.settings["download_path_templates"] = {
'lora': old_template, "lora": old_template,
'checkpoint': old_template, "checkpoint": old_template,
'embedding': old_template "embedding": old_template,
} }
# Remove old setting # Remove old setting
del self.settings['download_path_template'] del self.settings["download_path_template"]
self._save_settings() self._save_settings()
logger.info("Migration completed") logger.info("Migration completed")
def _auto_set_default_roots(self): def _auto_set_default_roots(self):
"""Auto set default root paths when the current default is unset or not among the options. """Ensure default root paths always point at a current valid root.
For single-path cases, always use that path. Empty or stale defaults are repaired to the first configured root.
For multi-path cases, only set if current default is empty or invalid. Skips auto-setting when the settings file matches the template
(user hasn't customized yet).
""" """
folder_paths = self.settings.get('folder_paths', {}) # Skip auto-setting if the user hasn't customized settings yet (template preserved)
if self._preserve_disk_template:
return
folder_paths = self.settings.get("folder_paths", {})
updated = False updated = False
# loras
loras = folder_paths.get('loras', []) def _check_and_auto_set(key: str, setting_key: str) -> bool:
if isinstance(loras, list) and len(loras) == 1: """Repair default roots when empty or no longer present."""
current_lora_root = self.settings.get('default_lora_root') current = self.settings.get(setting_key, "")
if current_lora_root not in loras: candidates = folder_paths.get(key, [])
self.settings['default_lora_root'] = loras[0] if not isinstance(candidates, list) or not candidates:
updated = True return False
# checkpoints
checkpoints = folder_paths.get('checkpoints', []) # Filter valid path strings
if isinstance(checkpoints, list) and len(checkpoints) == 1: valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
current_checkpoint_root = self.settings.get('default_checkpoint_root') if not valid_paths:
if current_checkpoint_root not in checkpoints: return False
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True if current in valid_paths:
# unet (diffusion models) - auto-set if empty or invalid return False
unet_paths = folder_paths.get('unet', [])
if isinstance(unet_paths, list) and len(unet_paths) >= 1: self.settings[setting_key] = valid_paths[0]
current_unet_root = self.settings.get('default_unet_root') if current:
# Set to first path if current is empty or not in the valid paths logger.info(
if not current_unet_root or current_unet_root not in unet_paths: "Repaired stale %s from '%s' to '%s'",
self.settings['default_unet_root'] = unet_paths[0] setting_key,
updated = True current,
# embeddings valid_paths[0],
embeddings = folder_paths.get('embeddings', []) )
if isinstance(embeddings, list) and len(embeddings) == 1: else:
current_embedding_root = self.settings.get('default_embedding_root') logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
if current_embedding_root not in embeddings: return True
self.settings['default_embedding_root'] = embeddings[0]
updated = True # Process all model types
updated = _check_and_auto_set("loras", "default_lora_root") or updated
updated = (
_check_and_auto_set("checkpoints", "default_checkpoint_root") or updated
)
updated = _check_and_auto_set("unet", "default_unet_root") or updated
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
if updated: if updated:
self._update_active_library_entry( self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_root'), default_lora_root=self.settings.get("default_lora_root"),
default_checkpoint_root=self.settings.get('default_checkpoint_root'), default_checkpoint_root=self.settings.get("default_checkpoint_root"),
default_unet_root=self.settings.get('default_unet_root'), default_unet_root=self.settings.get("default_unet_root"),
default_embedding_root=self.settings.get('default_embedding_root'), default_embedding_root=self.settings.get("default_embedding_root"),
) )
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
self._needs_initial_save = True self._needs_initial_save = True
@@ -682,11 +788,11 @@ class SettingsManager:
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
env_api_key = os.environ.get('CIVITAI_API_KEY') env_api_key = os.environ.get("CIVITAI_API_KEY")
if env_api_key: # Check if the environment variable exists and is not empty if env_api_key: # Check if the environment variable exists and is not empty
logger.info("Found CIVITAI_API_KEY environment variable") logger.info("Found CIVITAI_API_KEY environment variable")
# Always use the environment variable if it exists # Always use the environment variable if it exists
self.settings['civitai_api_key'] = env_api_key self.settings["civitai_api_key"] = env_api_key
self._save_settings() self._save_settings()
def _default_settings_actions(self) -> List[Dict[str, Any]]: def _default_settings_actions(self) -> List[Dict[str, Any]]:
@@ -751,7 +857,9 @@ class SettingsManager:
disk_value = self._original_disk_payload.get(key) disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key) default_value = defaults.get(key)
# Compare using JSON serialization for complex objects # Compare using JSON serialization for complex objects
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str): if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(
default_value, sort_keys=True, default=str
):
default_value_keys.add(key) default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file) # Only cleanup if there are "many" default keys (indicating a bloated file)
@@ -759,7 +867,7 @@ class SettingsManager:
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD: if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info( logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal", "Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys) len(default_value_keys),
) )
self._save_settings() self._save_settings()
# Update original payload to match what we just saved # Update original payload to match what we just saved
@@ -769,8 +877,8 @@ class SettingsManager:
if not self._standalone_mode: if not self._standalone_mode:
return return
folder_paths = self.settings.get('folder_paths', {}) or {} folder_paths = self.settings.get("folder_paths", {}) or {}
monitored_keys = ('loras', 'checkpoints', 'embeddings') monitored_keys = ("loras", "checkpoints", "embeddings")
has_valid_paths = False has_valid_paths = False
for key in monitored_keys: for key in monitored_keys:
@@ -781,7 +889,10 @@ class SettingsManager:
iterator = list(raw_paths) iterator = list(raw_paths)
except TypeError: except TypeError:
continue continue
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator): if any(
isinstance(path, str) and path and os.path.exists(path)
for path in iterator
):
has_valid_paths = True has_valid_paths = True
break break
@@ -812,22 +923,24 @@ class SettingsManager:
def _get_default_settings(self) -> Dict[str, Any]: def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings""" """Return default settings"""
defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults['base_model_path_mappings'] = {} defaults["base_model_path_mappings"] = {}
defaults['download_path_templates'] = {} defaults["download_path_templates"] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {}) defaults.setdefault("folder_paths", {})
defaults['auto_organize_exclusions'] = [] defaults.setdefault("extra_folder_paths", {})
defaults['metadata_refresh_skip_paths'] = [] defaults["auto_organize_exclusions"] = []
defaults["metadata_refresh_skip_paths"] = []
library_name = defaults.get("active_library") or "default" library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload( default_library = self._build_library_payload(
folder_paths=defaults.get("folder_paths", {}), folder_paths=defaults.get("folder_paths", {}),
extra_folder_paths=defaults.get("extra_folder_paths", {}),
default_lora_root=defaults.get("default_lora_root"), default_lora_root=defaults.get("default_lora_root"),
default_checkpoint_root=defaults.get("default_checkpoint_root"), default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"), default_embedding_root=defaults.get("default_embedding_root"),
) )
defaults['libraries'] = {library_name: default_library} defaults["libraries"] = {library_name: default_library}
defaults['active_library'] = library_name defaults["active_library"] = library_name
return defaults return defaults
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]: def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
@@ -843,6 +956,13 @@ class SettingsManager:
return normalized return normalized
def normalize_mature_blur_level(self, value: Any) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in VALID_MATURE_BLUR_LEVELS:
return normalized
return "R"
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]: def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None: if value is None:
return [] return []
@@ -851,7 +971,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") value.replace("\n", ",").replace(";", ",").split(",")
) )
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value candidates = value
else: else:
return [] return []
@@ -897,7 +1019,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") value.replace("\n", ",").replace(";", ",").split(",")
) )
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value candidates = value
else: else:
return [] return []
@@ -927,6 +1051,142 @@ class SettingsManager:
self._save_settings() self._save_settings()
return skip_paths return skip_paths
def normalize_download_skip_base_models(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value
else:
return []
base_models: List[str] = []
seen = set()
for raw in candidates:
if not isinstance(raw, str):
continue
token = raw.strip()
if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS:
continue
if token in seen:
continue
seen.add(token)
base_models.append(token)
return base_models
def get_download_skip_base_models(self) -> List[str]:
base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if base_models != self.settings.get("download_skip_base_models"):
self.settings["download_skip_base_models"] = base_models
self._save_settings()
return base_models
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
"""Get extra folder paths for the active library.
These paths are only used by LoRA Manager and not shared with ComfyUI.
Returns a dictionary with keys like 'loras', 'checkpoints', 'embeddings', 'unet'.
"""
extra_paths = self.settings.get("extra_folder_paths", {})
if not isinstance(extra_paths, dict):
return {}
return self._normalize_folder_paths(extra_paths)
def update_extra_folder_paths(
self,
extra_folder_paths: Mapping[str, Iterable[str]],
) -> None:
"""Update extra folder paths for the active library.
These paths are only used by LoRA Manager and not shared with ComfyUI.
Validates that extra paths don't overlap with other libraries' paths.
"""
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, extra_folder_paths)
active_library = self.get_active_library()
active_folder_paths = active_library.get("folder_paths", {})
active_lora_paths = active_folder_paths.get("loras", []) or []
requested_extra_lora_paths = extra_folder_paths.get("loras", []) or []
primary_real_paths = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(
os.path.normpath(os.path.realpath(stripped))
)
primary_real_paths.add(normalized)
primary_symlink_targets = set()
for path in active_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped or not os.path.isdir(stripped):
continue
try:
with os.scandir(stripped) as iterator:
for entry in iterator:
try:
if not entry.is_symlink():
continue
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
primary_symlink_targets.add(
os.path.normcase(os.path.normpath(target_path))
)
except Exception:
continue
except Exception:
continue
overlapping_paths = []
for path in requested_extra_lora_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped):
normalized = os.path.normcase(
os.path.normpath(os.path.realpath(stripped))
)
if (
normalized in primary_real_paths
or normalized in primary_symlink_targets
):
overlapping_paths.append(stripped)
if overlapping_paths:
collisions = ", ".join(sorted(set(overlapping_paths)))
# Settings writes should reject new conflicting configuration instead of tolerating it.
raise ValueError(
f"Extra LoRA path(s) {collisions} overlap with the active library's primary LoRA roots"
)
normalized_paths = self._normalize_folder_paths(extra_folder_paths)
self.settings["extra_folder_paths"] = normalized_paths
self._update_active_library_entry(extra_folder_paths=normalized_paths)
self._save_settings()
logger.info("Updated extra folder paths for library '%s'", active_name)
def get_startup_messages(self) -> List[Dict[str, Any]]: def get_startup_messages(self) -> List[Dict[str, Any]]:
return [message.copy() for message in self._startup_messages] return [message.copy() for message in self._startup_messages]
@@ -966,22 +1226,28 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value) value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths": elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value) value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "download_skip_base_models":
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):
portable_switch_pending = True portable_switch_pending = True
self._prepare_portable_switch(value) self._prepare_portable_switch(value)
if key == 'folder_paths' and isinstance(value, Mapping): if key == "folder_paths" and isinstance(value, Mapping):
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == 'default_lora_root': elif key == "extra_folder_paths" and isinstance(value, Mapping):
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == "default_lora_root":
self._update_active_library_entry(default_lora_root=str(value)) self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root': elif key == "default_checkpoint_root":
self._update_active_library_entry(default_checkpoint_root=str(value)) self._update_active_library_entry(default_checkpoint_root=str(value))
elif key == 'default_unet_root': elif key == "default_unet_root":
self._update_active_library_entry(default_unet_root=str(value)) self._update_active_library_entry(default_unet_root=str(value))
elif key == 'default_embedding_root': elif key == "default_embedding_root":
self._update_active_library_entry(default_embedding_root=str(value)) self._update_active_library_entry(default_embedding_root=str(value))
elif key == 'model_name_display': elif key == "model_name_display":
self._notify_model_name_display_change(value) self._notify_model_name_display_change(value)
self._save_settings() self._save_settings()
if portable_switch_pending: if portable_switch_pending:
@@ -1057,10 +1323,9 @@ class SettingsManager:
source_cache_dir = os.path.join(source_dir, "model_cache") source_cache_dir = os.path.join(source_dir, "model_cache")
target_cache_dir = os.path.join(target_dir, "model_cache") target_cache_dir = os.path.join(target_dir, "model_cache")
if ( if os.path.isdir(source_cache_dir) and os.path.abspath(
os.path.isdir(source_cache_dir) source_cache_dir
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir) ) != os.path.abspath(target_cache_dir):
):
try: try:
shutil.copytree( shutil.copytree(
source_cache_dir, source_cache_dir,
@@ -1078,10 +1343,9 @@ class SettingsManager:
source_cache_file = os.path.join(source_dir, "model_cache.sqlite") source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
target_cache_file = os.path.join(target_dir, "model_cache.sqlite") target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
if ( if os.path.isfile(source_cache_file) and os.path.abspath(
os.path.isfile(source_cache_file) source_cache_file
and os.path.abspath(source_cache_file) != os.path.abspath(target_cache_file) ) != os.path.abspath(target_cache_file):
):
try: try:
shutil.copy2(source_cache_file, target_cache_file) shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc: except Exception as exc:
@@ -1107,7 +1371,9 @@ class SettingsManager:
try: try:
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
except Exception as exc: except Exception as exc:
logger.warning("Failed to create user config directory %s: %s", config_dir, exc) logger.warning(
"Failed to create user config directory %s: %s", config_dir, exc
)
return config_dir return config_dir
@@ -1167,7 +1433,9 @@ class SettingsManager:
try: try:
asyncio.run(coroutine) asyncio.run(coroutine)
except RuntimeError: except RuntimeError:
logger.debug("Skipping name display update due to missing event loop") logger.debug(
"Skipping name display update due to missing event loop"
)
continue continue
if loop is not None and target_loop is loop: if loop is not None and target_loop is loop:
@@ -1190,7 +1458,7 @@ class SettingsManager:
"""Save settings to file""" """Save settings to file"""
try: try:
payload = self._serialize_settings_for_disk() payload = self._serialize_settings_for_disk()
with open(self.settings_file, 'w', encoding='utf-8') as f: with open(self.settings_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2) json.dump(payload, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
@@ -1231,7 +1499,9 @@ class SettingsManager:
minimal[key] = copy.deepcopy(value) minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison # Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None: elif isinstance(value, (dict, list)) and default_value is not None:
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str): if json.dumps(value, sort_keys=True, default=str) != json.dumps(
default_value, sort_keys=True, default=str
):
minimal[key] = copy.deepcopy(value) minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison # Simple values use direct comparison
elif value != default_value: elif value != default_value:
@@ -1284,6 +1554,7 @@ class SettingsManager:
library_name: str, library_name: str,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -1300,12 +1571,22 @@ class SettingsManager:
if folder_paths is not None: if folder_paths is not None:
self._validate_folder_paths(name, folder_paths) self._validate_folder_paths(name, folder_paths)
if extra_folder_paths is not None:
self._validate_folder_paths(name, extra_folder_paths)
libraries = self.settings.setdefault("libraries", {}) libraries = self.settings.setdefault("libraries", {})
existing = libraries.get(name, {}) existing = libraries.get(name, {})
payload = self._build_library_payload( payload = self._build_library_payload(
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"), folder_paths=folder_paths
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"), if folder_paths is not None
else existing.get("folder_paths"),
extra_folder_paths=extra_folder_paths
if extra_folder_paths is not None
else existing.get("extra_folder_paths"),
default_lora_root=default_lora_root
if default_lora_root is not None
else existing.get("default_lora_root"),
default_checkpoint_root=( default_checkpoint_root=(
default_checkpoint_root default_checkpoint_root
if default_checkpoint_root is not None if default_checkpoint_root is not None
@@ -1343,6 +1624,7 @@ class SettingsManager:
library_name: str, library_name: str,
*, *,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: str = "", default_lora_root: str = "",
default_checkpoint_root: str = "", default_checkpoint_root: str = "",
default_unet_root: str = "", default_unet_root: str = "",
@@ -1359,6 +1641,7 @@ class SettingsManager:
return self.upsert_library( return self.upsert_library(
library_name, library_name,
folder_paths=folder_paths, folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,
@@ -1417,6 +1700,7 @@ class SettingsManager:
self, self,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
*, *,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -1428,6 +1712,7 @@ class SettingsManager:
self.upsert_library( self.upsert_library(
active_name, active_name,
folder_paths=folder_paths, folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,
@@ -1461,7 +1746,9 @@ class SettingsManager:
if service and hasattr(service, "on_library_changed"): if service and hasattr(service, "on_library_changed"):
try: try:
service.on_library_changed() service.on_library_changed()
except Exception as service_exc: # pragma: no cover - defensive logging except (
Exception
) as service_exc: # pragma: no cover - defensive logging
logger.debug( logger.debug(
"Service %s failed to handle library change: %s", "Service %s failed to handle library change: %s",
service_name, service_name,
@@ -1479,7 +1766,7 @@ class SettingsManager:
Returns: Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}' Template string for the model type, defaults to '{base_model}/{first_tag}'
""" """
templates = self.settings.get('download_path_templates', {}) templates = self.settings.get("download_path_templates", {})
# Handle edge case where templates might be stored as JSON string # Handle edge case where templates might be stored as JSON string
if isinstance(templates, str): if isinstance(templates, str):
@@ -1488,36 +1775,40 @@ class SettingsManager:
parsed_templates = json.loads(templates) parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict): if isinstance(parsed_templates, dict):
# Update settings with parsed dictionary # Update settings with parsed dictionary
self.settings['download_path_templates'] = parsed_templates self.settings["download_path_templates"] = parsed_templates
self._save_settings() self._save_settings()
templates = parsed_templates templates = parsed_templates
logger.info("Successfully parsed download_path_templates from JSON string") logger.info(
"Successfully parsed download_path_templates from JSON string"
)
else: else:
raise ValueError("Parsed JSON is not a dictionary") raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
# If parsing fails, set default values # If parsing fails, set default values
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.") logger.warning(
default_template = '{base_model}/{first_tag}' f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
)
default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() self._save_settings()
# Ensure templates is a dictionary # Ensure templates is a dictionary
if not isinstance(templates, dict): if not isinstance(templates, dict):
default_template = '{base_model}/{first_tag}' default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() self._save_settings()
return templates.get(model_type, '{base_model}/{first_tag}') return templates.get(model_type, "{base_model}/{first_tag}")
_SETTINGS_MANAGER: Optional["SettingsManager"] = None _SETTINGS_MANAGER: Optional["SettingsManager"] = None

View File

@@ -69,7 +69,9 @@ class TagFTSIndex:
_DEFAULT_FILENAME = "tag_fts.sqlite" _DEFAULT_FILENAME = "tag_fts.sqlite"
_CSV_FILENAME = "danbooru_e621_merged.csv" _CSV_FILENAME = "danbooru_e621_merged.csv"
def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None: def __init__(
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
) -> None:
"""Initialize the FTS index. """Initialize the FTS index.
Args: Args:
@@ -92,7 +94,9 @@ class TagFTSIndex:
if directory: if directory:
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
except Exception as exc: except Exception as exc:
logger.warning("Could not create FTS index directory %s: %s", directory, exc) logger.warning(
"Could not create FTS index directory %s: %s", directory, exc
)
def _resolve_default_db_path(self) -> str: def _resolve_default_db_path(self) -> str:
"""Resolve the default database path.""" """Resolve the default database path."""
@@ -173,13 +177,15 @@ class TagFTSIndex:
# Set schema version # Set schema version
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)) ("schema_version", str(SCHEMA_VERSION)),
) )
conn.commit() conn.commit()
self._schema_initialized = True self._schema_initialized = True
self._needs_rebuild = needs_rebuild self._needs_rebuild = needs_rebuild
logger.debug("Tag FTS index schema initialized at %s", self._db_path) logger.debug(
"Tag FTS index schema initialized at %s", self._db_path
)
finally: finally:
conn.close() conn.close()
except Exception as exc: except Exception as exc:
@@ -206,13 +212,20 @@ class TagFTSIndex:
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
# Old schema without version, needs rebuild # Old schema without version, needs rebuild
logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION) logger.info(
"Migrating tag FTS index to schema version %d (adding alias support)",
SCHEMA_VERSION,
)
self._drop_old_tables(conn) self._drop_old_tables(conn)
return True return True
current_version = int(row[0]) current_version = int(row[0])
if current_version < SCHEMA_VERSION: if current_version < SCHEMA_VERSION:
logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION) logger.info(
"Migrating tag FTS index from version %d to %d",
current_version,
SCHEMA_VERSION,
)
self._drop_old_tables(conn) self._drop_old_tables(conn)
return True return True
@@ -246,7 +259,9 @@ class TagFTSIndex:
return return
if not os.path.exists(self._csv_path): if not os.path.exists(self._csv_path):
logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path) logger.warning(
"CSV file not found at %s, cannot build tag index", self._csv_path
)
return return
self._indexing_in_progress = True self._indexing_in_progress = True
@@ -314,22 +329,24 @@ class TagFTSIndex:
# Update metadata # Update metadata
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("last_build_time", str(time.time())) ("last_build_time", str(time.time())),
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("tag_count", str(total_inserted)) ("tag_count", str(total_inserted)),
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)) ("schema_version", str(SCHEMA_VERSION)),
) )
conn.commit() conn.commit()
elapsed = time.time() - start_time elapsed = time.time() - start_time
logger.info( logger.info(
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs", "Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
total_inserted, tags_with_aliases, elapsed total_inserted,
tags_with_aliases,
elapsed,
) )
finally: finally:
conn.close() conn.close()
@@ -350,7 +367,7 @@ class TagFTSIndex:
# Insert into tags table (with aliases) # Insert into tags table (with aliases)
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)", "INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
rows rows,
) )
# Build a map of tag_name -> aliases for FTS insertion # Build a map of tag_name -> aliases for FTS insertion
@@ -362,7 +379,7 @@ class TagFTSIndex:
placeholders = ",".join("?" * len(tag_names)) placeholders = ",".join("?" * len(tag_names))
cursor = conn.execute( cursor = conn.execute(
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})", f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
tag_names tag_names,
) )
# Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...") # Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...")
@@ -379,13 +396,17 @@ class TagFTSIndex:
alias = alias[1:] # Remove leading slash alias = alias[1:] # Remove leading slash
if alias: if alias:
alias_parts.append(alias) alias_parts.append(alias)
searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name searchable_text = (
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
)
else: else:
searchable_text = tag_name searchable_text = tag_name
fts_rows.append((rowid, searchable_text)) fts_rows.append((rowid, searchable_text))
if fts_rows: if fts_rows:
conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows) conn.executemany(
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
)
def ensure_ready(self) -> bool: def ensure_ready(self) -> bool:
"""Ensure the index is ready, building if necessary. """Ensure the index is ready, building if necessary.
@@ -420,21 +441,28 @@ class TagFTSIndex:
self, self,
query: str, query: str,
categories: Optional[List[int]] = None, categories: Optional[List[int]] = None,
limit: int = 20 limit: int = 20,
offset: int = 0,
) -> List[Dict]: ) -> List[Dict]:
"""Search tags using FTS5 with prefix matching. """Search tags using FTS5 with prefix matching.
Supports alias search: if the query matches an alias rather than Supports alias search: if the query matches an alias rather than
the tag_name, the result will include a "matched_alias" field. the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches)
2. Post count (popularity)
3. Exact prefix match boost (tag_name starts with query)
Args: Args:
query: The search query string. query: The search query string.
categories: Optional list of category IDs to filter by. categories: Optional list of category IDs to filter by.
limit: Maximum number of results to return. limit: Maximum number of results to return.
offset: Number of results to skip.
Returns: Returns:
List of dictionaries with tag_name, category, post_count, List of dictionaries with tag_name, category, post_count,
and optionally matched_alias. rank_score, and optionally matched_alias.
""" """
# Ensure index is ready (lazy initialization) # Ensure index is ready (lazy initialization)
if not self.ensure_ready(): if not self.ensure_ready():
@@ -450,35 +478,67 @@ class TagFTSIndex:
if not fts_query: if not fts_query:
return [] return []
query_lower = query.lower().strip()
try: try:
with self._lock: with self._lock:
conn = self._connect(readonly=True) conn = self._connect(readonly=True)
try: try:
# Build the SQL query - now also fetch aliases for matched_alias detection # Build the SQL query with bm25 ranking
# Use subquery for category filter to ensure FTS is evaluated first # FTS5 bm25() returns negative scores, lower is better
# We use -bm25() to get higher=better scores
# Weights: -100.0 for exact matches, 1.0 for others
# Add LOG10(post_count) weighting to boost popular tags
# Use CASE to boost tag_name prefix matches above alias matches
if categories: if categories:
placeholders = ",".join("?" * len(categories)) placeholders = ",".join("?" * len(categories))
sql = f""" sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases SELECT t.tag_name, t.category, t.post_count, t.aliases,
FROM tags t CASE
WHERE t.rowid IN ( WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
SELECT rowid FROM tag_fts WHERE searchable_text MATCH ? ELSE 0
) END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders}) AND t.category IN ({placeholders})
ORDER BY t.post_count DESC ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? LIMIT ? OFFSET ?
""" """
params = [fts_query] + categories + [limit] # Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = (
[query_escaped + "%", fts_query]
+ categories
+ [limit, offset]
)
else: else:
sql = """ sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases SELECT t.tag_name, t.category, t.post_count, t.aliases,
FROM tag_fts f CASE
JOIN tags t ON f.rowid = t.rowid WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
WHERE f.searchable_text MATCH ? ELSE 0
ORDER BY t.post_count DESC END AS is_tag_name_match,
LIMIT ? bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
""" """
params = [fts_query, limit] query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = [query_escaped + "%", fts_query, limit, offset]
cursor = conn.execute(sql, params) cursor = conn.execute(sql, params)
results = [] results = []
@@ -487,8 +547,17 @@ class TagFTSIndex:
"tag_name": row[0], "tag_name": row[0],
"category": row[1], "category": row[1],
"post_count": row[2], "post_count": row[2],
"is_tag_name_match": row[4] == 1,
"rank_score": row[5],
} }
# Set is_exact_prefix based on tag_name match
tag_name = row[0]
if tag_name.lower().startswith(query_lower.lstrip("/")):
result["is_exact_prefix"] = True
else:
result["is_exact_prefix"] = result["is_tag_name_match"]
# Check if search matched an alias rather than the tag_name # Check if search matched an alias rather than the tag_name
matched_alias = self._find_matched_alias(query, row[0], row[3]) matched_alias = self._find_matched_alias(query, row[0], row[3])
if matched_alias: if matched_alias:
@@ -502,7 +571,9 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc) logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return [] return []
def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]: def _find_matched_alias(
self, query: str, tag_name: str, aliases_str: str
) -> Optional[str]:
"""Find which alias matched the query, if any. """Find which alias matched the query, if any.
Args: Args:

View File

@@ -47,8 +47,7 @@ class BulkMetadataRefreshUseCase:
to_process: Sequence[Dict[str, Any]] = [ to_process: Sequence[Dict[str, Any]] = [
model model
for model in cache.raw_data for model in cache.raw_data
if model.get("sha256") if not model.get("skip_metadata_refresh", False)
and not model.get("skip_metadata_refresh", False)
and not self._is_in_skip_path(model.get("folder", ""), skip_paths) and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id")) and (not model.get("civitai") or not model["civitai"].get("id"))
and not ( and not (
@@ -85,6 +84,36 @@ class BulkMetadataRefreshUseCase:
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models} return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
# Handle lazy hash calculation for models with pending hash status
sha256 = model.get("sha256", "")
hash_status = model.get("hash_status", "completed")
file_path = model.get("file_path")
if not sha256 and hash_status == "pending" and file_path:
self._logger.info(f"Calculating pending hash for {file_path}")
# Check if scanner has calculate_hash_for_model method (CheckpointScanner)
calculate_hash_method = getattr(self._service.scanner, "calculate_hash_for_model", None)
if calculate_hash_method:
sha256 = await calculate_hash_method(file_path)
if sha256:
model["sha256"] = sha256
model["hash_status"] = "completed"
else:
self._logger.error(f"Failed to calculate hash for {file_path}")
processed += 1
continue
else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
processed += 1
continue
# Skip models without valid hash
if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}")
processed += 1
continue
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model( result, _ = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"], sha256=model["sha256"],

View File

@@ -110,6 +110,71 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B", "Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V", "Wan Video 2.5 T2V",
"Wan Video 2.5 I2V", "Wan Video 2.5 I2V",
"CogVideoX",
"Mochi",
"Qwen", "Qwen",
] ]
) )
# 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(
[
"SD 1.4",
"SD 1.5",
"SD 1.5 LCM",
"SD 1.5 Hyper",
"SD 2.0",
"SD 2.1",
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
"SDXL 1.0",
"SDXL Lightning",
"SDXL Hyper",
"Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
"AuraFlow",
"Chroma",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Illustrious",
"Pony",
"Pony V7",
"HiDream",
"Qwen",
"ZImageTurbo",
"ZImageBase",
"SVD",
"LTXV",
"LTXV2",
"LTXV 2.3",
"CogVideoX",
"Mochi",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
"Hunyuan Video",
"Anima",
]
)

View File

@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
return sha256_hash.hexdigest() return sha256_hash.hexdigest()
def find_preview_file(base_name: str, dir_path: str) -> str: def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory""" """Find preview file for given base name in directory.
Performs an exact-case check first (fast path), then falls back to a
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
are discovered on case-sensitive filesystems.
"""
temp_extensions = PREVIEW_EXTENSIONS.copy() temp_extensions = PREVIEW_EXTENSIONS.copy()
# Add example extension for compatibility # Add example extension for compatibility
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225 # https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
# The preview image will be optimized to lora-name.webp, so it won't affect other logic # The preview image will be optimized to lora-name.webp, so it won't affect other logic
temp_extensions.append(".example.0.jpeg") temp_extensions.append(".example.0.jpeg")
# Fast path: exact-case match
for ext in temp_extensions: for ext in temp_extensions:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}") full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern): if os.path.exists(full_pattern):
# Check if this is an image and not already webp
# TODO: disable the optimization for now, maybe add a config option later
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
# try:
# # Optimize the image to webp format
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
# # Use ExifUtils to optimize the image
# with open(full_pattern, 'rb') as f:
# image_data = f.read()
# optimized_data, _ = ExifUtils.optimize_image(
# image_data=image_data,
# target_width=CARD_PREVIEW_WIDTH,
# format='webp',
# quality=85,
# preserve_metadata=False
# )
# # Save the optimized webp file
# with open(webp_path, 'wb') as f:
# f.write(optimized_data)
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
# return webp_path.replace(os.sep, "/")
# except Exception as e:
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# # Fall back to original file if optimization fails
# return full_pattern.replace(os.sep, "/")
# Return the original path for webp images or non-image files
return full_pattern.replace(os.sep, "/") return full_pattern.replace(os.sep, "/")
# Slow path: case-insensitive match for systems with mixed-case extensions
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
try:
dir_entries = os.listdir(dir_path)
except OSError:
return ""
base_lower = base_name.lower()
for ext in temp_extensions:
target = f"{base_lower}{ext}" # ext is already lowercase
for entry in dir_entries:
if entry.lower() == target:
return os.path.join(dir_path, entry).replace(os.sep, "/")
return "" return ""
def get_preview_extension(preview_path: str) -> str: def get_preview_extension(preview_path: str) -> str:

View File

@@ -4,31 +4,40 @@ from datetime import datetime
import os import os
from .model_utils import determine_base_model from .model_utils import determine_base_model
@dataclass @dataclass
class BaseModelMetadata: class BaseModelMetadata:
"""Base class for all model metadata structures""" """Base class for all model metadata structures"""
file_name: str # The filename without extension
model_name: str # The model's name defined by the creator file_name: str # The filename without extension
file_path: str # Full path to the model file model_name: str # The model's name defined by the creator
size: int # File size in bytes file_path: str # Full path to the model file
modified: float # Timestamp when the model was added to the management system size: int # File size in bytes
sha256: str # SHA256 hash of the file modified: float # Timestamp when the model was added to the management system
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.) sha256: str # SHA256 hash of the file
preview_url: str # Preview image URL base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_nsfw_level: int = 0 # NSFW level of the preview image preview_url: str # Preview image URL
notes: str = "" # Additional notes preview_nsfw_level: int = 0 # NSFW level of the preview image
from_civitai: bool = True # Whether from Civitai notes: str = "" # Additional notes
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available from_civitai: bool = True # Whether from Civitai
tags: List[str] = None # Model tags civitai: Dict[str, Any] = field(
default_factory=dict
) # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB db_checked: bool = False # Whether checked in archive DB
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh skip_metadata_refresh: bool = (
False # Whether to skip this model during bulk metadata refresh
)
metadata_source: Optional[str] = None # Last provider that supplied metadata metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp last_checked_at: float = 0 # Last checked timestamp
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
def __post_init__(self): def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue # Initialize empty lists to avoid mutable default parameter issue
@@ -39,15 +48,15 @@ class BaseModelMetadata:
self.tags = [] self.tags = []
@classmethod @classmethod
def from_dict(cls, data: Dict) -> 'BaseModelMetadata': def from_dict(cls, data: Dict) -> "BaseModelMetadata":
"""Create instance from dictionary""" """Create instance from dictionary"""
data_copy = data.copy() data_copy = data.copy()
# Use cached fields if available, otherwise compute them # Use cached fields if available, otherwise compute them
if not hasattr(cls, '_known_fields_cache'): if not hasattr(cls, "_known_fields_cache"):
known_fields = set() known_fields = set()
for c in cls.mro(): for c in cls.mro():
if hasattr(c, '__annotations__'): if hasattr(c, "__annotations__"):
known_fields.update(c.__annotations__.keys()) known_fields.update(c.__annotations__.keys())
cls._known_fields_cache = known_fields cls._known_fields_cache = known_fields
@@ -57,7 +66,11 @@ class BaseModelMetadata:
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields} fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
# Store unknown fields separately # Store unknown fields separately
unknown_fields = {k: v for k, v in data_copy.items() if k not in known_fields and not k.startswith('_')} unknown_fields = {
k: v
for k, v in data_copy.items()
if k not in known_fields and not k.startswith("_")
}
# Create instance with known fields # Create instance with known fields
instance = cls(**fields_to_use) instance = cls(**fields_to_use)
@@ -72,10 +85,10 @@ class BaseModelMetadata:
result = asdict(self) result = asdict(self)
# Remove private fields # Remove private fields
result = {k: v for k, v in result.items() if not k.startswith('_')} result = {k: v for k, v in result.items() if not k.startswith("_")}
# Add back unknown fields if they exist # Add back unknown fields if they exist
if hasattr(self, '_unknown_fields'): if hasattr(self, "_unknown_fields"):
result.update(self._unknown_fields) result.update(self._unknown_fields)
return result return result
@@ -84,17 +97,29 @@ class BaseModelMetadata:
"""Update Civitai information""" """Update Civitai information"""
self.civitai = civitai_data self.civitai = civitai_data
def update_file_info(self, file_path: str) -> None: def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
"""Update metadata with actual file information""" """
Update metadata with actual file information.
Args:
file_path: Path to the model file
update_timestamps: If True, update size and modified from filesystem.
If False (default), only update file_path and file_name.
Set to True only when file has been moved/relocated.
"""
if os.path.exists(file_path): if os.path.exists(file_path):
self.size = os.path.getsize(file_path) if update_timestamps:
self.modified = os.path.getmtime(file_path) # Only update size and modified when file has been relocated
self.file_path = file_path.replace(os.sep, '/') self.size = os.path.getsize(file_path)
# Update file_name when file_path changes self.modified = os.path.getmtime(file_path)
# Always update paths when this method is called
self.file_path = file_path.replace(os.sep, "/")
self.file_name = os.path.splitext(os.path.basename(file_path))[0] self.file_name = os.path.splitext(os.path.basename(file_path))[0]
@staticmethod @staticmethod
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str: def generate_unique_filename(
target_dir: str, base_name: str, extension: str, hash_provider: callable = None
) -> str:
"""Generate a unique filename to avoid conflicts """Generate a unique filename to avoid conflicts
Args: Args:
@@ -135,115 +160,126 @@ class BaseModelMetadata:
return unique_filename return unique_filename
@dataclass @dataclass
class LoraMetadata(BaseModelMetadata): class LoraMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Lora model""" """Represents the metadata structure for a Lora model"""
usage_tips: str = "{}" # Usage tips for the model, json string
usage_tips: str = "{}" # Usage tips for the model, json string
@classmethod @classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'LoraMetadata': def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "LoraMetadata":
"""Create LoraMetadata instance from Civitai version info""" """Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]), model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(), sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download preview_nsfw_level=0, # Will be updated after preview download
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )
@dataclass @dataclass
class CheckpointMetadata(BaseModelMetadata): class CheckpointMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Checkpoint model""" """Represents the metadata structure for a Checkpoint model"""
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.) sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
@classmethod @classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata': def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "CheckpointMetadata":
"""Create CheckpointMetadata instance from Civitai version info""" """Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get('type', 'checkpoint') sub_type = version_info.get("type", "checkpoint")
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]), model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(), sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, preview_nsfw_level=0,
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
sub_type=sub_type, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )
@dataclass @dataclass
class EmbeddingMetadata(BaseModelMetadata): class EmbeddingMetadata(BaseModelMetadata):
"""Represents the metadata structure for an Embedding model""" """Represents the metadata structure for an Embedding model"""
sub_type: str = "embedding" sub_type: str = "embedding"
@classmethod @classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata': def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "EmbeddingMetadata":
"""Create EmbeddingMetadata instance from Civitai version info""" """Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get('type', 'embedding') sub_type = version_info.get("type", "embedding")
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]), model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(), sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, preview_nsfw_level=0,
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
sub_type=sub_type, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )

View File

@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple from typing import Any, Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object] PreviewMedia = Mapping[str, object]
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
def _extract_nsfw_level(entry: Mapping[str, object]) -> int: def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
return 0 return 0
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
"""Resolve the configured mature blur threshold from settings.
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
missing value falls back to ``R``.
"""
if not isinstance(settings, Mapping):
return NSFW_LEVELS.get("R", 4)
raw_level = settings.get("mature_blur_level", "R")
normalized = str(raw_level).strip().upper()
if normalized not in VALID_MATURE_BLUR_LEVELS:
normalized = "R"
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
def select_preview_media( def select_preview_media(
images: Sequence[Mapping[str, object]] | None, images: Sequence[Mapping[str, object]] | None,
*, *,
blur_mature_content: bool, blur_mature_content: bool,
mature_threshold: int | None = None,
) -> Tuple[Optional[PreviewMedia], int]: ) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry. """Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are item with an ``nsfwLevel`` lower than the configured mature threshold
available we return the media entry with the lowest NSFW level. When the (defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
setting is disabled we simply return the first entry. the media entry with the lowest NSFW level. When the setting is disabled we
simply return the first entry.
""" """
if not images: if not images:
@@ -45,7 +65,9 @@ def select_preview_media(
if not blur_mature_content: if not blur_mature_content:
return selected, selected_level return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4) safe_threshold = (
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
)
for candidate in candidates: for candidate in candidates:
level = _extract_nsfw_level(candidate) level = _extract_nsfw_level(candidate)
if level < safe_threshold: if level < safe_threshold:
@@ -60,4 +82,4 @@ def select_preview_media(
return selected, selected_level return selected, selected_level
__all__ = ["select_preview_media"] __all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]

View File

@@ -57,6 +57,9 @@ class UsageStats:
"last_save_time": 0 "last_save_time": 0
} }
# Track if stats have been modified since last save
self._is_dirty = False
# Queue for prompt_ids to process # Queue for prompt_ids to process
self.pending_prompt_ids = set() self.pending_prompt_ids = set()
@@ -180,10 +183,19 @@ class UsageStats:
async def save_stats(self, force=False): async def save_stats(self, force=False):
"""Save statistics to file""" """Save statistics to file"""
try: try:
# Only save if it's been at least save_interval since last save or force is True # Only save if:
# 1. force is True, OR
# 2. stats have been modified (is_dirty) AND save_interval has passed
current_time = time.time() current_time = time.time()
if not force and (current_time - self.stats.get("last_save_time", 0)) < self.save_interval: time_since_last_save = current_time - self.stats.get("last_save_time", 0)
return False
if not force:
if not self._is_dirty:
# No changes to save
return False
if time_since_last_save < self.save_interval:
# Too soon since last save
return False
# Use a lock to prevent concurrent writes # Use a lock to prevent concurrent writes
async with self._lock: async with self._lock:
@@ -201,6 +213,9 @@ class UsageStats:
# Replace the old file with the new one # Replace the old file with the new one
os.replace(temp_path, self._stats_file_path) os.replace(temp_path, self._stats_file_path)
# Clear dirty flag since we've saved
self._is_dirty = False
logger.debug(f"Saved usage statistics to {self._stats_file_path}") logger.debug(f"Saved usage statistics to {self._stats_file_path}")
return True return True
except Exception as e: except Exception as e:
@@ -227,16 +242,23 @@ class UsageStats:
self.pending_prompt_ids.clear() self.pending_prompt_ids.clear()
# Process each prompt_id # Process each prompt_id
registry = MetadataRegistry() try:
for prompt_id in prompt_ids: registry = MetadataRegistry()
try: except NameError:
metadata = registry.get_metadata(prompt_id) # MetadataRegistry not available (standalone mode)
await self._process_metadata(metadata) registry = None
except Exception as e:
logger.error(f"Error processing prompt_id {prompt_id}: {e}")
# Periodically save stats if registry:
await self.save_stats() for prompt_id in prompt_ids:
try:
metadata = registry.get_metadata(prompt_id)
await self._process_metadata(metadata)
except Exception as e:
logger.error(f"Error processing prompt_id {prompt_id}: {e}")
# Periodically save stats (only if there are changes)
if self._is_dirty:
await self.save_stats()
except asyncio.CancelledError: except asyncio.CancelledError:
# Task was cancelled, clean up # Task was cancelled, clean up
await self.save_stats(force=True) await self.save_stats(force=True)
@@ -257,6 +279,7 @@ class UsageStats:
# Increment total executions count # Increment total executions count
self.stats["total_executions"] += 1 self.stats["total_executions"] += 1
self._is_dirty = True
# Get today's date in YYYY-MM-DD format # Get today's date in YYYY-MM-DD format
today = datetime.datetime.now().strftime("%Y-%m-%d") today = datetime.datetime.now().strftime("%Y-%m-%d")
@@ -374,6 +397,10 @@ class UsageStats:
if not prompt_id: if not prompt_id:
return return
if standalone_mode:
# Usage statistics are not available in standalone mode
return
try: try:
# Process metadata for this prompt_id # Process metadata for this prompt_id
registry = MetadataRegistry() registry = MetadataRegistry()

View File

@@ -7,24 +7,38 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
import asyncio import asyncio
def get_lora_info(lora_name): def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache""" """Get the lora path and trigger words from cache"""
async def _get_lora_info_async(): async def _get_lora_info_async():
scanner = await ServiceRegistry.get_lora_scanner() scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
for item in cache.raw_data: for item in cache.raw_data:
if item.get('file_name') == lora_name: if item.get("file_name") == lora_name:
file_path = item.get('file_path') file_path = item.get("file_path")
if file_path: if file_path:
for root in config.loras_roots: # Check all lora roots including extra paths
root = root.replace(os.sep, '/') all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root): if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/') relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
# Get trigger words from civitai metadata # Get trigger words from civitai metadata
civitai = item.get('civitai', {}) civitai = item.get("civitai", {})
trigger_words = civitai.get('trainedWords', []) if civitai else [] trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words return relative_path, trigger_words
# If not found in any root, return path with trigger words from cache
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, [] return lora_name, []
try: try:
@@ -50,41 +64,199 @@ def get_lora_info(lora_name):
# No event loop is running, we can use asyncio.run() # No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_async()) return asyncio.run(_get_lora_info_async())
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching def get_lora_info_absolute(lora_name):
text = text.lower() """Get the absolute lora path and trigger words from cache
pattern = pattern.lower()
# Split pattern into words Returns:
search_words = pattern.split() tuple: (absolute_path, trigger_words) where absolute_path is the full
file system path to the LoRA file, or original lora_name if not found
"""
# Check each word async def _get_lora_info_absolute_async():
for word in search_words: scanner = await ServiceRegistry.get_lora_scanner()
# First check if word is a substring (faster) cache = await scanner.get_cached_data()
if word in text:
for item in cache.raw_data:
if item.get("file_name") == lora_name:
file_path = item.get("file_path")
if file_path:
# Return absolute path directly
# Get trigger words from civitai metadata
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_lora_info_absolute_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_absolute_async())
def get_checkpoint_info_absolute(checkpoint_name):
"""Get the absolute checkpoint path and metadata from cache
Supports ComfyUI-style model names (e.g., "folder/model_name.ext")
Args:
checkpoint_name: The model name, can be:
- ComfyUI format: "folder/model_name.safetensors"
- Simple name: "model_name"
Returns:
tuple: (absolute_path, metadata) where absolute_path is the full
file system path to the checkpoint file, or original checkpoint_name if not found,
metadata is the full model metadata dict or None
"""
async def _get_checkpoint_info_absolute_async():
from ..services.service_registry import ServiceRegistry
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get model roots for matching
model_roots = scanner.get_model_roots()
# Normalize the checkpoint name
normalized_name = checkpoint_name.replace(os.sep, "/")
for item in cache.raw_data:
file_path = item.get("file_path", "")
if not file_path:
continue continue
# If not found as substring, try fuzzy matching # Format the stored path as ComfyUI-style name
# Check if any part of the text matches this word formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match: # Match by formatted name (normalize separators for robust comparison)
return False if formatted_name.replace(os.sep, "/") == normalized_name or formatted_name == checkpoint_name:
return file_path, item
# Also try matching by basename only (for backward compatibility)
file_name = item.get("file_name", "")
if (
file_name == checkpoint_name
or file_name == os.path.splitext(normalized_name)[0]
):
return file_path, item
return checkpoint_name, None
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(
_get_checkpoint_info_absolute_async()
)
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_checkpoint_info_absolute_async())
def _format_model_name_for_comfyui(file_path: str, model_roots: list) -> str:
"""Format file path to ComfyUI-style model name (relative path with extension)
Example: /path/to/checkpoints/Illustrious/model.safetensors -> Illustrious/model.safetensors
Args:
file_path: Absolute path to the model file
model_roots: List of model root directories
Returns:
ComfyUI-style model name with relative path and extension
"""
# Find the matching root and get relative path
for root in model_roots:
try:
# Normalize paths for comparison
norm_file = os.path.normcase(os.path.abspath(file_path))
norm_root = os.path.normcase(os.path.abspath(root))
# Add trailing separator for prefix check
if not norm_root.endswith(os.sep):
norm_root += os.sep
if norm_file.startswith(norm_root):
# Use os.path.relpath to get relative path with OS-native separator
return os.path.relpath(file_path, root)
except (ValueError, TypeError):
continue
# If no root matches, just return the basename with extension
return os.path.basename(file_path)
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True
# All words found either as substrings or fuzzy matches
return True
def sanitize_folder_name(name: str, replacement: str = "_") -> str: def sanitize_folder_name(name: str, replacement: str = "_") -> str:
"""Sanitize a folder name by removing or replacing invalid characters. """Sanitize a folder name by removing or replacing invalid characters.
@@ -110,10 +282,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
# Collapse repeated replacement characters to a single instance # Collapse repeated replacement characters to a single instance
if replacement: if replacement:
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized) sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
sanitized = sanitized.strip(replacement) # Combine stripping to be idempotent:
# Right side: strip replacement, space, and dot (Windows restriction)
# Remove trailing spaces or periods which are invalid on Windows # Left side: strip replacement and space (leading dots are allowed)
sanitized = sanitized.rstrip(" .") sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
else:
# If no replacement, just strip spaces and dots from right, spaces from left
sanitized = sanitized.rstrip(" .").lstrip(" ")
if not sanitized: if not sanitized:
return "unnamed" return "unnamed"
@@ -143,7 +318,11 @@ def calculate_recipe_fingerprint(loras):
if lora.get("exclude", False): if lora.get("exclude", False):
continue continue
hash_value = lora.get("hash", "").lower() hash_value = lora.get("hash", "")
if isinstance(hash_value, str):
hash_value = hash_value.lower()
else:
hash_value = str(hash_value).lower() if hash_value else ""
if not hash_value and lora.get("modelVersionId"): if not hash_value and lora.get("modelVersionId"):
hash_value = str(lora.get("modelVersionId")) hash_value = str(lora.get("modelVersionId"))
@@ -151,7 +330,11 @@ def calculate_recipe_fingerprint(loras):
continue continue
# Normalize strength to 2 decimal places (check both strength and weight fields) # Normalize strength to 2 decimal places (check both strength and weight fields)
strength = round(float(lora.get("strength", lora.get("weight", 1.0))), 2) strength_val = lora.get("strength", lora.get("weight", 1.0))
try:
strength = round(float(strength_val), 2)
except (ValueError, TypeError):
strength = 1.0
valid_loras.append((hash_value, strength)) valid_loras.append((hash_value, strength))
@@ -159,11 +342,16 @@ def calculate_recipe_fingerprint(loras):
valid_loras.sort() valid_loras.sort()
# Join in format hash1:strength1|hash2:strength2|... # Join in format hash1:strength1|hash2:strength2|...
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras]) fingerprint = "|".join(
[f"{hash_value}:{strength}" for hash_value, strength in valid_loras]
)
return fingerprint return fingerprint
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
def calculate_relative_path_for_model(
model_data: Dict, model_type: str = "lora"
) -> str:
"""Calculate relative path for existing model using template from settings """Calculate relative path for existing model using template from settings
Args: Args:
@@ -179,54 +367,57 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: if not path_template:
return '' return ""
# Get base model name from model metadata # Get base model name from model metadata
civitai_data = model_data.get('civitai', {}) civitai_data = model_data.get("civitai", {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly # For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get('id') is not None: if civitai_data and civitai_data.get("id") is not None:
base_model = model_data.get('base_model', '') base_model = model_data.get("base_model", "")
# Get author from civitai creator data # Get author from civitai creator data
creator_info = civitai_data.get('creator') or {} creator_info = civitai_data.get("creator") or {}
author = creator_info.get('username') or 'Anonymous' author = creator_info.get("username") or "Anonymous"
else: else:
# Fallback to model_data fields for non-CivitAI models # Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '') base_model = model_data.get("base_model", "")
author = 'Anonymous' # Default for non-CivitAI models author = "Anonymous" # Default for non-CivitAI models
model_tags = model_data.get('tags', []) model_tags = model_data.get("tags", [])
# Apply mapping if available # Apply mapping if available
base_model_mappings = settings_manager.get('base_model_path_mappings', {}) base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model) mapped_base_model = base_model_mappings.get(base_model, base_model)
# Convert all tags to lowercase to avoid case sensitivity issues on Windows # Convert all tags to lowercase to avoid case sensitivity issues on Windows
lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)] lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)]
first_tag = settings_manager.resolve_priority_tag_for_model(lowercase_tags, model_type) first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
if not first_tag: if not first_tag:
first_tag = 'no tags' # Default if no tags available first_tag = "no tags" # Default if no tags available
# Format the template with available data # Format the template with available data
model_name = sanitize_folder_name(model_data.get('model_name', '')) model_name = sanitize_folder_name(model_data.get("model_name", ""))
version_name = '' version_name = ""
if isinstance(civitai_data, dict): if isinstance(civitai_data, dict):
version_name = sanitize_folder_name(civitai_data.get('name') or '') version_name = sanitize_folder_name(civitai_data.get("name") or "")
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace('{author}', author) formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace('{model_name}', model_name) formatted_path = formatted_path.replace("{model_name}", model_name)
formatted_path = formatted_path.replace('{version_name}', version_name) formatted_path = formatted_path.replace("{version_name}", version_name)
if model_type == 'embedding': if model_type == "embedding":
formatted_path = formatted_path.replace(' ', '_') formatted_path = formatted_path.replace(" ", "_")
return formatted_path return formatted_path
def remove_empty_dirs(path): def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path. """Recursively remove empty directories starting from the given path.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.16" version = "1.0.1"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -1,5 +1,5 @@
[pytest] [pytest]
addopts = -v --import-mode=importlib addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
testpaths = tests testpaths = tests
python_files = test_*.py python_files = test_*.py
python_classes = Test* python_classes = Test*
@@ -12,5 +12,6 @@ markers =
asyncio: execute test within asyncio event loop asyncio: execute test within asyncio event loop
no_settings_dir_isolation: allow tests to use real settings paths no_settings_dir_isolation: allow tests to use real settings paths
integration: integration tests requiring external resources integration: integration tests requiring external resources
performance: performance benchmarks (slow, skip by default)
# Skip problematic directories to avoid import conflicts # Skip problematic directories to avoid import conflicts
norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis

View File

@@ -0,0 +1,63 @@
import json
import os
import re
def update_readme():
# 1. Read JSON data
json_path = 'data/supporters.json'
if not os.path.exists(json_path):
print(f"Error: {json_path} not found.")
return
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 2. Generate Markdown content
special_thanks = data.get('specialThanks', [])
all_supporters = data.get('allSupporters', [])
total_count = data.get('totalCount', len(all_supporters))
md_content = "\n### 🌟 Special Thanks\n\n"
if special_thanks:
md_content += ", ".join([f"**{name}**" for name in special_thanks]) + "\n\n"
else:
md_content += "*None yet*\n\n"
md_content += f"### 💖 Supporters ({total_count})\n\n"
if all_supporters:
# Using a details block for the long list of supporters
md_content += "<details>\n<summary>Click to view all awesome supporters</summary>\n<br>\n\n"
md_content += ", ".join(all_supporters)
md_content += "\n\n</details>\n"
else:
md_content += "*No supporters listed yet*\n"
# 3. Read existing README.md
readme_path = 'README.md'
with open(readme_path, 'r', encoding='utf-8') as f:
readme = f.read()
# 4. Replace content between placeholders
start_tag = '<!-- SUPPORTERS-START -->'
end_tag = '<!-- SUPPORTERS-END -->'
if start_tag not in readme or end_tag not in readme:
print(f"Error: Placeholders {start_tag} and {end_tag} not found in {readme_path}")
return
# Using non-regex replacement to avoid issues with special characters in names
parts = readme.split(start_tag)
before_start = parts[0]
after_start = parts[1].split(end_tag)
after_end = after_start[1]
new_readme = f"{before_start}{start_tag}\n{md_content}\n{end_tag}{after_end}"
# 5. Write back to README.md
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(new_readme)
print(f"Successfully updated {readme_path} with {len(all_supporters)} supporters!")
if __name__ == '__main__':
update_readme()

View File

@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
"/ws/download-progress", ws_manager.handle_download_connection "/ws/download-progress", ws_manager.handle_download_connection
) )
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection) app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
app.router.add_get("/ws/batch-import-progress", ws_manager.handle_connection)
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -68,6 +68,7 @@ body {
--space-1: calc(8px * 1); --space-1: calc(8px * 1);
--space-2: calc(8px * 2); --space-2: calc(8px * 2);
--space-3: calc(8px * 3); --space-3: calc(8px * 3);
--space-4: calc(8px * 4);
/* Z-index Scale */ /* Z-index Scale */
--z-base: 10; --z-base: 10;
@@ -77,6 +78,7 @@ body {
/* Border Radius */ /* Border Radius */
--border-radius-base: 12px; --border-radius-base: 12px;
--border-radius-md: 12px;
--border-radius-sm: 8px; --border-radius-sm: 8px;
--border-radius-xs: 4px; --border-radius-xs: 4px;

View File

@@ -0,0 +1,677 @@
/* Batch Import Modal Styles */
/* Step Containers */
.batch-import-step {
margin: var(--space-2) 0;
}
/* Section Description */
.section-description {
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
font-size: 0.95em;
}
/* Hint Text */
.input-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
margin-top: 6px;
}
.input-hint i {
color: var(--lora-accent);
}
/* Textarea Styling */
#batchUrlInput {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
#batchUrlInput:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Checkbox Group */
.checkbox-group {
margin-top: var(--space-2);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-color);
font-size: 0.95em;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: var(--bg-color);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
/* Batch Options */
.batch-options {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
/* Input with Button */
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
min-width: 0;
}
.input-with-button button {
flex-shrink: 0;
white-space: nowrap;
padding: 8px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background-color 0.2s;
}
.input-with-button button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Dark theme adjustments for input-with-button */
[data-theme="dark"] .input-with-button button {
background: var(--lora-accent);
color: var(--lora-text);
}
[data-theme="dark"] .input-with-button button:hover {
background: oklch(from var(--lora-accent) calc(l - 0.1) c h);
}
/* Directory Browser */
.directory-browser {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
.browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
.browser-section {
margin-bottom: 16px;
}
.browser-section:last-child {
margin-bottom: 0;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.85em;
color: var(--text-color);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.section-label i {
color: var(--lora-accent);
}
.folder-list,
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.folder-item,
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.folder-item:hover,
.file-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
.folder-item.selected,
.file-item.selected {
background: oklch(from var(--lora-accent) l c h / 0.15);
border-color: var(--lora-accent);
}
.folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
.file-item i {
color: var(--text-color);
opacity: 0.6;
font-size: 1em;
}
.item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-size {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.browser-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.stats {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
}
.stats span {
font-weight: 600;
color: var(--lora-accent);
}
/* Dark theme adjustments */
[data-theme="dark"] .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] .browser-header,
[data-theme="dark"] .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] .folder-item i {
color: #fcd34d;
}
/* Progress Container */
.batch-progress-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.progress-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-icon {
color: var(--lora-accent);
font-size: 1.1em;
}
.status-icon i {
animation: fa-spin 2s infinite linear;
}
.status-text {
font-weight: 500;
color: var(--text-color);
}
.progress-percentage {
font-size: 1.2em;
font-weight: 600;
color: var(--lora-accent);
}
/* Progress Bar */
.progress-bar-container {
height: 8px;
background: var(--bg-color);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-3);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
border-radius: 4px;
transition: width 0.3s ease;
}
/* Progress Stats */
.progress-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.stat-item.success {
border-left: 3px solid #00B87A;
}
.stat-item.failed {
border-left: 3px solid var(--lora-error);
}
.stat-item.skipped {
border-left: 3px solid var(--lora-warning);
}
.stat-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Current Item */
.current-item {
display: flex;
align-items: baseline;
gap: 10px;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
}
.current-item-label {
color: var(--text-color);
opacity: 0.7;
flex-shrink: 0;
}
.current-item-name {
color: var(--text-color);
font-weight: 500;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Results Container */
.batch-results-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.results-header {
text-align: center;
margin-bottom: var(--space-3);
}
.results-icon {
font-size: 3em;
color: #00B87A;
margin-bottom: var(--space-1);
}
.results-icon.warning {
color: var(--lora-warning);
}
.results-icon.error {
color: var(--lora-error);
}
.results-title {
font-size: 1.3em;
font-weight: 600;
color: var(--text-color);
}
/* Results Summary - Matches progress-stats styling */
.results-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.result-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
text-align: center;
}
.result-card.success {
border-left: 3px solid #00B87A;
}
.result-card.failed {
border-left: 3px solid var(--lora-error);
}
.result-card.skipped {
border-left: 3px solid var(--lora-warning);
}
.result-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.result-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Results Details */
.results-details {
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.details-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
cursor: pointer;
color: var(--lora-accent);
font-weight: 500;
border-radius: var(--border-radius-xs);
transition: background 0.2s;
}
.details-toggle:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.details-toggle i {
transition: transform 0.2s;
}
.details-toggle.expanded i {
transform: rotate(180deg);
}
.details-list {
max-height: 250px;
overflow-y: auto;
margin-top: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
/* Result Item in Details */
.result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.result-item:last-child {
border-bottom: none;
}
.result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
}
.result-item-status.success {
background: oklch(from #00B87A l c h / 0.2);
color: #00B87A;
}
.result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2);
color: var(--lora-error);
}
.result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2);
color: var(--lora-warning);
}
.result-item-info {
flex: 1;
min-width: 0;
}
.result-item-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-item-error {
font-size: 0.8em;
color: var(--lora-error);
margin-top: 2px;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.progress-stats,
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
.batch-progress-container,
.batch-results-container {
padding: var(--space-2);
}
}
/* Dark Theme Adjustments */
[data-theme="dark"] .batch-progress-container,
[data-theme="dark"] .batch-results-container {
background: var(--card-bg);
}
[data-theme="dark"] .stat-item,
[data-theme="dark"] .result-card,
[data-theme="dark"] .current-item,
[data-theme="dark"] .details-list {
background: var(--lora-surface);
}
/* Cancelled State */
.batch-progress-container.cancelled .progress-bar {
background: var(--lora-warning);
}
.batch-progress-container.cancelled .status-icon {
color: var(--lora-warning);
}
/* Error State */
.batch-progress-container.error .progress-bar {
background: var(--lora-error);
}
.batch-progress-container.error .status-icon {
color: var(--lora-error);
}
/* Completed State */
.batch-progress-container.completed .progress-bar {
background: #00B87A;
}
.batch-progress-container.completed .status-icon {
color: #00B87A;
}
.batch-progress-container.completed .status-icon i {
animation: none;
}
.batch-progress-container.completed .status-icon i::before {
content: '\f00c';
}

View File

@@ -282,7 +282,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; /* Changed from flex-end to allow for text wrapping */ align-items: flex-start; /* Changed from flex-end to allow for text wrapping */
min-height: 32px; min-height: auto;
gap: var(--space-1); /* Add gap between model info and actions */ gap: var(--space-1); /* Add gap between model info and actions */
} }
@@ -413,7 +413,7 @@
font-size: 0.95em; font-size: 0.95em;
word-break: break-word; word-break: break-word;
display: block; display: block;
max-height: 3em; /* Increased to ensure two full lines */ max-height: 4.2em; /* Allow up to 3 lines */
overflow: hidden; overflow: hidden;
/* Add line height for consistency */ /* Add line height for consistency */
line-height: 1.4; line-height: 1.4;

View File

@@ -687,7 +687,7 @@
padding: 12px 16px; padding: 12px 16px;
background: oklch(var(--lora-warning) / 0.1); background: oklch(var(--lora-warning) / 0.1);
border: 1px solid var(--lora-warning); border: 1px solid var(--lora-warning);
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -392,6 +392,7 @@
border: 1px solid transparent; border: 1px solid transparent;
outline: none; outline: none;
flex: 1; flex: 1;
overflow-wrap: anywhere; /* Allow wrapping at any character, including hyphens */
} }
.model-name-content:focus { .model-name-content:focus {
@@ -834,7 +835,8 @@
} }
[data-theme="dark"] .creator-info, [data-theme="dark"] .creator-info,
[data-theme="dark"] .civitai-view { [data-theme="dark"] .civitai-view,
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -874,7 +876,8 @@
/* Add hover effect for creator info */ /* Add hover effect for creator info */
.creator-info:hover, .creator-info:hover,
.civitai-view:hover { .civitai-view:hover,
.modal-send-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
transform: translateY(-1px); transform: translateY(-1px);
@@ -909,3 +912,42 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* Send to ComfyUI Button */
.modal-send-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
}
[data-theme="dark"] .modal-send-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.modal-send-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.modal-send-btn:active {
transform: translateY(0);
}
.modal-send-btn i {
font-size: 14px;
}
.modal-send-btn span {
white-space: nowrap;
}

View File

@@ -130,7 +130,7 @@
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000; z-index: var(--z-overlay);
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }

View File

@@ -151,7 +151,8 @@ body.modal-open {
[data-theme="dark"] .changelog-section, [data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info, [data-theme="dark"] .update-info,
[data-theme="dark"] .info-item, [data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview { [data-theme="dark"] .path-preview,
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -349,3 +350,87 @@ button:disabled,
margin-top: var(--space-1); margin-top: var(--space-1);
text-align: center; text-align: center;
} }
/* Bulk Download Missing LoRAs Modal */
#bulkDownloadMissingLorasModal .modal-body {
padding: var(--space-3);
}
#bulkDownloadMissingLorasModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
#bulkDownloadMissingLorasModal .preview-title {
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--text-color);
font-size: 0.95em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
list-style: none;
padding: 0;
margin: 0;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
border-bottom: none;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
font-style: italic;
opacity: 0.7;
text-align: center;
justify-content: center;
padding: var(--space-2) 0;
}
#bulkDownloadMissingLorasModal .lora-name {
font-weight: 500;
color: var(--text-color);
flex: 1;
}
#bulkDownloadMissingLorasModal .lora-version {
font-size: 0.85em;
opacity: 0.7;
margin-left: var(--space-1);
color: var(--text-muted);
}
#bulkDownloadMissingLorasModal .confirmation-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2);
background: rgba(59, 130, 246, 0.1);
border-radius: var(--border-radius-sm);
font-size: 0.9em;
color: var(--text-color);
}
#bulkDownloadMissingLorasModal .confirmation-note i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}

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