mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
71 Commits
feature/la
...
4000b7f7e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4000b7f7e7 | ||
|
|
76c15105e6 | ||
|
|
b11c90e19b | ||
|
|
9f5d2d0c18 | ||
|
|
a0dc5229f4 | ||
|
|
61c31ecbd0 | ||
|
|
1ae1b0d607 | ||
|
|
8dd849892d | ||
|
|
03e1fa75c5 | ||
|
|
fefcaa4a45 | ||
|
|
701a6a6c44 | ||
|
|
0ef414d17e | ||
|
|
75dccaef87 | ||
|
|
7e87ec9521 | ||
|
|
46522edb1b | ||
|
|
2dae4c1291 | ||
|
|
a32325402e | ||
|
|
70c150bd80 | ||
|
|
9e81c33f8a | ||
|
|
22c0dbd734 | ||
|
|
d0c58472be | ||
|
|
b3c530bf36 | ||
|
|
05ebd7493d | ||
|
|
90986bd795 | ||
|
|
b5a0725d2c | ||
|
|
ef38bda04f | ||
|
|
58713ea6e0 | ||
|
|
8b91920058 | ||
|
|
ee466113d5 | ||
|
|
f86651652c | ||
|
|
c89d4dae85 | ||
|
|
55a18d401b | ||
|
|
7570936c75 | ||
|
|
4fcf641d57 | ||
|
|
5c29e26c4e | ||
|
|
ee765a6d22 | ||
|
|
c02f603ed2 | ||
|
|
ee84b30023 | ||
|
|
97979d9e7c | ||
|
|
cda271890a | ||
|
|
2fbe6c8843 | ||
|
|
4fb07370dd | ||
|
|
43f6bfab36 | ||
|
|
a802a89ff9 | ||
|
|
343dd91e4b | ||
|
|
3756f88368 | ||
|
|
acc625ead3 | ||
|
|
f402505f97 | ||
|
|
4d8113464c | ||
|
|
1ed503a6b5 | ||
|
|
d67914e095 | ||
|
|
2c810306fb | ||
|
|
dd94c6b31a | ||
|
|
1a0edec712 | ||
|
|
7ba9b998d3 | ||
|
|
8c5d5a8ca0 | ||
|
|
672e4cff90 | ||
|
|
c2716e3c39 | ||
|
|
b72cf7ba98 | ||
|
|
bde11b153f | ||
|
|
8b924b1551 | ||
|
|
ce08935b1e | ||
|
|
24fcbeaf76 | ||
|
|
c9e5ea42cb | ||
|
|
b005961ee5 | ||
|
|
ce03bbbc4e | ||
|
|
78b55d10ba | ||
|
|
77a2215e62 | ||
|
|
31901f1f0e | ||
|
|
12a789ef96 | ||
|
|
d50bbe71c2 |
153
.docs/batch-import-design.md
Normal file
153
.docs/batch-import-design.md
Normal 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
31
.github/workflows/update-supporters.yml
vendored
Normal 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"
|
||||
464
.specs/metadata.schema.json
Normal file
464
.specs/metadata.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ npm run test:coverage # Generate coverage report
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||
- 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
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
20
__init__.py
20
__init__.py
@@ -1,6 +1,8 @@
|
||||
try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.lora_manager import LoraManager
|
||||
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.prompt import PromptLM
|
||||
from .py.nodes.text import TextLM
|
||||
@@ -27,12 +29,12 @@ except (
|
||||
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
|
||||
TextLM = importlib.import_module("py.nodes.text").TextLM
|
||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||
LoraLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraLoaderLM
|
||||
LoraTextLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraTextLoaderLM
|
||||
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
|
||||
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
|
||||
CheckpointLoaderLM = importlib.import_module(
|
||||
"py.nodes.checkpoint_loader"
|
||||
).CheckpointLoaderLM
|
||||
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
|
||||
TriggerWordToggleLM = importlib.import_module(
|
||||
"py.nodes.trigger_word_toggle"
|
||||
).TriggerWordToggleLM
|
||||
@@ -49,9 +51,7 @@ except (
|
||||
LoraRandomizerLM = importlib.import_module(
|
||||
"py.nodes.lora_randomizer"
|
||||
).LoraRandomizerLM
|
||||
LoraCyclerLM = importlib.import_module(
|
||||
"py.nodes.lora_cycler"
|
||||
).LoraCyclerLM
|
||||
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -59,6 +59,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
TextLM.NAME: TextLM,
|
||||
LoraLoaderLM.NAME: LoraLoaderLM,
|
||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
||||
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||
LoraStackerLM.NAME: LoraStackerLM,
|
||||
SaveImageLM.NAME: SaveImageLM,
|
||||
|
||||
627
data/supporters.json
Normal file
627
data/supporters.json
Normal file
@@ -0,0 +1,627 @@
|
||||
{
|
||||
"specialThanks": [
|
||||
"dispenser",
|
||||
"EbonEagle",
|
||||
"DanielMagPizza",
|
||||
"Scott R"
|
||||
],
|
||||
"allSupporters": [
|
||||
"Insomnia Art Designs",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"wackop",
|
||||
"2018cfh",
|
||||
"Takkan",
|
||||
"stone9k",
|
||||
"$MetaSamsara",
|
||||
"itismyelement",
|
||||
"onesecondinosaur",
|
||||
"Carl G.",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Gooohokrbe",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"OldBones",
|
||||
"VantAI",
|
||||
"runte3221",
|
||||
"FreelancerZ",
|
||||
"Julian V",
|
||||
"Edgar Tejeda",
|
||||
"Birdy",
|
||||
"Liam MacDougal",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"Kiba",
|
||||
"Jorge Hussni",
|
||||
"Reno Lam",
|
||||
"Skalabananen",
|
||||
"esthe",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Arlecchino Shion",
|
||||
"ClockDaemon",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"confiscated Zyra",
|
||||
"SG",
|
||||
"carozzz",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Adam Shaw",
|
||||
"Tee Gee",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Anthony Rizzo",
|
||||
"tarek helmi",
|
||||
"Cosmosis",
|
||||
"iamresist",
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"FloPro4Sho",
|
||||
"James Todd",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Steven Owens",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"lh qwe",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Felipe dos Santos",
|
||||
"Penfore",
|
||||
"BadassArabianMofo",
|
||||
"Sterilized",
|
||||
"Pascal Dahle",
|
||||
"Markus",
|
||||
"quarz",
|
||||
"Greg",
|
||||
"Douglas Gaspar",
|
||||
"JSST",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"lmsupporter",
|
||||
"Phil",
|
||||
"Charles Blakemore",
|
||||
"IamAyam",
|
||||
"wfpearl",
|
||||
"Rob Williams",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Graham Colehour",
|
||||
"M Postkasse",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"Jimmy Ledbetter",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"Bishoujoker",
|
||||
"conner",
|
||||
"aai",
|
||||
"Yaboi",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"Princess Bright Eyes",
|
||||
"Damon Cunliffe",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"AbstractAss",
|
||||
"ViperC",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"jean jahren",
|
||||
"Ran C",
|
||||
"tedcor",
|
||||
"S Sang",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"Gordon Cole",
|
||||
"yuxz69",
|
||||
"MadSpin",
|
||||
"andrew.tappan",
|
||||
"dw",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"Gamalonia",
|
||||
"fancypants",
|
||||
"Vir",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"奚明 刘",
|
||||
"Dan",
|
||||
"Seth Christensen",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"Draven T",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"Aquatic Coffee",
|
||||
"Max Marklund",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"Noora",
|
||||
"ethanfel",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Mattssn",
|
||||
"Frank Nitty",
|
||||
"John Saveas",
|
||||
"Focuschannel",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"nahinahi9",
|
||||
"Anthony Faxlandez",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Steam Steam",
|
||||
"Paul Kroll",
|
||||
"otaku fra",
|
||||
"semicolon drainpipe",
|
||||
"Thesharingbrother",
|
||||
"Fotek Design",
|
||||
"Bas Imagineer",
|
||||
"Pat Hen",
|
||||
"ResidentDeviant",
|
||||
"Adam Taylor",
|
||||
"JC",
|
||||
"Weird_With_A_Beard",
|
||||
"Prompt Pirate",
|
||||
"Pozadine1",
|
||||
"uwutismxd",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"inbijiburu",
|
||||
"decoy",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"Nick “Loadstone” D",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"CloudValley",
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"linnfrey",
|
||||
"zenobeus",
|
||||
"Jackthemind",
|
||||
"Stryker",
|
||||
"Pkrsky",
|
||||
"raf8osz",
|
||||
"blikkies",
|
||||
"Josef Lanzl",
|
||||
"Griffin Dahlberg",
|
||||
"준희 김",
|
||||
"Error_Rule34_Not_found",
|
||||
"Gerald Welly",
|
||||
"Shock Shockor",
|
||||
"Roslynd",
|
||||
"Geolog",
|
||||
"Goldwaters",
|
||||
"Neco28",
|
||||
"Zude",
|
||||
"Cristian Vazquez",
|
||||
"Kyler",
|
||||
"Magic Noob",
|
||||
"aRtFuL_DodGeR",
|
||||
"X",
|
||||
"DougPeterson",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"CrimsonDX",
|
||||
"Kevin John Duck",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"DarkSunset",
|
||||
"dd",
|
||||
"Billy Gladky",
|
||||
"Probis",
|
||||
"shrshpp",
|
||||
"Dušan Ryban",
|
||||
"ItsGeneralButtNaked",
|
||||
"sjon kreutz",
|
||||
"Nimess",
|
||||
"John Statham",
|
||||
"Youguang",
|
||||
"Nihongasuki",
|
||||
"Metryman55",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"lrdchs",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
"Piccio08",
|
||||
"kumakichi",
|
||||
"cppbel",
|
||||
"starbugx",
|
||||
"Moon Knight",
|
||||
"몽타주",
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"ryoma",
|
||||
"John Martin",
|
||||
"Chris",
|
||||
"Brian M",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"moranqianlong",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Haru Yotu",
|
||||
"Eris3D",
|
||||
"m",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Mikko Hemilä",
|
||||
"Matura Arbeit",
|
||||
"Jamie Ogletree",
|
||||
"TBitz33",
|
||||
"Emil Bernhoff",
|
||||
"a _",
|
||||
"SendingRavens",
|
||||
"James Coleman",
|
||||
"Martial",
|
||||
"battu",
|
||||
"Emil Andersson",
|
||||
"Chad Idk",
|
||||
"Michael Docherty",
|
||||
"Yuji Kaneko",
|
||||
"elitassj",
|
||||
"Jacob Winter",
|
||||
"Jordan Shaw",
|
||||
"Sam",
|
||||
"Rops Alot",
|
||||
"SRDB",
|
||||
"g unit",
|
||||
"Ace Ventura",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Pen Bouryoung",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
"khanh duy",
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"momokai",
|
||||
"Janik",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"Inversity",
|
||||
"Crocket",
|
||||
"AIVORY3D",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Cruel",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"MRBlack",
|
||||
"Kevinj",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Mitchell Robson",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Kiyoe",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"humptynutz",
|
||||
"emyth",
|
||||
"michael.isaza",
|
||||
"Kalnei",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"ResidentDeviant",
|
||||
"Scott",
|
||||
"gzmzmvp",
|
||||
"Welkor",
|
||||
"hayden",
|
||||
"Richard",
|
||||
"ahoystan",
|
||||
"Leland Saunders",
|
||||
"Andrew",
|
||||
"Bob Barker",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"mrjuan",
|
||||
"Aeternyx",
|
||||
"Brian Buie",
|
||||
"YOU SINWOO",
|
||||
"Sadlip",
|
||||
"ja s",
|
||||
"Eric Whitney",
|
||||
"Doug Mason",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"y2Rxy7FdXzWo",
|
||||
"Jeremy Townsend",
|
||||
"Mike Simone",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"Morgandel",
|
||||
"Thomas Wanner",
|
||||
"Kyron Mahan",
|
||||
"Theerat Jiramate",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"kevin stoddard",
|
||||
"Sloan Steddy",
|
||||
"Jack Dole",
|
||||
"Ezokewn",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Maso",
|
||||
"Nathan",
|
||||
"Decx _",
|
||||
"Kevin Wallace",
|
||||
"Matheus Couto",
|
||||
"Paul Hartsuyker",
|
||||
"ChicRic",
|
||||
"mercur",
|
||||
"J C",
|
||||
"Distortik",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Вячеслав Маринин",
|
||||
"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",
|
||||
"Tree Tagger",
|
||||
"gwyar",
|
||||
"D",
|
||||
"edgecase",
|
||||
"Neoxena",
|
||||
"mrmhalo",
|
||||
"dg",
|
||||
"Whitepinetrader",
|
||||
"Maarten Harms",
|
||||
"OrganicArtifact",
|
||||
"四糸凜音",
|
||||
"MudkipMedkitz",
|
||||
"Israel",
|
||||
"deanbrian",
|
||||
"POPPIN",
|
||||
"Muratoraccio",
|
||||
"SelfishMedic",
|
||||
"Ginnie",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"adderleighn",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"D",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"EnragedAntelope",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"nanana",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Buecyb99",
|
||||
"4IXplr0r3r",
|
||||
"Coeur+de+cochon",
|
||||
"David Schenck",
|
||||
"han b",
|
||||
"Nico",
|
||||
"Wolfe7D1",
|
||||
"Banana Joe",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Ink Temptation",
|
||||
"edk",
|
||||
"Michael Eid",
|
||||
"beersandbacon",
|
||||
"Maximilian Pyko",
|
||||
"Invis",
|
||||
"Kalli Core",
|
||||
"Justin Houston",
|
||||
"james",
|
||||
"elleshar666",
|
||||
"OrochiNights",
|
||||
"Michael Zhu",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"gonzalo",
|
||||
"Seraphy",
|
||||
"雨の心 落",
|
||||
"AllTimeNoobie",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Kauffy",
|
||||
"Rim",
|
||||
"Dismem",
|
||||
"EpicElric",
|
||||
"John J Linehan",
|
||||
"Xan Dionysus",
|
||||
"Nathan lee",
|
||||
"Mewtora",
|
||||
"Elliot E",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Adictedtohumping",
|
||||
"Devil Lude",
|
||||
"Nick Kage",
|
||||
"Towelie",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Jean-françois SEMA",
|
||||
"Kurt",
|
||||
"hexxish",
|
||||
"giani kidd",
|
||||
"CptNeo",
|
||||
"notedfakes",
|
||||
"Chase Kwon",
|
||||
"Goober719",
|
||||
"Eric Ketchum",
|
||||
"Chad Barnes",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"James Ming",
|
||||
"vanditking",
|
||||
"kripitonga",
|
||||
"Rizzi",
|
||||
"nimin",
|
||||
"OMAR LUCIANO",
|
||||
"Jo+Example",
|
||||
"BrentBertram",
|
||||
"eumelzocker",
|
||||
"dxjaymz",
|
||||
"L C",
|
||||
"Dude"
|
||||
],
|
||||
"totalCount": 620
|
||||
}
|
||||
363
docs/metadata-json-schema.md
Normal file
363
docs/metadata-json-schema.md
Normal 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
|
||||
140
locales/de.json
140
locales/de.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"delete": "Löschen",
|
||||
"move": "Verschieben",
|
||||
"refresh": "Aktualisieren",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "Nach oben",
|
||||
"settings": "Einstellungen",
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen"
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"noDefault": "Kein Standard"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Zusätzliche Ordnerpfade",
|
||||
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
|
||||
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA-Pfade",
|
||||
"checkpoint": "Checkpoint-Pfade",
|
||||
"unet": "Diffusionsmodell-Pfade",
|
||||
"embedding": "Embedding-Pfade"
|
||||
},
|
||||
"pathPlaceholder": "/pfad/zu/extra/modellen",
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Prioritäts-Tags",
|
||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "Passwort (optional)",
|
||||
"proxyPasswordPlaceholder": "passwort",
|
||||
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Zusätzliche Ordnerpfade",
|
||||
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
|
||||
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA-Pfade",
|
||||
"checkpoint": "Checkpoint-Pfade",
|
||||
"unet": "Diffusionsmodell-Pfade",
|
||||
"embedding": "Embedding-Pfade"
|
||||
},
|
||||
"pathPlaceholder": "/pfad/zu/extra/modellen",
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "Wenigste"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "WeChat QR-Code anzeigen",
|
||||
"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": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "Fehler beim Laden der {modelType}s: {message}",
|
||||
"refreshComplete": "Aktualisierung abgeschlossen",
|
||||
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
|
||||
"syncComplete": "Synchronisation abgeschlossen",
|
||||
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
|
||||
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
|
||||
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
|
||||
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
|
||||
"importFailed": "Import fehlgeschlagen: {message}",
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
"folderTreeError": "Fehler beim Laden des Ordnerbaums"
|
||||
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "Wiederholen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
locales/en.json
102
locales/en.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"move": "Move",
|
||||
"refresh": "Refresh",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "Back to top",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "Least"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "Failed to repair recipe: {message}",
|
||||
"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": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "Show 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": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "Failed to load {modelType}s: {message}",
|
||||
"refreshComplete": "Refresh complete",
|
||||
"refreshFailed": "Failed to refresh recipes: {message}",
|
||||
"syncComplete": "Sync complete",
|
||||
"syncFailed": "Failed to sync recipes: {message}",
|
||||
"updateFailed": "Failed to update recipe: {error}",
|
||||
"updateError": "Error updating recipe: {message}",
|
||||
"nameSaved": "Recipe \"{name}\" saved successfully",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "Failed to save recipe: {error}",
|
||||
"importFailed": "Import failed: {message}",
|
||||
"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}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No models selected",
|
||||
|
||||
140
locales/es.json
140
locales/es.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"delete": "Eliminar",
|
||||
"move": "Mover",
|
||||
"refresh": "Actualizar",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "Volver arriba",
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir"
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"noDefault": "Sin predeterminado"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Rutas de carpetas adicionales",
|
||||
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
|
||||
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
|
||||
"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.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Etiquetas prioritarias",
|
||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "Contraseña (opcional)",
|
||||
"proxyPasswordPlaceholder": "contraseña",
|
||||
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Rutas de carpetas adicionales",
|
||||
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
|
||||
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
|
||||
"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.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "Menos"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "Mostrar 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": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "Error al cargar {modelType}s: {message}",
|
||||
"refreshComplete": "Actualización completa",
|
||||
"refreshFailed": "Error al actualizar recetas: {message}",
|
||||
"syncComplete": "Sincronización completa",
|
||||
"syncFailed": "Error al sincronizar recetas: {message}",
|
||||
"updateFailed": "Error al actualizar receta: {error}",
|
||||
"updateError": "Error actualizando receta: {message}",
|
||||
"nameSaved": "Receta \"{name}\" guardada exitosamente",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "Error al guardar receta: {error}",
|
||||
"importFailed": "Importación falló: {message}",
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
"folderTreeError": "Error cargando árbol de carpetas"
|
||||
"folderTreeError": "Error cargando árbol de carpetas",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No hay modelos seleccionados",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
locales/fr.json
140
locales/fr.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"delete": "Supprimer",
|
||||
"move": "Déplacer",
|
||||
"refresh": "Actualiser",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "Retour en haut",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"add": "Ajouter"
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
@@ -361,6 +365,23 @@
|
||||
"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"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Chemins de dossiers supplémentaires",
|
||||
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
|
||||
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
|
||||
"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.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"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))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "Mot de passe (optionnel)",
|
||||
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Chemins de dossiers supplémentaires",
|
||||
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
|
||||
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
|
||||
"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.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "Moins"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "Échec de la réparation de la recette : {message}",
|
||||
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "Afficher 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": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "Échec du chargement des {modelType}s : {message}",
|
||||
"refreshComplete": "Actualisation terminée",
|
||||
"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}",
|
||||
"updateError": "Erreur lors de la mise à jour de la recipe : {message}",
|
||||
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
|
||||
"importFailed": "Échec de l'importation : {message}",
|
||||
"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": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Aucun modèle sélectionné",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
locales/he.json
152
locales/he.json
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "ביטול",
|
||||
"confirm": "אישור",
|
||||
"actions": {
|
||||
"save": "שמור",
|
||||
"save": "שמירה",
|
||||
"cancel": "ביטול",
|
||||
"delete": "מחק",
|
||||
"move": "העבר",
|
||||
"refresh": "רענן",
|
||||
"back": "חזור",
|
||||
"confirm": "אישור",
|
||||
"delete": "מחיקה",
|
||||
"move": "העברה",
|
||||
"refresh": "רענון",
|
||||
"back": "חזרה",
|
||||
"next": "הבא",
|
||||
"backToTop": "חזור למעלה",
|
||||
"backToTop": "חזרה למעלה",
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה",
|
||||
"add": "הוסף"
|
||||
"add": "הוספה",
|
||||
"close": "סגור"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"noDefault": "אין ברירת מחדל"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "נתיבי תיקיות נוספים",
|
||||
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
|
||||
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
|
||||
"modelTypes": {
|
||||
"lora": "נתיבי LoRA",
|
||||
"checkpoint": "נתיבי Checkpoint",
|
||||
"unet": "נתיבי מודל דיפוזיה",
|
||||
"embedding": "נתיבי Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/נתיב/למודלים/נוספים",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "תגיות עדיפות",
|
||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "סיסמה (אופציונלי)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "נתיבי תיקיות נוספים",
|
||||
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
|
||||
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
|
||||
"modelTypes": {
|
||||
"lora": "נתיבי LoRA",
|
||||
"checkpoint": "נתיבי Checkpoint",
|
||||
"unet": "נתיבי מודל דיפוזיה",
|
||||
"embedding": "נתיבי Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/נתיב/למודלים/נוספים",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים"
|
||||
"title": "רענן רשימת מתכונים",
|
||||
"quick": "סנכרן שינויים",
|
||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
||||
"full": "בנה מטמון מחדש",
|
||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||
},
|
||||
"filteredByLora": "מסונן לפי LoRA",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
|
||||
"newFolderName": "שם תיקייה חדשה",
|
||||
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
|
||||
"emptyFolderName": "אנא הזן שם תיקייה",
|
||||
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
|
||||
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "לא נמצאו תיקיות",
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "הצג קוד QR של WeChat",
|
||||
"hideWechatQR": "הסתר קוד QR של WeChat"
|
||||
},
|
||||
"footer": "תודה על השימוש במנהל LoRA! ❤️"
|
||||
"footer": "תודה על השימוש במנהל LoRA! ❤️",
|
||||
"supporters": {
|
||||
"title": "תודה לכל התומכים",
|
||||
"subtitle": "תודה ל־{count} תומכים שהפכו את הפרויקט הזה לאפשרי",
|
||||
"specialThanks": "תודה מיוחדת",
|
||||
"allSupporters": "כל התומכים",
|
||||
"totalCount": "{count} תומכים בסך הכל"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "טעינת {modelType}s נכשלה: {message}",
|
||||
"refreshComplete": "הרענון הושלם",
|
||||
"refreshFailed": "רענון המתכונים נכשל: {message}",
|
||||
"syncComplete": "הסנכרון הושלם",
|
||||
"syncFailed": "סנכרון המתכונים נכשל: {message}",
|
||||
"updateFailed": "עדכון המתכון נכשל: {error}",
|
||||
"updateError": "שגיאה בעדכון המתכון: {message}",
|
||||
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
|
||||
"importFailed": "הייבוא נכשל: {message}",
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
"folderTreeError": "שגיאה בטעינת עץ התיקיות"
|
||||
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "לא נבחרו מודלים",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "נסה שוב"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
locales/ja.json
142
locales/ja.json
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"delete": "削除",
|
||||
"move": "移動",
|
||||
"refresh": "更新",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"backToTop": "トップに戻る",
|
||||
"backToTop": "トップへ戻る",
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ",
|
||||
"add": "追加"
|
||||
"add": "追加",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||
"noDefault": "デフォルトなし"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "追加フォルダーパス",
|
||||
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
|
||||
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRAパス",
|
||||
"checkpoint": "Checkpointパス",
|
||||
"unet": "Diffusionモデルパス",
|
||||
"embedding": "Embeddingパス"
|
||||
},
|
||||
"pathPlaceholder": "/追加モデルへのパス",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先タグ",
|
||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"proxyPasswordPlaceholder": "パスワード",
|
||||
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "追加フォルダーパス",
|
||||
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
|
||||
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRAパス",
|
||||
"checkpoint": "Checkpointパス",
|
||||
"unet": "Diffusionモデルパス",
|
||||
"embedding": "Embeddingパス"
|
||||
},
|
||||
"pathPlaceholder": "/追加モデルへのパス",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新"
|
||||
"title": "レシピリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||
},
|
||||
"filteredByLora": "LoRAでフィルタ済み",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||
"createFolderHint": "放して新しいフォルダを作成",
|
||||
"newFolderName": "新しいフォルダ名",
|
||||
"folderNameHint": "Enterで確定、Escでキャンセル",
|
||||
"emptyFolderName": "フォルダ名を入力してください",
|
||||
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
|
||||
"noDragState": "保留中のドラッグ操作が見つかりません"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "フォルダが見つかりません",
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "WeChat QRコードを表示",
|
||||
"hideWechatQR": "WeChat QRコードを非表示"
|
||||
},
|
||||
"footer": "LoRA Managerをご利用いただきありがとうございます! ❤️"
|
||||
"footer": "LoRA Managerをご利用いただきありがとうございます! ❤️",
|
||||
"supporters": {
|
||||
"title": "サポーターの皆様に感謝",
|
||||
"subtitle": "{count} 名のサポーターの皆様に、このプロジェクトを実現していただきありがとうございます",
|
||||
"specialThanks": "特別感謝",
|
||||
"allSupporters": "全サポーター",
|
||||
"totalCount": "サポーター {count} 名"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "{modelType}の読み込みに失敗しました:{message}",
|
||||
"refreshComplete": "更新完了",
|
||||
"refreshFailed": "レシピの更新に失敗しました:{message}",
|
||||
"syncComplete": "同期完了",
|
||||
"syncFailed": "レシピの同期に失敗しました:{message}",
|
||||
"updateFailed": "レシピの更新に失敗しました:{error}",
|
||||
"updateError": "レシピ更新エラー:{message}",
|
||||
"nameSaved": "レシピ\"{name}\"が正常に保存されました",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
|
||||
"importFailed": "インポートに失敗しました:{message}",
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
"folderTreeError": "フォルダツリー読み込みエラー"
|
||||
"folderTreeError": "フォルダツリー読み込みエラー",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "再試行"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
locales/ko.json
140
locales/ko.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"actions": {
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"delete": "삭제",
|
||||
"move": "이동",
|
||||
"refresh": "새로고침",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "맨 위로",
|
||||
"settings": "설정",
|
||||
"help": "도움말",
|
||||
"add": "추가"
|
||||
"add": "추가",
|
||||
"close": "닫기"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||
"noDefault": "기본값 없음"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "추가 폴다 경로",
|
||||
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
|
||||
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 경로",
|
||||
"checkpoint": "Checkpoint 경로",
|
||||
"unet": "Diffusion 모델 경로",
|
||||
"embedding": "Embedding 경로"
|
||||
},
|
||||
"pathPlaceholder": "/추가/모델/경로",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "우선순위 태그",
|
||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "비밀번호 (선택사항)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "추가 폴다 경로",
|
||||
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
|
||||
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 경로",
|
||||
"checkpoint": "Checkpoint 경로",
|
||||
"unet": "Diffusion 모델 경로",
|
||||
"embedding": "Embedding 경로"
|
||||
},
|
||||
"pathPlaceholder": "/추가/모델/경로",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "적은순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침"
|
||||
"title": "레시피 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||
},
|
||||
"filteredByLora": "LoRA로 필터링됨",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||
"createFolderHint": "놓아서 새 폴더 만들기",
|
||||
"newFolderName": "새 폴더 이름",
|
||||
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
|
||||
"emptyFolderName": "폴더 이름을 입력하세요",
|
||||
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
|
||||
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "WeChat QR 코드 표시",
|
||||
"hideWechatQR": "WeChat QR 코드 숨기기"
|
||||
},
|
||||
"footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️"
|
||||
"footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️",
|
||||
"supporters": {
|
||||
"title": "후원자 분들께 감사드립니다",
|
||||
"subtitle": "이 프로젝트를 가능하게 해준 {count}명의 후원자분들께 감사드립니다",
|
||||
"specialThanks": "특별 감사",
|
||||
"allSupporters": "모든 후원자",
|
||||
"totalCount": "총 {count}명의 후원자"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "{modelType} 로딩 실패: {message}",
|
||||
"refreshComplete": "새로고침 완료",
|
||||
"refreshFailed": "레시피 새로고침 실패: {message}",
|
||||
"syncComplete": "동기화 완료",
|
||||
"syncFailed": "레시피 동기화 실패: {message}",
|
||||
"updateFailed": "레시피 업데이트 실패: {error}",
|
||||
"updateError": "레시피 업데이트 오류: {message}",
|
||||
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "레시피 저장 실패: {error}",
|
||||
"importFailed": "가져오기 실패: {message}",
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
"folderTreeError": "폴더 트리 로딩 오류"
|
||||
"folderTreeError": "폴더 트리 로딩 오류",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
locales/ru.json
140
locales/ru.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"delete": "Удалить",
|
||||
"move": "Переместить",
|
||||
"refresh": "Обновить",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "Наверх",
|
||||
"settings": "Настройки",
|
||||
"help": "Справка",
|
||||
"add": "Добавить"
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||
"noDefault": "Не задано"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Дополнительные пути к папкам",
|
||||
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
|
||||
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
|
||||
"modelTypes": {
|
||||
"lora": "Пути LoRA",
|
||||
"checkpoint": "Пути Checkpoint",
|
||||
"unet": "Пути моделей диффузии",
|
||||
"embedding": "Пути Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/путь/к/дополнительным/моделям",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Приоритетные теги",
|
||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "Пароль (необязательно)",
|
||||
"proxyPasswordPlaceholder": "пароль",
|
||||
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Дополнительные пути к папкам",
|
||||
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
|
||||
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
|
||||
"modelTypes": {
|
||||
"lora": "Пути LoRA",
|
||||
"checkpoint": "Пути Checkpoint",
|
||||
"unet": "Пути моделей диффузии",
|
||||
"embedding": "Пути Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/путь/к/дополнительным/моделям",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "Меньше всего"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов"
|
||||
"title": "Обновить список рецептов",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||
},
|
||||
"filteredByLora": "Фильтр по LoRA",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||
"createFolderHint": "Отпустите, чтобы создать новую папку",
|
||||
"newFolderName": "Имя новой папки",
|
||||
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
|
||||
"emptyFolderName": "Пожалуйста, введите имя папки",
|
||||
"invalidFolderName": "Имя папки содержит недопустимые символы",
|
||||
"noDragState": "Ожидающая операция перетаскивания не найдена"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Папки не найдены",
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "Показать QR-код WeChat",
|
||||
"hideWechatQR": "Скрыть QR-код WeChat"
|
||||
},
|
||||
"footer": "Спасибо за использование LoRA Manager! ❤️"
|
||||
"footer": "Спасибо за использование LoRA Manager! ❤️",
|
||||
"supporters": {
|
||||
"title": "Спасибо всем сторонникам",
|
||||
"subtitle": "Спасибо {count} сторонникам, которые сделали этот проект возможным",
|
||||
"specialThanks": "Особая благодарность",
|
||||
"allSupporters": "Все сторонники",
|
||||
"totalCount": "Всего {count} сторонников"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "Не удалось загрузить {modelType}s: {message}",
|
||||
"refreshComplete": "Обновление завершено",
|
||||
"refreshFailed": "Не удалось обновить рецепты: {message}",
|
||||
"syncComplete": "Синхронизация завершена",
|
||||
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
|
||||
"updateFailed": "Не удалось обновить рецепт: {error}",
|
||||
"updateError": "Ошибка обновления рецепта: {message}",
|
||||
"nameSaved": "Рецепт \"{name}\" успешно сохранен",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
|
||||
"importFailed": "Импорт не удался: {message}",
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
"folderTreeError": "Ошибка загрузки дерева папок"
|
||||
"folderTreeError": "Ошибка загрузки дерева папок",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Модели не выбраны",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "Повторить"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"delete": "删除",
|
||||
"move": "移动",
|
||||
"refresh": "刷新",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "返回顶部",
|
||||
"settings": "设置",
|
||||
"help": "帮助",
|
||||
"add": "添加"
|
||||
"add": "添加",
|
||||
"close": "关闭"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
@@ -159,11 +163,11 @@
|
||||
"error": "清理示例图片文件夹失败:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
"label": "刷新许可证元数据",
|
||||
"loading": "正在刷新 {typePlural} 的许可证元数据...",
|
||||
"success": "已更新 {count} 个 {typePlural} 的许可证元数据",
|
||||
"none": "所有 {typePlural} 都已具备许可证元数据",
|
||||
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修复配方数据",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||
"noDefault": "无默认"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "额外文件夹路径",
|
||||
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
|
||||
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路径",
|
||||
"checkpoint": "Checkpoint 路径",
|
||||
"unet": "Diffusion 模型路径",
|
||||
"embedding": "Embedding 路径"
|
||||
},
|
||||
"pathPlaceholder": "/额外/模型/路径",
|
||||
"saveSuccess": "额外文件夹路径已更新。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "优先标签",
|
||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "密码 (可选)",
|
||||
"proxyPasswordPlaceholder": "密码",
|
||||
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "额外文件夹路径",
|
||||
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
|
||||
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路径",
|
||||
"checkpoint": "Checkpoint 路径",
|
||||
"unet": "Diffusion 模型路径",
|
||||
"embedding": "Embedding 路径"
|
||||
},
|
||||
"pathPlaceholder": "/额外/模型/路径",
|
||||
"saveSuccess": "额外文件夹路径已更新。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表"
|
||||
"title": "刷新配方列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||
},
|
||||
"filteredByLora": "按 LoRA 筛选",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "批量导入配方",
|
||||
"action": "批量导入",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
|
||||
"urlsLabel": "图片 URL 或本地路径",
|
||||
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "/图片/文件夹/路径",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "标签(可选,应用于所有配方)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "跳过无元数据的图片",
|
||||
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "开始导入",
|
||||
"importing": "正在导入配方...",
|
||||
"progress": "进度",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "当前",
|
||||
"preparing": "准备中...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "取消",
|
||||
"cancelled": "批量导入已取消",
|
||||
"completed": "导入完成",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "成功导入 {count} 个配方",
|
||||
"successCount": "成功",
|
||||
"failedCount": "失败",
|
||||
"skippedCount": "跳过",
|
||||
"totalProcessed": "总计处理",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "请至少输入一个 URL 或路径",
|
||||
"enterDirectory": "请输入目录路径",
|
||||
"startFailed": "启动导入失败:{message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "显示微信二维码",
|
||||
"hideWechatQR": "隐藏微信二维码"
|
||||
},
|
||||
"footer": "感谢使用 LoRA 管理器!❤️"
|
||||
"footer": "感谢使用 LoRA 管理器!❤️",
|
||||
"supporters": {
|
||||
"title": "感谢所有支持者",
|
||||
"subtitle": "感谢 {count} 位支持者让这个项目成为可能",
|
||||
"specialThanks": "特别感谢",
|
||||
"allSupporters": "所有支持者",
|
||||
"totalCount": "共 {count} 位支持者"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "加载 {modelType} 失败:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失败:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失败:{message}",
|
||||
"updateFailed": "更新配方失败:{error}",
|
||||
"updateError": "更新配方出错:{message}",
|
||||
"nameSaved": "配方“{name}”保存成功",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "保存配方失败:{error}",
|
||||
"importFailed": "导入失败:{message}",
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
"folderTreeError": "加载文件夹树出错"
|
||||
"folderTreeError": "加载文件夹树出错",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"actions": {
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"delete": "刪除",
|
||||
"move": "移動",
|
||||
"refresh": "重新整理",
|
||||
@@ -11,7 +14,8 @@
|
||||
"backToTop": "回到頂部",
|
||||
"settings": "設定",
|
||||
"help": "說明",
|
||||
"add": "新增"
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
@@ -219,7 +223,7 @@
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
@@ -361,6 +365,23 @@
|
||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||
"noDefault": "未設定預設"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "額外資料夾路徑",
|
||||
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
|
||||
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路徑",
|
||||
"checkpoint": "Checkpoint 路徑",
|
||||
"unet": "Diffusion 模型路徑",
|
||||
"embedding": "Embedding 路徑"
|
||||
},
|
||||
"pathPlaceholder": "/額外/模型/路徑",
|
||||
"saveSuccess": "額外資料夾路徑已更新。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先標籤",
|
||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||
@@ -485,23 +506,6 @@
|
||||
"proxyPassword": "密碼(選填)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "額外資料夾路徑",
|
||||
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
|
||||
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路徑",
|
||||
"checkpoint": "Checkpoint 路徑",
|
||||
"unet": "Diffusion 模型路徑",
|
||||
"embedding": "Embedding 路徑"
|
||||
},
|
||||
"pathPlaceholder": "/額外/模型/路徑",
|
||||
"saveSuccess": "額外資料夾路徑已更新。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -682,7 +686,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表"
|
||||
"title": "重新整理配方列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||
},
|
||||
"filteredByLora": "已依 LoRA 篩選",
|
||||
"favorites": {
|
||||
@@ -722,6 +730,64 @@
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "[TODO: Translate] Batch Import Recipes",
|
||||
"action": "[TODO: Translate] Batch Import",
|
||||
"urlList": "[TODO: Translate] URL List",
|
||||
"directory": "[TODO: Translate] Directory",
|
||||
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
|
||||
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
|
||||
"directoryPath": "[TODO: Translate] Directory Path",
|
||||
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
|
||||
"browse": "[TODO: Translate] Browse",
|
||||
"recursive": "[TODO: Translate] Include subdirectories",
|
||||
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
|
||||
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
|
||||
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "[TODO: Translate] Start Import",
|
||||
"startImport": "[TODO: Translate] Start Import",
|
||||
"importing": "[TODO: Translate] Importing...",
|
||||
"progress": "[TODO: Translate] Progress",
|
||||
"total": "[TODO: Translate] Total",
|
||||
"success": "[TODO: Translate] Success",
|
||||
"failed": "[TODO: Translate] Failed",
|
||||
"skipped": "[TODO: Translate] Skipped",
|
||||
"current": "[TODO: Translate] Current",
|
||||
"currentItem": "[TODO: Translate] Current",
|
||||
"preparing": "[TODO: Translate] Preparing...",
|
||||
"cancel": "[TODO: Translate] Cancel",
|
||||
"cancelImport": "[TODO: Translate] Cancel",
|
||||
"cancelled": "[TODO: Translate] Import cancelled",
|
||||
"completed": "[TODO: Translate] Import completed",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
|
||||
"successCount": "[TODO: Translate] Successful",
|
||||
"failedCount": "[TODO: Translate] Failed",
|
||||
"skippedCount": "[TODO: Translate] Skipped",
|
||||
"totalProcessed": "[TODO: Translate] Total processed",
|
||||
"viewDetails": "[TODO: Translate] View Details",
|
||||
"newImport": "[TODO: Translate] New Import",
|
||||
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
|
||||
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "[TODO: Translate] Back to parent directory",
|
||||
"folders": "[TODO: Translate] Folders",
|
||||
"folderCount": "[TODO: Translate] {count} folders",
|
||||
"imageFiles": "[TODO: Translate] Image Files",
|
||||
"images": "[TODO: Translate] images",
|
||||
"imageCount": "[TODO: Translate] {count} images",
|
||||
"selectFolder": "[TODO: Translate] Select This Folder",
|
||||
"errors": {
|
||||
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
|
||||
"enterDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"startFailed": "[TODO: Translate] Failed to start import: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -750,7 +816,17 @@
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"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": {
|
||||
@@ -1342,7 +1418,14 @@
|
||||
"showWechatQR": "顯示微信二維碼",
|
||||
"hideWechatQR": "隱藏微信二維碼"
|
||||
},
|
||||
"footer": "感謝您使用 LoRA 管理器!❤️"
|
||||
"footer": "感謝您使用 LoRA 管理器!❤️",
|
||||
"supporters": {
|
||||
"title": "感謝所有支持者",
|
||||
"subtitle": "感謝 {count} 位支持者讓這個專案成為可能",
|
||||
"specialThanks": "特別感謝",
|
||||
"allSupporters": "所有支持者",
|
||||
"totalCount": "共 {count} 位支持者"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"general": {
|
||||
@@ -1376,6 +1459,8 @@
|
||||
"loadFailed": "載入 {modelType} 失敗:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失敗:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失敗:{message}",
|
||||
"updateFailed": "更新配方失敗:{error}",
|
||||
"updateError": "更新配方錯誤:{message}",
|
||||
"nameSaved": "配方「{name}」已成功儲存",
|
||||
@@ -1412,7 +1497,14 @@
|
||||
"recipeSaveFailed": "儲存配方失敗:{error}",
|
||||
"importFailed": "匯入失敗:{message}",
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
"folderTreeError": "載入資料夾樹狀結構錯誤"
|
||||
"folderTreeError": "載入資料夾樹狀結構錯誤",
|
||||
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
|
||||
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
|
||||
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
@@ -1651,4 +1743,4 @@
|
||||
"retry": "重試"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -114,7 +114,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -138,7 +137,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1613,7 +1611,6 @@
|
||||
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.0.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
||||
319
py/config.py
319
py/config.py
@@ -2,7 +2,7 @@ import os
|
||||
import platform
|
||||
import threading
|
||||
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
|
||||
import logging
|
||||
import json
|
||||
@@ -10,16 +10,23 @@ import urllib.parse
|
||||
import time
|
||||
|
||||
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
|
||||
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__)
|
||||
|
||||
|
||||
def _normalize_folder_paths_for_comparison(
|
||||
folder_paths: Mapping[str, Iterable[str]]
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Normalize folder paths for comparison across libraries."""
|
||||
|
||||
@@ -49,7 +56,7 @@ def _normalize_folder_paths_for_comparison(
|
||||
|
||||
|
||||
def _normalize_library_folder_paths(
|
||||
library_payload: Mapping[str, Any]
|
||||
library_payload: Mapping[str, Any],
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths extracted from a library payload."""
|
||||
|
||||
@@ -74,11 +81,17 @@ def _get_template_folder_paths() -> Dict[str, Set[str]]:
|
||||
|
||||
class Config:
|
||||
"""Global configuration for LoRA Manager"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
|
||||
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')
|
||||
self.templates_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "templates"
|
||||
)
|
||||
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
|
||||
self._path_mappings: Dict[str, str] = {}
|
||||
# Normalized preview root directories used to validate preview access
|
||||
@@ -98,7 +111,7 @@ class Config:
|
||||
self.extra_embeddings_roots: List[str] = []
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
|
||||
if not standalone_mode:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
self.save_folder_paths_to_settings()
|
||||
@@ -152,17 +165,21 @@ class Config:
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
target_folder_paths = {
|
||||
'loras': list(self.loras_roots),
|
||||
'checkpoints': list(self.checkpoints_roots or []),
|
||||
'unet': list(self.unet_roots or []),
|
||||
'embeddings': list(self.embeddings_roots or []),
|
||||
"loras": list(self.loras_roots),
|
||||
"checkpoints": list(self.checkpoints_roots or []),
|
||||
"unet": list(self.unet_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
|
||||
if isinstance(default_library, Mapping):
|
||||
normalized_default_paths = _normalize_library_folder_paths(default_library)
|
||||
normalized_default_paths = _normalize_library_folder_paths(
|
||||
default_library
|
||||
)
|
||||
|
||||
if (
|
||||
not comfy_library
|
||||
@@ -185,13 +202,19 @@ class Config:
|
||||
default_lora_root = self.loras_roots[0]
|
||||
|
||||
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
|
||||
if (not default_checkpoint_root and self.checkpoints_roots and
|
||||
len(self.checkpoints_roots) == 1):
|
||||
if (
|
||||
not default_checkpoint_root
|
||||
and self.checkpoints_roots
|
||||
and len(self.checkpoints_roots) == 1
|
||||
):
|
||||
default_checkpoint_root = self.checkpoints_roots[0]
|
||||
|
||||
default_embedding_root = comfy_library.get("default_embedding_root", "")
|
||||
if (not default_embedding_root and self.embeddings_roots and
|
||||
len(self.embeddings_roots) == 1):
|
||||
if (
|
||||
not default_embedding_root
|
||||
and self.embeddings_roots
|
||||
and len(self.embeddings_roots) == 1
|
||||
):
|
||||
default_embedding_root = self.embeddings_roots[0]
|
||||
|
||||
metadata = dict(comfy_library.get("metadata", {}))
|
||||
@@ -216,11 +239,12 @@ class Config:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
return True
|
||||
if platform.system() == 'Windows':
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Windows reparse point: {e}")
|
||||
@@ -233,18 +257,19 @@ class Config:
|
||||
"""Check if a directory entry is a symlink, including Windows junctions."""
|
||||
if entry.is_symlink():
|
||||
return True
|
||||
if platform.system() == 'Windows':
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
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:
|
||||
canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
@@ -278,19 +303,18 @@ class Config:
|
||||
if self._entry_is_symlink(entry):
|
||||
try:
|
||||
target = os.path.realpath(entry.path)
|
||||
direct_symlinks.append([
|
||||
self._normalize_path(entry.path),
|
||||
self._normalize_path(target)
|
||||
])
|
||||
direct_symlinks.append(
|
||||
[
|
||||
self._normalize_path(entry.path),
|
||||
self._normalize_path(target),
|
||||
]
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"roots": unique_roots,
|
||||
"direct_symlinks": sorted(direct_symlinks)
|
||||
}
|
||||
return {"roots": unique_roots, "direct_symlinks": sorted(direct_symlinks)}
|
||||
|
||||
def _initialize_symlink_mappings(self) -> None:
|
||||
start = time.perf_counter()
|
||||
@@ -307,10 +331,14 @@ class Config:
|
||||
cached_fingerprint = self._cached_fingerprint
|
||||
|
||||
# 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)
|
||||
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:
|
||||
return
|
||||
@@ -370,7 +398,9 @@ class Config:
|
||||
for target, link in cached_mappings.items():
|
||||
if not isinstance(target, str) or not isinstance(link, str):
|
||||
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
|
||||
|
||||
@@ -391,7 +421,9 @@ class Config:
|
||||
parent_dir = loaded_path.parent
|
||||
if parent_dir.name == "cache" and not any(parent_dir.iterdir()):
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -402,7 +434,9 @@ class Config:
|
||||
exc,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -414,7 +448,7 @@ class Config:
|
||||
"""
|
||||
for target, link in self._path_mappings.items():
|
||||
# Convert normalized paths back to OS paths
|
||||
link_path = link.replace('/', os.sep)
|
||||
link_path = link.replace("/", os.sep)
|
||||
|
||||
# Check if symlink still exists
|
||||
if not self._is_link(link_path):
|
||||
@@ -427,7 +461,9 @@ class Config:
|
||||
if actual_target != target:
|
||||
logger.debug(
|
||||
"Symlink target changed: %s -> %s (cached: %s)",
|
||||
link_path, actual_target, target
|
||||
link_path,
|
||||
actual_target,
|
||||
target,
|
||||
)
|
||||
return False
|
||||
except OSError:
|
||||
@@ -446,7 +482,11 @@ class Config:
|
||||
try:
|
||||
with cache_path.open("w", encoding="utf-8") as handle:
|
||||
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:
|
||||
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
||||
|
||||
@@ -458,7 +498,7 @@ class Config:
|
||||
at the root level only (not nested symlinks in subdirectories).
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
|
||||
|
||||
# Reset mappings before rescanning to avoid stale entries
|
||||
self._path_mappings.clear()
|
||||
self._seed_root_symlink_mappings()
|
||||
@@ -472,7 +512,7 @@ class Config:
|
||||
|
||||
def _scan_first_level_symlinks(self, root: str):
|
||||
"""Scan only the first level of a directory for symlinks.
|
||||
|
||||
|
||||
This avoids traversing the entire directory tree which can be extremely
|
||||
slow for large model collections. Only symlinks directly under the root
|
||||
are detected.
|
||||
@@ -494,13 +534,13 @@ class Config:
|
||||
self.add_path_mapping(entry.path, target_path)
|
||||
except Exception as inner_exc:
|
||||
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:
|
||||
logger.error(f"Error scanning links in {root}: {e}")
|
||||
|
||||
|
||||
|
||||
def add_path_mapping(self, link_path: str, target_path: str):
|
||||
"""Add a symbolic link path mapping
|
||||
target_path: actual target path
|
||||
@@ -594,41 +634,46 @@ class Config:
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
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(
|
||||
"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.loras_roots or []), len(self.extra_loras_roots or []),
|
||||
len(self.base_models_roots or []), len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []), len(self.extra_embeddings_roots or []),
|
||||
len(self.loras_roots or []),
|
||||
len(self.extra_loras_roots or []),
|
||||
len(self.base_models_roots or []),
|
||||
len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
len(self.extra_embeddings_roots or []),
|
||||
len(self._path_mappings),
|
||||
)
|
||||
|
||||
def map_path_to_link(self, path: str) -> str:
|
||||
"""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
|
||||
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)
|
||||
if normalized_path == target_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
|
||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return normalized_path
|
||||
|
||||
|
||||
def map_link_to_path(self, link_path: str) -> str:
|
||||
"""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
|
||||
for target_path, link_path_mapped in self._path_mappings.items():
|
||||
# Match whole path components
|
||||
if normalized_link == link_path_mapped:
|
||||
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
|
||||
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
|
||||
return mapped_path
|
||||
@@ -641,8 +686,8 @@ class Config:
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
normalized = os.path.normpath(path).replace(os.sep, '/')
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, "/")
|
||||
normalized = os.path.normpath(path).replace(os.sep, "/")
|
||||
if real_path not in dedup:
|
||||
dedup[real_path] = normalized
|
||||
return dedup
|
||||
@@ -652,7 +697,9 @@ class Config:
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
|
||||
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:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
@@ -660,7 +707,13 @@ class Config:
|
||||
|
||||
def _prepare_checkpoint_paths(
|
||||
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)
|
||||
unet_map = self._dedupe_existing_paths(unet_paths)
|
||||
|
||||
@@ -674,7 +727,7 @@ class Config:
|
||||
"Please fix your ComfyUI path configuration to separate these folders. "
|
||||
"Falling back to 'checkpoints' for backward compatibility. "
|
||||
"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
|
||||
for rp in overlapping_real_paths:
|
||||
@@ -690,22 +743,26 @@ class Config:
|
||||
|
||||
checkpoint_values = set(checkpoint_map.values())
|
||||
unet_values = set(unet_map.values())
|
||||
self.checkpoints_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]
|
||||
checkpoint_roots = [p for p in unique_paths if p in checkpoint_values]
|
||||
unet_roots = [p for p in unique_paths if p in unet_values]
|
||||
|
||||
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:
|
||||
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]:
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
|
||||
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:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
@@ -719,28 +776,61 @@ class Config:
|
||||
self._path_mappings.clear()
|
||||
self._preview_root_paths = set()
|
||||
|
||||
lora_paths = folder_paths.get('loras', []) or []
|
||||
checkpoint_paths = folder_paths.get('checkpoints', []) or []
|
||||
unet_paths = folder_paths.get('unet', []) or []
|
||||
embedding_paths = folder_paths.get('embeddings', []) or []
|
||||
lora_paths = folder_paths.get("loras", []) or []
|
||||
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
||||
unet_paths = folder_paths.get("unet", []) or []
|
||||
embedding_paths = folder_paths.get("embeddings", []) or []
|
||||
|
||||
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)
|
||||
|
||||
# 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 []
|
||||
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 []
|
||||
|
||||
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
|
||||
self.extra_checkpoints_roots = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths)
|
||||
self.extra_embeddings_roots = self._prepare_embedding_paths(extra_embedding_paths)
|
||||
# extra_unet_roots is set by _prepare_checkpoint_paths (access unet_roots before it's reset)
|
||||
unet_roots_value: List[str] = getattr(self, 'unet_roots', None) or []
|
||||
self.extra_unet_roots = unet_roots_value
|
||||
(
|
||||
_,
|
||||
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()
|
||||
|
||||
@@ -749,7 +839,10 @@ class Config:
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths("loras")
|
||||
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:
|
||||
logger.warning("No valid loras folders found in ComfyUI configuration")
|
||||
@@ -765,12 +858,21 @@ class Config:
|
||||
try:
|
||||
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
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:
|
||||
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
|
||||
logger.warning(
|
||||
"No valid checkpoint folders found in ComfyUI configuration"
|
||||
)
|
||||
return []
|
||||
|
||||
return unique_paths
|
||||
@@ -783,10 +885,15 @@ class Config:
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths("embeddings")
|
||||
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:
|
||||
logger.warning("No valid embeddings folders found in ComfyUI configuration")
|
||||
logger.warning(
|
||||
"No valid embeddings folders found in ComfyUI configuration"
|
||||
)
|
||||
return []
|
||||
|
||||
return unique_paths
|
||||
@@ -798,13 +905,13 @@ class Config:
|
||||
if not preview_path:
|
||||
return ""
|
||||
|
||||
normalized = os.path.normpath(preview_path).replace(os.sep, '/')
|
||||
encoded_path = urllib.parse.quote(normalized, safe='')
|
||||
return f'/api/lm/previews?path={encoded_path}'
|
||||
normalized = os.path.normpath(preview_path).replace(os.sep, "/")
|
||||
encoded_path = urllib.parse.quote(normalized, safe="")
|
||||
return f"/api/lm/previews?path={encoded_path}"
|
||||
|
||||
def is_preview_path_allowed(self, preview_path: str) -> bool:
|
||||
"""Return ``True`` if ``preview_path`` is within an allowed directory.
|
||||
|
||||
|
||||
If the path is initially rejected, attempts to discover deep symlinks
|
||||
that were not scanned during initialization. If a symlink is found,
|
||||
updates the in-memory path mappings and retries the check.
|
||||
@@ -875,14 +982,18 @@ class Config:
|
||||
normalized_link = self._normalize_path(str(current))
|
||||
|
||||
self._path_mappings[normalized_target] = normalized_link
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||
self._preview_root_paths.update(
|
||||
self._expand_preview_root(normalized_target)
|
||||
)
|
||||
self._preview_root_paths.update(
|
||||
self._expand_preview_root(normalized_link)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Discovered deep symlink: %s -> %s (preview path: %s)",
|
||||
normalized_link,
|
||||
normalized_target,
|
||||
preview_path
|
||||
preview_path,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -900,8 +1011,16 @@ class Config:
|
||||
|
||||
def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
|
||||
"""Update runtime paths to match the provided library configuration."""
|
||||
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
|
||||
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):
|
||||
folder_paths = {}
|
||||
if not isinstance(extra_folder_paths, Mapping):
|
||||
@@ -911,9 +1030,12 @@ class Config:
|
||||
|
||||
logger.info(
|
||||
"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.extra_loras_roots or []),
|
||||
len(self.base_models_roots or []), len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []), len(self.extra_embeddings_roots or []),
|
||||
len(self.loras_roots or []),
|
||||
len(self.extra_loras_roots or []),
|
||||
len(self.base_models_roots or []),
|
||||
len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
len(self.extra_embeddings_roots or []),
|
||||
)
|
||||
|
||||
def get_library_registry_snapshot(self) -> Dict[str, object]:
|
||||
@@ -933,5 +1055,6 @@ class Config:
|
||||
logger.debug("Failed to collect library registry snapshot: %s", exc)
|
||||
return {"active_library": "", "libraries": {}}
|
||||
|
||||
|
||||
# Global config instance
|
||||
config = Config()
|
||||
|
||||
@@ -5,16 +5,22 @@ import logging
|
||||
from .utils.logging_config import setup_logging
|
||||
|
||||
# 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
|
||||
if not standalone_mode:
|
||||
setup_logging()
|
||||
|
||||
from server import PromptServer # type: ignore
|
||||
from server import PromptServer # type: ignore
|
||||
|
||||
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.stats_routes import StatsRoutes
|
||||
from .routes.update_routes import UpdateRoutes
|
||||
@@ -61,9 +67,10 @@ class _SettingsProxy:
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
|
||||
class LoraManager:
|
||||
"""Main entry point for LoRA Manager plugin"""
|
||||
|
||||
|
||||
@classmethod
|
||||
def add_routes(cls):
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
@@ -76,7 +83,8 @@ class LoraManager:
|
||||
(
|
||||
idx
|
||||
for idx, middleware in enumerate(app.middlewares)
|
||||
if getattr(middleware, "__name__", "") == "block_external_middleware"
|
||||
if getattr(middleware, "__name__", "")
|
||||
== "block_external_middleware"
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -84,7 +92,9 @@ class LoraManager:
|
||||
if block_middleware_index is None:
|
||||
app.middlewares.append(relax_csp_for_remote_media)
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
class ConnectionResetFilter(logging.Filter):
|
||||
@@ -124,50 +134,89 @@ class LoraManager:
|
||||
asyncio_logger.addFilter(ConnectionResetFilter())
|
||||
|
||||
# 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}")
|
||||
if example_images_path and os.path.exists(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}")
|
||||
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}"
|
||||
)
|
||||
|
||||
# Add static route for locales JSON files
|
||||
if os.path.exists(config.i18n_path):
|
||||
app.router.add_static('/locales', config.i18n_path)
|
||||
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
||||
app.router.add_static("/locales", config.i18n_path)
|
||||
logger.info(
|
||||
f"Added static route for locales: /locales -> {config.i18n_path}"
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
# Setup all model routes using the factory
|
||||
ModelServiceFactory.setup_all_routes(app)
|
||||
|
||||
|
||||
# Setup non-model-specific routes
|
||||
stats_routes = StatsRoutes()
|
||||
stats_routes.setup_routes(app)
|
||||
RecipeRoutes.setup_routes(app)
|
||||
UpdateRoutes.setup_routes(app)
|
||||
UpdateRoutes.setup_routes(app)
|
||||
MiscRoutes.setup_routes(app)
|
||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||
PreviewRoutes.setup_routes(app)
|
||||
|
||||
|
||||
# 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/download-progress', ws_manager.handle_download_connection)
|
||||
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection)
|
||||
|
||||
# Schedule service initialization
|
||||
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("/ws/init-progress", ws_manager.handle_init_connection)
|
||||
|
||||
# Schedule service initialization
|
||||
app.on_startup.append(lambda app: cls._initialize_services())
|
||||
|
||||
|
||||
# Add cleanup
|
||||
app.on_shutdown.append(cls._cleanup)
|
||||
|
||||
|
||||
@classmethod
|
||||
async def _initialize_services(cls):
|
||||
"""Initialize all services using the ServiceRegistry"""
|
||||
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
|
||||
await ServiceRegistry.get_civitai_client()
|
||||
|
||||
@@ -175,163 +224,200 @@ class LoraManager:
|
||||
await ServiceRegistry.get_download_manager()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
|
||||
await initialize_metadata_providers()
|
||||
|
||||
|
||||
# Initialize WebSocket manager
|
||||
await ServiceRegistry.get_websocket_manager()
|
||||
|
||||
|
||||
# Initialize scanners in background
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
|
||||
# Initialize recipe scanner if needed
|
||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
|
||||
|
||||
# Create low-priority initialization tasks
|
||||
init_tasks = [
|
||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_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')
|
||||
asyncio.create_task(
|
||||
lora_scanner.initialize_in_background(), name="lora_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()
|
||||
|
||||
|
||||
# Schedule post-initialization tasks to run after scanners complete
|
||||
asyncio.create_task(
|
||||
cls._run_post_initialization_tasks(init_tasks),
|
||||
name='post_init_tasks'
|
||||
cls._run_post_initialization_tasks(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:
|
||||
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
|
||||
async def _run_post_initialization_tasks(cls, init_tasks):
|
||||
"""Run post-initialization tasks after all scanners complete"""
|
||||
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
|
||||
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
|
||||
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
|
||||
# asyncio.create_task(cls._another_post_task(), name='another_task'),
|
||||
]
|
||||
|
||||
|
||||
# Run all post-initialization tasks
|
||||
results = await asyncio.gather(*post_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Log results
|
||||
for i, result in enumerate(results):
|
||||
task_name = post_tasks[i].get_name()
|
||||
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:
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
async def _cleanup_backup_files(cls):
|
||||
"""Clean up .bak files in all model roots"""
|
||||
try:
|
||||
logger.debug("Starting cleanup of .bak files in model directories...")
|
||||
|
||||
|
||||
# Collect all model roots
|
||||
all_roots = set()
|
||||
all_roots.update(config.loras_roots)
|
||||
all_roots.update(config.base_models_roots)
|
||||
all_roots.update(config.embeddings_roots)
|
||||
|
||||
all_roots.update(config.base_models_roots or [])
|
||||
all_roots.update(config.embeddings_roots or [])
|
||||
|
||||
total_deleted = 0
|
||||
total_size_freed = 0
|
||||
|
||||
|
||||
for root_path in all_roots:
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
|
||||
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_size_freed += size_freed
|
||||
|
||||
|
||||
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:
|
||||
logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
|
||||
|
||||
|
||||
# Yield control periodically
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
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:
|
||||
logger.debug("Backup cleanup completed: no .bak files found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during backup file cleanup: {e}", exc_info=True)
|
||||
|
||||
|
||||
@classmethod
|
||||
async def _cleanup_backup_files_in_directory(cls, directory_path: str):
|
||||
"""Clean up .bak files in a specific directory recursively
|
||||
|
||||
|
||||
Args:
|
||||
directory_path: Path to the directory to clean
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (number of files deleted, total size freed in bytes)
|
||||
"""
|
||||
deleted_count = 0
|
||||
size_freed = 0
|
||||
visited_paths = set()
|
||||
|
||||
|
||||
def cleanup_recursive(path):
|
||||
nonlocal deleted_count, size_freed
|
||||
|
||||
|
||||
try:
|
||||
real_path = os.path.realpath(path)
|
||||
if real_path in visited_paths:
|
||||
return
|
||||
visited_paths.add(real_path)
|
||||
|
||||
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
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
|
||||
os.remove(entry.path)
|
||||
deleted_count += 1
|
||||
size_freed += file_size
|
||||
logger.debug(f"Deleted .bak file: {entry.path}")
|
||||
|
||||
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
cleanup_recursive(entry.path)
|
||||
|
||||
|
||||
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:
|
||||
logger.error(f"Error scanning directory {path} for .bak files: {e}")
|
||||
|
||||
|
||||
# Run the recursive cleanup in a thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, cleanup_recursive, directory_path)
|
||||
|
||||
|
||||
return deleted_count, size_freed
|
||||
|
||||
|
||||
@classmethod
|
||||
async def _cleanup_example_images_folders(cls):
|
||||
"""Invoke the example images cleanup service for manual execution."""
|
||||
@@ -339,21 +425,21 @@ class LoraManager:
|
||||
service = ExampleImagesCleanupService()
|
||||
result = await service.cleanup_example_image_folders()
|
||||
|
||||
if result.get('success'):
|
||||
if result.get("success"):
|
||||
logger.debug(
|
||||
"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(
|
||||
"Manual example images cleanup partially succeeded: moved=%s failures=%s",
|
||||
result.get('moved_total'),
|
||||
result.get('move_failures'),
|
||||
result.get("moved_total"),
|
||||
result.get("move_failures"),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Manual example images cleanup skipped or failed: %s",
|
||||
result.get('error', 'no changes'),
|
||||
result.get("error", "no changes"),
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -361,9 +447,9 @@ class LoraManager:
|
||||
except Exception as e: # pragma: no cover - defensive guard
|
||||
logger.error(f"Error during example images cleanup: {e}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'error_code': 'unexpected_error',
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"error_code": "unexpected_error",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -371,6 +457,6 @@ class LoraManager:
|
||||
"""Cleanup resources using ServiceRegistry"""
|
||||
try:
|
||||
logger.info("LoRA Manager: Cleaning up services")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
|
||||
@@ -4,7 +4,10 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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:
|
||||
from .metadata_hook import MetadataHook
|
||||
@@ -13,13 +16,13 @@ if not standalone_mode:
|
||||
def init():
|
||||
# Install hooks to collect metadata during execution
|
||||
MetadataHook.install()
|
||||
|
||||
|
||||
# Initialize registry
|
||||
registry = MetadataRegistry()
|
||||
|
||||
|
||||
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"""
|
||||
registry = MetadataRegistry()
|
||||
return registry.get_metadata(prompt_id)
|
||||
@@ -27,7 +30,7 @@ else:
|
||||
# Standalone mode - provide dummy implementations
|
||||
def init():
|
||||
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"""
|
||||
return {}
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
import time
|
||||
from nodes import NODE_CLASS_MAPPINGS
|
||||
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||
from .constants import METADATA_CATEGORIES, IMAGES
|
||||
|
||||
|
||||
class MetadataRegistry:
|
||||
"""A singleton registry to store and retrieve workflow metadata"""
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._reset()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def _reset(self):
|
||||
self.current_prompt_id = None
|
||||
self.current_prompt = None
|
||||
self.metadata = {}
|
||||
self.prompt_metadata = {}
|
||||
self.executed_nodes = set()
|
||||
|
||||
|
||||
# Node-level cache for metadata
|
||||
self.node_cache = {}
|
||||
|
||||
|
||||
# Limit the number of stored prompts
|
||||
self.max_prompt_history = 3
|
||||
|
||||
|
||||
# Categories we want to track and retrieve from cache
|
||||
self.metadata_categories = METADATA_CATEGORIES
|
||||
|
||||
|
||||
def _clean_old_prompts(self):
|
||||
"""Clean up old prompt metadata, keeping only recent ones"""
|
||||
if len(self.prompt_metadata) <= self.max_prompt_history:
|
||||
return
|
||||
|
||||
|
||||
# Sort all prompt_ids by timestamp
|
||||
sorted_prompts = sorted(
|
||||
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
|
||||
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:
|
||||
del self.prompt_metadata[pid]
|
||||
|
||||
|
||||
def start_collection(self, prompt_id):
|
||||
"""Begin metadata collection for a new prompt"""
|
||||
self.current_prompt_id = prompt_id
|
||||
@@ -53,90 +57,96 @@ class MetadataRegistry:
|
||||
category: {} for category in METADATA_CATEGORIES
|
||||
}
|
||||
# Add additional metadata fields
|
||||
self.prompt_metadata[prompt_id].update({
|
||||
"execution_order": [],
|
||||
"current_prompt": None, # Will store the prompt object
|
||||
"timestamp": time.time()
|
||||
})
|
||||
|
||||
self.prompt_metadata[prompt_id].update(
|
||||
{
|
||||
"execution_order": [],
|
||||
"current_prompt": None, # Will store the prompt object
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Clean up old prompt data
|
||||
self._clean_old_prompts()
|
||||
|
||||
|
||||
def set_current_prompt(self, prompt):
|
||||
"""Set the current prompt object reference"""
|
||||
self.current_prompt = prompt
|
||||
if self.current_prompt_id and self.current_prompt_id in self.prompt_metadata:
|
||||
# Store the prompt in the metadata for later relationship tracing
|
||||
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
||||
|
||||
|
||||
def get_metadata(self, prompt_id=None):
|
||||
"""Get collected metadata for a prompt"""
|
||||
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
||||
if key not in self.prompt_metadata:
|
||||
return {}
|
||||
|
||||
|
||||
metadata = self.prompt_metadata[key]
|
||||
|
||||
|
||||
# If we have a current prompt object, check for non-executed nodes
|
||||
prompt_obj = metadata.get("current_prompt")
|
||||
if prompt_obj and hasattr(prompt_obj, "original_prompt"):
|
||||
original_prompt = prompt_obj.original_prompt
|
||||
|
||||
|
||||
# Fill in missing metadata from cache for nodes that weren't executed
|
||||
self._fill_missing_metadata(key, original_prompt)
|
||||
|
||||
|
||||
return self.prompt_metadata.get(key, {})
|
||||
|
||||
|
||||
def _fill_missing_metadata(self, prompt_id, original_prompt):
|
||||
"""Fill missing metadata from cache for non-executed nodes"""
|
||||
if not original_prompt:
|
||||
return
|
||||
|
||||
|
||||
executed_nodes = self.executed_nodes
|
||||
metadata = self.prompt_metadata[prompt_id]
|
||||
|
||||
|
||||
# Iterate through nodes in the original prompt
|
||||
for node_id, node_data in original_prompt.items():
|
||||
# Skip if already executed in this run
|
||||
if node_id in executed_nodes:
|
||||
continue
|
||||
|
||||
|
||||
# Get the node type from the prompt (this is the key in NODE_CLASS_MAPPINGS)
|
||||
prompt_class_type = node_data.get("class_type")
|
||||
if not prompt_class_type:
|
||||
continue
|
||||
|
||||
|
||||
# Convert to actual class name (which is what we use in our cache)
|
||||
class_type = prompt_class_type
|
||||
if prompt_class_type in NODE_CLASS_MAPPINGS:
|
||||
class_obj = NODE_CLASS_MAPPINGS[prompt_class_type]
|
||||
class_type = class_obj.__name__
|
||||
|
||||
|
||||
# Create cache key using the actual class name
|
||||
cache_key = f"{node_id}:{class_type}"
|
||||
|
||||
|
||||
# Check if this node type is relevant for metadata collection
|
||||
if class_type in NODE_EXTRACTORS:
|
||||
# Check if we have cached metadata for this node
|
||||
if cache_key in self.node_cache:
|
||||
cached_data = self.node_cache[cache_key]
|
||||
|
||||
|
||||
# Apply cached metadata to the current metadata
|
||||
for category in self.metadata_categories:
|
||||
if category in cached_data and node_id in cached_data[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):
|
||||
"""Record information about a node's execution"""
|
||||
if not self.current_prompt_id:
|
||||
return
|
||||
|
||||
|
||||
# Add to execution order and mark as executed
|
||||
if node_id not in self.executed_nodes:
|
||||
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
|
||||
processed_inputs = {}
|
||||
for input_name, input_values in inputs.items():
|
||||
@@ -145,63 +155,61 @@ class MetadataRegistry:
|
||||
processed_inputs[input_name] = input_values[0]
|
||||
else:
|
||||
processed_inputs[input_name] = input_values
|
||||
|
||||
|
||||
# Extract node-specific metadata
|
||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||
extractor.extract(
|
||||
node_id,
|
||||
processed_inputs,
|
||||
outputs,
|
||||
self.prompt_metadata[self.current_prompt_id]
|
||||
node_id,
|
||||
processed_inputs,
|
||||
outputs,
|
||||
self.prompt_metadata[self.current_prompt_id],
|
||||
)
|
||||
|
||||
|
||||
# Cache this node's metadata
|
||||
self._cache_node_metadata(node_id, class_type)
|
||||
|
||||
|
||||
def update_node_execution(self, node_id, class_type, outputs):
|
||||
"""Update node metadata with output information"""
|
||||
if not self.current_prompt_id:
|
||||
return
|
||||
|
||||
|
||||
# Process outputs to make them more usable
|
||||
processed_outputs = outputs
|
||||
|
||||
|
||||
# Use the same extractor to update with outputs
|
||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||
if hasattr(extractor, 'update'):
|
||||
if hasattr(extractor, "update"):
|
||||
extractor.update(
|
||||
node_id,
|
||||
processed_outputs,
|
||||
self.prompt_metadata[self.current_prompt_id]
|
||||
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id]
|
||||
)
|
||||
|
||||
|
||||
# Update the cached metadata for this node
|
||||
self._cache_node_metadata(node_id, class_type)
|
||||
|
||||
|
||||
def _cache_node_metadata(self, node_id, class_type):
|
||||
"""Cache the metadata for a specific node"""
|
||||
if not self.current_prompt_id or not node_id or not class_type:
|
||||
return
|
||||
|
||||
|
||||
# Create a cache key combining node_id and class_type
|
||||
cache_key = f"{node_id}:{class_type}"
|
||||
|
||||
|
||||
# Create a shallow copy of the node's metadata
|
||||
node_metadata = {}
|
||||
current_metadata = self.prompt_metadata[self.current_prompt_id]
|
||||
|
||||
|
||||
for category in self.metadata_categories:
|
||||
if category in current_metadata and node_id in current_metadata[category]:
|
||||
if category not in node_metadata:
|
||||
node_metadata[category] = {}
|
||||
node_metadata[category][node_id] = current_metadata[category][node_id]
|
||||
|
||||
|
||||
# Save new metadata or clear stale cache entries when metadata is empty
|
||||
if any(node_metadata.values()):
|
||||
self.node_cache[cache_key] = node_metadata
|
||||
else:
|
||||
self.node_cache.pop(cache_key, None)
|
||||
|
||||
|
||||
def clear_unused_cache(self):
|
||||
"""Clean up node_cache entries that are no longer in use"""
|
||||
# Collect all node_ids currently in prompt_metadata
|
||||
@@ -210,18 +218,18 @@ class MetadataRegistry:
|
||||
for category in self.metadata_categories:
|
||||
if category in prompt_data:
|
||||
active_node_ids.update(prompt_data[category].keys())
|
||||
|
||||
|
||||
# Find cache keys that are no longer needed
|
||||
keys_to_remove = []
|
||||
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:
|
||||
keys_to_remove.append(cache_key)
|
||||
|
||||
|
||||
# Remove cache entries that are no longer needed
|
||||
for key in keys_to_remove:
|
||||
del self.node_cache[key]
|
||||
|
||||
|
||||
def clear_metadata(self, prompt_id=None):
|
||||
"""Clear metadata for a specific prompt or reset all data"""
|
||||
if prompt_id is not None:
|
||||
@@ -232,25 +240,25 @@ class MetadataRegistry:
|
||||
else:
|
||||
# Reset all data
|
||||
self._reset()
|
||||
|
||||
|
||||
def get_first_decoded_image(self, prompt_id=None):
|
||||
"""Get the first decoded image result"""
|
||||
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
||||
if key not in self.prompt_metadata:
|
||||
return None
|
||||
|
||||
|
||||
metadata = self.prompt_metadata[key]
|
||||
if IMAGES in metadata and "first_decode" in metadata[IMAGES]:
|
||||
image_data = metadata[IMAGES]["first_decode"]["image"]
|
||||
|
||||
|
||||
# If it's an image batch or tuple, handle various formats
|
||||
if isinstance(image_data, (list, tuple)) and len(image_data) > 0:
|
||||
# Return first element of list/tuple
|
||||
return image_data[0]
|
||||
|
||||
|
||||
# If it's a tensor, return as is for processing in the route handler
|
||||
return image_data
|
||||
|
||||
|
||||
# If no image is found in the current metadata, try to find it in the cache
|
||||
# This handles the case where VAEDecode was cached by ComfyUI and not executed
|
||||
prompt_obj = metadata.get("current_prompt")
|
||||
@@ -270,8 +278,11 @@ class MetadataRegistry:
|
||||
if IMAGES in cached_data and node_id in cached_data[IMAGES]:
|
||||
image_data = cached_data[IMAGES][node_id]["image"]
|
||||
# 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
|
||||
|
||||
|
||||
return None
|
||||
|
||||
118
py/nodes/checkpoint_loader.py
Normal file
118
py/nodes/checkpoint_loader.py
Normal 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]
|
||||
161
py/nodes/gguf_import_helper.py
Normal file
161
py/nodes/gguf_import_helper.py
Normal 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")
|
||||
@@ -56,6 +56,9 @@ class LoraCyclerLM:
|
||||
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||
sort_by = "filename"
|
||||
|
||||
# Include "no lora" option
|
||||
include_no_lora = cycler_config.get("include_no_lora", False)
|
||||
|
||||
# Dual-index mechanism for batch queue synchronization
|
||||
execution_index = cycler_config.get("execution_index") # Can be None
|
||||
# next_index_from_config = cycler_config.get("next_index") # Not used on backend
|
||||
@@ -71,7 +74,10 @@ class LoraCyclerLM:
|
||||
|
||||
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")
|
||||
return {
|
||||
"result": ([],),
|
||||
@@ -93,42 +99,66 @@ class LoraCyclerLM:
|
||||
else:
|
||||
actual_index = current_index
|
||||
|
||||
# Clamp index to valid range (1-based)
|
||||
clamped_index = max(1, min(actual_index, total_count))
|
||||
# Clamp index to valid range (1-based, includes no lora if enabled)
|
||||
clamped_index = max(1, min(actual_index, effective_total_count))
|
||||
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
# Check if current index is the "no lora" option (last position when include_no_lora is True)
|
||||
is_no_lora = include_no_lora and clamped_index == effective_total_count
|
||||
|
||||
# Build LORA_STACK with single LoRA
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
if not lora_path:
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
if is_no_lora:
|
||||
# "No LoRA" option - return empty stack
|
||||
lora_stack = []
|
||||
current_lora_name = "No LoRA"
|
||||
current_lora_filename = "No LoRA"
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
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)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
|
||||
# Calculate next index (wrap to 1 if at end)
|
||||
next_index = clamped_index + 1
|
||||
if next_index > total_count:
|
||||
if next_index > effective_total_count:
|
||||
next_index = 1
|
||||
|
||||
# Get next LoRA for UI display (what will be used next generation)
|
||||
next_lora = lora_list[next_index - 1]
|
||||
next_display_name = next_lora["file_name"]
|
||||
is_next_no_lora = include_no_lora and next_index == effective_total_count
|
||||
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 {
|
||||
"result": (lora_stack,),
|
||||
"ui": {
|
||||
"current_index": [clamped_index],
|
||||
"next_index": [next_index],
|
||||
"total_count": [total_count],
|
||||
"current_lora_name": [current_lora["file_name"]],
|
||||
"current_lora_filename": [current_lora["file_name"]],
|
||||
"total_count": [
|
||||
total_count
|
||||
], # 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_filename": [next_lora["file_name"]],
|
||||
"next_lora_filename": [next_lora_filename],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class LoraPoolLM:
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"favoritesOnly": False,
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
|
||||
},
|
||||
"preview": {"matchCount": 0, "lastUpdated": 0},
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import extract_lora_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
import numpy as np
|
||||
import folder_paths # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||
from ..metadata_collector import get_metadata
|
||||
@@ -12,6 +13,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaveImageLM:
|
||||
NAME = "Save Image (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
@@ -23,42 +25,60 @@ class SaveImageLM:
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
self.counter = 0
|
||||
|
||||
|
||||
# Add pattern format regex for filename substitution
|
||||
pattern_format = re.compile(r"(%[^%]+%)")
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {
|
||||
"default": "ComfyUI",
|
||||
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc."
|
||||
}),
|
||||
"file_format": (["png", "jpeg", "webp"], {
|
||||
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
|
||||
}),
|
||||
"filename_prefix": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "ComfyUI",
|
||||
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc.",
|
||||
},
|
||||
),
|
||||
"file_format": (
|
||||
["png", "jpeg", "webp"],
|
||||
{
|
||||
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"lossless_webp": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss."
|
||||
}),
|
||||
"quality": ("INT", {
|
||||
"default": 100,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files."
|
||||
}),
|
||||
"embed_workflow": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats."
|
||||
}),
|
||||
"add_counter_to_filename": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images."
|
||||
}),
|
||||
"lossless_webp": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss.",
|
||||
},
|
||||
),
|
||||
"quality": (
|
||||
"INT",
|
||||
{
|
||||
"default": 100,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
|
||||
},
|
||||
),
|
||||
"embed_workflow": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
|
||||
},
|
||||
),
|
||||
"add_counter_to_filename": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
@@ -75,57 +95,59 @@ class SaveImageLM:
|
||||
def get_lora_hash(self, lora_name):
|
||||
"""Get the lora hash from cache"""
|
||||
scanner = ServiceRegistry.get_service_sync("lora_scanner")
|
||||
|
||||
|
||||
# Use the new direct filename lookup method
|
||||
hash_value = scanner.get_hash_by_filename(lora_name)
|
||||
if hash_value:
|
||||
return hash_value
|
||||
|
||||
if scanner is not None:
|
||||
hash_value = scanner.get_hash_by_filename(lora_name)
|
||||
if hash_value:
|
||||
return hash_value
|
||||
|
||||
return None
|
||||
|
||||
def get_checkpoint_hash(self, checkpoint_path):
|
||||
"""Get the checkpoint hash from cache"""
|
||||
scanner = ServiceRegistry.get_service_sync("checkpoint_scanner")
|
||||
|
||||
|
||||
if not checkpoint_path:
|
||||
return None
|
||||
|
||||
|
||||
# Extract basename without extension
|
||||
checkpoint_name = os.path.basename(checkpoint_path)
|
||||
checkpoint_name = os.path.splitext(checkpoint_name)[0]
|
||||
|
||||
|
||||
# Try direct filename lookup first
|
||||
hash_value = scanner.get_hash_by_filename(checkpoint_name)
|
||||
if hash_value:
|
||||
return hash_value
|
||||
|
||||
if scanner is not None:
|
||||
hash_value = scanner.get_hash_by_filename(checkpoint_name)
|
||||
if hash_value:
|
||||
return hash_value
|
||||
|
||||
return None
|
||||
|
||||
def format_metadata(self, metadata_dict):
|
||||
"""Format metadata in the requested format similar to userComment example"""
|
||||
if not metadata_dict:
|
||||
return ""
|
||||
|
||||
|
||||
# Helper function to only add parameter if value is not None
|
||||
def add_param_if_not_none(param_list, label, value):
|
||||
if value is not None:
|
||||
param_list.append(f"{label}: {value}")
|
||||
|
||||
|
||||
# Extract the prompt and negative prompt
|
||||
prompt = metadata_dict.get('prompt', '')
|
||||
negative_prompt = metadata_dict.get('negative_prompt', '')
|
||||
|
||||
prompt = metadata_dict.get("prompt", "")
|
||||
negative_prompt = metadata_dict.get("negative_prompt", "")
|
||||
|
||||
# Extract loras from the prompt if present
|
||||
loras_text = metadata_dict.get('loras', '')
|
||||
loras_text = metadata_dict.get("loras", "")
|
||||
lora_hashes = {}
|
||||
|
||||
|
||||
# If loras are found, add them on a new line after the prompt
|
||||
if loras_text:
|
||||
prompt_with_loras = f"{prompt}\n{loras_text}"
|
||||
|
||||
|
||||
# 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
|
||||
for lora_name, strength in lora_matches:
|
||||
hash_value = self.get_lora_hash(lora_name)
|
||||
@@ -133,112 +155,114 @@ class SaveImageLM:
|
||||
lora_hashes[lora_name] = hash_value
|
||||
else:
|
||||
prompt_with_loras = prompt
|
||||
|
||||
|
||||
# Format the first part (prompt and loras)
|
||||
metadata_parts = [prompt_with_loras]
|
||||
|
||||
|
||||
# Add negative prompt
|
||||
if negative_prompt:
|
||||
metadata_parts.append(f"Negative prompt: {negative_prompt}")
|
||||
|
||||
|
||||
# Format the second part (generation parameters)
|
||||
params = []
|
||||
|
||||
|
||||
# Add standard parameters in the correct order
|
||||
if 'steps' in metadata_dict:
|
||||
add_param_if_not_none(params, "Steps", metadata_dict.get('steps'))
|
||||
|
||||
if "steps" in metadata_dict:
|
||||
add_param_if_not_none(params, "Steps", metadata_dict.get("steps"))
|
||||
|
||||
# Combine sampler and scheduler information
|
||||
sampler_name = None
|
||||
scheduler_name = None
|
||||
|
||||
if 'sampler' in metadata_dict:
|
||||
sampler = metadata_dict.get('sampler')
|
||||
|
||||
if "sampler" in metadata_dict:
|
||||
sampler = metadata_dict.get("sampler")
|
||||
# Convert ComfyUI sampler names to user-friendly names
|
||||
sampler_mapping = {
|
||||
'euler': 'Euler',
|
||||
'euler_ancestral': 'Euler a',
|
||||
'dpm_2': 'DPM2',
|
||||
'dpm_2_ancestral': 'DPM2 a',
|
||||
'heun': 'Heun',
|
||||
'dpm_fast': 'DPM fast',
|
||||
'dpm_adaptive': 'DPM adaptive',
|
||||
'lms': 'LMS',
|
||||
'dpmpp_2s_ancestral': 'DPM++ 2S a',
|
||||
'dpmpp_sde': 'DPM++ SDE',
|
||||
'dpmpp_sde_gpu': 'DPM++ SDE',
|
||||
'dpmpp_2m': 'DPM++ 2M',
|
||||
'dpmpp_2m_sde': 'DPM++ 2M SDE',
|
||||
'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE',
|
||||
'ddim': 'DDIM'
|
||||
"euler": "Euler",
|
||||
"euler_ancestral": "Euler a",
|
||||
"dpm_2": "DPM2",
|
||||
"dpm_2_ancestral": "DPM2 a",
|
||||
"heun": "Heun",
|
||||
"dpm_fast": "DPM fast",
|
||||
"dpm_adaptive": "DPM adaptive",
|
||||
"lms": "LMS",
|
||||
"dpmpp_2s_ancestral": "DPM++ 2S a",
|
||||
"dpmpp_sde": "DPM++ SDE",
|
||||
"dpmpp_sde_gpu": "DPM++ SDE",
|
||||
"dpmpp_2m": "DPM++ 2M",
|
||||
"dpmpp_2m_sde": "DPM++ 2M SDE",
|
||||
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
|
||||
"ddim": "DDIM",
|
||||
}
|
||||
sampler_name = sampler_mapping.get(sampler, sampler)
|
||||
|
||||
if 'scheduler' in metadata_dict:
|
||||
scheduler = metadata_dict.get('scheduler')
|
||||
|
||||
if "scheduler" in metadata_dict:
|
||||
scheduler = metadata_dict.get("scheduler")
|
||||
scheduler_mapping = {
|
||||
'normal': 'Simple',
|
||||
'karras': 'Karras',
|
||||
'exponential': 'Exponential',
|
||||
'sgm_uniform': 'SGM Uniform',
|
||||
'sgm_quadratic': 'SGM Quadratic'
|
||||
"normal": "Simple",
|
||||
"karras": "Karras",
|
||||
"exponential": "Exponential",
|
||||
"sgm_uniform": "SGM Uniform",
|
||||
"sgm_quadratic": "SGM Quadratic",
|
||||
}
|
||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
|
||||
|
||||
|
||||
# Add combined sampler and scheduler information
|
||||
if sampler_name:
|
||||
if scheduler_name:
|
||||
params.append(f"Sampler: {sampler_name} {scheduler_name}")
|
||||
else:
|
||||
params.append(f"Sampler: {sampler_name}")
|
||||
|
||||
|
||||
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg)
|
||||
if 'guidance' in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get('guidance'))
|
||||
elif 'cfg_scale' in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg_scale'))
|
||||
elif 'cfg' in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg'))
|
||||
|
||||
if "guidance" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance"))
|
||||
elif "cfg_scale" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale"))
|
||||
elif "cfg" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg"))
|
||||
|
||||
# Seed
|
||||
if 'seed' in metadata_dict:
|
||||
add_param_if_not_none(params, "Seed", metadata_dict.get('seed'))
|
||||
|
||||
if "seed" in metadata_dict:
|
||||
add_param_if_not_none(params, "Seed", metadata_dict.get("seed"))
|
||||
|
||||
# Size
|
||||
if 'size' in metadata_dict:
|
||||
add_param_if_not_none(params, "Size", metadata_dict.get('size'))
|
||||
|
||||
if "size" in metadata_dict:
|
||||
add_param_if_not_none(params, "Size", metadata_dict.get("size"))
|
||||
|
||||
# Model info
|
||||
if 'checkpoint' in metadata_dict:
|
||||
if "checkpoint" in metadata_dict:
|
||||
# Ensure checkpoint is a string before processing
|
||||
checkpoint = metadata_dict.get('checkpoint')
|
||||
checkpoint = metadata_dict.get("checkpoint")
|
||||
if checkpoint is not None:
|
||||
# Get model hash
|
||||
model_hash = self.get_checkpoint_hash(checkpoint)
|
||||
|
||||
|
||||
# Extract basename without path
|
||||
checkpoint_name = os.path.basename(checkpoint)
|
||||
# Remove extension if present
|
||||
checkpoint_name = os.path.splitext(checkpoint_name)[0]
|
||||
|
||||
|
||||
# Add model hash if available
|
||||
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:
|
||||
params.append(f"Model: {checkpoint_name}")
|
||||
|
||||
|
||||
# Add LoRA hashes if available
|
||||
if lora_hashes:
|
||||
lora_hash_parts = []
|
||||
for lora_name, hash_value in lora_hashes.items():
|
||||
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
|
||||
|
||||
|
||||
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
|
||||
metadata_parts.append(", ".join(params))
|
||||
|
||||
|
||||
# Join all parts with a new line
|
||||
return "\n".join(metadata_parts)
|
||||
|
||||
@@ -248,36 +272,36 @@ class SaveImageLM:
|
||||
"""Format filename with metadata values"""
|
||||
if not metadata_dict:
|
||||
return filename
|
||||
|
||||
|
||||
result = re.findall(self.pattern_format, filename)
|
||||
for segment in result:
|
||||
parts = segment.replace("%", "").split(":")
|
||||
key = parts[0]
|
||||
|
||||
if key == "seed" and 'seed' in metadata_dict:
|
||||
filename = filename.replace(segment, str(metadata_dict.get('seed', '')))
|
||||
elif key == "width" and 'size' in metadata_dict:
|
||||
size = metadata_dict.get('size', 'x')
|
||||
w = size.split('x')[0] if isinstance(size, str) else size[0]
|
||||
|
||||
if key == "seed" and "seed" in metadata_dict:
|
||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
||||
elif key == "width" and "size" in metadata_dict:
|
||||
size = metadata_dict.get("size", "x")
|
||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||
filename = filename.replace(segment, str(w))
|
||||
elif key == "height" and 'size' in metadata_dict:
|
||||
size = metadata_dict.get('size', 'x')
|
||||
h = size.split('x')[1] if isinstance(size, str) else size[1]
|
||||
elif key == "height" and "size" in metadata_dict:
|
||||
size = metadata_dict.get("size", "x")
|
||||
h = size.split("x")[1] if isinstance(size, str) else size[1]
|
||||
filename = filename.replace(segment, str(h))
|
||||
elif key == "pprompt" and 'prompt' in metadata_dict:
|
||||
prompt = metadata_dict.get('prompt', '').replace("\n", " ")
|
||||
elif key == "pprompt" and "prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "nprompt" and 'negative_prompt' in metadata_dict:
|
||||
prompt = metadata_dict.get('negative_prompt', '').replace("\n", " ")
|
||||
elif key == "nprompt" and "negative_prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "model":
|
||||
model_value = metadata_dict.get('checkpoint')
|
||||
model_value = metadata_dict.get("checkpoint")
|
||||
if isinstance(model_value, (bytes, os.PathLike)):
|
||||
model_value = str(model_value)
|
||||
|
||||
@@ -291,6 +315,7 @@ class SaveImageLM:
|
||||
filename = filename.replace(segment, model)
|
||||
elif key == "date":
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
date_table = {
|
||||
"yyyy": f"{now.year:04d}",
|
||||
@@ -311,46 +336,62 @@ class SaveImageLM:
|
||||
for k, v in date_table.items():
|
||||
date_format = date_format.replace(k, v)
|
||||
filename = filename.replace(segment, date_format)
|
||||
|
||||
|
||||
return filename
|
||||
|
||||
def save_images(self, images, filename_prefix, file_format, id, prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
def save_images(
|
||||
self,
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
id,
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
embed_workflow=False,
|
||||
add_counter_to_filename=True,
|
||||
):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
|
||||
# Get metadata using the metadata collector
|
||||
raw_metadata = get_metadata()
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
|
||||
|
||||
|
||||
metadata = self.format_metadata(metadata_dict)
|
||||
|
||||
|
||||
# Process filename_prefix with pattern substitution
|
||||
filename_prefix = self.format_filename(filename_prefix, metadata_dict)
|
||||
|
||||
|
||||
# Get initial save path info once for the batch
|
||||
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
full_output_folder, filename, counter, subfolder, processed_prefix = (
|
||||
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
|
||||
if not os.path.exists(full_output_folder):
|
||||
os.makedirs(full_output_folder, exist_ok=True)
|
||||
|
||||
|
||||
# Process each image with incrementing counter
|
||||
for i, image in enumerate(images):
|
||||
# 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))
|
||||
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
base_filename += f"_{current_counter:05}_"
|
||||
|
||||
|
||||
# Set file extension and prepare saving parameters
|
||||
file: str
|
||||
save_kwargs: Dict[str, Any]
|
||||
pnginfo: Optional[PngImagePlugin.PngInfo] = None
|
||||
if file_format == "png":
|
||||
file = base_filename + ".png"
|
||||
file_extension = ".png"
|
||||
@@ -362,17 +403,24 @@ class SaveImageLM:
|
||||
file_extension = ".jpg"
|
||||
save_kwargs = {"quality": quality, "optimize": True}
|
||||
elif file_format == "webp":
|
||||
file = base_filename + ".webp"
|
||||
file = base_filename + ".webp"
|
||||
file_extension = ".webp"
|
||||
# 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
|
||||
file_path = os.path.join(full_output_folder, file)
|
||||
|
||||
|
||||
# Save the image with metadata
|
||||
try:
|
||||
if file_format == "png":
|
||||
assert pnginfo is not None
|
||||
if metadata:
|
||||
pnginfo.add_text("parameters", metadata)
|
||||
if embed_workflow and extra_pnginfo is not None:
|
||||
@@ -384,7 +432,12 @@ class SaveImageLM:
|
||||
# For JPEG, use piexif
|
||||
if metadata:
|
||||
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)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
except Exception as e:
|
||||
@@ -396,37 +449,52 @@ class SaveImageLM:
|
||||
exif_dict = {}
|
||||
|
||||
if 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
|
||||
if embed_workflow and extra_pnginfo is not None:
|
||||
workflow_json = json.dumps(extra_pnginfo["workflow"])
|
||||
exif_dict['0th'] = {piexif.ImageIFD.ImageDescription: "Workflow:" + workflow_json}
|
||||
|
||||
workflow_json = json.dumps(extra_pnginfo["workflow"])
|
||||
exif_dict["0th"] = {
|
||||
piexif.ImageIFD.ImageDescription: "Workflow:"
|
||||
+ workflow_json
|
||||
}
|
||||
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding EXIF data: {e}")
|
||||
|
||||
|
||||
img.save(file_path, format="WEBP", **save_kwargs)
|
||||
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type
|
||||
})
|
||||
|
||||
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving image: {e}")
|
||||
|
||||
|
||||
return results
|
||||
|
||||
def process_image(self, images, id, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
def process_image(
|
||||
self,
|
||||
images,
|
||||
id,
|
||||
filename_prefix="ComfyUI",
|
||||
file_format="png",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
embed_workflow=False,
|
||||
add_counter_to_filename=True,
|
||||
):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
|
||||
# If images is already a list or array of images, do nothing; otherwise, convert to list
|
||||
if isinstance(images, (list, np.ndarray)):
|
||||
pass
|
||||
@@ -436,19 +504,19 @@ class SaveImageLM:
|
||||
images = [images]
|
||||
else: # Multiple images (batch, height, width, channels)
|
||||
images = [img for img in images]
|
||||
|
||||
|
||||
# Save all images
|
||||
results = self.save_images(
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
id,
|
||||
prompt,
|
||||
prompt,
|
||||
extra_pnginfo,
|
||||
lossless_webp,
|
||||
quality,
|
||||
embed_workflow,
|
||||
add_counter_to_filename
|
||||
add_counter_to_filename,
|
||||
)
|
||||
|
||||
|
||||
return (images,)
|
||||
|
||||
205
py/nodes/unet_loader.py
Normal file
205
py/nodes/unet_loader.py
Normal 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)}"
|
||||
)
|
||||
@@ -1,33 +1,35 @@
|
||||
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)
|
||||
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
|
||||
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
|
||||
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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
item is the type. This can be a real type, or use the AnyType for additional flexibility.
|
||||
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
|
||||
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.
|
||||
"""
|
||||
def __init__(self, type):
|
||||
self.type = type
|
||||
This should be forwards compatible unless more changes occur in the PR.
|
||||
"""
|
||||
|
||||
def __getitem__(self, key):
|
||||
return (self.type, )
|
||||
def __init__(self, type):
|
||||
self.type = type
|
||||
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
def __getitem__(self, key):
|
||||
return (self.type,)
|
||||
|
||||
def __contains__(self, key):
|
||||
return True
|
||||
|
||||
|
||||
any_type = AnyType("*")
|
||||
@@ -37,25 +39,27 @@ import os
|
||||
import logging
|
||||
import copy
|
||||
import sys
|
||||
import folder_paths
|
||||
import folder_paths # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_lora_name(lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
|
||||
|
||||
def get_loras_list(kwargs):
|
||||
"""Helper to extract loras list from either old or new kwargs format"""
|
||||
if 'loras' not in kwargs:
|
||||
if "loras" not in kwargs:
|
||||
return []
|
||||
|
||||
loras_data = kwargs['loras']
|
||||
|
||||
loras_data = kwargs["loras"]
|
||||
# Handle new format: {'loras': {'__value__': [...]}}
|
||||
if isinstance(loras_data, dict) and '__value__' in loras_data:
|
||||
return loras_data['__value__']
|
||||
if isinstance(loras_data, dict) and "__value__" in loras_data:
|
||||
return loras_data["__value__"]
|
||||
# Handle old format: {'loras': [...]}
|
||||
elif isinstance(loras_data, list):
|
||||
return loras_data
|
||||
@@ -64,24 +68,26 @@ def get_loras_list(kwargs):
|
||||
logger.warning(f"Unexpected loras format: {type(loras_data)}")
|
||||
return []
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
if filter_prefix and not k.startswith(filter_prefix):
|
||||
continue
|
||||
state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k)
|
||||
return state_dict
|
||||
|
||||
|
||||
def to_diffusers(input_lora):
|
||||
"""Simplified version of to_diffusers for Flux LoRA conversion"""
|
||||
import torch
|
||||
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):
|
||||
tensors = load_state_dict_in_safetensors(input_lora, device="cpu")
|
||||
else:
|
||||
@@ -91,22 +97,27 @@ def to_diffusers(input_lora):
|
||||
for k, v in tensors.items():
|
||||
if v.dtype not in [torch.float64, torch.float32, torch.bfloat16, torch.float16]:
|
||||
tensors[k] = v.to(torch.bfloat16)
|
||||
|
||||
|
||||
new_tensors = FluxLoraLoaderMixin.lora_state_dict(tensors)
|
||||
new_tensors = convert_unet_state_dict_to_peft(new_tensors)
|
||||
|
||||
return new_tensors
|
||||
|
||||
|
||||
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.
|
||||
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):
|
||||
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||
return model
|
||||
|
||||
model_wrapper = model.model.diffusion_model
|
||||
|
||||
|
||||
# Try to find copy_with_ctx in the same module as ComfyFluxWrapper
|
||||
module_name = model_wrapper.__class__.__module__
|
||||
module = sys.modules.get(module_name)
|
||||
@@ -118,14 +129,16 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
|
||||
else:
|
||||
# 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
|
||||
|
||||
|
||||
# Save the transformer temporarily
|
||||
model_wrapper.model = None
|
||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
|
||||
|
||||
# Restore the model and set it for the copy
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
@@ -133,15 +146,15 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
|
||||
# Convert the LoRA to diffusers format
|
||||
sd = to_diffusers(lora_path)
|
||||
|
||||
|
||||
# Handle embedding adjustment if needed
|
||||
if "transformer.x_embedder.lora_A.weight" in sd:
|
||||
new_in_channels = sd["transformer.x_embedder.lora_A.weight"].shape[1]
|
||||
assert new_in_channels % 4 == 0
|
||||
new_in_channels = new_in_channels // 4
|
||||
|
||||
|
||||
old_in_channels = ret_model.model.model_config.unet_config["in_channels"]
|
||||
if old_in_channels < new_in_channels:
|
||||
ret_model.model.model_config.unet_config["in_channels"] = new_in_channels
|
||||
|
||||
return ret_model
|
||||
|
||||
return ret_model
|
||||
|
||||
@@ -6,23 +6,24 @@ from .parsers import (
|
||||
ComfyMetadataParser,
|
||||
MetaFormatParser,
|
||||
AutomaticMetadataParser,
|
||||
CivitaiApiMetadataParser
|
||||
CivitaiApiMetadataParser,
|
||||
)
|
||||
from .base import RecipeMetadataParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeParserFactory:
|
||||
"""Factory for creating recipe metadata parsers"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def create_parser(metadata) -> RecipeMetadataParser:
|
||||
def create_parser(metadata) -> RecipeMetadataParser | None:
|
||||
"""
|
||||
Create appropriate parser based on the metadata content
|
||||
|
||||
|
||||
Args:
|
||||
metadata: The metadata from the image (dict or str)
|
||||
|
||||
|
||||
Returns:
|
||||
Appropriate RecipeMetadataParser implementation
|
||||
"""
|
||||
@@ -34,17 +35,18 @@ class RecipeParserFactory:
|
||||
except Exception as e:
|
||||
logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
|
||||
pass
|
||||
|
||||
|
||||
# Convert dict to string for other parsers that expect string input
|
||||
try:
|
||||
import json
|
||||
|
||||
metadata_str = json.dumps(metadata)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to convert dict to JSON string: {e}")
|
||||
return None
|
||||
else:
|
||||
metadata_str = metadata
|
||||
|
||||
|
||||
# Try ComfyMetadataParser which requires valid JSON
|
||||
try:
|
||||
if ComfyMetadataParser().is_metadata_matching(metadata_str):
|
||||
@@ -52,7 +54,7 @@ class RecipeParserFactory:
|
||||
except Exception:
|
||||
# If JSON parsing fails, move on to other parsers
|
||||
pass
|
||||
|
||||
|
||||
# Check other parsers that expect string input
|
||||
if RecipeFormatParser().is_metadata_matching(metadata_str):
|
||||
return RecipeFormatParser()
|
||||
|
||||
@@ -9,15 +9,16 @@ from ...services.metadata_service import get_default_metadata_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
"""Parser for Civitai image metadata format"""
|
||||
|
||||
|
||||
def is_metadata_matching(self, metadata) -> bool:
|
||||
"""Check if the metadata matches the Civitai image metadata format
|
||||
|
||||
|
||||
Args:
|
||||
metadata: The metadata from the image (dict)
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if this parser can handle the metadata
|
||||
"""
|
||||
@@ -28,7 +29,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Check for common CivitAI image metadata fields
|
||||
civitai_image_fields = (
|
||||
"resources",
|
||||
"civitaiResources",
|
||||
"civitaiResources",
|
||||
"additionalResources",
|
||||
"hashes",
|
||||
"prompt",
|
||||
@@ -40,7 +41,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
"width",
|
||||
"height",
|
||||
"Model",
|
||||
"Model hash"
|
||||
"Model hash",
|
||||
)
|
||||
return any(key in payload for key in civitai_image_fields)
|
||||
|
||||
@@ -50,7 +51,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
# Check for LoRA hash patterns
|
||||
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
|
||||
|
||||
# Check nested meta object (common in CivitAI image responses)
|
||||
@@ -61,22 +64,28 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
# Also check for LoRA hash patterns in nested meta
|
||||
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 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
|
||||
|
||||
|
||||
Args:
|
||||
metadata: The metadata from the image (dict)
|
||||
user_comment: The metadata from the image (dict)
|
||||
recipe_scanner: Optional recipe scanner service
|
||||
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
||||
|
||||
|
||||
Returns:
|
||||
Dict containing parsed recipe data
|
||||
"""
|
||||
metadata: Dict[str, Any] = user_comment # type: ignore[assignment]
|
||||
metadata = user_comment
|
||||
try:
|
||||
# Get metadata provider instead of using civitai_client directly
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
@@ -100,19 +109,19 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
)
|
||||
):
|
||||
metadata = inner_meta
|
||||
|
||||
|
||||
# Initialize result structure
|
||||
result = {
|
||||
'base_model': None,
|
||||
'loras': [],
|
||||
'model': None,
|
||||
'gen_params': {},
|
||||
'from_civitai_image': True
|
||||
"base_model": None,
|
||||
"loras": [],
|
||||
"model": None,
|
||||
"gen_params": {},
|
||||
"from_civitai_image": True,
|
||||
}
|
||||
|
||||
|
||||
# Track already added LoRAs to prevent duplicates
|
||||
added_loras = {} # key: model_version_id or hash, value: index in result["loras"]
|
||||
|
||||
|
||||
# Extract hash information from hashes field for LoRA matching
|
||||
lora_hashes = {}
|
||||
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
||||
@@ -121,14 +130,14 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
if key_str.lower().startswith("lora:"):
|
||||
lora_name = key_str.split(":", 1)[1]
|
||||
lora_hashes[lora_name] = hash_value
|
||||
|
||||
|
||||
# Extract prompt and negative prompt
|
||||
if "prompt" in metadata:
|
||||
result["gen_params"]["prompt"] = metadata["prompt"]
|
||||
|
||||
|
||||
if "negativePrompt" in metadata:
|
||||
result["gen_params"]["negative_prompt"] = metadata["negativePrompt"]
|
||||
|
||||
|
||||
# Extract other generation parameters
|
||||
param_mapping = {
|
||||
"steps": "steps",
|
||||
@@ -138,98 +147,117 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
"Size": "size",
|
||||
"clipSkip": "clip_skip",
|
||||
}
|
||||
|
||||
|
||||
for civitai_key, our_key in param_mapping.items():
|
||||
if civitai_key in metadata and our_key in GEN_PARAM_KEYS:
|
||||
result["gen_params"][our_key] = metadata[civitai_key]
|
||||
|
||||
|
||||
# Extract base model information - directly if available
|
||||
if "baseModel" in metadata:
|
||||
result["base_model"] = metadata["baseModel"]
|
||||
elif "Model hash" in metadata and metadata_provider:
|
||||
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:
|
||||
result["base_model"] = model_info.get("baseModel", "")
|
||||
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
|
||||
# Try to find base model in 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
|
||||
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:
|
||||
result["base_model"] = model_info.get("baseModel", "")
|
||||
|
||||
|
||||
base_model_counts = {}
|
||||
|
||||
|
||||
# Process standard resources array
|
||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||
for resource in metadata["resources"]:
|
||||
# Modified to process resources without a type field as potential LoRAs
|
||||
if resource.get("type", "lora") == "lora":
|
||||
lora_hash = resource.get("hash", "")
|
||||
|
||||
|
||||
# Try to get hash from the hashes field if not present in resource
|
||||
if not lora_hash and resource.get("name"):
|
||||
lora_hash = lora_hashes.get(resource["name"], "")
|
||||
|
||||
|
||||
# Skip LoRAs without proper identification (hash or 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
|
||||
|
||||
|
||||
# Skip if we've already added this LoRA by hash
|
||||
if lora_hash and lora_hash in added_loras:
|
||||
continue
|
||||
|
||||
|
||||
lora_entry = {
|
||||
'name': resource.get("name", "Unknown LoRA"),
|
||||
'type': "lora",
|
||||
'weight': float(resource.get("weight", 1.0)),
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': resource.get("name", "Unknown"),
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"name": resource.get("name", "Unknown LoRA"),
|
||||
"type": "lora",
|
||||
"weight": float(resource.get("weight", 1.0)),
|
||||
"hash": lora_hash,
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": resource.get("name", "Unknown"),
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
|
||||
# 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:
|
||||
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(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
lora_hash,
|
||||
)
|
||||
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
|
||||
# If we have a version ID from Civitai, track it for deduplication
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
if "id" in lora_entry and lora_entry["id"]:
|
||||
added_loras[str(lora_entry["id"])] = len(
|
||||
result["loras"]
|
||||
)
|
||||
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
|
||||
if lora_hash:
|
||||
added_loras[lora_hash] = len(result["loras"])
|
||||
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
|
||||
# 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"]:
|
||||
# Get resource type and identifier
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
@@ -237,32 +265,39 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
if resource_type == "checkpoint":
|
||||
checkpoint_entry = {
|
||||
'id': resource.get("modelVersionId", 0),
|
||||
'modelId': resource.get("modelId", 0),
|
||||
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||
'version': resource.get("modelVersionName", ""),
|
||||
'type': resource.get("type", "checkpoint"),
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': resource.get("modelName", ""),
|
||||
'hash': resource.get("hash", "") or "",
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"id": resource.get("modelVersionId", 0),
|
||||
"modelId": resource.get("modelId", 0),
|
||||
"name": resource.get("modelName", "Unknown Checkpoint"),
|
||||
"version": resource.get("modelVersionName", ""),
|
||||
"type": resource.get("type", "checkpoint"),
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": resource.get("modelName", ""),
|
||||
"hash": resource.get("hash", "") or "",
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
if version_id and metadata_provider:
|
||||
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,
|
||||
civitai_info
|
||||
checkpoint_entry = (
|
||||
await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry, civitai_info
|
||||
)
|
||||
)
|
||||
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:
|
||||
result["model"] = checkpoint_entry
|
||||
@@ -275,31 +310,35 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'id': resource.get("modelVersionId", 0),
|
||||
'modelId': resource.get("modelId", 0),
|
||||
'name': resource.get("modelName", "Unknown LoRA"),
|
||||
'version': resource.get("modelVersionName", ""),
|
||||
'type': resource.get("type", "lora"),
|
||||
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||
'existsLocally': False,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"id": resource.get("modelVersionId", 0),
|
||||
"modelId": resource.get("modelId", 0),
|
||||
"name": resource.get("modelName", "Unknown LoRA"),
|
||||
"version": resource.get("modelVersionName", ""),
|
||||
"type": resource.get("type", "lora"),
|
||||
"weight": round(float(resource.get("weight", 1.0)), 2),
|
||||
"existsLocally": False,
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
# Try to get info from Civitai if modelVersionId is available
|
||||
if version_id and metadata_provider:
|
||||
try:
|
||||
# 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(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts
|
||||
base_model_counts,
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
@@ -307,74 +346,87 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
lora_entry = populated_entry
|
||||
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
|
||||
if version_id:
|
||||
added_loras[version_id] = len(result["loras"])
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
|
||||
# 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"]:
|
||||
# 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
|
||||
|
||||
|
||||
lora_type = resource.get("type", "lora")
|
||||
name = resource.get("name", "")
|
||||
|
||||
|
||||
# Extract ID from URN format if available
|
||||
version_id = None
|
||||
if name and "civitai:" in name:
|
||||
parts = name.split("@")
|
||||
if len(parts) > 1:
|
||||
version_id = parts[1]
|
||||
|
||||
|
||||
# Skip if we've already added this LoRA
|
||||
if version_id in added_loras:
|
||||
continue
|
||||
|
||||
|
||||
lora_entry = {
|
||||
'name': name,
|
||||
'type': lora_type,
|
||||
'weight': float(resource.get("strength", 1.0)),
|
||||
'hash': "",
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"name": name,
|
||||
"type": lora_type,
|
||||
"weight": float(resource.get("strength", 1.0)),
|
||||
"hash": "",
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": name,
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
|
||||
# If we have a version ID and metadata provider, try to get more info
|
||||
if version_id and metadata_provider:
|
||||
try:
|
||||
# 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(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts
|
||||
base_model_counts,
|
||||
)
|
||||
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
|
||||
# Track this LoRA for deduplication
|
||||
if version_id:
|
||||
added_loras[version_id] = len(result["loras"])
|
||||
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)
|
||||
|
||||
# If we found LoRA hashes in the metadata but haven't already
|
||||
@@ -390,30 +442,32 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
continue
|
||||
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': "lora",
|
||||
'weight': 1.0,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"name": lora_name,
|
||||
"type": "lora",
|
||||
"weight": 1.0,
|
||||
"hash": lora_hash,
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": lora_name,
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
if metadata_provider:
|
||||
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(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
lora_hash,
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
@@ -421,80 +475,93 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
if "id" in lora_entry and lora_entry["id"]:
|
||||
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
||||
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"])
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
||||
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_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
|
||||
if lora_hash and lora_hash in added_loras:
|
||||
lora_index += 1
|
||||
continue
|
||||
|
||||
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': "lora",
|
||||
'weight': lora_strength_model,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
"name": lora_name,
|
||||
"type": "lora",
|
||||
"weight": lora_strength_model,
|
||||
"hash": lora_hash,
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": lora_name,
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
|
||||
# Try to get info from Civitai if hash is available
|
||||
if lora_entry['hash'] and metadata_provider:
|
||||
if lora_entry["hash"] and metadata_provider:
|
||||
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(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
lora_hash,
|
||||
)
|
||||
|
||||
|
||||
if populated_entry is None:
|
||||
lora_index += 1
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
|
||||
# If we have a version ID from Civitai, track it for deduplication
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
if "id" in lora_entry and lora_entry["id"]:
|
||||
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
||||
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
|
||||
if lora_hash:
|
||||
added_loras[lora_hash] = len(result["loras"])
|
||||
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
|
||||
lora_index += 1
|
||||
|
||||
|
||||
# If base model wasn't found earlier, use the most common one from LoRAs
|
||||
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
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Base infrastructure shared across recipe routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -16,12 +17,14 @@ from ..services.recipes import (
|
||||
RecipePersistenceService,
|
||||
RecipeSharingService,
|
||||
)
|
||||
from ..services.batch_import_service import BatchImportService
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from .handlers.recipe_handlers import (
|
||||
BatchImportHandler,
|
||||
RecipeAnalysisHandler,
|
||||
RecipeHandlerSet,
|
||||
RecipeListingHandler,
|
||||
@@ -116,7 +119,10 @@ class BaseRecipeRoutes:
|
||||
recipe_scanner_getter = lambda: self.recipe_scanner
|
||||
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:
|
||||
from ..metadata_collector import get_metadata # 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,
|
||||
)
|
||||
|
||||
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(
|
||||
page_view=page_view,
|
||||
listing=listing,
|
||||
@@ -197,4 +219,5 @@ class BaseRecipeRoutes:
|
||||
management=management,
|
||||
analysis=analysis,
|
||||
sharing=sharing,
|
||||
batch_import=batch_import,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, List, Set
|
||||
from aiohttp import web
|
||||
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
@@ -82,12 +82,22 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
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:
|
||||
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({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
"roots": unique_roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting checkpoint roots: {e}", exc_info=True)
|
||||
@@ -97,12 +107,22 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
}, status=500)
|
||||
|
||||
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:
|
||||
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({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
"roots": unique_roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unet roots: {e}", exc_info=True)
|
||||
|
||||
@@ -9,6 +9,7 @@ objects that can be composed by the route controller.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -218,20 +219,149 @@ class HealthCheckHandler:
|
||||
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:
|
||||
"""Sync settings between backend and frontend."""
|
||||
|
||||
# Settings keys that should NOT be synced to frontend.
|
||||
# All other settings are synced by default.
|
||||
_NO_SYNC_KEYS = frozenset({
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
})
|
||||
_NO_SYNC_KEYS = frozenset(
|
||||
{
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
}
|
||||
)
|
||||
|
||||
_PROXY_KEYS = {
|
||||
"proxy_enabled",
|
||||
@@ -1186,6 +1316,7 @@ class CustomWordsHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ...services.custom_words_service import get_custom_words_service
|
||||
|
||||
self._service = get_custom_words_service()
|
||||
|
||||
async def search_custom_words(self, request: web.Request) -> web.Response:
|
||||
@@ -1194,6 +1325,7 @@ class CustomWordsHandler:
|
||||
Query parameters:
|
||||
search: The search term to match against.
|
||||
limit: Maximum number of results to return (default: 20).
|
||||
offset: Number of results to skip (default: 0).
|
||||
category: Optional category filter. Can be:
|
||||
- A category name (e.g., "character", "artist", "general")
|
||||
- Comma-separated category IDs (e.g., "4,11" for character)
|
||||
@@ -1203,6 +1335,7 @@ class CustomWordsHandler:
|
||||
try:
|
||||
search_term = request.query.get("search", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
category_param = request.query.get("category", "")
|
||||
enriched_param = request.query.get("enriched", "").lower() == "true"
|
||||
|
||||
@@ -1212,13 +1345,14 @@ class CustomWordsHandler:
|
||||
categories = self._parse_category_param(category_param)
|
||||
|
||||
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({
|
||||
"success": True,
|
||||
"words": results
|
||||
})
|
||||
return web.json_response({"success": True, "words": results})
|
||||
except Exception as exc:
|
||||
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
@@ -1482,6 +1616,8 @@ class MiscHandlerSet:
|
||||
metadata_archive: MetadataArchiveHandler,
|
||||
filesystem: FileSystemHandler,
|
||||
custom_words: CustomWordsHandler,
|
||||
supporters: SupportersHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -1494,6 +1630,8 @@ class MiscHandlerSet:
|
||||
self.metadata_archive = metadata_archive
|
||||
self.filesystem = filesystem
|
||||
self.custom_words = custom_words
|
||||
self.supporters = supporters
|
||||
self.example_workflows = example_workflows
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -1522,6 +1660,9 @@ class MiscHandlerSet:
|
||||
"open_file_location": self.filesystem.open_file_location,
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ class ModelPageView:
|
||||
self._logger = logger
|
||||
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:
|
||||
version = "1.0.0"
|
||||
short_hash = "stable"
|
||||
@@ -292,6 +309,13 @@ class ModelListingHandler:
|
||||
else:
|
||||
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 {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -311,6 +335,9 @@ class ModelListingHandler:
|
||||
"credit_required": credit_required,
|
||||
"allow_selling_generated_content": allow_selling_generated_content,
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -383,20 +410,26 @@ class ModelManagementHandler:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model not found in cache"}, status=404
|
||||
)
|
||||
|
||||
|
||||
# Check if hash needs to be calculated (lazy hash for checkpoints)
|
||||
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}")
|
||||
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
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to calculate SHA256 hash",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
# Update model_data with new hash
|
||||
model_data["sha256"] = sha256
|
||||
@@ -524,6 +557,153 @@ class ModelManagementHandler:
|
||||
self._logger.error("Error replacing preview: %s", exc, exc_info=True)
|
||||
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:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -814,9 +994,7 @@ class ModelQueryHandler:
|
||||
# Format response
|
||||
group = {"hash": sha256, "models": []}
|
||||
for model in sorted_models:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
group["models"].append(await self._service.format_response(model))
|
||||
|
||||
# Only include groups with 2+ models after filtering
|
||||
if len(group["models"]) > 1:
|
||||
@@ -845,7 +1023,9 @@ class ModelQueryHandler:
|
||||
"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."""
|
||||
result = models
|
||||
|
||||
@@ -886,7 +1066,9 @@ class ModelQueryHandler:
|
||||
|
||||
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)."""
|
||||
if len(models) <= 1:
|
||||
return models
|
||||
@@ -1096,8 +1278,11 @@ class ModelQueryHandler:
|
||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
search = request.query.get("search", "").strip()
|
||||
limit = min(int(request.query.get("limit", "15")), 50)
|
||||
matching_paths = await self._service.search_relative_paths(search, limit)
|
||||
limit = min(int(request.query.get("limit", "15")), 100)
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
matching_paths = await self._service.search_relative_paths(
|
||||
search, limit, offset
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": True, "relative_paths": matching_paths}
|
||||
)
|
||||
@@ -1171,10 +1356,13 @@ class ModelDownloadHandler:
|
||||
data["source"] = source
|
||||
if file_params_json:
|
||||
import json
|
||||
|
||||
try:
|
||||
data["file_params"] = json.loads(file_params_json)
|
||||
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()
|
||||
future = loop.create_future()
|
||||
@@ -1905,7 +2093,8 @@ class ModelUpdateHandler:
|
||||
from dataclasses import replace
|
||||
|
||||
new_record = replace(
|
||||
record, versions=list(version_map.values()),
|
||||
record,
|
||||
versions=list(version_map.values()),
|
||||
)
|
||||
|
||||
# Optionally persist to database for caching
|
||||
@@ -2120,6 +2309,7 @@ class ModelUpdateHandler:
|
||||
if version.early_access_ends_at:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ea_date = datetime.fromisoformat(
|
||||
version.early_access_ends_at.replace("Z", "+00:00")
|
||||
)
|
||||
@@ -2127,7 +2317,7 @@ class ModelUpdateHandler:
|
||||
except (ValueError, AttributeError):
|
||||
# If date parsing fails, treat as active EA (conservative)
|
||||
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
|
||||
is_early_access = True
|
||||
|
||||
@@ -2207,6 +2397,7 @@ class ModelHandlerSet:
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
"replace_preview": self.management.replace_preview,
|
||||
"set_preview_from_url": self.management.set_preview_from_url,
|
||||
"save_metadata": self.management.save_metadata,
|
||||
"add_tags": self.management.add_tags,
|
||||
"rename_model": self.management.rename_model,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Dedicated handler objects for recipe-related routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -8,6 +9,7 @@ import re
|
||||
import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -29,6 +31,7 @@ from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||
from ...services.batch_import_service import BatchImportService
|
||||
|
||||
Logger = logging.Logger
|
||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
@@ -46,8 +49,11 @@ class RecipeHandlerSet:
|
||||
management: "RecipeManagementHandler"
|
||||
analysis: "RecipeAnalysisHandler"
|
||||
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."""
|
||||
|
||||
return {
|
||||
@@ -81,6 +87,11 @@ class RecipeHandlerSet:
|
||||
"cancel_repair": self.management.cancel_repair,
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"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 +181,10 @@ class RecipeListingHandler:
|
||||
search_options = {
|
||||
"title": request.query.get("search_title", "true").lower() == "true",
|
||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||
"lora_name": request.query.get("search_lora_name", "true").lower()
|
||||
== "true",
|
||||
"lora_model": request.query.get("search_lora_model", "true").lower()
|
||||
== "true",
|
||||
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -246,7 +259,9 @@ class RecipeListingHandler:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
return web.json_response(recipe)
|
||||
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)
|
||||
|
||||
def format_recipe_file_url(self, file_path: str) -> str:
|
||||
@@ -256,7 +271,9 @@ class RecipeListingHandler:
|
||||
if static_url:
|
||||
return static_url
|
||||
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"
|
||||
@@ -293,7 +310,9 @@ class RecipeQueryHandler:
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
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)
|
||||
return web.json_response({"success": True, "tags": sorted_tags[:limit]})
|
||||
except Exception as exc:
|
||||
@@ -313,9 +332,14 @@ class RecipeQueryHandler:
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
base_model = recipe.get("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)
|
||||
return web.json_response({"success": True, "base_models": sorted_models})
|
||||
except Exception as exc:
|
||||
@@ -345,7 +369,9 @@ class RecipeQueryHandler:
|
||||
folders = await recipe_scanner.get_folders()
|
||||
return web.json_response({"success": True, "folders": folders})
|
||||
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)
|
||||
|
||||
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||
@@ -358,7 +384,9 @@ class RecipeQueryHandler:
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
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)
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
@@ -371,7 +399,9 @@ class RecipeQueryHandler:
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
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)
|
||||
|
||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||
@@ -383,7 +413,9 @@ class RecipeQueryHandler:
|
||||
|
||||
lora_hash = request.query.get("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)
|
||||
return web.json_response({"success": True, "recipes": matching_recipes})
|
||||
@@ -400,7 +432,9 @@ class RecipeQueryHandler:
|
||||
|
||||
self._logger.info("Manually triggering recipe cache rebuild")
|
||||
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:
|
||||
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -429,7 +463,9 @@ class RecipeQueryHandler:
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title"),
|
||||
"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"),
|
||||
"created_date": recipe.get("created_date"),
|
||||
"lora_count": len(recipe.get("loras", [])),
|
||||
@@ -437,7 +473,9 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
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(
|
||||
{
|
||||
"type": "fingerprint",
|
||||
@@ -460,7 +498,9 @@ class RecipeQueryHandler:
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title"),
|
||||
"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"),
|
||||
"created_date": recipe.get("created_date"),
|
||||
"lora_count": len(recipe.get("loras", [])),
|
||||
@@ -468,7 +508,9 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
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(
|
||||
{
|
||||
"type": "source_url",
|
||||
@@ -479,9 +521,13 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
async def get_recipe_syntax(self, request: web.Request) -> web.Response:
|
||||
@@ -498,9 +544,13 @@ class RecipeQueryHandler:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
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:
|
||||
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
@@ -561,11 +611,17 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -579,11 +635,12 @@ class RecipeManagementHandler:
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
})
|
||||
self._logger.error(
|
||||
f"Error in recipe repair task: {e}", exc_info=True
|
||||
)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress(
|
||||
{"status": "error", "error": str(e)}
|
||||
)
|
||||
finally:
|
||||
# Keep the final status for a while so the UI can see it
|
||||
await asyncio.sleep(5)
|
||||
@@ -593,7 +650,9 @@ class RecipeManagementHandler:
|
||||
|
||||
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:
|
||||
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -603,10 +662,15 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
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()
|
||||
return web.json_response({"success": True, "message": "Cancellation requested"})
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Cancellation requested"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -616,7 +680,10 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
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"]
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
@@ -632,25 +699,26 @@ class RecipeManagementHandler:
|
||||
progress = self._ws_manager.get_recipe_repair_progress()
|
||||
if 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:
|
||||
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def import_remote_recipe(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")
|
||||
|
||||
|
||||
# 1. Parse Parameters
|
||||
params = request.rel_url.query
|
||||
image_url = params.get("image_url")
|
||||
name = params.get("name")
|
||||
resources_raw = params.get("resources")
|
||||
|
||||
|
||||
if not image_url:
|
||||
raise RecipeValidationError("Missing required field: image_url")
|
||||
if not name:
|
||||
@@ -658,64 +726,80 @@ class RecipeManagementHandler:
|
||||
if not resources_raw:
|
||||
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"))
|
||||
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
"loras": lora_entries,
|
||||
"gen_params": gen_params_request or {},
|
||||
"source_url": image_url
|
||||
"source_url": image_url,
|
||||
}
|
||||
|
||||
|
||||
source_path = params.get("source_path")
|
||||
if source_path:
|
||||
metadata["source_path"] = source_path
|
||||
|
||||
|
||||
# Checkpoint handling
|
||||
if checkpoint_entry:
|
||||
metadata["checkpoint"] = checkpoint_entry
|
||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||
|
||||
|
||||
# Try to resolve base model from checkpoint if not explicitly provided
|
||||
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:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
tags = self._parse_tags(params.get("tags"))
|
||||
|
||||
|
||||
# 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
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# However, logic in Enricher merges: request > civitai > embedded.
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# OR pass them to enricher to handle?
|
||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||
|
||||
|
||||
# Let's extract embedded metadata first
|
||||
embedded_gen_params = {}
|
||||
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_path = temp_img.name
|
||||
|
||||
|
||||
try:
|
||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||
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:
|
||||
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:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
else:
|
||||
@@ -724,7 +808,9 @@ class RecipeManagementHandler:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
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
|
||||
if embedded_gen_params:
|
||||
@@ -732,18 +818,18 @@ class RecipeManagementHandler:
|
||||
# But wait, we want request params to override everything.
|
||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||
metadata["gen_params"] = embedded_gen_params
|
||||
|
||||
|
||||
# 5. Enrich with unified logic
|
||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
recipe=metadata,
|
||||
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),
|
||||
# we might want to manually merge it?
|
||||
# we might want to manually merge it?
|
||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||
|
||||
@@ -762,7 +848,9 @@ class RecipeManagementHandler:
|
||||
except RecipeDownloadError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
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)
|
||||
|
||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||
@@ -816,7 +904,11 @@ class RecipeManagementHandler:
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_id or not target_path:
|
||||
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(
|
||||
@@ -845,7 +937,11 @@ class RecipeManagementHandler:
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_ids or not target_path:
|
||||
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(
|
||||
@@ -934,7 +1030,9 @@ class RecipeManagementHandler:
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
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)
|
||||
|
||||
async def _parse_save_payload(self, reader) -> dict[str, Any]:
|
||||
@@ -1006,7 +1104,9 @@ class RecipeManagementHandler:
|
||||
raise RecipeValidationError("gen_params payload must be an object")
|
||||
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:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -1066,15 +1166,19 @@ class RecipeManagementHandler:
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
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))
|
||||
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")
|
||||
if not media_url:
|
||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||
|
||||
|
||||
# Use optimized preview URLs if possible
|
||||
media_type = image_info.get("type")
|
||||
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
||||
@@ -1083,18 +1187,24 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
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:
|
||||
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||
|
||||
|
||||
# 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()
|
||||
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:
|
||||
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:
|
||||
raise
|
||||
except RecipeValidationError:
|
||||
@@ -1108,14 +1218,15 @@ class RecipeManagementHandler:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_int(self, value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
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"))
|
||||
|
||||
if not version_id:
|
||||
@@ -1134,7 +1245,9 @@ class RecipeManagementHandler:
|
||||
base_model = version_info.get("baseModel") or ""
|
||||
return str(base_model) if base_model is not None else ""
|
||||
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 ""
|
||||
|
||||
@@ -1279,5 +1392,311 @@ class RecipeSharingHandler:
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
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/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
@@ -37,12 +38,24 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||
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/download-metadata-archive", "download_metadata_archive"
|
||||
),
|
||||
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("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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -66,7 +79,11 @@ class MiscRouteRegistrar:
|
||||
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
|
||||
) -> None:
|
||||
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:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
ExampleWorkflowsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -29,6 +30,7 @@ from .handlers.misc_handlers import (
|
||||
NodeRegistry,
|
||||
NodeRegistryHandler,
|
||||
SettingsHandler,
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
build_service_registry_adapter,
|
||||
@@ -37,9 +39,10 @@ from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class MiscRoutes:
|
||||
@@ -74,7 +77,9 @@ class MiscRoutes:
|
||||
self._node_registry = node_registry or NodeRegistry()
|
||||
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
|
||||
def setup_routes(app: web.Application) -> None:
|
||||
@@ -86,7 +91,9 @@ class MiscRoutes:
|
||||
registrar = self._registrar_factory(app)
|
||||
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:
|
||||
handler_set = self._create_handler_set()
|
||||
self._handler_mapping = handler_set.to_route_mapping()
|
||||
@@ -119,6 +126,8 @@ class MiscRoutes:
|
||||
metadata_provider_factory=self._metadata_provider_factory,
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
supporters = SupportersHandler()
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -132,6 +141,8 @@ class MiscRoutes:
|
||||
metadata_archive=metadata_archive,
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
supporters=supporters,
|
||||
example_workflows=example_workflows,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Route registrar for model endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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}/relink-civitai", "relink_civitai"),
|
||||
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}/add-tags", "add_tags"),
|
||||
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("GET", "/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}/base-models", "get_base_models"),
|
||||
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}/folders", "get_folders"),
|
||||
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-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}/preview-url", "get_model_preview_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}/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}/civitai/versions/{model_id}", "get_civitai_versions"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
|
||||
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(
|
||||
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/{prefix}/civitai/model/version/{modelVersionId}",
|
||||
"get_civitai_model_by_version",
|
||||
),
|
||||
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("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_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("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
@@ -94,12 +130,18 @@ class ModelRouteRegistrar:
|
||||
definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS,
|
||||
) -> None:
|
||||
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:
|
||||
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)
|
||||
|
||||
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Route registrar for recipe endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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/recipes/import-remote", "import_remote_recipe"),
|
||||
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("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
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/folders", "get_folders"),
|
||||
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/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("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
@@ -40,13 +47,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
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/scan", "scan_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
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 +83,9 @@ class RecipeRouteRegistrar:
|
||||
def __init__(self, app: web.Application) -> None:
|
||||
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:
|
||||
handler = handler_lookup[definition.handler_name]
|
||||
self._bind_route(definition.method, definition.path, handler)
|
||||
|
||||
@@ -209,6 +209,80 @@ class StatsRoutes:
|
||||
'error': str(e)
|
||||
}, 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:
|
||||
"""Get base model distribution statistics"""
|
||||
try:
|
||||
@@ -530,6 +604,7 @@ class StatsRoutes:
|
||||
# Register API routes
|
||||
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/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/tag-analytics', self.get_tag_analytics)
|
||||
app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
@@ -207,7 +208,11 @@ class BaseModelService(ABC):
|
||||
|
||||
reverse = sort_params.order == "desc"
|
||||
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,
|
||||
)
|
||||
return annotated
|
||||
@@ -383,7 +388,9 @@ class BaseModelService(ABC):
|
||||
# Check user setting for hiding early access updates
|
||||
hide_early_access = False
|
||||
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:
|
||||
hide_early_access = False
|
||||
|
||||
@@ -413,7 +420,11 @@ class BaseModelService(ABC):
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
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:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
@@ -426,7 +437,9 @@ class BaseModelService(ABC):
|
||||
|
||||
if resolved is None:
|
||||
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
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -588,13 +601,19 @@ class BaseModelService(ABC):
|
||||
normalized_type = normalize_sub_type(resolve_sub_type(entry))
|
||||
if not normalized_type:
|
||||
continue
|
||||
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
sorted_types = sorted(
|
||||
@@ -807,38 +826,61 @@ class BaseModelService(ABC):
|
||||
|
||||
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
|
||||
def _relative_path_matches_tokens(
|
||||
path_lower: str, include_terms: List[str], exclude_terms: List[str]
|
||||
) -> bool:
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens."""
|
||||
if any(term and term in path_lower for term in exclude_terms):
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens.
|
||||
|
||||
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
|
||||
|
||||
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 True
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||
"""Sort paths by how well they satisfy the include tokens."""
|
||||
path_lower = relative_path.lower()
|
||||
"""Sort paths by how well they satisfy the include tokens.
|
||||
|
||||
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(
|
||||
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 = [
|
||||
path_lower.find(term)
|
||||
path_for_sorting.find(term)
|
||||
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
|
||||
|
||||
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(
|
||||
self, search_term: str, limit: int = 15
|
||||
self, search_term: str, limit: int = 15, offset: int = 0
|
||||
) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
@@ -849,6 +891,7 @@ class BaseModelService(ABC):
|
||||
# Get model roots for path calculation
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
# Collect all matching paths first (needed for proper sorting and offset)
|
||||
for model in cache.raw_data:
|
||||
file_path = model.get("file_path", "")
|
||||
if not file_path:
|
||||
@@ -877,12 +920,12 @@ class BaseModelService(ABC):
|
||||
):
|
||||
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)
|
||||
matching_paths.sort(
|
||||
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]
|
||||
|
||||
593
py/services/batch_import_service.py
Normal file
593
py/services/batch_import_service.py
Normal 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]
|
||||
@@ -58,6 +58,7 @@ class CacheEntryValidator:
|
||||
'preview_nsfw_level': (0, False),
|
||||
'notes': ('', False),
|
||||
'usage_tips': ('', False),
|
||||
'hash_status': ('completed', False),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -90,13 +91,31 @@ class CacheEntryValidator:
|
||||
|
||||
errors: List[str] = []
|
||||
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
|
||||
|
||||
# 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():
|
||||
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
|
||||
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:
|
||||
errors.append(f"Required field '{field_name}' is missing or None")
|
||||
if auto_repair:
|
||||
@@ -107,6 +126,10 @@ class CacheEntryValidator:
|
||||
# Validate field type and value
|
||||
field_error = cls._validate_field(field_name, value, default_value)
|
||||
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)
|
||||
if auto_repair:
|
||||
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
|
||||
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
|
||||
sha256 = working_entry.get('sha256', '')
|
||||
# Use the effective hash_status we determined earlier
|
||||
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
|
||||
errors.append("Required field 'sha256' is empty")
|
||||
# Cannot repair empty sha256 - entry is invalid
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
repaired=repaired,
|
||||
errors=errors,
|
||||
entry=working_entry if auto_repair else None
|
||||
)
|
||||
# Allow empty sha256 for lazy hash calculation (checkpoints)
|
||||
if hash_status != 'pending':
|
||||
errors.append("Required field 'sha256' is empty")
|
||||
# Cannot repair empty sha256 - entry is invalid
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
repaired=repaired,
|
||||
errors=errors,
|
||||
entry=working_entry if auto_repair else None
|
||||
)
|
||||
|
||||
# Normalize sha256 to lowercase if needed
|
||||
if isinstance(sha256, str):
|
||||
normalized_sha = sha256.lower().strip()
|
||||
if normalized_sha != sha256:
|
||||
working_entry['sha256'] = normalized_sha
|
||||
repaired = True
|
||||
if auto_repair:
|
||||
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
|
||||
# Entry is valid if no critical required field errors remain after repair
|
||||
|
||||
@@ -13,22 +13,35 @@ from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckpointScanner(ModelScanner):
|
||||
"""Service for scanning and managing checkpoint files"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# 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__(
|
||||
model_type="checkpoint",
|
||||
model_class=CheckpointMetadata,
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex()
|
||||
hash_index=ModelHashIndex(),
|
||||
)
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[CheckpointMetadata]:
|
||||
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.
|
||||
@@ -38,13 +51,13 @@ class CheckpointScanner(ModelScanner):
|
||||
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,
|
||||
@@ -59,70 +72,76 @@ class CheckpointScanner(ModelScanner):
|
||||
modelDescription="",
|
||||
sub_type="checkpoint",
|
||||
from_civitai=False, # Mark as local model since no hash yet
|
||||
hash_status="pending" # Mark hash as pending
|
||||
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}")
|
||||
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)
|
||||
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)
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata:
|
||||
metadata.hash_status = "failed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
@@ -130,43 +149,46 @@ class CheckpointScanner(ModelScanner):
|
||||
pass
|
||||
return None
|
||||
|
||||
async def calculate_all_pending_hashes(self, progress_callback=None) -> Dict[str, int]:
|
||||
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')
|
||||
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}
|
||||
|
||||
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')
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
sha256 = await self.calculate_hash_for_model(file_path)
|
||||
if sha256:
|
||||
@@ -176,77 +198,102 @@ class CheckpointScanner(ModelScanner):
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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'):
|
||||
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:
|
||||
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:
|
||||
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_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']}
|
||||
})
|
||||
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}")
|
||||
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]:
|
||||
"""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:
|
||||
return None
|
||||
|
||||
# Check standard ComfyUI checkpoint paths
|
||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||
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:
|
||||
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
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
|
||||
@@ -3,36 +3,42 @@ import copy
|
||||
import logging
|
||||
import os
|
||||
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 .errors import RateLimitError, ResourceNotFoundError
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CivitaiClient:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
"""Get singleton instance of CivitaiClient"""
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
|
||||
|
||||
# Register this client as a metadata provider
|
||||
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
|
||||
|
||||
def __init__(self):
|
||||
# Check if already initialized for singleton pattern
|
||||
if hasattr(self, '_initialized'):
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
|
||||
self.base_url = "https://civitai.com/api/v1"
|
||||
|
||||
async def _make_request(
|
||||
@@ -75,8 +81,10 @@ class CivitaiClient:
|
||||
meta = image.get("meta")
|
||||
if isinstance(meta, dict) and "comfy" in meta:
|
||||
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
|
||||
|
||||
Args:
|
||||
@@ -90,41 +98,48 @@ class CivitaiClient:
|
||||
"""
|
||||
downloader = await get_downloader()
|
||||
save_path = os.path.join(save_dir, default_filename)
|
||||
|
||||
|
||||
# Use unified downloader with CivitAI authentication
|
||||
success, result = await downloader.download_file(
|
||||
url=url,
|
||||
save_path=save_path,
|
||||
progress_callback=progress_callback,
|
||||
use_auth=True, # Enable CivitAI authentication
|
||||
allow_resume=True
|
||||
allow_resume=True,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
use_auth=True,
|
||||
)
|
||||
if not success:
|
||||
message = str(version)
|
||||
if "not found" in message.lower():
|
||||
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
|
||||
|
||||
model_id = version.get('modelId')
|
||||
if model_id:
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if model_data:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
if isinstance(version, dict):
|
||||
model_id = version.get("modelId")
|
||||
if model_id:
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if model_data:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version, None
|
||||
self._remove_comfy_metadata(version)
|
||||
return version, None
|
||||
else:
|
||||
return None, "Invalid response format"
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -136,19 +151,19 @@ class CivitaiClient:
|
||||
downloader = await get_downloader()
|
||||
success, content, headers = await downloader.download_to_memory(
|
||||
image_url,
|
||||
use_auth=False # Preview images don't need auth
|
||||
use_auth=False, # Preview images don't need auth
|
||||
)
|
||||
if success:
|
||||
# Ensure directory exists
|
||||
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)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_message(payload: Any) -> str:
|
||||
"""Return a human-readable error message from an API payload."""
|
||||
@@ -175,19 +190,17 @@ class CivitaiClient:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
)
|
||||
if success:
|
||||
# Also return model type along with versions
|
||||
return {
|
||||
'modelVersions': result.get('modelVersions', []),
|
||||
'type': result.get('type', ''),
|
||||
'name': result.get('name', '')
|
||||
"modelVersions": result.get("modelVersions", []),
|
||||
"type": result.get("type", ""),
|
||||
"name": result.get("name", ""),
|
||||
}
|
||||
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}")
|
||||
if message:
|
||||
raise RuntimeError(message)
|
||||
@@ -221,15 +234,15 @@ class CivitaiClient:
|
||||
try:
|
||||
query = ",".join(normalized_ids)
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={'ids': query},
|
||||
params={"ids": query},
|
||||
)
|
||||
if not success:
|
||||
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):
|
||||
return {}
|
||||
|
||||
@@ -237,19 +250,19 @@ class CivitaiClient:
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = item.get('id')
|
||||
model_id = item.get("id")
|
||||
try:
|
||||
normalized_id = int(model_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
payload[normalized_id] = {
|
||||
'modelVersions': item.get('modelVersions', []),
|
||||
'type': item.get('type', ''),
|
||||
'name': item.get('name', ''),
|
||||
'allowNoCredit': item.get('allowNoCredit'),
|
||||
'allowCommercialUse': item.get('allowCommercialUse'),
|
||||
'allowDerivatives': item.get('allowDerivatives'),
|
||||
'allowDifferentLicense': item.get('allowDifferentLicense'),
|
||||
"modelVersions": item.get("modelVersions", []),
|
||||
"type": item.get("type", ""),
|
||||
"name": item.get("name", ""),
|
||||
"allowNoCredit": item.get("allowNoCredit"),
|
||||
"allowCommercialUse": item.get("allowCommercialUse"),
|
||||
"allowDerivatives": item.get("allowDerivatives"),
|
||||
"allowDifferentLicense": item.get("allowDifferentLicense"),
|
||||
}
|
||||
return payload
|
||||
except RateLimitError:
|
||||
@@ -257,8 +270,10 @@ class CivitaiClient:
|
||||
except Exception as exc:
|
||||
logger.error(f"Error fetching model versions in bulk: {exc}")
|
||||
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."""
|
||||
try:
|
||||
if model_id is None and version_id is not None:
|
||||
@@ -281,7 +296,7 @@ class CivitaiClient:
|
||||
if version is None:
|
||||
return None
|
||||
|
||||
model_id = version.get('modelId')
|
||||
model_id = version.get("modelId")
|
||||
if not model_id:
|
||||
logger.error(f"No modelId found in version {version_id}")
|
||||
return None
|
||||
@@ -293,7 +308,9 @@ class CivitaiClient:
|
||||
self._remove_comfy_metadata(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)
|
||||
if not model_data:
|
||||
return None
|
||||
@@ -302,8 +319,12 @@ class CivitaiClient:
|
||||
if target_version is None:
|
||||
return None
|
||||
|
||||
target_version_id = target_version.get('id')
|
||||
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
|
||||
target_version_id = target_version.get("id")
|
||||
version = (
|
||||
await self._fetch_version_by_id(target_version_id)
|
||||
if target_version_id
|
||||
else None
|
||||
)
|
||||
|
||||
if version is None:
|
||||
model_hash = self._extract_primary_model_hash(target_version)
|
||||
@@ -315,7 +336,9 @@ class CivitaiClient:
|
||||
)
|
||||
|
||||
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._remove_comfy_metadata(version)
|
||||
@@ -323,9 +346,7 @@ class CivitaiClient:
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
@@ -337,9 +358,7 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True
|
||||
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -352,9 +371,7 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -362,16 +379,17 @@ class CivitaiClient:
|
||||
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||
return None
|
||||
|
||||
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||
model_versions = model_data.get('modelVersions', [])
|
||||
def _select_target_version(
|
||||
self, model_data: Dict, model_id: int, version_id: Optional[int]
|
||||
) -> Optional[Dict]:
|
||||
model_versions = model_data.get("modelVersions", [])
|
||||
if not model_versions:
|
||||
logger.warning(f"No model versions found for model {model_id}")
|
||||
return None
|
||||
|
||||
if version_id is not None:
|
||||
target_version = next(
|
||||
(item for item in model_versions if item.get('id') == version_id),
|
||||
None
|
||||
(item for item in model_versions if item.get("id") == version_id), None
|
||||
)
|
||||
if target_version is None:
|
||||
logger.warning(
|
||||
@@ -383,46 +401,50 @@ class CivitaiClient:
|
||||
return model_versions[0]
|
||||
|
||||
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
|
||||
for file_info in version_entry.get('files', []):
|
||||
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||
hashes = file_info.get('hashes', {})
|
||||
model_hash = hashes.get('SHA256')
|
||||
for file_info in version_entry.get("files", []):
|
||||
if file_info.get("type") == "Model" and file_info.get("primary"):
|
||||
hashes = file_info.get("hashes", {})
|
||||
model_hash = hashes.get("SHA256")
|
||||
if model_hash:
|
||||
return model_hash
|
||||
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.pop('index', None)
|
||||
version['modelId'] = model_id
|
||||
version['model'] = {
|
||||
'name': model_data.get('name'),
|
||||
'type': model_data.get('type'),
|
||||
'nsfw': model_data.get('nsfw'),
|
||||
'poi': model_data.get('poi')
|
||||
version.pop("index", None)
|
||||
version["modelId"] = model_id
|
||||
version["model"] = {
|
||||
"name": model_data.get("name"),
|
||||
"type": model_data.get("type"),
|
||||
"nsfw": model_data.get("nsfw"),
|
||||
"poi": model_data.get("poi"),
|
||||
}
|
||||
return version
|
||||
|
||||
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):
|
||||
model_info = {}
|
||||
version['model'] = model_info
|
||||
version["model"] = model_info
|
||||
|
||||
model_info['description'] = model_data.get("description")
|
||||
model_info['tags'] = model_data.get("tags", [])
|
||||
version['creator'] = model_data.get("creator")
|
||||
model_info["description"] = model_data.get("description")
|
||||
model_info["tags"] = model_data.get("tags", [])
|
||||
version["creator"] = model_data.get("creator")
|
||||
|
||||
license_payload = resolve_license_payload(model_data)
|
||||
for field, value in license_payload.items():
|
||||
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
|
||||
|
||||
|
||||
Args:
|
||||
version_id: The Civitai model version ID
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Dict], Optional[str]]: A tuple containing:
|
||||
- The model version data or None if not found
|
||||
@@ -430,25 +452,23 @@ class CivitaiClient:
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
)
|
||||
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
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)
|
||||
return result, None
|
||||
|
||||
|
||||
# Handle specific error cases
|
||||
if "not found" in str(result):
|
||||
error_msg = f"Model not found"
|
||||
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
||||
return None, error_msg
|
||||
|
||||
|
||||
# Other error cases
|
||||
logger.error(f"Failed to fetch model info for {version_id}: {result}")
|
||||
return None, str(result)
|
||||
@@ -464,27 +484,23 @@ class CivitaiClient:
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
)
|
||||
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and len(result["items"]) > 0:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return result["items"][0]
|
||||
logger.warning(f"No image found with ID: {image_id}")
|
||||
return None
|
||||
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
return None
|
||||
except RateLimitError:
|
||||
@@ -501,11 +517,7 @@ class CivitaiClient:
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if not success:
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
|
||||
@@ -49,6 +49,7 @@ class CustomWordsService:
|
||||
if self._tag_index is None:
|
||||
try:
|
||||
from .tag_fts_index import get_tag_fts_index
|
||||
|
||||
self._tag_index = get_tag_fts_index()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||
@@ -59,14 +60,16 @@ class CustomWordsService:
|
||||
self,
|
||||
search_term: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
categories: Optional[List[int]] = None,
|
||||
enriched: bool = False
|
||||
enriched: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search tags using TagFTSIndex with category filtering.
|
||||
|
||||
Args:
|
||||
search_term: The search term to match against.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
enriched: If True, always return enriched results with category
|
||||
and post_count (default behavior now).
|
||||
@@ -76,7 +79,9 @@ class CustomWordsService:
|
||||
"""
|
||||
tag_index = self._get_tag_index()
|
||||
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
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
|
||||
@@ -10,7 +10,11 @@ import uuid
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import urlparse
|
||||
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,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
@@ -352,10 +356,12 @@ class DownloadManager:
|
||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
||||
is_diffusion_model = False
|
||||
if model_type == "checkpoint":
|
||||
base_model_value = version_info.get('baseModel', '')
|
||||
base_model_value = version_info.get("baseModel", "")
|
||||
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
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
|
||||
if model_version_id is None:
|
||||
@@ -464,7 +470,7 @@ class DownloadManager:
|
||||
# 2. Get file information
|
||||
files = version_info.get("files", [])
|
||||
file_info = None
|
||||
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_type = file_params.get("type", "Model")
|
||||
@@ -472,23 +478,28 @@ class DownloadManager:
|
||||
target_size = file_params.get("size", "full")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
|
||||
if is_primary:
|
||||
# Find primary file
|
||||
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:
|
||||
# Match by metadata
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
f_meta = f.get("metadata", {})
|
||||
|
||||
|
||||
# Check type match
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
|
||||
# Check metadata match
|
||||
if f_meta.get("format") != target_format:
|
||||
continue
|
||||
@@ -496,10 +507,10 @@ class DownloadManager:
|
||||
continue
|
||||
if target_fp and f_meta.get("fp") != target_fp:
|
||||
continue
|
||||
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
|
||||
# Fallback to primary file if no match found
|
||||
if not file_info:
|
||||
file_info = next(
|
||||
@@ -510,7 +521,7 @@ class DownloadManager:
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
mirrors = file_info.get("mirrors") or []
|
||||
@@ -1220,7 +1231,13 @@ class DownloadManager:
|
||||
entries: List = []
|
||||
for index, file_path in enumerate(file_paths):
|
||||
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
|
||||
# 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)
|
||||
entry.sha256 = await calculate_sha256(file_path)
|
||||
entries.append(entry)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class LoraService(BaseModelService):
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", 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,
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
|
||||
if 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
|
||||
|
||||
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
|
||||
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
else:
|
||||
clip_str = round(
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
|
||||
|
||||
result_loras.append(
|
||||
{
|
||||
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
|
||||
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
|
||||
|
||||
async def get_cycler_list(
|
||||
self,
|
||||
pool_config: Optional[Dict] = None,
|
||||
sort_by: str = "filename"
|
||||
self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get filtered and sorted LoRA list for cycling.
|
||||
@@ -516,12 +635,18 @@ class LoraService(BaseModelService):
|
||||
if sort_by == "model_name":
|
||||
available_loras = sorted(
|
||||
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
|
||||
available_loras = sorted(
|
||||
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
|
||||
|
||||
@@ -221,33 +221,45 @@ class ModelCache:
|
||||
start_time = time.perf_counter()
|
||||
reverse = (order == 'desc')
|
||||
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(
|
||||
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
|
||||
)
|
||||
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(
|
||||
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
|
||||
)
|
||||
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(
|
||||
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
|
||||
)
|
||||
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(
|
||||
data,
|
||||
key=lambda x: (
|
||||
x.get('usage_count', 0),
|
||||
self._get_display_name(x).lower()
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from .model_lifecycle_service import delete_model_artifacts
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
@@ -1442,14 +1441,13 @@ class ModelScanner:
|
||||
file_path = self._hash_index.get_path(sha256.lower())
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
preview_path = f"{base_name}{ext}"
|
||||
if os.path.exists(preview_path):
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
|
||||
dir_path = os.path.dirname(file_path)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
preview_path = find_preview_file(base_name, dir_path)
|
||||
if preview_path:
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
return None
|
||||
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -56,6 +56,7 @@ class PersistentModelCache:
|
||||
"exclude",
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
"hash_status",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
@@ -186,6 +187,7 @@ class PersistentModelCache:
|
||||
"civitai_deleted": bool(row["civitai_deleted"]),
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
"hash_status": row["hash_status"] or "completed",
|
||||
}
|
||||
raw_data.append(item)
|
||||
|
||||
@@ -449,6 +451,7 @@ class PersistentModelCache:
|
||||
exclude INTEGER,
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
hash_status TEXT,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
@@ -496,6 +499,7 @@ class PersistentModelCache:
|
||||
"skip_metadata_refresh": "INTEGER DEFAULT 0",
|
||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||
"hash_status": "TEXT DEFAULT 'completed'",
|
||||
}
|
||||
|
||||
for column, definition in required_columns.items():
|
||||
@@ -570,6 +574,7 @@ class PersistentModelCache:
|
||||
1 if item.get("exclude") else 0,
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
item.get("hash_status", "completed"),
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
"""Cache structure for Recipe data"""
|
||||
@@ -21,11 +22,18 @@ class RecipeCache:
|
||||
self.folder_tree = self.folder_tree or {}
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
Args:
|
||||
@@ -37,7 +45,7 @@ class RecipeCache:
|
||||
"""
|
||||
async with self._lock:
|
||||
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)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -52,7 +60,9 @@ class RecipeCache:
|
||||
if resort:
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -64,14 +74,16 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
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)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
return removed
|
||||
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."""
|
||||
|
||||
id_set = {str(recipe_id) for recipe_id in recipe_ids}
|
||||
@@ -79,21 +91,25 @@ class RecipeCache:
|
||||
return []
|
||||
|
||||
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:
|
||||
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:
|
||||
self._resort_locked()
|
||||
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."""
|
||||
|
||||
async with self._lock:
|
||||
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
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -105,7 +121,7 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
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 None
|
||||
|
||||
@@ -115,16 +131,14 @@ class RecipeCache:
|
||||
async with self._lock:
|
||||
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``."""
|
||||
|
||||
self.sorted_by_name = natsorted(
|
||||
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:
|
||||
self.sorted_by_date = sorted(
|
||||
self.raw_data,
|
||||
key=itemgetter('created_date', 'file_path'),
|
||||
reverse=True
|
||||
)
|
||||
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
"""Services responsible for recipe metadata analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -69,7 +70,9 @@ class RecipeAnalysisService:
|
||||
try:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
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(
|
||||
metadata,
|
||||
@@ -105,29 +108,33 @@ class RecipeAnalysisService:
|
||||
if civitai_match:
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
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")
|
||||
if not image_url:
|
||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||
|
||||
|
||||
is_video = image_info.get("type") == "video"
|
||||
|
||||
|
||||
# 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:
|
||||
image_url = rewritten_url
|
||||
|
||||
if is_video:
|
||||
# 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"
|
||||
else:
|
||||
extension = ".jpg"
|
||||
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(image_url, temp_path)
|
||||
|
||||
|
||||
metadata = image_info.get("meta") if "meta" in image_info else None
|
||||
if (
|
||||
isinstance(metadata, dict)
|
||||
@@ -135,15 +142,23 @@ class RecipeAnalysisService:
|
||||
and isinstance(metadata["meta"], dict)
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# 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:
|
||||
# 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()
|
||||
if extension in [".mp4", ".webm"]:
|
||||
is_video = True
|
||||
else:
|
||||
extension = ".jpg"
|
||||
|
||||
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(url, temp_path)
|
||||
|
||||
@@ -211,7 +226,9 @@ class RecipeAnalysisService:
|
||||
|
||||
image_bytes = self._convert_tensor_to_png_bytes(latest_image)
|
||||
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(
|
||||
{
|
||||
@@ -222,6 +239,22 @@ class RecipeAnalysisService:
|
||||
|
||||
# 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(
|
||||
self,
|
||||
metadata: dict[str, Any],
|
||||
@@ -234,7 +267,12 @@ class RecipeAnalysisService:
|
||||
) -> AnalysisResult:
|
||||
parser = self._recipe_parser_factory.create_parser(metadata)
|
||||
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:
|
||||
payload["image_base64"] = self._encode_file(image_path)
|
||||
payload["is_video"] = is_video
|
||||
@@ -257,7 +295,9 @@ class RecipeAnalysisService:
|
||||
|
||||
matching_recipes: list[str] = []
|
||||
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
|
||||
|
||||
return AnalysisResult(result)
|
||||
@@ -269,7 +309,10 @@ class RecipeAnalysisService:
|
||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||
|
||||
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):
|
||||
payload["image_base64"] = self._encode_file(path)
|
||||
return AnalysisResult(payload)
|
||||
@@ -305,7 +348,9 @@ class RecipeAnalysisService:
|
||||
|
||||
if hasattr(tensor_image, "shape"):
|
||||
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]
|
||||
|
||||
@@ -69,7 +69,9 @@ class TagFTSIndex:
|
||||
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
||||
_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.
|
||||
|
||||
Args:
|
||||
@@ -92,7 +94,9 @@ class TagFTSIndex:
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
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:
|
||||
"""Resolve the default database path."""
|
||||
@@ -173,13 +177,15 @@ class TagFTSIndex:
|
||||
# Set schema version
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self._schema_initialized = True
|
||||
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:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -206,13 +212,20 @@ class TagFTSIndex:
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
# 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)
|
||||
return True
|
||||
|
||||
current_version = int(row[0])
|
||||
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)
|
||||
return True
|
||||
|
||||
@@ -246,7 +259,9 @@ class TagFTSIndex:
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
self._indexing_in_progress = True
|
||||
@@ -314,22 +329,24 @@ class TagFTSIndex:
|
||||
# Update metadata
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("last_build_time", str(time.time()))
|
||||
("last_build_time", str(time.time())),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("tag_count", str(total_inserted))
|
||||
("tag_count", str(total_inserted)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"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:
|
||||
conn.close()
|
||||
@@ -350,7 +367,7 @@ class TagFTSIndex:
|
||||
# Insert into tags table (with aliases)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
||||
rows
|
||||
rows,
|
||||
)
|
||||
|
||||
# Build a map of tag_name -> aliases for FTS insertion
|
||||
@@ -362,7 +379,7 @@ class TagFTSIndex:
|
||||
placeholders = ",".join("?" * len(tag_names))
|
||||
cursor = conn.execute(
|
||||
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 ...")
|
||||
@@ -379,13 +396,17 @@ class TagFTSIndex:
|
||||
alias = alias[1:] # Remove leading slash
|
||||
if 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:
|
||||
searchable_text = tag_name
|
||||
fts_rows.append((rowid, searchable_text))
|
||||
|
||||
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:
|
||||
"""Ensure the index is ready, building if necessary.
|
||||
@@ -420,21 +441,28 @@ class TagFTSIndex:
|
||||
self,
|
||||
query: str,
|
||||
categories: Optional[List[int]] = None,
|
||||
limit: int = 20
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Search tags using FTS5 with prefix matching.
|
||||
|
||||
Supports alias search: if the query matches an alias rather than
|
||||
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:
|
||||
query: The search query string.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
|
||||
Returns:
|
||||
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)
|
||||
if not self.ensure_ready():
|
||||
@@ -450,35 +478,67 @@ class TagFTSIndex:
|
||||
if not fts_query:
|
||||
return []
|
||||
|
||||
query_lower = query.lower().strip()
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
# Build the SQL query - now also fetch aliases for matched_alias detection
|
||||
# Use subquery for category filter to ensure FTS is evaluated first
|
||||
# Build the SQL query with bm25 ranking
|
||||
# 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:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
FROM tags t
|
||||
WHERE t.rowid IN (
|
||||
SELECT rowid FROM tag_fts WHERE searchable_text MATCH ?
|
||||
)
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
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})
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
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:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
FROM tag_fts f
|
||||
JOIN tags t ON f.rowid = t.rowid
|
||||
WHERE f.searchable_text MATCH ?
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
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 ?
|
||||
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)
|
||||
results = []
|
||||
@@ -487,8 +547,17 @@ class TagFTSIndex:
|
||||
"tag_name": row[0],
|
||||
"category": row[1],
|
||||
"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
|
||||
matched_alias = self._find_matched_alias(query, row[0], row[3])
|
||||
if matched_alias:
|
||||
@@ -502,7 +571,9 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
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.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -47,8 +47,7 @@ class BulkMetadataRefreshUseCase:
|
||||
to_process: Sequence[Dict[str, Any]] = [
|
||||
model
|
||||
for model in cache.raw_data
|
||||
if model.get("sha256")
|
||||
and not model.get("skip_metadata_refresh", False)
|
||||
if not model.get("skip_metadata_refresh", False)
|
||||
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 (
|
||||
@@ -85,6 +84,36 @@ class BulkMetadataRefreshUseCase:
|
||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
|
||||
try:
|
||||
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)
|
||||
result, _ = await self._metadata_sync.fetch_and_update_model(
|
||||
sha256=model["sha256"],
|
||||
|
||||
@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
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()
|
||||
# Add example extension for compatibility
|
||||
# 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
|
||||
temp_extensions.append(".example.0.jpeg")
|
||||
|
||||
# Fast path: exact-case match
|
||||
for ext in temp_extensions:
|
||||
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
||||
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, "/")
|
||||
|
||||
|
||||
# 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 ""
|
||||
|
||||
def get_preview_extension(preview_path: str) -> str:
|
||||
|
||||
@@ -4,32 +4,40 @@ from datetime import datetime
|
||||
import os
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseModelMetadata:
|
||||
"""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_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
|
||||
file_name: str # The filename without extension
|
||||
model_name: str # The model's name defined by the creator
|
||||
file_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
civitai_deleted: bool = False # Whether deleted from Civitai
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
skip_metadata_refresh: bool = (
|
||||
False # Whether to skip this model during bulk metadata refresh
|
||||
)
|
||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
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
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize empty lists to avoid mutable default parameter issue
|
||||
@@ -40,211 +48,238 @@ class BaseModelMetadata:
|
||||
self.tags = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'BaseModelMetadata':
|
||||
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
data_copy = data.copy()
|
||||
|
||||
|
||||
# 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()
|
||||
for c in cls.mro():
|
||||
if hasattr(c, '__annotations__'):
|
||||
if hasattr(c, "__annotations__"):
|
||||
known_fields.update(c.__annotations__.keys())
|
||||
cls._known_fields_cache = known_fields
|
||||
|
||||
|
||||
known_fields = cls._known_fields_cache
|
||||
|
||||
|
||||
# Extract fields that match our class attributes
|
||||
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
|
||||
|
||||
|
||||
# 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
|
||||
instance = cls(**fields_to_use)
|
||||
|
||||
|
||||
# Add unknown fields as a separate attribute
|
||||
instance._unknown_fields = unknown_fields
|
||||
|
||||
|
||||
return instance
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
result = asdict(self)
|
||||
|
||||
|
||||
# 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
|
||||
if hasattr(self, '_unknown_fields'):
|
||||
if hasattr(self, "_unknown_fields"):
|
||||
result.update(self._unknown_fields)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def update_civitai_info(self, civitai_data: Dict) -> None:
|
||||
"""Update Civitai information"""
|
||||
self.civitai = civitai_data
|
||||
|
||||
def update_file_info(self, file_path: str) -> None:
|
||||
"""Update metadata with actual file information"""
|
||||
def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
|
||||
"""
|
||||
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):
|
||||
self.size = os.path.getsize(file_path)
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
self.file_path = file_path.replace(os.sep, '/')
|
||||
# Update file_name when file_path changes
|
||||
if update_timestamps:
|
||||
# Only update size and modified when file has been relocated
|
||||
self.size = os.path.getsize(file_path)
|
||||
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]
|
||||
|
||||
@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
|
||||
|
||||
|
||||
Args:
|
||||
target_dir: Target directory path
|
||||
base_name: Base filename without extension
|
||||
extension: File extension including the dot
|
||||
hash_provider: A callable that returns the SHA256 hash when needed
|
||||
|
||||
|
||||
Returns:
|
||||
str: Unique filename that doesn't conflict with existing files
|
||||
"""
|
||||
original_filename = f"{base_name}{extension}"
|
||||
target_path = os.path.join(target_dir, original_filename)
|
||||
|
||||
|
||||
# If no conflict, return original filename
|
||||
if not os.path.exists(target_path):
|
||||
return original_filename
|
||||
|
||||
|
||||
# Only compute hash when needed
|
||||
if hash_provider:
|
||||
sha256_hash = hash_provider()
|
||||
else:
|
||||
sha256_hash = "0000"
|
||||
|
||||
|
||||
# Generate short hash (first 4 characters of SHA256)
|
||||
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||
|
||||
|
||||
# Try with short hash suffix
|
||||
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
|
||||
|
||||
# If still conflicts, add incremental number
|
||||
counter = 1
|
||||
while os.path.exists(unique_path):
|
||||
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
counter += 1
|
||||
|
||||
|
||||
return unique_filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoraMetadata(BaseModelMetadata):
|
||||
"""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
|
||||
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"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_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, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
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,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for a Checkpoint model"""
|
||||
|
||||
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
|
||||
|
||||
@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"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'checkpoint')
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sub_type = version_info.get("type", "checkpoint")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_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, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
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,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for an Embedding model"""
|
||||
|
||||
sub_type: str = "embedding"
|
||||
|
||||
@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"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'embedding')
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sub_type = version_info.get("type", "embedding")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_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, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
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,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,33 +7,47 @@ from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
import asyncio
|
||||
|
||||
|
||||
def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
|
||||
async def _get_lora_info_async():
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if item.get("file_name") == lora_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
for root in config.loras_roots:
|
||||
root = root.replace(os.sep, '/')
|
||||
# Check all lora roots including extra paths
|
||||
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):
|
||||
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
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
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, []
|
||||
|
||||
|
||||
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)
|
||||
@@ -41,11 +55,11 @@ def get_lora_info(lora_name):
|
||||
return new_loop.run_until_complete(_get_lora_info_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_async())
|
||||
@@ -53,33 +67,34 @@ def get_lora_info(lora_name):
|
||||
|
||||
def get_lora_info_absolute(lora_name):
|
||||
"""Get the absolute lora path and trigger words from cache
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (absolute_path, trigger_words) where absolute_path is the full
|
||||
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
|
||||
"""
|
||||
|
||||
async def _get_lora_info_absolute_async():
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
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 []
|
||||
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)
|
||||
@@ -87,50 +102,161 @@ def get_lora_info_absolute(lora_name):
|
||||
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 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:
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Format the stored path as ComfyUI-style name
|
||||
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||
|
||||
# Match by formatted name (normalize separators for robust comparison)
|
||||
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
|
||||
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
@@ -156,10 +282,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
# Collapse repeated replacement characters to a single instance
|
||||
if replacement:
|
||||
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
||||
sanitized = sanitized.strip(replacement)
|
||||
|
||||
# Remove trailing spaces or periods which are invalid on Windows
|
||||
sanitized = sanitized.rstrip(" .")
|
||||
# Combine stripping to be idempotent:
|
||||
# Right side: strip replacement, space, and dot (Windows restriction)
|
||||
# Left side: strip replacement and space (leading dots are allowed)
|
||||
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:
|
||||
return "unnamed"
|
||||
@@ -170,25 +299,25 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
def calculate_recipe_fingerprint(loras):
|
||||
"""
|
||||
Calculate a unique fingerprint for a recipe based on its LoRAs.
|
||||
|
||||
|
||||
The fingerprint is created by sorting LoRA hashes, filtering invalid entries,
|
||||
normalizing strength values to 2 decimal places, and joining in format:
|
||||
hash1:strength1|hash2:strength2|...
|
||||
|
||||
|
||||
Args:
|
||||
loras (list): List of LoRA dictionaries with hash and strength values
|
||||
|
||||
|
||||
Returns:
|
||||
str: The calculated fingerprint
|
||||
"""
|
||||
if not loras:
|
||||
return ""
|
||||
|
||||
|
||||
valid_loras = []
|
||||
for lora in loras:
|
||||
if lora.get("exclude", False):
|
||||
continue
|
||||
|
||||
|
||||
hash_value = lora.get("hash", "")
|
||||
if isinstance(hash_value, str):
|
||||
hash_value = hash_value.lower()
|
||||
@@ -206,18 +335,23 @@ def calculate_recipe_fingerprint(loras):
|
||||
strength = round(float(strength_val), 2)
|
||||
except (ValueError, TypeError):
|
||||
strength = 1.0
|
||||
|
||||
|
||||
valid_loras.append((hash_value, strength))
|
||||
|
||||
|
||||
# Sort by hash
|
||||
valid_loras.sort()
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
@@ -233,77 +367,80 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
|
||||
|
||||
# If template is empty, return empty path (flat structure)
|
||||
if not path_template:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
# 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
|
||||
if civitai_data and civitai_data.get('id') is not None:
|
||||
base_model = model_data.get('base_model', '')
|
||||
if civitai_data and civitai_data.get("id") is not None:
|
||||
base_model = model_data.get("base_model", "")
|
||||
# Get author from civitai creator data
|
||||
creator_info = civitai_data.get('creator') or {}
|
||||
author = creator_info.get('username') or 'Anonymous'
|
||||
creator_info = civitai_data.get("creator") or {}
|
||||
author = creator_info.get("username") or "Anonymous"
|
||||
else:
|
||||
# Fallback to model_data fields for non-CivitAI models
|
||||
base_model = model_data.get('base_model', '')
|
||||
author = 'Anonymous' # Default for non-CivitAI models
|
||||
base_model = model_data.get("base_model", "")
|
||||
author = "Anonymous" # Default for non-CivitAI models
|
||||
|
||||
model_tags = model_data.get('tags', [])
|
||||
model_tags = model_data.get("tags", [])
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)]
|
||||
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:
|
||||
first_tag = 'no tags' # Default if no tags available
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get('model_name', ''))
|
||||
version_name = ''
|
||||
model_name = sanitize_folder_name(model_data.get("model_name", ""))
|
||||
version_name = ""
|
||||
|
||||
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 = formatted_path.replace('{base_model}', mapped_base_model)
|
||||
formatted_path = formatted_path.replace('{first_tag}', first_tag)
|
||||
formatted_path = formatted_path.replace('{author}', author)
|
||||
formatted_path = formatted_path.replace('{model_name}', model_name)
|
||||
formatted_path = formatted_path.replace('{version_name}', version_name)
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
formatted_path = formatted_path.replace("{first_tag}", first_tag)
|
||||
formatted_path = formatted_path.replace("{author}", author)
|
||||
formatted_path = formatted_path.replace("{model_name}", model_name)
|
||||
formatted_path = formatted_path.replace("{version_name}", version_name)
|
||||
|
||||
if model_type == 'embedding':
|
||||
formatted_path = formatted_path.replace(' ', '_')
|
||||
if model_type == "embedding":
|
||||
formatted_path = formatted_path.replace(" ", "_")
|
||||
|
||||
return formatted_path
|
||||
|
||||
|
||||
def remove_empty_dirs(path):
|
||||
"""Recursively remove empty directories starting from the given path.
|
||||
|
||||
|
||||
Args:
|
||||
path (str): Root directory to start cleaning from
|
||||
|
||||
|
||||
Returns:
|
||||
int: Number of empty directories removed
|
||||
"""
|
||||
removed_count = 0
|
||||
|
||||
|
||||
if not os.path.isdir(path):
|
||||
return removed_count
|
||||
|
||||
|
||||
# List all files in directory
|
||||
files = os.listdir(path)
|
||||
|
||||
|
||||
# Process all subdirectories first
|
||||
for file in files:
|
||||
full_path = os.path.join(path, file)
|
||||
if os.path.isdir(full_path):
|
||||
removed_count += remove_empty_dirs(full_path)
|
||||
|
||||
|
||||
# Check if directory is now empty (after processing subdirectories)
|
||||
if not os.listdir(path):
|
||||
try:
|
||||
@@ -311,5 +448,5 @@ def remove_empty_dirs(path):
|
||||
removed_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
return removed_count
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.9.16"
|
||||
version = "1.0.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
addopts = -v --import-mode=importlib
|
||||
addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
@@ -12,5 +12,6 @@ markers =
|
||||
asyncio: execute test within asyncio event loop
|
||||
no_settings_dir_isolation: allow tests to use real settings paths
|
||||
integration: integration tests requiring external resources
|
||||
performance: performance benchmarks (slow, skip by default)
|
||||
# Skip problematic directories to avoid import conflicts
|
||||
norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis
|
||||
63
scripts/update_supporters.py
Normal file
63
scripts/update_supporters.py
Normal 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()
|
||||
@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
|
||||
"/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/batch-import-progress", ws_manager.handle_connection)
|
||||
|
||||
# Schedule service initialization
|
||||
app.on_startup.append(lambda app: cls._initialize_services())
|
||||
|
||||
@@ -68,6 +68,7 @@ body {
|
||||
--space-1: calc(8px * 1);
|
||||
--space-2: calc(8px * 2);
|
||||
--space-3: calc(8px * 3);
|
||||
--space-4: calc(8px * 4);
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 10;
|
||||
@@ -77,6 +78,7 @@ body {
|
||||
|
||||
/* Border Radius */
|
||||
--border-radius-base: 12px;
|
||||
--border-radius-md: 12px;
|
||||
--border-radius-sm: 8px;
|
||||
--border-radius-xs: 4px;
|
||||
|
||||
|
||||
677
static/css/components/batch-import-modal.css
Normal file
677
static/css/components/batch-import-modal.css
Normal 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';
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-overlay);
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
/* Support Modal Styles */
|
||||
.support-modal {
|
||||
max-width: 570px;
|
||||
max-width: 1000px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
/* Two-column layout */
|
||||
.support-container {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.support-left {
|
||||
flex: 0 0 42%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--lora-border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.support-header {
|
||||
@@ -214,6 +234,11 @@
|
||||
.support-links {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.support-modal {
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Civitai link styles */
|
||||
@@ -239,4 +264,223 @@
|
||||
.folder-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Supporters Section Styles */
|
||||
.supporters-section {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.supporters-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.supporters-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
font-size: 1.3em !important;
|
||||
color: var(--lora-accent) !important;
|
||||
}
|
||||
|
||||
.supporters-title i {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.supporters-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.supporters-group {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.supporters-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: 1em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.supporters-group-title i {
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Special Thanks - Clean Card Style */
|
||||
.special-thanks-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.special-thanks-group .supporters-group-title {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.special-thanks-group .supporters-group-title i {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.all-supporters-group .supporters-group-title i {
|
||||
color: var(--lora-error);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.supporters-special-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.supporter-special-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.supporter-special-card:hover {
|
||||
border-color: var(--lora-accent);
|
||||
border-left-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.supporter-special-card .supporter-special-name {
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.supporter-special-card:hover .supporter-special-name {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* All Supporters - Elegant Text Flow */
|
||||
.all-supporters-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative; /* Base for masks */
|
||||
}
|
||||
|
||||
/* Optional: Fading effect for credits feel at top and bottom */
|
||||
.all-supporters-group::before,
|
||||
.all-supporters-group::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.all-supporters-group::before {
|
||||
top: 30px; /* Below the title */
|
||||
background: linear-gradient(to bottom, var(--lora-surface), transparent);
|
||||
}
|
||||
|
||||
.all-supporters-group::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--lora-surface), transparent);
|
||||
}
|
||||
|
||||
.all-supporters-group .supporters-group-title {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.supporters-all-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
line-height: 2.2;
|
||||
max-height: 550px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2) 0 40px 0; /* Extra padding at bottom for final visibility */
|
||||
color: var(--text-color);
|
||||
scroll-behavior: auto; /* Ensure manual scroll is immediate */
|
||||
}
|
||||
|
||||
/* Subtle scrollbar for credits look */
|
||||
.supporters-all-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.supporters-all-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.supporters-all-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.supporters-all-list:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.supporter-name-item {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.85;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.supporter-name-item:hover {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.supporter-separator {
|
||||
margin: 0 10px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.25;
|
||||
font-weight: 300;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.support-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.support-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.support-right {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--lora-border);
|
||||
padding-left: 0;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.supporters-all-list {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.supporters-special-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -250,12 +250,11 @@
|
||||
.changelog-content {
|
||||
max-height: 550px;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.changelog-item {
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
@@ -302,8 +301,7 @@
|
||||
|
||||
.changelog-item.latest {
|
||||
background-color: rgba(66, 153, 225, 0.05);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid rgba(66, 153, 225, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@@ -573,3 +573,171 @@
|
||||
.sidebar-tree-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Drag and Drop - Create Folder Zone ===== */
|
||||
|
||||
/* Empty state drag hint */
|
||||
.sidebar-empty-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||
border: 1px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
||||
}
|
||||
|
||||
.sidebar-empty-hint i {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Create folder drop zone */
|
||||
.sidebar-create-folder-zone {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 16px;
|
||||
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-zone.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content i {
|
||||
font-size: 1.5em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Create folder input container */
|
||||
.sidebar-create-folder-input-container {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 20;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper > i {
|
||||
color: var(--lora-accent);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-confirm:hover {
|
||||
background: oklch(from var(--success-color) l c h / 0.15);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-cancel:hover {
|
||||
background: oklch(from var(--error-color) l c h / 0.15);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Dragging state for sidebar */
|
||||
.folder-sidebar.dragging-active {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||
box-shadow: 0 0 0 3px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.folder-sidebar.dragging-active .sidebar-tree-container {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||
}
|
||||
|
||||
/* Tree container positioning for create folder elements */
|
||||
.sidebar-tree-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
|
||||
@@ -86,6 +86,7 @@ export function getApiEndpoints(modelType) {
|
||||
|
||||
// Preview management
|
||||
replacePreview: `/api/lm/${modelType}/replace-preview`,
|
||||
setPreviewFromUrl: `/api/lm/${modelType}/set-preview-from-url`,
|
||||
|
||||
// Query operations
|
||||
scan: `/api/lm/${modelType}/scan`,
|
||||
|
||||
@@ -251,7 +251,7 @@ export class BaseModelApiClient {
|
||||
replaceModelPreview(filePath) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,video/mp4';
|
||||
input.accept = 'image/*,image/webp,video/mp4';
|
||||
|
||||
input.onchange = async () => {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
@@ -307,6 +307,56 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a preview from a remote URL (e.g., CivitAI)
|
||||
* @param {string} filePath - Path to the model file
|
||||
* @param {string} imageUrl - Remote image URL
|
||||
* @param {number} nsfwLevel - NSFW level for the preview
|
||||
*/
|
||||
async setPreviewFromUrl(filePath, imageUrl, nsfwLevel = 0) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Setting preview from URL...');
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.setPreviewFromUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_path: filePath,
|
||||
image_url: imageUrl,
|
||||
nsfw_level: nsfwLevel
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to set preview from URL');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const pageState = this.getPageState();
|
||||
|
||||
const timestamp = Date.now();
|
||||
if (pageState.previewVersions) {
|
||||
pageState.previewVersions.set(filePath, timestamp);
|
||||
|
||||
const storageKey = `${this.modelType}_preview_versions`;
|
||||
saveMapToStorage(storageKey, pageState.previewVersions);
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
preview_url: data.preview_url,
|
||||
preview_nsfw_level: data.preview_nsfw_level
|
||||
};
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updateData);
|
||||
showToast('toast.api.previewUpdated', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error setting preview from URL:', error);
|
||||
showToast('toast.api.previewUploadFailed', {}, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async saveModelMetadata(filePath, data) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
@@ -259,6 +259,26 @@ export async function resetAndReload(updateFolders = false) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
||||
*/
|
||||
export async function syncChanges() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error syncing recipes:', error);
|
||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
|
||||
@@ -117,7 +117,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
countSkipStatus(skipState) {
|
||||
let count = 0;
|
||||
for (const filePath of state.selectedModels) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
const isSkipped = card.dataset.skip_metadata_refresh === 'true';
|
||||
if (isSkipped === skipState) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FilterManager } from '../managers/FilterManager.js';
|
||||
import { initPageState } from '../state/index.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
||||
import { renderSupporters } from '../services/supportersService.js';
|
||||
|
||||
/**
|
||||
* Header.js - Manages the application header behavior across different pages
|
||||
@@ -85,9 +86,15 @@ export class HeaderManager {
|
||||
// Handle support toggle
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
supportToggle.addEventListener('click', () => {
|
||||
supportToggle.addEventListener('click', async () => {
|
||||
if (window.modalManager) {
|
||||
window.modalManager.toggleModal('supportModal');
|
||||
// Load supporters data when modal opens
|
||||
try {
|
||||
await renderSupporters();
|
||||
} catch (error) {
|
||||
console.error('Error loading supporters:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class RecipeCard {
|
||||
createCardElement() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.draggable = true;
|
||||
card.dataset.filepath = this.recipe.file_path;
|
||||
card.dataset.title = this.recipe.title;
|
||||
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
|
||||
@@ -200,8 +201,9 @@ class RecipeCard {
|
||||
this.recipe.favorite = isFavorite;
|
||||
|
||||
// Re-find star icon in case of re-render during fault
|
||||
const filePathForXpath = this.recipe.file_path.replace(/"/g, '"');
|
||||
const currentCard = card.ownerDocument.evaluate(
|
||||
`.//*[@data-filepath="${this.recipe.file_path}"]`,
|
||||
`.//*[@data-filepath="${filePathForXpath}"]`,
|
||||
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
||||
).singleNodeValue || card;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
@@ -29,11 +30,14 @@ export class SidebarManager {
|
||||
this.draggedRootPath = null;
|
||||
this.draggedFromBulk = false;
|
||||
this.dragHandlersInitialized = false;
|
||||
this.sidebarDragHandlersInitialized = false;
|
||||
this.folderTreeElement = null;
|
||||
this.currentDropTarget = null;
|
||||
this.lastPageControls = null;
|
||||
this.isDisabledBySetting = false;
|
||||
this.initializationPromise = null;
|
||||
this.isCreatingFolder = false;
|
||||
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -56,6 +60,12 @@ export class SidebarManager {
|
||||
this.handleFolderDragOver = this.handleFolderDragOver.bind(this);
|
||||
this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this);
|
||||
this.handleFolderDrop = this.handleFolderDrop.bind(this);
|
||||
this.handleSidebarDragEnter = this.handleSidebarDragEnter.bind(this);
|
||||
this.handleSidebarDragOver = this.handleSidebarDragOver.bind(this);
|
||||
this.handleSidebarDragLeave = this.handleSidebarDragLeave.bind(this);
|
||||
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
||||
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
||||
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
||||
}
|
||||
|
||||
setHostPageControls(pageControls) {
|
||||
@@ -118,19 +128,18 @@ export class SidebarManager {
|
||||
this.removeEventHandlers();
|
||||
|
||||
this.clearAllDropHighlights();
|
||||
if (this.dragHandlersInitialized) {
|
||||
document.removeEventListener('dragstart', this.handleCardDragStart);
|
||||
document.removeEventListener('dragend', this.handleCardDragEnd);
|
||||
this.dragHandlersInitialized = false;
|
||||
}
|
||||
if (this.folderTreeElement) {
|
||||
this.folderTreeElement.removeEventListener('dragenter', this.handleFolderDragEnter);
|
||||
this.folderTreeElement.removeEventListener('dragover', this.handleFolderDragOver);
|
||||
this.folderTreeElement.removeEventListener('dragleave', this.handleFolderDragLeave);
|
||||
this.folderTreeElement.removeEventListener('drop', this.handleFolderDrop);
|
||||
this.folderTreeElement = null;
|
||||
}
|
||||
this.resetDragState();
|
||||
this.hideCreateFolderInput();
|
||||
|
||||
// Cleanup sidebar drag handlers
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar && this.sidebarDragHandlersInitialized) {
|
||||
sidebar.removeEventListener('dragenter', this.handleSidebarDragEnter);
|
||||
sidebar.removeEventListener('dragover', this.handleSidebarDragOver);
|
||||
sidebar.removeEventListener('dragleave', this.handleSidebarDragLeave);
|
||||
sidebar.removeEventListener('drop', this.handleSidebarDrop);
|
||||
this.sidebarDragHandlersInitialized = false;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.pageControls = null;
|
||||
@@ -233,6 +242,16 @@ export class SidebarManager {
|
||||
|
||||
this.folderTreeElement = folderTree;
|
||||
}
|
||||
|
||||
// Add sidebar-level drag handlers for creating new folders
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar && !this.sidebarDragHandlersInitialized) {
|
||||
sidebar.addEventListener('dragenter', this.handleSidebarDragEnter);
|
||||
sidebar.addEventListener('dragover', this.handleSidebarDragOver);
|
||||
sidebar.addEventListener('dragleave', this.handleSidebarDragLeave);
|
||||
sidebar.addEventListener('drop', this.handleSidebarDrop);
|
||||
this.sidebarDragHandlersInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
handleCardDragStart(event) {
|
||||
@@ -271,6 +290,12 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
card.classList.add('dragging');
|
||||
|
||||
// Add dragging state to sidebar for visual feedback
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('dragging-active');
|
||||
}
|
||||
}
|
||||
|
||||
handleCardDragEnd(event) {
|
||||
@@ -278,6 +303,13 @@ export class SidebarManager {
|
||||
if (card) {
|
||||
card.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Remove dragging state from sidebar
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('dragging-active');
|
||||
}
|
||||
|
||||
this.clearAllDropHighlights();
|
||||
this.resetDragState();
|
||||
}
|
||||
@@ -417,7 +449,12 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
async performDragMove(targetRelativePath) {
|
||||
console.log('[SidebarManager] performDragMove called with targetRelativePath:', targetRelativePath);
|
||||
console.log('[SidebarManager] draggedFilePaths:', this.draggedFilePaths);
|
||||
console.log('[SidebarManager] draggedRootPath:', this.draggedRootPath);
|
||||
|
||||
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
|
||||
console.log('[SidebarManager] performDragMove returning false - no draggedFilePaths');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -428,12 +465,15 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||
console.log('[SidebarManager] performDragMove returning false - supportsMove is false');
|
||||
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
||||
console.log('[SidebarManager] rootPath:', rootPath);
|
||||
if (!rootPath) {
|
||||
console.log('[SidebarManager] performDragMove returning false - no rootPath');
|
||||
showToast(
|
||||
'toast.models.moveFailed',
|
||||
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
|
||||
@@ -446,15 +486,19 @@ export class SidebarManager {
|
||||
const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1;
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
@@ -462,10 +506,12 @@ export class SidebarManager {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
console.log('[SidebarManager] performDragMove returning true');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error moving model(s) via drag-and-drop:', error);
|
||||
console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
|
||||
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
|
||||
console.log('[SidebarManager] performDragMove returning false due to error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -476,6 +522,365 @@ export class SidebarManager {
|
||||
this.draggedFromBulk = false;
|
||||
}
|
||||
|
||||
// Version of performDragMove that accepts state as parameters (for create folder submit)
|
||||
async performDragMoveWithState(targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk) {
|
||||
console.log('[SidebarManager] performDragMoveWithState called with:', { targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk });
|
||||
|
||||
if (!draggedFilePaths || draggedFilePaths.length === 0) {
|
||||
console.log('[SidebarManager] performDragMoveWithState returning false - no draggedFilePaths');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.apiClient) {
|
||||
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||
|| this.pageControls?.sidebarApiClient
|
||||
|| getModelApiClient();
|
||||
}
|
||||
|
||||
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||
console.log('[SidebarManager] performDragMoveWithState returning false - supportsMove is false');
|
||||
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootPath = draggedRootPath ? draggedRootPath.replace(/\\/g, '/') : '';
|
||||
console.log('[SidebarManager] rootPath:', rootPath);
|
||||
if (!rootPath) {
|
||||
console.log('[SidebarManager] performDragMoveWithState returning false - no rootPath');
|
||||
showToast(
|
||||
'toast.models.moveFailed',
|
||||
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
|
||||
'error'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
|
||||
const useBulkMove = draggedFromBulk || draggedFilePaths.length > 1;
|
||||
|
||||
try {
|
||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||
if (useBulkMove) {
|
||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||
} else {
|
||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||
}
|
||||
console.log('[SidebarManager] apiClient.move successful');
|
||||
|
||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
||||
console.log('[SidebarManager] calling resetAndReload');
|
||||
await this.pageControls.resetAndReload(true);
|
||||
} else {
|
||||
console.log('[SidebarManager] calling refresh');
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
console.log('[SidebarManager] performDragMoveWithState returning true');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
|
||||
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
|
||||
console.log('[SidebarManager] performDragMoveWithState returning false due to error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sidebar-level drag handlers for creating new folders =====
|
||||
|
||||
handleSidebarDragEnter(event) {
|
||||
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
||||
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Only show create folder zone if not hovering over an existing folder
|
||||
const folderElement = this.getFolderElementFromEvent(event);
|
||||
if (folderElement) {
|
||||
this.hideCreateFolderZone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if drag is within the sidebar tree container area
|
||||
const treeContainer = document.querySelector('.sidebar-tree-container');
|
||||
if (treeContainer && treeContainer.contains(event.target)) {
|
||||
event.preventDefault();
|
||||
this.showCreateFolderZone();
|
||||
}
|
||||
}
|
||||
|
||||
handleSidebarDragOver(event) {
|
||||
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
||||
|
||||
const folderElement = this.getFolderElementFromEvent(event);
|
||||
if (folderElement) {
|
||||
this.hideCreateFolderZone();
|
||||
return;
|
||||
}
|
||||
|
||||
const treeContainer = document.querySelector('.sidebar-tree-container');
|
||||
if (treeContainer && treeContainer.contains(event.target)) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSidebarDragLeave(event) {
|
||||
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
||||
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
|
||||
|
||||
// Only hide if leaving the sidebar entirely
|
||||
if (!relatedTarget || !sidebar.contains(relatedTarget)) {
|
||||
this.hideCreateFolderZone();
|
||||
}
|
||||
}
|
||||
|
||||
async handleSidebarDrop(event) {
|
||||
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
|
||||
|
||||
const folderElement = this.getFolderElementFromEvent(event);
|
||||
if (folderElement) {
|
||||
// Let the folder drop handler take over
|
||||
return;
|
||||
}
|
||||
|
||||
const treeContainer = document.querySelector('.sidebar-tree-container');
|
||||
if (!treeContainer || !treeContainer.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Show create folder input
|
||||
this.showCreateFolderInput();
|
||||
}
|
||||
|
||||
showCreateFolderZone() {
|
||||
if (this.isCreatingFolder) return;
|
||||
|
||||
const treeContainer = document.querySelector('.sidebar-tree-container');
|
||||
if (!treeContainer) return;
|
||||
|
||||
let zone = document.getElementById('sidebarCreateFolderZone');
|
||||
if (!zone) {
|
||||
zone = document.createElement('div');
|
||||
zone.id = 'sidebarCreateFolderZone';
|
||||
zone.className = 'sidebar-create-folder-zone';
|
||||
zone.innerHTML = `
|
||||
<div class="sidebar-create-folder-content">
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
<span>${translate('sidebar.dragDrop.createFolderHint', {}, 'Release to create new folder')}</span>
|
||||
</div>
|
||||
`;
|
||||
treeContainer.appendChild(zone);
|
||||
}
|
||||
|
||||
zone.classList.add('active');
|
||||
}
|
||||
|
||||
hideCreateFolderZone() {
|
||||
const zone = document.getElementById('sidebarCreateFolderZone');
|
||||
if (zone) {
|
||||
zone.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
showCreateFolderInput() {
|
||||
console.log('[SidebarManager] showCreateFolderInput called');
|
||||
this.isCreatingFolder = true;
|
||||
|
||||
// 立即保存拖拽状态,防止后续事件(如blur)清空状态
|
||||
this._pendingDragState = {
|
||||
filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null,
|
||||
rootPath: this.draggedRootPath,
|
||||
fromBulk: this.draggedFromBulk
|
||||
};
|
||||
console.log('[SidebarManager] saved pending drag state:', this._pendingDragState);
|
||||
|
||||
this.hideCreateFolderZone();
|
||||
|
||||
const treeContainer = document.querySelector('.sidebar-tree-container');
|
||||
if (!treeContainer) return;
|
||||
|
||||
// Remove existing input if any
|
||||
this.hideCreateFolderInput();
|
||||
|
||||
const inputContainer = document.createElement('div');
|
||||
inputContainer.id = 'sidebarCreateFolderInput';
|
||||
inputContainer.className = 'sidebar-create-folder-input-container';
|
||||
inputContainer.innerHTML = `
|
||||
<div class="sidebar-create-folder-input-wrapper">
|
||||
<i class="fas fa-folder-plus"></i>
|
||||
<input type="text"
|
||||
class="sidebar-create-folder-input"
|
||||
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}"
|
||||
autofocus />
|
||||
<button class="sidebar-create-folder-btn sidebar-create-folder-confirm" title="${translate('common.confirm', {}, 'Confirm')}">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="sidebar-create-folder-btn sidebar-create-folder-cancel" title="${translate('common.cancel', {}, 'Cancel')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-create-folder-hint">
|
||||
${translate('sidebar.dragDrop.folderNameHint', {}, 'Press Enter to confirm, Escape to cancel')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
treeContainer.appendChild(inputContainer);
|
||||
|
||||
// Focus input
|
||||
const input = inputContainer.querySelector('.sidebar-create-folder-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Bind events
|
||||
const confirmBtn = inputContainer.querySelector('.sidebar-create-folder-confirm');
|
||||
const cancelBtn = inputContainer.querySelector('.sidebar-create-folder-cancel');
|
||||
|
||||
// Flag to prevent blur from canceling when clicking buttons
|
||||
let isButtonClick = false;
|
||||
|
||||
confirmBtn?.addEventListener('mousedown', () => {
|
||||
isButtonClick = true;
|
||||
console.log('[SidebarManager] confirmBtn mousedown - isButtonClick set to true');
|
||||
});
|
||||
cancelBtn?.addEventListener('mousedown', () => {
|
||||
isButtonClick = true;
|
||||
console.log('[SidebarManager] cancelBtn mousedown - isButtonClick set to true');
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', (e) => {
|
||||
console.log('[SidebarManager] confirmBtn click event triggered');
|
||||
this.handleCreateFolderSubmit();
|
||||
});
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
console.log('[SidebarManager] cancelBtn click event triggered');
|
||||
this.handleCreateFolderCancel();
|
||||
});
|
||||
input?.addEventListener('keydown', (e) => {
|
||||
console.log('[SidebarManager] input keydown:', e.key);
|
||||
if (e.key === 'Enter') {
|
||||
console.log('[SidebarManager] Enter pressed, calling handleCreateFolderSubmit');
|
||||
this.handleCreateFolderSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
console.log('[SidebarManager] Escape pressed, calling handleCreateFolderCancel');
|
||||
this.handleCreateFolderCancel();
|
||||
}
|
||||
});
|
||||
input?.addEventListener('blur', () => {
|
||||
console.log('[SidebarManager] input blur event - isButtonClick:', isButtonClick);
|
||||
// Delay to allow button clicks to process first
|
||||
setTimeout(() => {
|
||||
console.log('[SidebarManager] blur timeout - isButtonClick:', isButtonClick, 'activeElement:', document.activeElement?.className);
|
||||
if (!isButtonClick && document.activeElement !== confirmBtn && document.activeElement !== cancelBtn) {
|
||||
console.log('[SidebarManager] blur timeout - calling handleCreateFolderCancel');
|
||||
this.handleCreateFolderCancel();
|
||||
} else {
|
||||
console.log('[SidebarManager] blur timeout - NOT canceling (button click detected)');
|
||||
}
|
||||
isButtonClick = false;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
hideCreateFolderInput() {
|
||||
console.log('[SidebarManager] hideCreateFolderInput called');
|
||||
const inputContainer = document.getElementById('sidebarCreateFolderInput');
|
||||
console.log('[SidebarManager] inputContainer:', inputContainer);
|
||||
if (inputContainer) {
|
||||
inputContainer.remove();
|
||||
console.log('[SidebarManager] inputContainer removed');
|
||||
}
|
||||
this.isCreatingFolder = false;
|
||||
console.log('[SidebarManager] isCreatingFolder set to false');
|
||||
}
|
||||
|
||||
async handleCreateFolderSubmit() {
|
||||
console.log('[SidebarManager] handleCreateFolderSubmit called');
|
||||
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
||||
console.log('[SidebarManager] input element:', input);
|
||||
if (!input) {
|
||||
console.log('[SidebarManager] input not found, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = input.value.trim();
|
||||
console.log('[SidebarManager] folderName:', folderName);
|
||||
if (!folderName) {
|
||||
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate folder name (no slashes, no special chars)
|
||||
if (/[\\/:*?"<>|]/.test(folderName)) {
|
||||
showToast('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build target path - use selected path as parent, or root if none selected
|
||||
const parentPath = this.selectedPath || '';
|
||||
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
||||
console.log('[SidebarManager] targetRelativePath:', targetRelativePath);
|
||||
|
||||
// 使用 showCreateFolderInput 时保存的拖拽状态
|
||||
const pendingState = this._pendingDragState;
|
||||
console.log('[SidebarManager] using pending drag state:', pendingState);
|
||||
|
||||
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) {
|
||||
console.log('[SidebarManager] no pending drag state found, cannot proceed');
|
||||
showToast('sidebar.dragDrop.noDragState', {}, 'error');
|
||||
this.hideCreateFolderInput();
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideCreateFolderInput();
|
||||
|
||||
// Perform the move with saved state
|
||||
console.log('[SidebarManager] calling performDragMove with pending state');
|
||||
const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk);
|
||||
console.log('[SidebarManager] performDragMove result:', success);
|
||||
|
||||
if (success) {
|
||||
// Expand the parent folder to show the new folder
|
||||
if (parentPath) {
|
||||
this.expandedNodes.add(parentPath);
|
||||
this.saveExpandedState();
|
||||
}
|
||||
// Refresh the tree to show the newly created folder
|
||||
// restoreSelectedFolder() inside refresh() will maintain the current active folder
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// 清理待处理的拖拽状态
|
||||
this._pendingDragState = null;
|
||||
this.resetDragState();
|
||||
this.clearAllDropHighlights();
|
||||
}
|
||||
|
||||
handleCreateFolderCancel() {
|
||||
this.hideCreateFolderInput();
|
||||
// 清理待处理的拖拽状态
|
||||
this._pendingDragState = null;
|
||||
this.resetDragState();
|
||||
this.clearAllDropHighlights();
|
||||
}
|
||||
|
||||
saveSelectedFolder() {
|
||||
setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath);
|
||||
}
|
||||
|
||||
clearAllDropHighlights() {
|
||||
const highlighted = document.querySelectorAll('.sidebar-tree-node-content.drop-target, .sidebar-node-content.drop-target');
|
||||
highlighted.forEach((element) => element.classList.remove('drop-target'));
|
||||
@@ -890,15 +1295,19 @@ export class SidebarManager {
|
||||
const isExpanded = this.expandedNodes.has(currentPath);
|
||||
const isSelected = this.selectedPath === currentPath;
|
||||
|
||||
const escapedPath = escapeAttribute(currentPath);
|
||||
const escapedFolderName = escapeHtml(folderName);
|
||||
const escapedTitle = escapeAttribute(folderName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
||||
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
|
||||
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
|
||||
<div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div>
|
||||
</div>
|
||||
${hasChildren ? `
|
||||
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
||||
@@ -917,7 +1326,11 @@ export class SidebarManager {
|
||||
folderTree.innerHTML = `
|
||||
<div class="sidebar-tree-placeholder">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<div>No folders found</div>
|
||||
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>
|
||||
<div class="sidebar-empty-hint">
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
${translate('sidebar.empty.dragHint', {}, 'Drag items here to create folders')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -934,12 +1347,15 @@ export class SidebarManager {
|
||||
const foldersHtml = this.foldersList.map(folder => {
|
||||
const displayName = folder === '' ? '/' : folder;
|
||||
const isSelected = this.selectedPath === folder;
|
||||
const escapedPath = escapeAttribute(folder);
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const escapedTitle = escapeAttribute(displayName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||
<div class="sidebar-node-content" data-path="${folder}">
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-node-content" data-path="${escapedPath}">
|
||||
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
|
||||
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1162,7 +1578,8 @@ export class SidebarManager {
|
||||
|
||||
// Add selection to current path
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${escapedPathSelector}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
@@ -1173,7 +1590,8 @@ export class SidebarManager {
|
||||
});
|
||||
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${escapedPathSelector}"] .sidebar-tree-node-content`);
|
||||
if (selectedNode) {
|
||||
selectedNode.classList.add('selected');
|
||||
this.expandPathParents(this.selectedPath);
|
||||
@@ -1247,7 +1665,7 @@ export class SidebarManager {
|
||||
const breadcrumbs = [`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
|
||||
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||
<i class="fas fa-home"></i> ${escapeHtml(this.apiClient.apiConfig.config.displayName)} root
|
||||
</span>
|
||||
</div>
|
||||
`];
|
||||
@@ -1267,8 +1685,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${nextLevelFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
@@ -1284,12 +1702,14 @@ export class SidebarManager {
|
||||
|
||||
// Get siblings for this level
|
||||
const siblings = this.getSiblingFolders(parts, index);
|
||||
const escapedCurrentPath = escapeAttribute(currentPath);
|
||||
const escapedPart = escapeHtml(part);
|
||||
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
|
||||
${part}
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${escapedCurrentPath}">
|
||||
${escapedPart}
|
||||
${siblings.length > 1 ? `
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
@@ -1298,11 +1718,14 @@ export class SidebarManager {
|
||||
</span>
|
||||
${siblings.length > 1 ? `
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${siblings.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${currentPath.replace(part, folder)}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
${siblings.map(folder => {
|
||||
const siblingPath = parts.slice(0, index).concat(folder).join('/');
|
||||
return `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${escapeAttribute(siblingPath)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`;
|
||||
}).join('')
|
||||
}
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -1324,8 +1747,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${childFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(currentPath + '/' + folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -846,8 +846,14 @@ function setupLoraSpecificFields(filePath) {
|
||||
|
||||
const currentPath = resolveFilePath();
|
||||
if (!currentPath) return;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(currentPath)
|
||||
: currentPath.replace(/["\\]/g, '\\$&');
|
||||
const escapedFilePath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedCurrentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
if (key === 'strength_range') {
|
||||
|
||||
@@ -49,7 +49,10 @@ function formatPresetKey(key) {
|
||||
*/
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
|
||||
@@ -26,8 +26,7 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
||||
</button>
|
||||
` : ''}
|
||||
${mediaControlsHtml}
|
||||
<video controls autoplay muted loop crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
<video controls autoplay muted loop
|
||||
data-local-src="${localUrl || ''}"
|
||||
data-remote-src="${remoteUrl}"
|
||||
data-nsfw-level="${nsfwLevel}"
|
||||
|
||||
@@ -527,17 +527,18 @@ function initSetPreviewHandlers(container) {
|
||||
const response = await fetch(mediaElement.dataset.localSrc);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'preview.jpg', { type: blob.type });
|
||||
|
||||
|
||||
// Use the existing baseModelApi uploadPreview method with nsfw level
|
||||
await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel);
|
||||
await apiClient.uploadPreview(modelFilePath, file, nsfwLevel);
|
||||
} else {
|
||||
// We need to download the remote file first
|
||||
const response = await fetch(mediaElement.src);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'preview.jpg', { type: blob.type });
|
||||
|
||||
// Use the existing baseModelApi uploadPreview method with nsfw level
|
||||
await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel);
|
||||
// Remote file - send URL to backend to download (avoids CORS issues)
|
||||
const imageUrl = mediaElement.src || mediaElement.dataset.remoteSrc;
|
||||
if (!imageUrl) {
|
||||
throw new Error('No image URL available');
|
||||
}
|
||||
|
||||
// Use the new setPreviewFromUrl method
|
||||
await apiClient.setPreviewFromUrl(modelFilePath, imageUrl, nsfwLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting preview:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* MetadataPanel.js
|
||||
* Generates metadata panels for showcase media items
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Generate metadata panel HTML
|
||||
@@ -49,6 +50,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
prompt = escapeHtml(prompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
@@ -64,6 +66,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (negativePrompt) {
|
||||
negativePrompt = escapeHtml(negativePrompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
@@ -80,4 +83,4 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
|
||||
content += '</div></div>';
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './MediaUtils.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||
|
||||
export const showcaseListenerMetrics = {
|
||||
wheelListeners: 0,
|
||||
@@ -61,8 +62,14 @@ export async function loadExampleImages(images, modelHash) {
|
||||
|
||||
// Re-initialize the showcase event listeners
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
if (carousel) {
|
||||
// Always bind scroll-indicator click events (even when collapsed)
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
// Only initialize full showcase content when expanded
|
||||
if (!carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the example import functionality
|
||||
@@ -151,11 +158,19 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
|
||||
function renderMediaItem(img, index, exampleFiles) {
|
||||
// Find matching file in our list of actual files
|
||||
let localFile = findLocalFile(img, index, exampleFiles);
|
||||
|
||||
const remoteUrl = img.url || '';
|
||||
|
||||
// Get original remote URL
|
||||
const originalRemoteUrl = img.url || '';
|
||||
|
||||
// Determine media type for optimization
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Optimize CivitAI URLs for showcase display (full quality)
|
||||
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
|
||||
|
||||
const localUrl = localFile ? localFile.path : '';
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
||||
|
||||
// Calculate appropriate aspect ratio
|
||||
const aspectRatio = (img.height / img.width) * 100;
|
||||
@@ -576,6 +591,41 @@ export function toggleShowcase(element) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind scroll-indicator click events (works even when carousel is collapsed)
|
||||
* @param {HTMLElement} carousel - The carousel element
|
||||
*/
|
||||
function bindScrollIndicatorEvents(carousel) {
|
||||
if (!carousel) return;
|
||||
|
||||
const scrollIndicator = carousel.previousElementSibling;
|
||||
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
|
||||
// Remove previous listeners to avoid duplicates
|
||||
scrollIndicator.onclick = null;
|
||||
scrollIndicator.removeEventListener('click', scrollIndicator._leftClickHandler);
|
||||
scrollIndicator.removeEventListener('mousedown', scrollIndicator._middleClickHandler);
|
||||
|
||||
// Handler for left-click (button 0) - uses 'click' event
|
||||
scrollIndicator._leftClickHandler = (event) => {
|
||||
if (event.button === 0) {
|
||||
event.preventDefault();
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for middle-click (button 1) - uses 'mousedown' event
|
||||
scrollIndicator._middleClickHandler = (event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
scrollIndicator.addEventListener('click', scrollIndicator._leftClickHandler);
|
||||
scrollIndicator.addEventListener('mousedown', scrollIndicator._middleClickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all showcase content interactions
|
||||
* @param {HTMLElement} carousel - The carousel element
|
||||
@@ -589,15 +639,8 @@ export function initShowcaseContent(carousel) {
|
||||
initMediaControlHandlers(carousel);
|
||||
positionAllMediaControls(carousel);
|
||||
|
||||
// Bind scroll-indicator click to toggleShowcase
|
||||
const scrollIndicator = carousel.previousElementSibling;
|
||||
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
|
||||
// Remove previous click listeners to avoid duplicates
|
||||
scrollIndicator.onclick = null;
|
||||
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
|
||||
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||
}
|
||||
// Bind scroll-indicator click events
|
||||
bindScrollIndicatorEvents(carousel);
|
||||
|
||||
// Add window resize handler
|
||||
const resizeHandler = () => positionAllMediaControls(carousel);
|
||||
|
||||
815
static/js/managers/BatchImportManager.js
Normal file
815
static/js/managers/BatchImportManager.js
Normal file
@@ -0,0 +1,815 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* Manager for batch importing recipes from multiple images
|
||||
*/
|
||||
export class BatchImportManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.inputMode = 'urls'; // 'urls' or 'directory'
|
||||
this.operationId = null;
|
||||
this.wsConnection = null;
|
||||
this.pollingInterval = null;
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the batch import modal
|
||||
*/
|
||||
showModal() {
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
this.resetState();
|
||||
modalManager.showModal('batchImportModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager
|
||||
*/
|
||||
initialize() {
|
||||
this.initialized = true;
|
||||
|
||||
// Add event listener for persisting "Skip images without metadata" choice
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
skipNoMetadata.addEventListener('change', (e) => {
|
||||
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state to initial values
|
||||
*/
|
||||
resetState() {
|
||||
this.inputMode = 'urls';
|
||||
this.operationId = null;
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
|
||||
// Reset UI
|
||||
this.showStep('batchInputStep');
|
||||
this.toggleInputMode('urls');
|
||||
|
||||
// Clear inputs
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) urlInput.value = '';
|
||||
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
if (directoryInput) directoryInput.value = '';
|
||||
|
||||
const tagsInput = document.getElementById('batchTagsInput');
|
||||
if (tagsInput) tagsInput.value = '';
|
||||
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
// Load preference from storage, defaulting to true
|
||||
skipNoMetadata.checked = getStorageItem('batch_import_skip_no_metadata', true);
|
||||
}
|
||||
|
||||
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||
if (recursiveCheck) recursiveCheck.checked = true;
|
||||
|
||||
// Reset progress UI
|
||||
this.updateProgressUI({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
progress_percent: 0,
|
||||
current_item: '',
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
// Reset results
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
if (detailsList) {
|
||||
detailsList.innerHTML = '';
|
||||
detailsList.style.display = 'none';
|
||||
}
|
||||
|
||||
const toggleIcon = document.getElementById('resultsToggleIcon');
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.remove('expanded');
|
||||
}
|
||||
|
||||
// Clean up any existing connections
|
||||
this.cleanupConnections();
|
||||
|
||||
// Focus on the URL input field for better UX
|
||||
setTimeout(() => {
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific step in the modal
|
||||
*/
|
||||
showStep(stepId) {
|
||||
document.querySelectorAll('.batch-import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
|
||||
const step = document.getElementById(stepId);
|
||||
if (step) {
|
||||
step.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between URL list and directory input modes
|
||||
*/
|
||||
toggleInputMode(mode) {
|
||||
this.inputMode = mode;
|
||||
|
||||
// Update toggle buttons
|
||||
document.querySelectorAll('.toggle-btn[data-mode]').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
const activeBtn = document.querySelector(`.toggle-btn[data-mode="${mode}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
}
|
||||
|
||||
// Show/hide appropriate sections
|
||||
const urlSection = document.getElementById('urlListSection');
|
||||
const directorySection = document.getElementById('directorySection');
|
||||
|
||||
if (urlSection && directorySection) {
|
||||
if (mode === 'urls') {
|
||||
urlSection.style.display = 'block';
|
||||
directorySection.style.display = 'none';
|
||||
} else {
|
||||
urlSection.style.display = 'none';
|
||||
directorySection.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the batch import process
|
||||
*/
|
||||
async startImport() {
|
||||
const data = this.collectInputData();
|
||||
|
||||
if (!this.validateInput(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress step
|
||||
this.showStep('batchProgressStep');
|
||||
|
||||
// Start the import
|
||||
const response = await this.sendStartRequest(data);
|
||||
|
||||
if (response.success) {
|
||||
this.operationId = response.operation_id;
|
||||
this.isCancelled = false;
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
this.connectWebSocket();
|
||||
|
||||
// Start polling as fallback
|
||||
this.startPolling();
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting batch import:', error);
|
||||
showToast('toast.recipes.batchImportFailed', { message: error.message }, 'error');
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect input data from the form
|
||||
*/
|
||||
collectInputData() {
|
||||
const data = {
|
||||
mode: this.inputMode,
|
||||
tags: [],
|
||||
skip_no_metadata: false
|
||||
};
|
||||
|
||||
// Collect tags
|
||||
const tagsInput = document.getElementById('batchTagsInput');
|
||||
if (tagsInput && tagsInput.value.trim()) {
|
||||
data.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
|
||||
// Collect skip_no_metadata
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
data.skip_no_metadata = skipNoMetadata.checked;
|
||||
}
|
||||
|
||||
if (this.inputMode === 'urls') {
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) {
|
||||
const urls = urlInput.value.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
// Convert to items format
|
||||
data.items = urls.map(url => ({
|
||||
source: url,
|
||||
type: this.detectUrlType(url)
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
if (directoryInput) {
|
||||
data.directory = directoryInput.value.trim();
|
||||
}
|
||||
|
||||
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||
if (recursiveCheck) {
|
||||
data.recursive = recursiveCheck.checked;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a URL is http or local path
|
||||
*/
|
||||
detectUrlType(url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return 'url';
|
||||
}
|
||||
return 'local_path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data
|
||||
*/
|
||||
validateInput(data) {
|
||||
if (data.mode === 'urls') {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
showToast('toast.recipes.batchImportNoUrls', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!data.directory) {
|
||||
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the start batch import request
|
||||
*/
|
||||
async sendStartRequest(data) {
|
||||
const endpoint = data.mode === 'urls'
|
||||
? '/api/lm/recipes/batch-import/start'
|
||||
: '/api/lm/recipes/batch-import/directory';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket for real-time progress updates
|
||||
*/
|
||||
connectWebSocket() {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/batch-import-progress?id=${this.operationId}`;
|
||||
|
||||
this.wsConnection = new WebSocket(wsUrl);
|
||||
|
||||
this.wsConnection.onopen = () => {
|
||||
console.log('Connected to batch import progress WebSocket');
|
||||
};
|
||||
|
||||
this.wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'batch_import_progress') {
|
||||
this.handleProgressUpdate(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.wsConnection.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
this.wsConnection.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for progress updates (fallback)
|
||||
*/
|
||||
startPolling() {
|
||||
this.pollingInterval = setInterval(async () => {
|
||||
if (!this.operationId || this.isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipes/batch-import/progress?operation_id=${this.operationId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.progress) {
|
||||
this.handleProgressUpdate(data.progress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling progress:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle progress update from WebSocket or polling
|
||||
*/
|
||||
handleProgressUpdate(progress) {
|
||||
this.progress = progress;
|
||||
this.updateProgressUI(progress);
|
||||
|
||||
// Check if import is complete
|
||||
if (progress.status === 'completed' || progress.status === 'cancelled' ||
|
||||
(progress.total > 0 && progress.completed >= progress.total)) {
|
||||
this.importComplete(progress);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress UI
|
||||
*/
|
||||
updateProgressUI(progress) {
|
||||
// Update progress bar
|
||||
const progressBar = document.getElementById('batchProgressBar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress.progress_percent || 0}%`;
|
||||
}
|
||||
|
||||
// Update percentage
|
||||
const progressPercent = document.getElementById('batchProgressPercent');
|
||||
if (progressPercent) {
|
||||
progressPercent.textContent = `${Math.round(progress.progress_percent || 0)}%`;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const totalCount = document.getElementById('batchTotalCount');
|
||||
if (totalCount) totalCount.textContent = progress.total || 0;
|
||||
|
||||
const successCount = document.getElementById('batchSuccessCount');
|
||||
if (successCount) successCount.textContent = progress.success || 0;
|
||||
|
||||
const failedCount = document.getElementById('batchFailedCount');
|
||||
if (failedCount) failedCount.textContent = progress.failed || 0;
|
||||
|
||||
const skippedCount = document.getElementById('batchSkippedCount');
|
||||
if (skippedCount) skippedCount.textContent = progress.skipped || 0;
|
||||
|
||||
// Update current item
|
||||
const currentItem = document.getElementById('batchCurrentItem');
|
||||
if (currentItem) {
|
||||
currentItem.textContent = progress.current_item || '-';
|
||||
}
|
||||
|
||||
// Update status text
|
||||
const statusText = document.getElementById('batchStatusText');
|
||||
if (statusText) {
|
||||
if (progress.status === 'running') {
|
||||
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...');
|
||||
} else if (progress.status === 'completed') {
|
||||
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
|
||||
} else if (progress.status === 'cancelled') {
|
||||
statusText.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update container classes
|
||||
const progressContainer = document.querySelector('.batch-progress-container');
|
||||
if (progressContainer) {
|
||||
progressContainer.classList.remove('completed', 'cancelled', 'error');
|
||||
if (progress.status === 'completed') {
|
||||
progressContainer.classList.add('completed');
|
||||
} else if (progress.status === 'cancelled') {
|
||||
progressContainer.classList.add('cancelled');
|
||||
} else if (progress.failed > 0 && progress.failed === progress.total) {
|
||||
progressContainer.classList.add('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle import completion
|
||||
*/
|
||||
importComplete(progress) {
|
||||
this.cleanupConnections();
|
||||
this.results = progress;
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
window.recipeManager.loadRecipes();
|
||||
}
|
||||
|
||||
// Show results step
|
||||
this.showStep('batchResultsStep');
|
||||
this.updateResultsUI(progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the results UI
|
||||
*/
|
||||
updateResultsUI(progress) {
|
||||
// Update summary cards
|
||||
const resultsTotal = document.getElementById('resultsTotal');
|
||||
if (resultsTotal) resultsTotal.textContent = progress.total || 0;
|
||||
|
||||
const resultsSuccess = document.getElementById('resultsSuccess');
|
||||
if (resultsSuccess) resultsSuccess.textContent = progress.success || 0;
|
||||
|
||||
const resultsFailed = document.getElementById('resultsFailed');
|
||||
if (resultsFailed) resultsFailed.textContent = progress.failed || 0;
|
||||
|
||||
const resultsSkipped = document.getElementById('resultsSkipped');
|
||||
if (resultsSkipped) resultsSkipped.textContent = progress.skipped || 0;
|
||||
|
||||
// Update header based on results
|
||||
const resultsHeader = document.getElementById('batchResultsHeader');
|
||||
if (resultsHeader) {
|
||||
const icon = resultsHeader.querySelector('.results-icon i');
|
||||
const title = resultsHeader.querySelector('.results-title');
|
||||
|
||||
if (this.isCancelled) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-stop-circle';
|
||||
icon.parentElement.classList.add('warning');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
|
||||
} else if (progress.failed === 0 && progress.success > 0) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-check-circle';
|
||||
icon.parentElement.classList.remove('warning', 'error');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
|
||||
} else if (progress.failed > 0 && progress.success === 0) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-times-circle';
|
||||
icon.parentElement.classList.add('error');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.failed', {}, 'Import failed');
|
||||
} else {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-exclamation-circle';
|
||||
icon.parentElement.classList.add('warning');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.completedWithErrors', {}, 'Completed with errors');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the results details visibility
|
||||
*/
|
||||
toggleResultsDetails() {
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
const toggleIcon = document.getElementById('resultsToggleIcon');
|
||||
const toggle = document.querySelector('.details-toggle');
|
||||
|
||||
if (detailsList && toggleIcon) {
|
||||
if (detailsList.style.display === 'none') {
|
||||
detailsList.style.display = 'block';
|
||||
toggleIcon.classList.add('expanded');
|
||||
if (toggle) toggle.classList.add('expanded');
|
||||
|
||||
// Load details if not loaded
|
||||
if (detailsList.children.length === 0 && this.results && this.results.items) {
|
||||
this.loadResultsDetails(this.results.items);
|
||||
}
|
||||
} else {
|
||||
detailsList.style.display = 'none';
|
||||
toggleIcon.classList.remove('expanded');
|
||||
if (toggle) toggle.classList.remove('expanded');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load results details into the list
|
||||
*/
|
||||
loadResultsDetails(items) {
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
if (!detailsList) return;
|
||||
|
||||
detailsList.innerHTML = '';
|
||||
|
||||
items.forEach(item => {
|
||||
const resultItem = document.createElement('div');
|
||||
resultItem.className = 'result-item';
|
||||
|
||||
const statusClass = item.status === 'success' ? 'success' :
|
||||
item.status === 'failed' ? 'failed' : 'skipped';
|
||||
const statusIcon = item.status === 'success' ? 'check' :
|
||||
item.status === 'failed' ? 'times' : 'forward';
|
||||
|
||||
resultItem.innerHTML = `
|
||||
<div class="result-item-status ${statusClass}">
|
||||
<i class="fas fa-${statusIcon}"></i>
|
||||
</div>
|
||||
<div class="result-item-info">
|
||||
<div class="result-item-name">${this.escapeHtml(item.source || item.current_item || 'Unknown')}</div>
|
||||
${item.error_message ? `<div class="result-item-error">${this.escapeHtml(item.error_message)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailsList.appendChild(resultItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current import
|
||||
*/
|
||||
async cancelImport() {
|
||||
if (!this.operationId) return;
|
||||
|
||||
this.isCancelled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ operation_id: this.operationId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('toast.recipes.batchImportCancelling', {}, 'info');
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling import:', error);
|
||||
showToast('toast.recipes.batchImportCancelFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and reset state
|
||||
*/
|
||||
closeAndReset() {
|
||||
this.cleanupConnections();
|
||||
this.resetState();
|
||||
modalManager.closeModal('batchImportModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new import (from results step)
|
||||
*/
|
||||
startNewImport() {
|
||||
this.resetState();
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle directory browser visibility
|
||||
*/
|
||||
toggleDirectoryBrowser() {
|
||||
const browser = document.getElementById('batchDirectoryBrowser');
|
||||
if (browser) {
|
||||
const isVisible = browser.style.display !== 'none';
|
||||
browser.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
// Load initial directory when opening
|
||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
||||
this.loadDirectory(currentPath || '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load directory contents
|
||||
*/
|
||||
async loadDirectory(path) {
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/browse-directory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.renderDirectoryBrowser(data);
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportBrowseFailed', { message: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
showToast('toast.recipes.batchImportBrowseFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render directory browser UI
|
||||
*/
|
||||
renderDirectoryBrowser(data) {
|
||||
const currentPathEl = document.getElementById('batchCurrentPath');
|
||||
const folderList = document.getElementById('batchFolderList');
|
||||
const fileList = document.getElementById('batchFileList');
|
||||
const directoryCount = document.getElementById('batchDirectoryCount');
|
||||
const imageCount = document.getElementById('batchImageCount');
|
||||
|
||||
if (currentPathEl) {
|
||||
currentPathEl.textContent = data.current_path;
|
||||
}
|
||||
|
||||
// Render folders
|
||||
if (folderList) {
|
||||
folderList.innerHTML = '';
|
||||
|
||||
// Add parent directory if available
|
||||
if (data.parent_path) {
|
||||
const parentItem = this.createFolderItem('..', data.parent_path, true);
|
||||
folderList.appendChild(parentItem);
|
||||
}
|
||||
|
||||
data.directories.forEach(dir => {
|
||||
folderList.appendChild(this.createFolderItem(dir.name, dir.path));
|
||||
});
|
||||
}
|
||||
|
||||
// Render files
|
||||
if (fileList) {
|
||||
fileList.innerHTML = '';
|
||||
data.image_files.forEach(file => {
|
||||
fileList.appendChild(this.createFileItem(file.name, file.path, file.size));
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if (directoryCount) {
|
||||
directoryCount.textContent = data.directory_count;
|
||||
}
|
||||
if (imageCount) {
|
||||
imageCount.textContent = data.image_count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create folder item element
|
||||
*/
|
||||
createFolderItem(name, path, isParent = false) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
item.dataset.path = path;
|
||||
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-folder${isParent ? '' : ''}"></i>
|
||||
<span class="item-name">${this.escapeHtml(name)}</span>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
if (isParent) {
|
||||
this.navigateToParentDirectory();
|
||||
} else {
|
||||
this.loadDirectory(path);
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file item element
|
||||
*/
|
||||
createFileItem(name, path, size) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item';
|
||||
item.dataset.path = path;
|
||||
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-image"></i>
|
||||
<span class="item-name">${this.escapeHtml(name)}</span>
|
||||
<span class="item-size">${this.formatFileSize(size)}</span>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to parent directory
|
||||
*/
|
||||
navigateToParentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
if (currentPath) {
|
||||
// Get parent path using path manipulation
|
||||
const lastSeparator = currentPath.lastIndexOf('/');
|
||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
||||
this.loadDirectory(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select current directory
|
||||
*/
|
||||
selectCurrentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
|
||||
if (currentPath && directoryInput) {
|
||||
directoryInput.value = currentPath;
|
||||
this.toggleDirectoryBrowser(); // Close browser
|
||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse for directory using File System Access API (deprecated - kept for compatibility)
|
||||
*/
|
||||
async browseDirectory() {
|
||||
// Now redirects to the new directory browser
|
||||
this.toggleDirectoryBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up WebSocket and polling connections
|
||||
*/
|
||||
cleanupConnections() {
|
||||
if (this.wsConnection) {
|
||||
if (this.wsConnection.readyState === WebSocket.OPEN ||
|
||||
this.wsConnection.readyState === WebSocket.CONNECTING) {
|
||||
this.wsConnection.close();
|
||||
}
|
||||
this.wsConnection = null;
|
||||
}
|
||||
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const batchImportManager = new BatchImportManager();
|
||||
@@ -568,7 +568,8 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
deselectItem(filepath) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
@@ -632,7 +633,8 @@ export class BulkManager {
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
if (metadata) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,22 @@ export class LoadingManager {
|
||||
return LoadingManager.instance;
|
||||
}
|
||||
|
||||
// Delay DOM creation until first use to ensure i18n is ready
|
||||
this._initialized = false;
|
||||
this.overlay = null;
|
||||
this.loadingContent = null;
|
||||
this.progressBar = null;
|
||||
this.statusText = null;
|
||||
this.cancelButton = null;
|
||||
this.onCancelCallback = null;
|
||||
this.detailsContainer = null;
|
||||
|
||||
LoadingManager.instance = this;
|
||||
}
|
||||
|
||||
_ensureInitialized() {
|
||||
if (this._initialized) return;
|
||||
|
||||
this.overlay = document.getElementById('loading-overlay');
|
||||
|
||||
if (!this.overlay) {
|
||||
@@ -53,7 +69,6 @@ export class LoadingManager {
|
||||
this.loadingContent.appendChild(this.cancelButton);
|
||||
}
|
||||
|
||||
this.onCancelCallback = null;
|
||||
this.cancelButton.onclick = () => {
|
||||
if (this.onCancelCallback) {
|
||||
this.onCancelCallback();
|
||||
@@ -62,12 +77,11 @@ export class LoadingManager {
|
||||
}
|
||||
};
|
||||
|
||||
this.detailsContainer = null; // Will be created when needed
|
||||
|
||||
LoadingManager.instance = this;
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
show(message = 'Loading...', progress = 0) {
|
||||
this._ensureInitialized();
|
||||
this.overlay.style.display = 'flex';
|
||||
this.setProgress(progress);
|
||||
this.setStatus(message);
|
||||
@@ -77,21 +91,25 @@ export class LoadingManager {
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this._initialized) return;
|
||||
this.overlay.style.display = 'none';
|
||||
this.reset();
|
||||
this.removeDetailsContainer();
|
||||
}
|
||||
|
||||
setProgress(percent) {
|
||||
if (!this._initialized) return;
|
||||
this.progressBar.style.width = `${percent}%`;
|
||||
this.progressBar.setAttribute('aria-valuenow', percent);
|
||||
}
|
||||
|
||||
setStatus(message) {
|
||||
if (!this._initialized) return;
|
||||
this.statusText.textContent = message;
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this._initialized) return;
|
||||
this.setProgress(0);
|
||||
this.setStatus('');
|
||||
this.removeDetailsContainer();
|
||||
@@ -100,6 +118,7 @@ export class LoadingManager {
|
||||
}
|
||||
|
||||
showCancelButton(onCancel) {
|
||||
this._ensureInitialized();
|
||||
if (this.cancelButton) {
|
||||
this.onCancelCallback = onCancel;
|
||||
this.cancelButton.style.display = 'flex';
|
||||
@@ -109,6 +128,7 @@ export class LoadingManager {
|
||||
}
|
||||
|
||||
hideCancelButton() {
|
||||
if (!this._initialized) return;
|
||||
if (this.cancelButton) {
|
||||
this.cancelButton.style.display = 'none';
|
||||
this.onCancelCallback = null;
|
||||
@@ -117,6 +137,7 @@ export class LoadingManager {
|
||||
|
||||
// Create a details container for enhanced progress display
|
||||
createDetailsContainer() {
|
||||
this._ensureInitialized();
|
||||
// Remove existing container if any
|
||||
this.removeDetailsContainer();
|
||||
|
||||
@@ -332,12 +353,14 @@ export class LoadingManager {
|
||||
}
|
||||
|
||||
showSimpleLoading(message = 'Loading...') {
|
||||
this._ensureInitialized();
|
||||
this.overlay.style.display = 'flex';
|
||||
this.progressBar.style.display = 'none';
|
||||
this.setStatus(message);
|
||||
}
|
||||
|
||||
restoreProgressBar() {
|
||||
if (!this._initialized) return;
|
||||
this.progressBar.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add batchImportModal registration
|
||||
const batchImportModal = document.getElementById('batchImportModal');
|
||||
if (batchImportModal) {
|
||||
this.registerModal('batchImportModal', {
|
||||
element: batchImportModal,
|
||||
onClose: () => {
|
||||
this.getModal('batchImportModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add recipeModal registration
|
||||
const recipeModal = document.getElementById('recipeModal');
|
||||
if (recipeModal) {
|
||||
|
||||
@@ -88,6 +88,11 @@ class MoveManager {
|
||||
folderPathInput.value = '';
|
||||
}
|
||||
|
||||
// Reset folder tree selection
|
||||
if (this.folderTreeManager) {
|
||||
this.folderTreeManager.clearSelection();
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch model roots
|
||||
const modelRootSelect = document.getElementById('moveModelRoot');
|
||||
@@ -286,6 +291,9 @@ class MoveManager {
|
||||
|
||||
if (recursive) {
|
||||
// Visible if it's in activeFolder or any subfolder
|
||||
// Special case for root: if activeFolder is empty, everything is visible in recursive mode
|
||||
if (normalizedActive === '') return true;
|
||||
|
||||
return normalizedRelative === normalizedActive ||
|
||||
normalizedRelative.startsWith(normalizedActive + '/');
|
||||
} else {
|
||||
@@ -305,7 +313,7 @@ class MoveManager {
|
||||
}
|
||||
|
||||
// Get selected folder path from folder tree manager
|
||||
const targetFolder = this.folderTreeManager.getSelectedPath();
|
||||
const targetFolder = this.useDefaultPath ? '' : this.folderTreeManager.getSelectedPath();
|
||||
|
||||
let targetPath = selectedRoot;
|
||||
if (targetFolder) {
|
||||
@@ -315,81 +323,31 @@ class MoveManager {
|
||||
try {
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
|
||||
// Update virtual scroller visibility/metadata
|
||||
const pageState = getCurrentPageState();
|
||||
if (state.virtualScroller) {
|
||||
results.forEach(result => {
|
||||
if (result.success) {
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(result.original_file_path);
|
||||
|
||||
const newRelativeFolder = this._getRelativeFolder(result.new_file_path);
|
||||
const isVisible = this._isModelVisible(newRelativeFolder, pageState);
|
||||
|
||||
if (!isVisible) {
|
||||
state.virtualScroller.removeItemByFilePath(result.original_file_path);
|
||||
} else {
|
||||
const newFileNameWithExt = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||
const baseFileName = newFileNameWithExt.substring(0, newFileNameWithExt.lastIndexOf('.'));
|
||||
|
||||
const updateData = {
|
||||
file_path: result.new_file_path,
|
||||
file_name: baseFileName,
|
||||
folder: newRelativeFolder
|
||||
};
|
||||
|
||||
// Only update sub_type if it's present in the cache_entry
|
||||
if (result.cache_entry && result.cache_entry.sub_type) {
|
||||
updateData.sub_type = result.cache_entry.sub_type;
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(result.original_file_path, updateData);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||
|
||||
// Deselect moving items
|
||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||
} else {
|
||||
// Single move mode
|
||||
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
if (result && result.new_file_path && state.virtualScroller) {
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(this.currentFilePath);
|
||||
|
||||
const newRelativeFolder = this._getRelativeFolder(result.new_file_path);
|
||||
const isVisible = this._isModelVisible(newRelativeFolder, pageState);
|
||||
|
||||
if (!isVisible) {
|
||||
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
|
||||
} else {
|
||||
const newFileNameWithExt = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||
const baseFileName = newFileNameWithExt.substring(0, newFileNameWithExt.lastIndexOf('.'));
|
||||
|
||||
const updateData = {
|
||||
file_path: result.new_file_path,
|
||||
file_name: baseFileName,
|
||||
folder: newRelativeFolder
|
||||
};
|
||||
|
||||
// Only update sub_type if it's present in the cache_entry
|
||||
if (result.cache_entry && result.cache_entry.sub_type) {
|
||||
updateData.sub_type = result.cache_entry.sub_type;
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.currentFilePath, updateData);
|
||||
}
|
||||
}
|
||||
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||
|
||||
// Deselect moving item
|
||||
bulkManager.deselectItem(this.currentFilePath);
|
||||
}
|
||||
|
||||
// Refresh folder tags after successful move
|
||||
sidebarManager.refresh();
|
||||
// Refresh UI by reloading the current page, same as drag-and-drop behavior
|
||||
// This ensures all metadata (like preview URLs) are correctly formatted by the backend
|
||||
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.pageControls.resetAndReload(true);
|
||||
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
|
||||
await sidebarManager.lastPageControls.resetAndReload(true);
|
||||
}
|
||||
|
||||
// Refresh folder tree in sidebar
|
||||
await sidebarManager.refresh();
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error moving model(s):', error);
|
||||
showToast('toast.models.moveFailed', { message: error.message }, 'error');
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Recipe manager module
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { state, getCurrentPageState } from './state/index.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { sidebarManager } from './components/SidebarManager.js';
|
||||
|
||||
class RecipePageControls {
|
||||
@@ -27,7 +28,7 @@ class RecipePageControls {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshVirtualScroll();
|
||||
await syncChanges();
|
||||
}
|
||||
|
||||
getSidebarApiClient() {
|
||||
@@ -46,6 +47,10 @@ class RecipeManager {
|
||||
// Initialize ImportManager
|
||||
this.importManager = new ImportManager();
|
||||
|
||||
// Initialize BatchImportManager and make it globally accessible
|
||||
this.batchImportManager = new BatchImportManager();
|
||||
window.batchImportManager = this.batchImportManager;
|
||||
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
@@ -236,6 +241,70 @@ class RecipeManager {
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dropdown functionality for refresh button
|
||||
this.initDropdowns();
|
||||
}
|
||||
|
||||
initDropdowns() {
|
||||
// Handle dropdown toggles
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
||||
dropdownToggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const dropdownGroup = toggle.closest('.dropdown-group');
|
||||
|
||||
// Close all other open dropdowns first
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
if (group !== dropdownGroup) {
|
||||
group.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
dropdownGroup.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option (Sync Changes)
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(false);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option (Rebuild Cache)
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
fullRebuildOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(true);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle main refresh button (default: sync changes)
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.pageControls.refreshModels(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.dropdown-group')) {
|
||||
this.closeDropdowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeDropdowns() {
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
group.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
|
||||
209
static/js/services/supportersService.js
Normal file
209
static/js/services/supportersService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Supporters service - Fetches and manages supporters data
|
||||
*/
|
||||
|
||||
let supportersData = null;
|
||||
let isLoading = false;
|
||||
let loadPromise = null;
|
||||
|
||||
/**
|
||||
* Fetch supporters data from the API
|
||||
* @returns {Promise<Object>} Supporters data
|
||||
*/
|
||||
export async function fetchSupporters() {
|
||||
// Return cached data if available
|
||||
if (supportersData) {
|
||||
return supportersData;
|
||||
}
|
||||
|
||||
// Return existing promise if already loading
|
||||
if (isLoading && loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
loadPromise = fetch('/api/lm/supporters')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch supporters: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success && data.supporters) {
|
||||
supportersData = data.supporters;
|
||||
return supportersData;
|
||||
}
|
||||
throw new Error(data.error || 'Failed to load supporters data');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading supporters:', error);
|
||||
// Return empty data on error
|
||||
return {
|
||||
specialThanks: [],
|
||||
allSupporters: [],
|
||||
totalCount: 0
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading = false;
|
||||
loadPromise = null;
|
||||
});
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached supporters data
|
||||
*/
|
||||
export function clearSupportersCache() {
|
||||
supportersData = null;
|
||||
}
|
||||
|
||||
let autoScrollRequest = null;
|
||||
let autoScrollTimeout = null;
|
||||
let isUserInteracting = false;
|
||||
let isHovering = false;
|
||||
let currentScrollPos = 0;
|
||||
|
||||
/**
|
||||
* Handle user interaction to stop auto-scroll
|
||||
*/
|
||||
function handleInteraction() {
|
||||
isUserInteracting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse enter to pause auto-scroll
|
||||
*/
|
||||
function handleMouseEnter() {
|
||||
isHovering = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse leave to resume auto-scroll
|
||||
*/
|
||||
function handleMouseLeave() {
|
||||
isHovering = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-scrolling for the supporters list like movie credits
|
||||
* @param {HTMLElement} container The scrollable container
|
||||
*/
|
||||
function initAutoScroll(container) {
|
||||
if (!container) return;
|
||||
|
||||
// Stop any existing animation and clear any pending timeout
|
||||
if (autoScrollRequest) {
|
||||
cancelAnimationFrame(autoScrollRequest);
|
||||
autoScrollRequest = null;
|
||||
}
|
||||
if (autoScrollTimeout) {
|
||||
clearTimeout(autoScrollTimeout);
|
||||
autoScrollTimeout = null;
|
||||
}
|
||||
|
||||
// Reset state for new scroll
|
||||
isUserInteracting = false;
|
||||
isHovering = false;
|
||||
container.scrollTop = 0;
|
||||
currentScrollPos = 0;
|
||||
|
||||
const scrollSpeed = 0.4; // Pixels per frame (~24px/sec at 60fps)
|
||||
|
||||
const step = () => {
|
||||
// Stop animation if container is hidden or no longer in DOM
|
||||
if (!container.offsetParent) {
|
||||
autoScrollRequest = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isHovering && !isUserInteracting) {
|
||||
const prevScrollTop = container.scrollTop;
|
||||
currentScrollPos += scrollSpeed;
|
||||
container.scrollTop = currentScrollPos;
|
||||
|
||||
// Check if we reached the bottom
|
||||
if (container.scrollTop === prevScrollTop && currentScrollPos > 1) {
|
||||
const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
|
||||
if (isAtBottom) {
|
||||
autoScrollRequest = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep currentScrollPos in sync if user scrolls manually or pauses
|
||||
currentScrollPos = container.scrollTop;
|
||||
}
|
||||
|
||||
autoScrollRequest = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
// Remove existing listeners before adding to avoid duplicates
|
||||
container.removeEventListener('mouseenter', handleMouseEnter);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
container.removeEventListener('wheel', handleInteraction);
|
||||
container.removeEventListener('touchstart', handleInteraction);
|
||||
container.removeEventListener('mousedown', handleInteraction);
|
||||
|
||||
// Event listeners to handle user control
|
||||
container.addEventListener('mouseenter', handleMouseEnter);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// Use { passive: true } for better scroll performance
|
||||
container.addEventListener('wheel', handleInteraction, { passive: true });
|
||||
container.addEventListener('touchstart', handleInteraction, { passive: true });
|
||||
container.addEventListener('mousedown', handleInteraction);
|
||||
|
||||
// Initial delay before starting the credits-style scroll
|
||||
autoScrollTimeout = setTimeout(() => {
|
||||
if (container.scrollHeight > container.clientHeight) {
|
||||
autoScrollRequest = requestAnimationFrame(step);
|
||||
}
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render supporters in the support modal
|
||||
*/
|
||||
export async function renderSupporters() {
|
||||
const supporters = await fetchSupporters();
|
||||
|
||||
// Update subtitle with total count
|
||||
const subtitleEl = document.getElementById('supportersSubtitle');
|
||||
if (subtitleEl) {
|
||||
const originalText = subtitleEl.textContent;
|
||||
subtitleEl.textContent = originalText.replace(/\d+/, supporters.totalCount);
|
||||
}
|
||||
|
||||
// Render special thanks
|
||||
const specialThanksGrid = document.getElementById('specialThanksGrid');
|
||||
if (specialThanksGrid && supporters.specialThanks) {
|
||||
specialThanksGrid.innerHTML = supporters.specialThanks
|
||||
.map(supporter => `
|
||||
<div class="supporter-special-card" title="${supporter}">
|
||||
<span class="supporter-special-name">${supporter}</span>
|
||||
</div>
|
||||
`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Render all supporters
|
||||
const supportersGrid = document.getElementById('supportersGrid');
|
||||
if (supportersGrid && supporters.allSupporters) {
|
||||
supportersGrid.innerHTML = supporters.allSupporters
|
||||
.map((supporter, index, array) => {
|
||||
const separator = index < array.length - 1
|
||||
? '<span class="supporter-separator">·</span>'
|
||||
: '';
|
||||
return `
|
||||
<span class="supporter-name-item" title="${supporter}">${supporter}</span>${separator}
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Initialize the auto-scroll effect
|
||||
initAutoScroll(supportersGrid);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ export class StatisticsManager {
|
||||
this.charts = {};
|
||||
this.data = {};
|
||||
this.initialized = false;
|
||||
this.listStates = {
|
||||
lora: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
|
||||
checkpoint: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
|
||||
embedding: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true }
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -24,7 +29,7 @@ export class StatisticsManager {
|
||||
await this.loadAllData();
|
||||
|
||||
// Initialize charts and visualizations
|
||||
this.initializeVisualizations();
|
||||
await this.initializeVisualizations();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -97,7 +102,7 @@ export class StatisticsManager {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
initializeVisualizations() {
|
||||
async initializeVisualizations() {
|
||||
// Initialize metrics cards
|
||||
this.renderMetricsCards();
|
||||
|
||||
@@ -105,7 +110,8 @@ export class StatisticsManager {
|
||||
this.initializeCharts();
|
||||
|
||||
// Initialize lists and other components
|
||||
this.renderTopModelsLists();
|
||||
await this.initializeLists();
|
||||
this.renderLargestModelsList();
|
||||
this.renderTagCloud();
|
||||
this.renderInsights();
|
||||
}
|
||||
@@ -548,86 +554,87 @@ export class StatisticsManager {
|
||||
});
|
||||
}
|
||||
|
||||
renderTopModelsLists() {
|
||||
this.renderTopLorasList();
|
||||
this.renderTopCheckpointsList();
|
||||
this.renderTopEmbeddingsList();
|
||||
this.renderLargestModelsList();
|
||||
async initializeLists() {
|
||||
const listTypes = [
|
||||
{ type: 'lora', containerId: 'topLorasList' },
|
||||
{ type: 'checkpoint', containerId: 'topCheckpointsList' },
|
||||
{ type: 'embedding', containerId: 'topEmbeddingsList' }
|
||||
];
|
||||
|
||||
const promises = listTypes.map(({ type, containerId }) => {
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (container) {
|
||||
// Handle infinite scrolling
|
||||
container.addEventListener('scroll', () => {
|
||||
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) {
|
||||
this.fetchAndRenderList(type, container);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
return this.fetchAndRenderList(type, container);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
renderTopLorasList() {
|
||||
const container = document.getElementById('topLorasList');
|
||||
if (!container || !this.data.usage?.top_loras) return;
|
||||
async fetchAndRenderList(type, container) {
|
||||
const state = this.listStates[type];
|
||||
if (state.isLoading || !state.hasMore) return;
|
||||
|
||||
const topLoras = this.data.usage.top_loras;
|
||||
state.isLoading = true;
|
||||
|
||||
if (topLoras.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
// Show loading indicator on initial load
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = topLoras.map(lora => `
|
||||
<div class="model-item">
|
||||
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${lora.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${lora.name}">${lora.name}</div>
|
||||
<div class="model-meta">${lora.base_model} • ${lora.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${lora.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
try {
|
||||
const url = `/api/lm/stats/model-usage-list?type=${type}&sort=${state.sort}&offset=${state.offset}&limit=${state.limit}`;
|
||||
const result = await this.fetchData(url);
|
||||
|
||||
if (result.success) {
|
||||
const items = result.data.items;
|
||||
|
||||
// Remove loading indicator if it's the first page
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
renderTopCheckpointsList() {
|
||||
const container = document.getElementById('topCheckpointsList');
|
||||
if (!container || !this.data.usage?.top_checkpoints) return;
|
||||
if (items.length === 0 && state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No models found</div>';
|
||||
state.hasMore = false;
|
||||
} else if (items.length < state.limit) {
|
||||
state.hasMore = false;
|
||||
}
|
||||
|
||||
const topCheckpoints = this.data.usage.top_checkpoints;
|
||||
|
||||
if (topCheckpoints.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
const html = items.map(model => `
|
||||
<div class="model-item">
|
||||
<img src="${model.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${model.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||
<div class="model-meta">${model.base_model} • ${model.folder || 'Root'}</div>
|
||||
</div>
|
||||
<div class="model-usage">${model.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
state.offset += state.limit;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} list:`, error);
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
|
||||
container.innerHTML = topCheckpoints.map(checkpoint => `
|
||||
<div class="model-item">
|
||||
<img src="${checkpoint.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${checkpoint.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${checkpoint.name}">${checkpoint.name}</div>
|
||||
<div class="model-meta">${checkpoint.base_model} • ${checkpoint.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${checkpoint.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderTopEmbeddingsList() {
|
||||
const container = document.getElementById('topEmbeddingsList');
|
||||
if (!container || !this.data.usage?.top_embeddings) return;
|
||||
|
||||
const topEmbeddings = this.data.usage.top_embeddings;
|
||||
|
||||
if (topEmbeddings.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = topEmbeddings.map(embedding => `
|
||||
<div class="model-item">
|
||||
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${embedding.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
|
||||
<div class="model-meta">${embedding.base_model} • ${embedding.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${embedding.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderLargestModelsList() {
|
||||
|
||||
119
static/js/utils/civitaiUtils.js
Normal file
119
static/js/utils/civitaiUtils.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* CivitAI URL utilities
|
||||
* Functions for working with CivitAI media URLs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Optimization strategies for CivitAI URLs
|
||||
*/
|
||||
export const OptimizationMode = {
|
||||
/** Full quality for showcase/display - uses /optimized=true only */
|
||||
SHOWCASE: 'showcase',
|
||||
/** Thumbnail size for cards - uses /width=450,optimized=true */
|
||||
THUMBNAIL: 'thumbnail',
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite Civitai preview URLs to use optimized renditions.
|
||||
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
|
||||
*
|
||||
* @param {string|null} sourceUrl - Original preview URL from the Civitai API
|
||||
* @param {string|null} mediaType - Optional media type hint ("image" or "video")
|
||||
* @param {string} mode - Optimization mode ('showcase' or 'thumbnail')
|
||||
* @returns {[string|null, boolean]} - Tuple of [rewritten URL or original, wasRewritten flag]
|
||||
*/
|
||||
export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = OptimizationMode.THUMBNAIL) {
|
||||
if (!sourceUrl) {
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
|
||||
// Check if it's a CivitAI image domain
|
||||
if (url.hostname.toLowerCase() !== 'image.civitai.com') {
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
|
||||
// Determine replacement based on mode and media type
|
||||
let replacement;
|
||||
if (mode === OptimizationMode.SHOWCASE) {
|
||||
// Full quality for showcase - no width restriction
|
||||
replacement = '/optimized=true';
|
||||
} else {
|
||||
// Thumbnail mode with width restriction
|
||||
replacement = '/width=450,optimized=true';
|
||||
if (mediaType && mediaType.toLowerCase() === 'video') {
|
||||
replacement = '/transcode=true,width=450,optimized=true';
|
||||
}
|
||||
}
|
||||
|
||||
// Replace /original=true with optimized version
|
||||
if (!url.pathname.includes('/original=true')) {
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
|
||||
const updatedPath = url.pathname.replace('/original=true', replacement, 1);
|
||||
|
||||
if (updatedPath === url.pathname) {
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
|
||||
url.pathname = updatedPath;
|
||||
return [url.toString(), true];
|
||||
} catch (e) {
|
||||
// Invalid URL
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the optimized URL for a media item, falling back to original if not a CivitAI URL
|
||||
*
|
||||
* @param {string} url - Original URL
|
||||
* @param {string} type - Media type ("image" or "video")
|
||||
* @param {string} mode - Optimization mode ('showcase' or 'thumbnail')
|
||||
* @returns {string} - Optimized URL or original URL
|
||||
*/
|
||||
export function getOptimizedUrl(url, type = 'image', mode = OptimizationMode.THUMBNAIL) {
|
||||
const [optimizedUrl] = rewriteCivitaiUrl(url, type, mode);
|
||||
return optimizedUrl || url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get showcase-optimized URL (full quality)
|
||||
*
|
||||
* @param {string} url - Original URL
|
||||
* @param {string} type - Media type ("image" or "video")
|
||||
* @returns {string} - Optimized URL for showcase display
|
||||
*/
|
||||
export function getShowcaseUrl(url, type = 'image') {
|
||||
return getOptimizedUrl(url, type, OptimizationMode.SHOWCASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail-optimized URL (width=450)
|
||||
*
|
||||
* @param {string} url - Original URL
|
||||
* @param {string} type - Media type ("image" or "video")
|
||||
* @returns {string} - Optimized URL for thumbnail display
|
||||
*/
|
||||
export function getThumbnailUrl(url, type = 'image') {
|
||||
return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is from CivitAI
|
||||
*
|
||||
* @param {string} url - URL to check
|
||||
* @returns {boolean} - True if it's a CivitAI URL
|
||||
*/
|
||||
export function isCivitaiUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.toLowerCase() === 'image.civitai.com';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ let pendingExcludePath = null;
|
||||
export function showDeleteModal(filePath) {
|
||||
pendingDeletePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('deleteModal').element;
|
||||
const modelInfo = modal.querySelector('.delete-model-info');
|
||||
@@ -47,7 +50,10 @@ export function closeDeleteModal() {
|
||||
export function showExcludeModal(filePath) {
|
||||
pendingExcludePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('excludeModal').element;
|
||||
const modelInfo = modal.querySelector('.exclude-model-info');
|
||||
|
||||
@@ -197,7 +197,10 @@ export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
}
|
||||
|
||||
export function openCivitai(filePath) {
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (!loraCard) return;
|
||||
|
||||
const metaData = JSON.parse(loraCard.dataset.meta);
|
||||
@@ -483,8 +486,12 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
// Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching
|
||||
// Backend removes extensions from paths before matching, so search term should not include extension
|
||||
const searchTerm = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=10`);
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(searchTerm)}&limit=10`);
|
||||
if (!response.ok) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
206
templates/components/batch_import_modal.html
Normal file
206
templates/components/batch_import_modal.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<div id="batchImportModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button class="close" onclick="modalManager.closeModal('batchImportModal')">×</button>
|
||||
<h2>{{ t('recipes.batchImport.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Input Selection -->
|
||||
<div class="batch-import-step" id="batchInputStep">
|
||||
<div class="import-mode-toggle">
|
||||
<button class="toggle-btn active" data-mode="urls" onclick="batchImportManager.toggleInputMode('urls')">
|
||||
<i class="fas fa-link"></i> {{ t('recipes.batchImport.urlList') }}
|
||||
</button>
|
||||
<button class="toggle-btn" data-mode="directory" onclick="batchImportManager.toggleInputMode('directory')">
|
||||
<i class="fas fa-folder"></i> {{ t('recipes.batchImport.directory') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- URL List Section -->
|
||||
<div class="import-section" id="urlListSection">
|
||||
<p class="section-description">{{ t('recipes.batchImport.urlDescription') }}</p>
|
||||
<div class="input-group">
|
||||
<label for="batchUrlInput">{{ t('recipes.batchImport.urlsLabel') }}</label>
|
||||
<textarea id="batchUrlInput" rows="8" placeholder="{{ t('recipes.batchImport.urlsPlaceholder') }}"></textarea>
|
||||
<div class="input-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('recipes.batchImport.urlsHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Section -->
|
||||
<div class="import-section" id="directorySection" style="display: none;">
|
||||
<p class="section-description">{{ t('recipes.batchImport.directoryDescription') }}</p>
|
||||
<div class="input-group">
|
||||
<label for="batchDirectoryInput">{{ t('recipes.batchImport.directoryPath') }}</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="batchDirectoryInput" placeholder="{{ t('recipes.batchImport.directoryPlaceholder') }}" autocomplete="off">
|
||||
<button class="secondary-btn" onclick="batchImportManager.toggleDirectoryBrowser()">
|
||||
<i class="fas fa-folder-open"></i> {{ t('recipes.batchImport.browse') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Browser -->
|
||||
<div class="directory-browser" id="batchDirectoryBrowser" style="display: none;">
|
||||
<div class="browser-header">
|
||||
<button class="back-btn" onclick="batchImportManager.navigateToParentDirectory()" title="{{ t('recipes.batchImport.backToParent') }}">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<div class="current-path" id="batchCurrentPath"></div>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<div class="browser-section">
|
||||
<div class="section-label"><i class="fas fa-folder"></i> {{ t('recipes.batchImport.folders') }}</div>
|
||||
<div class="folder-list" id="batchFolderList"></div>
|
||||
</div>
|
||||
<div class="browser-section">
|
||||
<div class="section-label"><i class="fas fa-image"></i> {{ t('recipes.batchImport.imageFiles') }}</div>
|
||||
<div class="file-list" id="batchFileList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browser-footer">
|
||||
<div class="stats">
|
||||
<span id="batchDirectoryCount">0</span> {{ t('recipes.batchImport.folders') }},
|
||||
<span id="batchImageCount">0</span> {{ t('recipes.batchImport.images') }}
|
||||
</div>
|
||||
<button class="primary-btn" onclick="batchImportManager.selectCurrentDirectory()">
|
||||
<i class="fas fa-check"></i> {{ t('recipes.batchImport.selectFolder') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="batchRecursiveCheck" checked>
|
||||
<span class="checkmark"></span>
|
||||
{{ t('recipes.batchImport.recursive') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Options -->
|
||||
<div class="batch-options">
|
||||
<div class="input-group">
|
||||
<label for="batchTagsInput">{{ t('recipes.batchImport.tagsOptional') }}</label>
|
||||
<input type="text" id="batchTagsInput" placeholder="{{ t('recipes.batchImport.tagsPlaceholder') }}">
|
||||
<div class="input-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('recipes.batchImport.tagsHint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="batchSkipNoMetadata">
|
||||
<span class="checkmark"></span>
|
||||
{{ t('recipes.batchImport.skipNoMetadata') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="modalManager.closeModal('batchImportModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="batchImportStartBtn" onclick="batchImportManager.startImport()">
|
||||
<i class="fas fa-play"></i> {{ t('recipes.batchImport.start') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Progress -->
|
||||
<div class="batch-import-step" id="batchProgressStep" style="display: none;">
|
||||
<div class="batch-progress-container">
|
||||
<div class="progress-header">
|
||||
<div class="progress-status">
|
||||
<span class="status-icon"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
<span class="status-text" id="batchStatusText">{{ t('recipes.batchImport.importing') }}</span>
|
||||
</div>
|
||||
<div class="progress-percentage" id="batchProgressPercent">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="batchProgressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.total') }}</span>
|
||||
<span class="stat-value" id="batchTotalCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item success">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.success') }}</span>
|
||||
<span class="stat-value" id="batchSuccessCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item failed">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.failed') }}</span>
|
||||
<span class="stat-value" id="batchFailedCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item skipped">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.skipped') }}</span>
|
||||
<span class="stat-value" id="batchSkippedCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="current-item" id="batchCurrentItemContainer">
|
||||
<span class="current-item-label">{{ t('recipes.batchImport.current') }}</span>
|
||||
<span class="current-item-name" id="batchCurrentItem">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" id="batchCancelBtn" onclick="batchImportManager.cancelImport()">
|
||||
<i class="fas fa-stop"></i> {{ t('recipes.batchImport.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Results -->
|
||||
<div class="batch-import-step" id="batchResultsStep" style="display: none;">
|
||||
<div class="batch-results-container">
|
||||
<div class="results-header" id="batchResultsHeader">
|
||||
<div class="results-icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="results-title">{{ t('recipes.batchImport.completed') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="results-summary">
|
||||
<div class="result-card total">
|
||||
<span class="result-label">{{ t('recipes.batchImport.total') }}</span>
|
||||
<span class="result-value" id="resultsTotal">0</span>
|
||||
</div>
|
||||
<div class="result-card success">
|
||||
<span class="result-label">{{ t('recipes.batchImport.success') }}</span>
|
||||
<span class="result-value" id="resultsSuccess">0</span>
|
||||
</div>
|
||||
<div class="result-card failed">
|
||||
<span class="result-label">{{ t('recipes.batchImport.failed') }}</span>
|
||||
<span class="result-value" id="resultsFailed">0</span>
|
||||
</div>
|
||||
<div class="result-card skipped">
|
||||
<span class="result-label">{{ t('recipes.batchImport.skipped') }}</span>
|
||||
<span class="result-value" id="resultsSkipped">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-details" id="batchResultsDetails">
|
||||
<div class="details-toggle" onclick="batchImportManager.toggleResultsDetails()">
|
||||
<i class="fas fa-chevron-down" id="resultsToggleIcon"></i>
|
||||
<span>{{ t('recipes.batchImport.viewDetails') }}</span>
|
||||
</div>
|
||||
<div class="details-list" id="batchDetailsList" style="display: none;">
|
||||
<!-- Details will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="batchImportManager.closeAndReset()">{{ t('common.actions.close') }}</button>
|
||||
<button class="primary-btn" onclick="batchImportManager.startNewImport()">
|
||||
<i class="fas fa-plus"></i> {{ t('recipes.batchImport.newImport') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,90 +2,133 @@
|
||||
<div id="supportModal" class="modal">
|
||||
<div class="modal-content support-modal">
|
||||
<button class="close" onclick="modalManager.closeModal('supportModal')">×</button>
|
||||
<div class="support-header">
|
||||
<i class="fas fa-heart support-icon"></i>
|
||||
<h2>{{ t('support.title') }}</h2>
|
||||
</div>
|
||||
<div class="support-content">
|
||||
<p>{{ t('support.message') }}</p>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-comment"></i> {{ t('support.feedback.title') }}</h3>
|
||||
<p>{{ t('support.feedback.description') }}</p>
|
||||
<div class="support-links">
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" class="social-link" target="_blank">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>{{ t('support.links.submitGithubIssue') }}</span>
|
||||
</a>
|
||||
<a href="https://discord.gg/vcqNrWVFvM" class="social-link" target="_blank">
|
||||
<i class="fab fa-discord"></i>
|
||||
<span>{{ t('support.links.joinDiscord') }}</span>
|
||||
</a>
|
||||
|
||||
<div class="support-container">
|
||||
<!-- Left Side: Support Options -->
|
||||
<div class="support-left">
|
||||
<div class="support-header">
|
||||
<i class="fas fa-heart support-icon"></i>
|
||||
<h2>{{ t('support.title') }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-rss"></i> {{ t('support.sections.followUpdates') }}</h3>
|
||||
<div class="support-links">
|
||||
<a href="https://www.youtube.com/@pixelpaws-ai" class="social-link" target="_blank">
|
||||
<i class="fab fa-youtube"></i>
|
||||
<span>{{ t('support.links.youtubeChannel') }}</span>
|
||||
</a>
|
||||
<a href="https://civitai.com/user/PixelPawsAI" class="social-link civitai-link" target="_blank">
|
||||
<svg class="civitai-icon" viewBox="0 0 225 225" width="20" height="20">
|
||||
<g transform="translate(0,225) scale(0.1,-0.1)" fill="currentColor">
|
||||
<path d="M950 1899 c-96 -55 -262 -150 -367 -210 -106 -61 -200 -117 -208
|
||||
-125 -13 -13 -15 -76 -15 -443 0 -395 1 -429 18 -443 9 -9 116 -73 237 -143
|
||||
121 -70 283 -163 359 -208 76 -45 146 -80 155 -80 9 1 183 98 386 215 l370
|
||||
215 2 444 3 444 -376 215 c-206 118 -378 216 -382 217 -4 1 -86 -43 -182 -98z
|
||||
m346 -481 l163 -93 1 -57 0 -58 -89 0 c-87 0 -91 1 -166 44 l-78 45 -51 -30
|
||||
c-28 -17 -61 -35 -73 -41 -21 -10 -23 -18 -23 -99 l0 -87 71 -41 c39 -23 73
|
||||
-41 76 -41 3 0 37 18 75 40 68 39 72 40 164 40 l94 0 0 -53 c0 -60 23 -41
|
||||
-198 -168 l-133 -77 -92 52 c-51 29 -126 73 -167 97 l-75 45 0 193 0 192 164
|
||||
95 c91 52 167 94 169 94 2 0 78 -42 168 -92z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>{{ t('support.links.civitaiProfile') }}</span>
|
||||
</a>
|
||||
<div class="support-content">
|
||||
<p>{{ t('support.message') }}</p>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-comment"></i> {{ t('support.feedback.title') }}</h3>
|
||||
<p>{{ t('support.feedback.description') }}</p>
|
||||
<div class="support-links">
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" class="social-link" target="_blank">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>{{ t('support.links.submitGithubIssue') }}</span>
|
||||
</a>
|
||||
<a href="https://discord.gg/vcqNrWVFvM" class="social-link" target="_blank">
|
||||
<i class="fab fa-discord"></i>
|
||||
<span>{{ t('support.links.joinDiscord') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-rss"></i> {{ t('support.sections.followUpdates') }}</h3>
|
||||
<div class="support-links">
|
||||
<a href="https://www.youtube.com/@pixelpaws-ai" class="social-link" target="_blank">
|
||||
<i class="fab fa-youtube"></i>
|
||||
<span>{{ t('support.links.youtubeChannel') }}</span>
|
||||
</a>
|
||||
<a href="https://civitai.com/user/PixelPawsAI" class="social-link civitai-link" target="_blank">
|
||||
<svg class="civitai-icon" viewBox="0 0 225 225" width="20" height="20">
|
||||
<g transform="translate(0,225) scale(0.1,-0.1)" fill="currentColor">
|
||||
<path d="M950 1899 c-96 -55 -262 -150 -367 -210 -106 -61 -200 -117 -208
|
||||
-125 -13 -13 -15 -76 -15 -443 0 -395 1 -429 18 -443 9 -9 116 -73 237 -143
|
||||
121 -70 283 -163 359 -208 76 -45 146 -80 155 -80 9 1 183 98 386 215 l370
|
||||
215 2 444 3 444 -376 215 c-206 118 -378 216 -382 217 -4 1 -86 -43 -182 -98z
|
||||
m346 -481 l163 -93 1 -57 0 -58 -89 0 c-87 0 -91 1 -166 44 l-78 45 -51 -30
|
||||
c-28 -17 -61 -35 -73 -41 -21 -10 -23 -18 -23 -99 l0 -87 71 -41 c39 -23 73
|
||||
-41 76 -41 3 0 37 18 75 40 68 39 72 40 164 40 l94 0 0 -53 c0 -60 23 -41
|
||||
-198 -168 l-133 -77 -92 52 c-51 29 -126 73 -167 97 l-75 45 0 193 0 192 164
|
||||
95 c91 52 167 94 169 94 2 0 78 -42 168 -92z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>{{ t('support.links.civitaiProfile') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-coffee"></i> {{ t('support.sections.buyMeCoffee') }}</h3>
|
||||
<p>{{ t('support.sections.coffeeDescription') }}</p>
|
||||
<a href="https://ko-fi.com/pixelpawsai" class="kofi-button" target="_blank">
|
||||
<i class="fas fa-mug-hot"></i>
|
||||
<span>{{ t('support.links.supportKofi') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Patreon Support Section -->
|
||||
<div class="support-section">
|
||||
<h3><i class="fab fa-patreon"></i> {{ t('support.sections.becomePatron') }}</h3>
|
||||
<p>{{ t('support.sections.patronDescription') }}</p>
|
||||
<a href="https://patreon.com/PixelPawsAI" class="patreon-button" target="_blank">
|
||||
<i class="fab fa-patreon"></i>
|
||||
<span>{{ t('support.links.supportPatreon') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- New section for Chinese payment methods -->
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-qrcode"></i> {{ t('support.sections.wechatSupport') }}</h3>
|
||||
<p>{{ t('support.sections.wechatDescription') }}</p>
|
||||
<button class="secondary-btn qrcode-toggle" id="toggleQRCode">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span class="toggle-text">{{ t('support.sections.showWechatQR') }}</span>
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
</button>
|
||||
<div class="qrcode-container" id="qrCodeContainer">
|
||||
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-footer">
|
||||
<p>{{ t('support.footer') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-coffee"></i> {{ t('support.sections.buyMeCoffee') }}</h3>
|
||||
<p>{{ t('support.sections.coffeeDescription') }}</p>
|
||||
<a href="https://ko-fi.com/pixelpawsai" class="kofi-button" target="_blank">
|
||||
<i class="fas fa-mug-hot"></i>
|
||||
<span>{{ t('support.links.supportKofi') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Patreon Support Section -->
|
||||
<div class="support-section">
|
||||
<h3><i class="fab fa-patreon"></i> {{ t('support.sections.becomePatron') }}</h3>
|
||||
<p>{{ t('support.sections.patronDescription') }}</p>
|
||||
<a href="https://patreon.com/PixelPawsAI" class="patreon-button" target="_blank">
|
||||
<i class="fab fa-patreon"></i>
|
||||
<span>{{ t('support.links.supportPatreon') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Right Side: Supporters -->
|
||||
<div class="support-right">
|
||||
<div class="supporters-section">
|
||||
<div class="supporters-header">
|
||||
<h2 class="supporters-title">
|
||||
<i class="fas fa-hands-helping"></i>
|
||||
{{ t('support.supporters.title') }}
|
||||
</h2>
|
||||
<p class="supporters-subtitle" id="supportersSubtitle">
|
||||
{{ t('support.supporters.subtitle', count=0) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Special Thanks Section -->
|
||||
<div class="supporters-group special-thanks-group">
|
||||
<h3 class="supporters-group-title">
|
||||
<i class="fas fa-star"></i>
|
||||
{{ t('support.supporters.specialThanks') }}
|
||||
</h3>
|
||||
<div class="supporters-special-grid" id="specialThanksGrid">
|
||||
<!-- Supporters will be loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New section for Chinese payment methods -->
|
||||
<div class="support-section">
|
||||
<h3><i class="fas fa-qrcode"></i> {{ t('support.sections.wechatSupport') }}</h3>
|
||||
<p>{{ t('support.sections.wechatDescription') }}</p>
|
||||
<button class="secondary-btn qrcode-toggle" id="toggleQRCode">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span class="toggle-text">{{ t('support.sections.showWechatQR') }}</span>
|
||||
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||
</button>
|
||||
<div class="qrcode-container" id="qrCodeContainer">
|
||||
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
|
||||
<!-- All Supporters Section -->
|
||||
<div class="supporters-group all-supporters-group">
|
||||
<h3 class="supporters-group-title">
|
||||
<i class="fas fa-heart"></i>
|
||||
{{ t('support.supporters.allSupporters') }}
|
||||
</h3>
|
||||
<div class="supporters-all-list" id="supportersGrid">
|
||||
<!-- Supporters will be loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-footer">
|
||||
<p>{{ t('support.footer') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,10 +7,12 @@
|
||||
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_components %}
|
||||
{% include 'components/import_modal.html' %}
|
||||
{% include 'components/batch_import_modal.html' %}
|
||||
{% include 'components/recipe_modal.html' %}
|
||||
|
||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||
@@ -66,15 +68,29 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{
|
||||
t('common.actions.refresh')
|
||||
}}</button>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
|
||||
t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user