Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9a437725 | ||
|
|
815a483cf0 | ||
|
|
ae37765017 | ||
|
|
ebef300279 | ||
|
|
38cd328e57 | ||
|
|
71a200ed5c | ||
|
|
c96a164f68 | ||
|
|
0ae0716272 | ||
|
|
b692270f87 | ||
|
|
a9675a5d83 | ||
|
|
ac4a168f13 | ||
|
|
8b9f3a0e65 | ||
|
|
8d7e32eaf6 | ||
|
|
e964f26798 | ||
|
|
3cfbb5ac0e | ||
|
|
4472357537 | ||
|
|
aabf3f99b3 | ||
|
|
6bd6b19c1d | ||
|
|
411219df7d | ||
|
|
cc29349aee | ||
|
|
f639e3c795 | ||
|
|
5251eeaa93 | ||
|
|
3bfc6c28af | ||
|
|
c91eff16ae | ||
|
|
2d638a3451 | ||
|
|
280b6ed7c0 | ||
|
|
7de73ae09c | ||
|
|
0fdea64c79 | ||
|
|
2b9327e6ca | ||
|
|
c33b4e0333 | ||
|
|
6dcaed7764 | ||
|
|
ab4e0d38e1 | ||
|
|
581d2c14fc | ||
|
|
811f1bc352 | ||
|
|
5342b7ec92 | ||
|
|
30e1714397 | ||
|
|
384a106917 | ||
|
|
7378a7deae |
86
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
86
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: 'Something is not behaving as expected.'
|
||||||
|
title: '[Bug]: '
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting a **Bug Report**, please ensure the following:
|
||||||
|
|
||||||
|
- **1:** You are running the latest version of ComfyUI-Model-Manager.
|
||||||
|
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||||
|
- **3:** You confirmed that the bug is not caused by other custom nodes.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: 'Describe as detailed as possible what your current usage environment is. local? cloud? etc...'
|
||||||
|
value: |
|
||||||
|
[Operating System]:
|
||||||
|
[Python Version]:
|
||||||
|
[ComfyUI Version]:
|
||||||
|
[ComfyUI Frontend Version]:
|
||||||
|
[ComfyUI-Model-Manager Version]:
|
||||||
|
[Browser Version]:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: 'What you expected to happen.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Debug Logs
|
||||||
|
description: 'Please copy the output from your terminal logs here.'
|
||||||
|
render: powershell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Browser Logs
|
||||||
|
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Setting JSON
|
||||||
|
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: browsers
|
||||||
|
attributes:
|
||||||
|
label: What browsers do you use to access the UI ?
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Mozilla Firefox
|
||||||
|
- Google Chrome
|
||||||
|
- Brave
|
||||||
|
- Apple Safari
|
||||||
|
- Microsoft Edge
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- Other
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Other
|
||||||
|
description: 'Any other additional information you think might be helpful.'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: '[Feature Request]: '
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and checked the recent builds/commits
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
|
||||||
|
- type: textarea
|
||||||
|
id: feature
|
||||||
|
attributes:
|
||||||
|
label: What would your feature do ?
|
||||||
|
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: workflow
|
||||||
|
attributes:
|
||||||
|
label: Proposed workflow
|
||||||
|
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
|
||||||
|
value: |
|
||||||
|
1. Go to ....
|
||||||
|
2. Press ....
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: misc
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
|||||||
publish-node:
|
publish-node:
|
||||||
name: Release and Publish Custom Node to registry
|
name: Release and Publish Custom Node to registry
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository_owner == 'hayden-fr' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -60,7 +61,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm run build
|
||||||
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
|
tar -czf dist.tar.gz assets/ py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
|
||||||
|
|
||||||
- name: Create release draft
|
- name: Create release draft
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -197,3 +197,6 @@ web/
|
|||||||
|
|
||||||
# config
|
# config
|
||||||
config/
|
config/
|
||||||
|
|
||||||
|
# private info
|
||||||
|
private.key
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"esbenp.prettier-vscode"
|
"esbenp.prettier-vscode",
|
||||||
|
"lokalise.i18n-ally"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -43,5 +43,10 @@
|
|||||||
"editor.quickSuggestions": {
|
"editor.quickSuggestions": {
|
||||||
"strings": "on"
|
"strings": "on"
|
||||||
},
|
},
|
||||||
"css.lint.unknownAtRules": "ignore"
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"src/locales"
|
||||||
|
],
|
||||||
|
"i18n-ally.sourceLanguage": "en",
|
||||||
|
"i18n-ally.keystyle": "nested"
|
||||||
}
|
}
|
||||||
|
|||||||
10
__init__.py
10
__init__.py
@@ -1,3 +1,11 @@
|
|||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: This is an experiment
|
||||||
|
# Add .gguf extension to supported_pt_extensions
|
||||||
|
folder_paths.supported_pt_extensions.add(".gguf")
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from .py import config
|
from .py import config
|
||||||
from .py import utils
|
from .py import utils
|
||||||
@@ -33,12 +41,14 @@ utils.download_web_distribution(version)
|
|||||||
from .py import manager
|
from .py import manager
|
||||||
from .py import download
|
from .py import download
|
||||||
from .py import information
|
from .py import information
|
||||||
|
from .py import upload
|
||||||
|
|
||||||
routes = config.routes
|
routes = config.routes
|
||||||
|
|
||||||
manager.ModelManager().add_routes(routes)
|
manager.ModelManager().add_routes(routes)
|
||||||
download.ModelDownload().add_routes(routes)
|
download.ModelDownload().add_routes(routes)
|
||||||
information.Information().add_routes(routes)
|
information.Information().add_routes(routes)
|
||||||
|
upload.ModelUploader().add_routes(routes)
|
||||||
|
|
||||||
|
|
||||||
WEB_DIRECTORY = "web"
|
WEB_DIRECTORY = "web"
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markdown-it-metadata-block": "^1.0.6",
|
"markdown-it-metadata-block": "^1.0.6",
|
||||||
"primevue": "^4.0.7",
|
"primevue": "^4.0.7",
|
||||||
"vue": "^3.4.31",
|
"vue": "^3.5.6",
|
||||||
"vue-i18n": "^9.13.1",
|
"vue-i18n": "^9.14.0",
|
||||||
"yaml": "^2.6.0"
|
"yaml": "^2.6.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -30,10 +30,10 @@ importers:
|
|||||||
specifier: ^4.0.7
|
specifier: ^4.0.7
|
||||||
version: 4.0.7(vue@3.5.6(typescript@5.6.2))
|
version: 4.0.7(vue@3.5.6(typescript@5.6.2))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.4.31
|
specifier: ^3.5.6
|
||||||
version: 3.5.6(typescript@5.6.2)
|
version: 3.5.6(typescript@5.6.2)
|
||||||
vue-i18n:
|
vue-i18n:
|
||||||
specifier: ^9.13.1
|
specifier: ^9.14.0
|
||||||
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
|
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
@@ -705,8 +705,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001662:
|
caniuse-lite@1.0.30001712:
|
||||||
resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==}
|
resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
@@ -2271,7 +2271,7 @@ snapshots:
|
|||||||
autoprefixer@10.4.20(postcss@8.4.47):
|
autoprefixer@10.4.20(postcss@8.4.47):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.23.3
|
browserslist: 4.23.3
|
||||||
caniuse-lite: 1.0.30001662
|
caniuse-lite: 1.0.30001712
|
||||||
fraction.js: 4.3.7
|
fraction.js: 4.3.7
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.1.0
|
picocolors: 1.1.0
|
||||||
@@ -2299,7 +2299,7 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.23.3:
|
browserslist@4.23.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001662
|
caniuse-lite: 1.0.30001712
|
||||||
electron-to-chromium: 1.5.25
|
electron-to-chromium: 1.5.25
|
||||||
node-releases: 2.0.18
|
node-releases: 2.0.18
|
||||||
update-browserslist-db: 1.1.0(browserslist@4.23.3)
|
update-browserslist-db: 1.1.0(browserslist@4.23.3)
|
||||||
@@ -2308,7 +2308,7 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001662: {}
|
caniuse-lite@1.0.30001712: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +95,68 @@ class TaskContent:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey:
|
||||||
|
|
||||||
|
__store: dict[str, str] = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.__cache_file = os.path.join(config.extension_uri, "private.key")
|
||||||
|
|
||||||
|
def init(self, request):
|
||||||
|
# Try to migrate api key from user setting
|
||||||
|
if not os.path.exists(self.__cache_file):
|
||||||
|
self.__store = {
|
||||||
|
"civitai": utils.get_setting_value(request, "api_key.civitai"),
|
||||||
|
"huggingface": utils.get_setting_value(request, "api_key.huggingface"),
|
||||||
|
}
|
||||||
|
self.__update__()
|
||||||
|
# Remove api key from user setting
|
||||||
|
utils.set_setting_value(request, "api_key.civitai", None)
|
||||||
|
utils.set_setting_value(request, "api_key.huggingface", None)
|
||||||
|
self.__store = utils.load_dict_pickle_file(self.__cache_file)
|
||||||
|
# Desensitization returns
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for key in self.__store:
|
||||||
|
v = self.__store[key]
|
||||||
|
if v is not None:
|
||||||
|
result[key] = v[:4] + "****" + v[-4:]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_value(self, key: str):
|
||||||
|
return self.__store.get(key, None)
|
||||||
|
|
||||||
|
def set_value(self, key: str, value: str):
|
||||||
|
self.__store[key] = value
|
||||||
|
self.__update__()
|
||||||
|
|
||||||
|
def __update__(self):
|
||||||
|
utils.save_dict_pickle_file(self.__cache_file, self.__store)
|
||||||
|
|
||||||
|
|
||||||
class ModelDownload:
|
class ModelDownload:
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = ApiKey()
|
||||||
|
|
||||||
def add_routes(self, routes):
|
def add_routes(self, routes):
|
||||||
|
@routes.post("/model-manager/download/init")
|
||||||
|
async def init_download(request):
|
||||||
|
"""
|
||||||
|
Init download setting.
|
||||||
|
"""
|
||||||
|
result = self.api_key.init(request)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
|
||||||
|
@routes.post("/model-manager/download/setting")
|
||||||
|
async def set_download_setting(request):
|
||||||
|
"""
|
||||||
|
Set download setting.
|
||||||
|
"""
|
||||||
|
json_data = await request.json()
|
||||||
|
key = json_data.get("key", None)
|
||||||
|
value = json_data.get("value", None)
|
||||||
|
value = base64.b64decode(value).decode("utf-8") if value is not None else None
|
||||||
|
self.api_key.set_value(key, value)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
|
||||||
@routes.get("/model-manager/download/task")
|
@routes.get("/model-manager/download/task")
|
||||||
async def scan_download_tasks(request):
|
async def scan_download_tasks(request):
|
||||||
@@ -263,7 +326,7 @@ class ModelDownload:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
preview_file = task_data.pop("previewFile", None)
|
preview_file = task_data.pop("previewFile", None)
|
||||||
utils.save_model_preview_image(task_path, preview_file, download_platform)
|
utils.save_model_preview(task_path, preview_file, download_platform)
|
||||||
self.set_task_content(task_id, task_data)
|
self.set_task_content(task_id, task_data)
|
||||||
task_status = TaskStatus(
|
task_status = TaskStatus(
|
||||||
taskId=task_id,
|
taskId=task_id,
|
||||||
@@ -329,12 +392,12 @@ class ModelDownload:
|
|||||||
|
|
||||||
download_platform = task_status.platform
|
download_platform = task_status.platform
|
||||||
if download_platform == "civitai":
|
if download_platform == "civitai":
|
||||||
api_key = utils.get_setting_value(request, "api_key.civitai")
|
api_key = self.api_key.get_value("civitai")
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
elif download_platform == "huggingface":
|
elif download_platform == "huggingface":
|
||||||
api_key = utils.get_setting_value(request, "api_key.huggingface")
|
api_key = self.api_key.get_value("huggingface")
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
@@ -457,8 +520,10 @@ class ModelDownload:
|
|||||||
|
|
||||||
# When parsing model information from HuggingFace API,
|
# When parsing model information from HuggingFace API,
|
||||||
# the file size was not found and needs to be obtained from the response header.
|
# the file size was not found and needs to be obtained from the response header.
|
||||||
if total_size == 0:
|
# Fixed issue #169. Some model information from Civitai, providing the wrong file size
|
||||||
total_size = float(response.headers.get("content-length", 0))
|
response_total_size = float(response.headers.get("content-length", 0))
|
||||||
|
if total_size == 0 or total_size != response_total_size:
|
||||||
|
total_size = response_total_size
|
||||||
task_content.sizeBytes = total_size
|
task_content.sizeBytes = total_size
|
||||||
task_status.totalSize = total_size
|
task_status.totalSize = total_size
|
||||||
self.set_task_content(task_id, task_content)
|
self.set_task_content(task_id, task_content)
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
|
import math
|
||||||
import yaml
|
import yaml
|
||||||
import requests
|
import requests
|
||||||
import markdownify
|
import markdownify
|
||||||
|
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
|
from . import config
|
||||||
|
from . import thread
|
||||||
|
|
||||||
|
|
||||||
class ModelSearcher(ABC):
|
class ModelSearcher(ABC):
|
||||||
@@ -58,8 +69,12 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
models: list[dict] = []
|
models: list[dict] = []
|
||||||
|
|
||||||
for version in model_versions:
|
for version in model_versions:
|
||||||
model_files: list[dict] = version.get("files", [])
|
version_files: list[dict] = version.get("files", [])
|
||||||
model_files = utils.filter_with(model_files, {"type": "Model"})
|
model_files = utils.filter_with(version_files, {"type": "Model"})
|
||||||
|
# issue: https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/188
|
||||||
|
# Some Embeddings do not have Model file, but Negative
|
||||||
|
# Make sure there are at least downloadable files
|
||||||
|
model_files = version_files if len(model_files) == 0 else model_files
|
||||||
|
|
||||||
shortname = version.get("name", None) if len(model_files) > 0 else None
|
shortname = version.get("name", None) if len(model_files) > 0 else None
|
||||||
|
|
||||||
@@ -97,7 +112,7 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
|
|
||||||
model = {
|
model = {
|
||||||
"id": file.get("id"),
|
"id": version.get("id"),
|
||||||
"shortname": shortname or basename,
|
"shortname": shortname or basename,
|
||||||
"basename": basename,
|
"basename": basename,
|
||||||
"extension": extension,
|
"extension": extension,
|
||||||
@@ -111,6 +126,7 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
"downloadPlatform": "civitai",
|
"downloadPlatform": "civitai",
|
||||||
"downloadUrl": file.get("downloadUrl"),
|
"downloadUrl": file.get("downloadUrl"),
|
||||||
"hashes": file.get("hashes"),
|
"hashes": file.get("hashes"),
|
||||||
|
"files": version_files if len(version_files) > 1 else None,
|
||||||
}
|
}
|
||||||
models.append(model)
|
models.append(model)
|
||||||
|
|
||||||
@@ -282,25 +298,6 @@ class HuggingfaceModelSearcher(ModelSearcher):
|
|||||||
return _filter_tree_files
|
return _filter_tree_files
|
||||||
|
|
||||||
|
|
||||||
def get_model_searcher_by_url(url: str) -> ModelSearcher:
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
host_name = parsed_url.hostname
|
|
||||||
if host_name == "civitai.com":
|
|
||||||
return CivitaiModelSearcher()
|
|
||||||
elif host_name == "huggingface.co":
|
|
||||||
return HuggingfaceModelSearcher()
|
|
||||||
return UnknownWebsiteSearcher()
|
|
||||||
|
|
||||||
|
|
||||||
import folder_paths
|
|
||||||
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
|
|
||||||
class Information:
|
class Information:
|
||||||
def add_routes(self, routes):
|
def add_routes(self, routes):
|
||||||
|
|
||||||
@@ -318,16 +315,38 @@ class Information:
|
|||||||
utils.print_error(error_msg)
|
utils.print_error(error_msg)
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/model-info/scan")
|
||||||
|
async def get_model_info_download_task(request):
|
||||||
|
"""
|
||||||
|
Get model information download task list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = self.get_scan_model_info_task_list()
|
||||||
|
if result is not None:
|
||||||
|
await self.download_model_info(request)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Get model info download task list failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
@routes.post("/model-manager/model-info/scan")
|
@routes.post("/model-manager/model-info/scan")
|
||||||
async def download_model_info(request):
|
async def create_model_info_download_task(request):
|
||||||
"""
|
"""
|
||||||
Create a task to download model information.
|
Create a task to download model information.
|
||||||
|
|
||||||
|
- scanMode: The alternatives are diff and full.
|
||||||
|
- mode: The alternatives are diff and full.
|
||||||
|
- path: Scanning root path.
|
||||||
"""
|
"""
|
||||||
post = await utils.get_request_body(request)
|
post = await utils.get_request_body(request)
|
||||||
try:
|
try:
|
||||||
|
# TODO scanMode is deprecated, use mode instead.
|
||||||
scan_mode = post.get("scanMode", "diff")
|
scan_mode = post.get("scanMode", "diff")
|
||||||
await self.download_model_info(scan_mode, request)
|
scan_mode = post.get("mode", scan_mode)
|
||||||
return web.json_response({"success": True})
|
scan_path = post.get("path", None)
|
||||||
|
result = await self.create_scan_model_info_task(scan_mode, scan_path, request)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Download model info failed: {str(e)}"
|
error_msg = f"Download model info failed: {str(e)}"
|
||||||
utils.print_error(error_msg)
|
utils.print_error(error_msg)
|
||||||
@@ -336,12 +355,12 @@ class Information:
|
|||||||
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
||||||
async def read_model_preview(request):
|
async def read_model_preview(request):
|
||||||
"""
|
"""
|
||||||
Get the file stream of the specified image.
|
Get the file stream of the specified preview
|
||||||
If the file does not exist, no-preview.png is returned.
|
If the file does not exist, no-preview.png is returned.
|
||||||
|
|
||||||
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
||||||
:param index: The index of the model folders.
|
:param index: The index of the model folders.
|
||||||
:param filename: The filename of the image.
|
:param filename: The filename of the preview.
|
||||||
"""
|
"""
|
||||||
model_type = request.match_info.get("type", None)
|
model_type = request.match_info.get("type", None)
|
||||||
index = int(request.match_info.get("index", None))
|
index = int(request.match_info.get("index", None))
|
||||||
@@ -353,12 +372,26 @@ class Information:
|
|||||||
folders = folder_paths.get_folder_paths(model_type)
|
folders = folder_paths.get_folder_paths(model_type)
|
||||||
base_path = folders[index]
|
base_path = folders[index]
|
||||||
abs_path = utils.join_path(base_path, filename)
|
abs_path = utils.join_path(base_path, filename)
|
||||||
|
preview_name = utils.get_model_preview_name(abs_path)
|
||||||
|
if preview_name:
|
||||||
|
dir_name = os.path.dirname(abs_path)
|
||||||
|
abs_path = utils.join_path(dir_name, preview_name)
|
||||||
except:
|
except:
|
||||||
abs_path = extension_uri
|
abs_path = extension_uri
|
||||||
|
|
||||||
if not os.path.isfile(abs_path):
|
if not os.path.isfile(abs_path):
|
||||||
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
return web.FileResponse(abs_path)
|
|
||||||
|
# Determine content type from the actual file
|
||||||
|
content_type = utils.resolve_file_content_type(abs_path)
|
||||||
|
|
||||||
|
if content_type == "video":
|
||||||
|
# Serve video files directly
|
||||||
|
return web.FileResponse(abs_path)
|
||||||
|
else:
|
||||||
|
# Serve image files (WebP or fallback images)
|
||||||
|
image_data = self.get_image_preview_data(abs_path)
|
||||||
|
return web.Response(body=image_data.getvalue(), content_type="image/webp")
|
||||||
|
|
||||||
@routes.get("/model-manager/preview/download/{filename}")
|
@routes.get("/model-manager/preview/download/{filename}")
|
||||||
async def read_download_preview(request):
|
async def read_download_preview(request):
|
||||||
@@ -373,65 +406,179 @@ class Information:
|
|||||||
|
|
||||||
return web.FileResponse(preview_path)
|
return web.FileResponse(preview_path)
|
||||||
|
|
||||||
|
def get_image_preview_data(self, filename: str):
|
||||||
|
with Image.open(filename) as img:
|
||||||
|
max_size = 1024
|
||||||
|
original_format = img.format
|
||||||
|
|
||||||
|
exif_data = img.info.get("exif")
|
||||||
|
icc_profile = img.info.get("icc_profile")
|
||||||
|
|
||||||
|
if getattr(img, "is_animated", False) and img.n_frames > 1:
|
||||||
|
total_frames = img.n_frames
|
||||||
|
step = max(1, math.ceil(total_frames / 30))
|
||||||
|
|
||||||
|
frames, durations = [], []
|
||||||
|
|
||||||
|
for frame_idx in range(0, total_frames, step):
|
||||||
|
img.seek(frame_idx)
|
||||||
|
frame = img.copy()
|
||||||
|
frame.thumbnail((max_size, max_size), Image.Resampling.NEAREST)
|
||||||
|
|
||||||
|
frames.append(frame)
|
||||||
|
durations.append(img.info.get("duration", 100) * step)
|
||||||
|
|
||||||
|
save_args = {
|
||||||
|
"format": "WEBP",
|
||||||
|
"save_all": True,
|
||||||
|
"append_images": frames[1:],
|
||||||
|
"duration": durations,
|
||||||
|
"loop": 0,
|
||||||
|
"quality": 80,
|
||||||
|
"method": 0,
|
||||||
|
"allow_mixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if exif_data:
|
||||||
|
save_args["exif"] = exif_data
|
||||||
|
|
||||||
|
if icc_profile:
|
||||||
|
save_args["icc_profile"] = icc_profile
|
||||||
|
|
||||||
|
img_byte_arr = BytesIO()
|
||||||
|
frames[0].save(img_byte_arr, **save_args)
|
||||||
|
img_byte_arr.seek(0)
|
||||||
|
return img_byte_arr
|
||||||
|
|
||||||
|
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
|
||||||
|
|
||||||
|
img_byte_arr = BytesIO()
|
||||||
|
save_args = {"format": "WEBP", "quality": 80}
|
||||||
|
|
||||||
|
if exif_data:
|
||||||
|
save_args["exif"] = exif_data
|
||||||
|
if icc_profile:
|
||||||
|
save_args["icc_profile"] = icc_profile
|
||||||
|
|
||||||
|
img.save(img_byte_arr, **save_args)
|
||||||
|
img_byte_arr.seek(0)
|
||||||
|
return img_byte_arr
|
||||||
|
|
||||||
def fetch_model_info(self, model_page: str):
|
def fetch_model_info(self, model_page: str):
|
||||||
if not model_page:
|
if not model_page:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
model_searcher = get_model_searcher_by_url(model_page)
|
model_searcher = self.get_model_searcher_by_url(model_page)
|
||||||
result = model_searcher.search_by_url(model_page)
|
result = model_searcher.search_by_url(model_page)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def download_model_info(self, scan_mode: str, request):
|
def get_scan_information_task_filepath(self):
|
||||||
utils.print_info(f"Download model info for {scan_mode}")
|
download_dir = utils.get_download_path()
|
||||||
model_base_paths = utils.resolve_model_base_paths()
|
return utils.join_path(download_dir, "scan_information.task")
|
||||||
for model_type in model_base_paths:
|
|
||||||
|
|
||||||
folders, *others = folder_paths.folder_names_and_paths[model_type]
|
def get_scan_model_info_task_list(self):
|
||||||
for path_index, base_path in enumerate(folders):
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
files = utils.recursive_search_files(base_path, request)
|
if os.path.isfile(scan_info_task_file):
|
||||||
|
return utils.load_dict_pickle_file(scan_info_task_file)
|
||||||
|
return None
|
||||||
|
|
||||||
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
|
async def create_scan_model_info_task(self, scan_mode: str, scan_path: str | None, request):
|
||||||
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
|
scan_info_task_content = {"mode": scan_mode}
|
||||||
|
scan_models: dict[str, bool] = {}
|
||||||
|
|
||||||
for fullname in models:
|
scan_paths: list[str] = []
|
||||||
fullname = utils.normalize_path(fullname)
|
if scan_path is None:
|
||||||
basename = os.path.splitext(fullname)[0]
|
model_base_paths = utils.resolve_model_base_paths()
|
||||||
|
for model_type in model_base_paths:
|
||||||
|
folders, *others = folder_paths.folder_names_and_paths[model_type]
|
||||||
|
for path_index, base_path in enumerate(folders):
|
||||||
|
scan_paths.append(base_path)
|
||||||
|
else:
|
||||||
|
scan_paths = [scan_path]
|
||||||
|
|
||||||
abs_model_path = utils.join_path(base_path, fullname)
|
for base_path in scan_paths:
|
||||||
|
files = utils.recursive_search_files(base_path, request)
|
||||||
|
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
|
||||||
|
for fullname in models:
|
||||||
|
fullname = utils.normalize_path(fullname)
|
||||||
|
abs_model_path = utils.join_path(base_path, fullname)
|
||||||
|
utils.print_debug(f"Found model: {abs_model_path}")
|
||||||
|
scan_models[abs_model_path] = False
|
||||||
|
|
||||||
image_name = utils.get_model_preview_name(abs_model_path)
|
scan_info_task_content["models"] = scan_models
|
||||||
abs_image_path = utils.join_path(base_path, image_name)
|
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
|
||||||
|
await self.download_model_info(request)
|
||||||
|
return scan_info_task_content
|
||||||
|
|
||||||
has_preview = os.path.isfile(abs_image_path)
|
download_thread_pool = thread.DownloadThreadPool()
|
||||||
|
|
||||||
description_name = utils.get_model_description_name(abs_model_path)
|
async def download_model_info(self, request):
|
||||||
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
|
async def download_information_task(task_id: str):
|
||||||
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
|
scan_info_task_content = utils.load_dict_pickle_file(scan_info_task_file)
|
||||||
|
scan_mode = scan_info_task_content.get("mode", "diff")
|
||||||
|
scan_models: dict[str, bool] = scan_info_task_content.get("models", {})
|
||||||
|
for key, value in scan_models.items():
|
||||||
|
if value is True:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
abs_model_path = key
|
||||||
|
base_path = os.path.dirname(abs_model_path)
|
||||||
|
|
||||||
utils.print_info(f"Checking model {abs_model_path}")
|
image_name = utils.get_model_preview_name(abs_model_path)
|
||||||
utils.print_debug(f"Scan mode: {scan_mode}")
|
abs_image_path = utils.join_path(base_path, image_name)
|
||||||
utils.print_debug(f"Has preview: {has_preview}")
|
|
||||||
utils.print_debug(f"Has description: {has_description}")
|
|
||||||
|
|
||||||
if scan_mode != "full" and (has_preview and has_description):
|
has_preview = os.path.isfile(abs_image_path)
|
||||||
continue
|
|
||||||
|
|
||||||
|
description_name = utils.get_model_description_name(abs_model_path)
|
||||||
|
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
|
||||||
|
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.print_info(f"Checking model {abs_model_path}")
|
||||||
|
utils.print_debug(f"Scan mode: {scan_mode}")
|
||||||
|
utils.print_debug(f"Has preview: {has_preview}")
|
||||||
|
utils.print_debug(f"Has description: {has_description}")
|
||||||
|
|
||||||
|
if scan_mode == "full" or not has_preview or not has_description:
|
||||||
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
|
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
|
||||||
hash_value = utils.calculate_sha256(abs_model_path)
|
hash_value = utils.calculate_sha256(abs_model_path)
|
||||||
utils.print_info(f"Searching model info by hash {hash_value}")
|
utils.print_info(f"Searching model info by hash {hash_value}")
|
||||||
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
|
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
|
||||||
|
|
||||||
preview_url_list = model_info.get("preview", [])
|
preview_url_list = model_info.get("preview", [])
|
||||||
preview_image_url = preview_url_list[0] if preview_url_list else None
|
preview_url = preview_url_list[0] if preview_url_list else None
|
||||||
if preview_image_url:
|
if preview_url:
|
||||||
utils.print_debug(f"Save preview image to {abs_image_path}")
|
utils.print_debug(f"Save preview to {abs_model_path}")
|
||||||
utils.save_model_preview_image(abs_model_path, preview_image_url)
|
utils.save_model_preview(abs_model_path, preview_url)
|
||||||
|
|
||||||
description = model_info.get("description", None)
|
description = model_info.get("description", None)
|
||||||
if description:
|
if description:
|
||||||
utils.save_model_description(abs_model_path, description)
|
utils.save_model_description(abs_model_path, description)
|
||||||
except Exception as e:
|
|
||||||
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
|
||||||
|
|
||||||
utils.print_debug("Completed scan model information.")
|
scan_models[abs_model_path] = True
|
||||||
|
scan_info_task_content["models"] = scan_models
|
||||||
|
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
|
||||||
|
utils.print_debug(f"Send update scan information task to frontend.")
|
||||||
|
await utils.send_json("update_scan_information_task", scan_info_task_content)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
||||||
|
|
||||||
|
os.remove(scan_info_task_file)
|
||||||
|
utils.print_info("Completed scan model information.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
self.download_thread_pool.submit(download_information_task, task_id)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_debug(str(e))
|
||||||
|
|
||||||
|
def get_model_searcher_by_url(self, url: str) -> ModelSearcher:
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
host_name = parsed_url.hostname
|
||||||
|
if host_name == "civitai.com":
|
||||||
|
return CivitaiModelSearcher()
|
||||||
|
elif host_name == "huggingface.co":
|
||||||
|
return HuggingfaceModelSearcher()
|
||||||
|
return UnknownWebsiteSearcher()
|
||||||
|
|||||||
@@ -131,10 +131,15 @@ class ModelManager:
|
|||||||
basename = os.path.splitext(filename)[0] if is_file else filename
|
basename = os.path.splitext(filename)[0] if is_file else filename
|
||||||
extension = os.path.splitext(filename)[1] if is_file else ""
|
extension = os.path.splitext(filename)[1] if is_file else ""
|
||||||
|
|
||||||
if is_file and extension not in folder_paths.supported_pt_extensions:
|
model_preview = None
|
||||||
return None
|
if is_file:
|
||||||
|
preview_name = utils.get_model_preview_name(entry.path)
|
||||||
|
preview_ext = f".{preview_name.split('.')[-1]}"
|
||||||
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
||||||
|
|
||||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
|
if not os.path.exists(entry.path):
|
||||||
|
utils.print_error(f"{entry.path} is not file or directory.")
|
||||||
|
return None
|
||||||
|
|
||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
return {
|
return {
|
||||||
@@ -145,7 +150,7 @@ class ModelManager:
|
|||||||
"extension": extension,
|
"extension": extension,
|
||||||
"pathIndex": path_index,
|
"pathIndex": path_index,
|
||||||
"sizeBytes": stat.st_size if is_file else 0,
|
"sizeBytes": stat.st_size if is_file else 0,
|
||||||
"preview": model_preview if is_file else None,
|
"preview": model_preview,
|
||||||
"createdAt": round(stat.st_ctime_ns / 1000000),
|
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||||
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||||
}
|
}
|
||||||
@@ -154,26 +159,34 @@ class ModelManager:
|
|||||||
entries: list[os.DirEntry[str]] = []
|
entries: list[os.DirEntry[str]] = []
|
||||||
with os.scandir(directory) as it:
|
with os.scandir(directory) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
# Skip hidden files
|
if not include_hidden_files and entry.name.startswith("."):
|
||||||
if not include_hidden_files:
|
continue
|
||||||
if entry.name.startswith("."):
|
|
||||||
continue
|
if entry.is_file():
|
||||||
entries.append(entry)
|
extension = os.path.splitext(entry.name)[1]
|
||||||
if entry.is_dir():
|
if extension in folder_paths.supported_pt_extensions:
|
||||||
|
entries.append(entry)
|
||||||
|
else:
|
||||||
|
entries.append(entry)
|
||||||
entries.extend(get_all_files_entry(entry.path))
|
entries.extend(get_all_files_entry(entry.path))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
BATCH_SIZE = 200
|
||||||
|
MAX_WORKERS = min(4, os.cpu_count() or 1)
|
||||||
|
|
||||||
for path_index, base_path in enumerate(folders):
|
for path_index, base_path in enumerate(folders):
|
||||||
if not os.path.exists(base_path):
|
if not os.path.exists(base_path):
|
||||||
continue
|
continue
|
||||||
file_entries = get_all_files_entry(base_path)
|
file_entries = get_all_files_entry(base_path)
|
||||||
with ThreadPoolExecutor() as executor:
|
|
||||||
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
|
for i in range(0, len(file_entries), BATCH_SIZE):
|
||||||
for future in as_completed(futures):
|
batch = file_entries[i:i + BATCH_SIZE]
|
||||||
file_info = future.result()
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
if file_info is None:
|
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in batch}
|
||||||
continue
|
for future in as_completed(futures):
|
||||||
result.append(file_info)
|
file_info = future.result()
|
||||||
|
if file_info is not None:
|
||||||
|
result.append(file_info)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -198,10 +211,11 @@ class ModelManager:
|
|||||||
|
|
||||||
if "previewFile" in model_data:
|
if "previewFile" in model_data:
|
||||||
previewFile = model_data["previewFile"]
|
previewFile = model_data["previewFile"]
|
||||||
if type(previewFile) is str and previewFile == "undefined":
|
# Always remove existing preview files first in case the file extension has changed
|
||||||
utils.remove_model_preview_image(model_path)
|
utils.remove_model_preview(model_path)
|
||||||
else:
|
# Nothing else to do if the preview file was being removed
|
||||||
utils.save_model_preview_image(model_path, previewFile)
|
if not (type(previewFile) is str and previewFile == "undefined"):
|
||||||
|
utils.save_model_preview(model_path, previewFile)
|
||||||
|
|
||||||
if "description" in model_data:
|
if "description" in model_data:
|
||||||
description = model_data["description"]
|
description = model_data["description"]
|
||||||
@@ -223,7 +237,7 @@ class ModelManager:
|
|||||||
model_dirname = os.path.dirname(model_path)
|
model_dirname = os.path.dirname(model_path)
|
||||||
os.remove(model_path)
|
os.remove(model_path)
|
||||||
|
|
||||||
model_previews = utils.get_model_all_images(model_path)
|
model_previews = utils.get_model_all_previews(model_path)
|
||||||
for preview in model_previews:
|
for preview in model_previews:
|
||||||
os.remove(utils.join_path(model_dirname, preview))
|
os.remove(utils.join_path(model_dirname, preview))
|
||||||
|
|
||||||
|
|||||||
79
py/upload.py
Normal file
79
py/upload.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUploader:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/supported-extensions")
|
||||||
|
async def fetch_model_exts(request):
|
||||||
|
"""
|
||||||
|
Get model exts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
supported_extensions = list(folder_paths.supported_pt_extensions)
|
||||||
|
return web.json_response({"success": True, "data": supported_extensions})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Get model supported extension failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.post("/model-manager/upload")
|
||||||
|
async def upload_model(request):
|
||||||
|
"""
|
||||||
|
Upload model
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reader = await request.multipart()
|
||||||
|
await self.upload_model(reader)
|
||||||
|
utils.print_info(f"Upload model success")
|
||||||
|
return web.json_response({"success": True, "data": None})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Upload model failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
async def upload_model(self, reader):
|
||||||
|
uploaded_size = 0
|
||||||
|
last_update_time = time.time()
|
||||||
|
interval = 1.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
part = await reader.next()
|
||||||
|
if part is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
name = part.name
|
||||||
|
if name == "folder":
|
||||||
|
file_folder = await part.text()
|
||||||
|
|
||||||
|
if name == "file":
|
||||||
|
filename = part.filename
|
||||||
|
filepath = f"{file_folder}/{filename}"
|
||||||
|
tmp_filepath = f"{file_folder}/{filename}.tmp"
|
||||||
|
|
||||||
|
with open(tmp_filepath, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await part.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
uploaded_size += len(chunk)
|
||||||
|
|
||||||
|
if time.time() - last_update_time >= interval:
|
||||||
|
update_upload_progress = {
|
||||||
|
"uploaded_size": uploaded_size,
|
||||||
|
}
|
||||||
|
await utils.send_json("update_upload_progress", update_upload_progress)
|
||||||
|
|
||||||
|
update_upload_progress = {
|
||||||
|
"uploaded_size": uploaded_size,
|
||||||
|
}
|
||||||
|
await utils.send_json("update_upload_progress", update_upload_progress)
|
||||||
|
os.rename(tmp_filepath, filepath)
|
||||||
210
py/utils.py
210
py/utils.py
@@ -8,14 +8,34 @@ import requests
|
|||||||
import traceback
|
import traceback
|
||||||
import configparser
|
import configparser
|
||||||
import functools
|
import functools
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
# Media file extensions
|
||||||
|
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
|
||||||
|
IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
# Preview extensions in priority order (videos first, then images)
|
||||||
|
PREVIEW_EXTENSIONS = ['.webm', '.mp4', '.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
# Content type mappings
|
||||||
|
VIDEO_CONTENT_TYPE_MAP = {
|
||||||
|
'video/mp4': '.mp4',
|
||||||
|
'video/webm': '.webm',
|
||||||
|
'video/quicktime': '.mov',
|
||||||
|
'video/x-msvideo': '.avi',
|
||||||
|
'video/x-matroska': '.mkv',
|
||||||
|
'video/x-flv': '.flv',
|
||||||
|
'video/x-ms-wmv': '.wmv',
|
||||||
|
'video/ogg': '.ogv',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_info(msg, *args, **kwargs):
|
def print_info(msg, *args, **kwargs):
|
||||||
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
||||||
@@ -26,7 +46,7 @@ def print_warning(msg, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def print_error(msg, *args, **kwargs):
|
def print_error(msg, *args, **kwargs):
|
||||||
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
logging.error(f"[{config.extension_tag}][ERROR] {msg}", *args, **kwargs)
|
||||||
logging.debug(traceback.format_exc())
|
logging.debug(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +169,20 @@ def resolve_model_base_paths() -> dict[str, list[str]]:
|
|||||||
return model_base_paths
|
return model_base_paths
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_content_type(filename: str):
|
||||||
|
extension_mimetypes_cache = folder_paths.extension_mimetypes_cache
|
||||||
|
extension = filename.split(".")[-1]
|
||||||
|
if extension not in extension_mimetypes_cache:
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename, strict=False)
|
||||||
|
if not mime_type:
|
||||||
|
return None
|
||||||
|
content_type = mime_type.split("/")[0]
|
||||||
|
extension_mimetypes_cache[extension] = content_type
|
||||||
|
else:
|
||||||
|
content_type = extension_mimetypes_cache[extension]
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
def get_full_path(model_type: str, path_index: int, filename: str):
|
def get_full_path(model_type: str, path_index: int, filename: str):
|
||||||
"""
|
"""
|
||||||
Get the absolute path in the model type through string concatenation.
|
Get the absolute path in the model type through string concatenation.
|
||||||
@@ -237,79 +271,145 @@ def get_model_metadata(filename: str):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_images(model_path: str):
|
def _check_preview_variants(base_dirname: str, basename: str, extensions: list[str]) -> list[str]:
|
||||||
|
"""Check for preview files with given extensions and return found files"""
|
||||||
|
found = []
|
||||||
|
for ext in extensions:
|
||||||
|
# Direct match (basename.ext)
|
||||||
|
preview_file = f"{basename}{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_file)):
|
||||||
|
found.append(preview_file)
|
||||||
|
|
||||||
|
# Preview variant (basename.preview.ext)
|
||||||
|
preview_file = f"{basename}.preview{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_file)):
|
||||||
|
found.append(preview_file)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _get_preview_path(model_path: str, extension: str) -> str:
|
||||||
|
"""Generate preview file path with given extension"""
|
||||||
|
basename = os.path.splitext(model_path)[0]
|
||||||
|
return f"{basename}{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_all_previews(model_path: str) -> list[str]:
|
||||||
|
"""Get all preview files for a model"""
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
files = search_files(base_dirname)
|
|
||||||
files = folder_paths.filter_files_content_types(files, ["image"])
|
|
||||||
|
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
output: list[str] = []
|
return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||||
for file in files:
|
|
||||||
file_basename = os.path.splitext(file)[0]
|
|
||||||
if file_basename == basename:
|
|
||||||
output.append(file)
|
|
||||||
if file_basename == f"{basename}.preview":
|
|
||||||
output.append(file)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_preview_name(model_path: str):
|
def get_model_preview_name(model_path: str) -> str:
|
||||||
images = get_model_all_images(model_path)
|
"""Get the first available preview file or 'no-preview.png' if none found"""
|
||||||
|
base_dirname = os.path.dirname(model_path)
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
for image in images:
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
image_name = os.path.splitext(image)[0]
|
# Check direct match first
|
||||||
image_ext = os.path.splitext(image)[1]
|
preview_name = f"{basename}{ext}"
|
||||||
if image_name == basename and image_ext.lower() == ".webp":
|
if os.path.isfile(join_path(base_dirname, preview_name)):
|
||||||
return image
|
return preview_name
|
||||||
|
|
||||||
return images[0] if len(images) > 0 else "no-preview.png"
|
# Check preview variant
|
||||||
|
preview_name = f"{basename}.preview{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_name)):
|
||||||
|
return preview_name
|
||||||
|
|
||||||
|
return "no-preview.png"
|
||||||
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
def remove_model_preview_image(model_path: str):
|
def remove_model_preview(model_path: str):
|
||||||
basename = os.path.splitext(model_path)[0]
|
"""Remove all preview files for a model"""
|
||||||
preview_path = f"{basename}.webp"
|
base_dirname = os.path.dirname(model_path)
|
||||||
if os.path.exists(preview_path):
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
os.remove(preview_path)
|
|
||||||
|
previews = _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||||
|
for preview in previews:
|
||||||
|
preview_path = join_path(base_dirname, preview)
|
||||||
|
if os.path.exists(preview_path):
|
||||||
|
os.remove(preview_path)
|
||||||
|
|
||||||
|
|
||||||
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
|
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
|
||||||
basename = os.path.splitext(model_path)[0]
|
"""Save a preview file for a model. Images -> WebP, videos -> original format"""
|
||||||
preview_path = f"{basename}.webp"
|
|
||||||
# Download image file if it is url
|
# Download file if it is a URL
|
||||||
if type(image_file_or_url) is str:
|
if type(file_or_url) is str:
|
||||||
image_url = image_file_or_url
|
url = file_or_url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
image_response = requests.get(image_url)
|
response = requests.get(url)
|
||||||
image_response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
image = Image.open(BytesIO(image_response.content))
|
# Determine content type from response headers or URL extension
|
||||||
image.save(preview_path, "WEBP")
|
content_type = response.headers.get('content-type', '')
|
||||||
|
if not content_type:
|
||||||
|
# Fallback to URL extension detection
|
||||||
|
content_type = resolve_file_content_type(url) or ''
|
||||||
|
|
||||||
|
content = response.content
|
||||||
|
|
||||||
|
if content_type.startswith("video/"):
|
||||||
|
# Save video in original format
|
||||||
|
# Try to get extension from URL or content-type
|
||||||
|
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type) or '.mp4'
|
||||||
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
else:
|
||||||
|
# Default to image processing for unknown or image types
|
||||||
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
|
image = Image.open(BytesIO(content))
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(f"Failed to download image: {e}")
|
print_error(f"Failed to download preview: {e}")
|
||||||
|
|
||||||
|
# Handle uploaded file
|
||||||
else:
|
else:
|
||||||
# Assert image as file
|
file_obj = file_or_url
|
||||||
image_file = image_file_or_url
|
|
||||||
|
|
||||||
if not isinstance(image_file, web.FileField):
|
if not isinstance(file_obj, web.FileField):
|
||||||
raise RuntimeError("Invalid image file")
|
raise RuntimeError("Invalid file")
|
||||||
|
|
||||||
content_type: str = image_file.content_type
|
content_type: str = file_obj.content_type
|
||||||
if not content_type.startswith("image/"):
|
filename: str = getattr(file_obj, 'filename', '')
|
||||||
if platform == "huggingface":
|
|
||||||
# huggingface previewFile content_type='text/plain', not startswith("image/")
|
if content_type.startswith("video/"):
|
||||||
return
|
# Save video in original format for now, consider transcoding to webm to follow the pattern for images converting to webp
|
||||||
else:
|
ext = os.path.splitext(filename.lower())[1] or '.mp4'
|
||||||
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
image = Image.open(image_file.file)
|
file_obj.file.seek(0)
|
||||||
image.save(preview_path, "WEBP")
|
content = file_obj.file.read()
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
elif content_type.startswith("image/"):
|
||||||
|
# Convert image to webp
|
||||||
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
|
image = Image.open(file_obj.file)
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"FileTypeError: expected image or video, got {content_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_video_extension_from_url(url: str) -> Optional[str]:
|
||||||
|
"""Extract video extension from URL."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
path = urlparse(url).path.lower()
|
||||||
|
for ext in VIDEO_EXTENSIONS:
|
||||||
|
if path.endswith(ext):
|
||||||
|
return ext
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_extension_from_content_type(content_type: str) -> Optional[str]:
|
||||||
|
"""Map content-type to file extension."""
|
||||||
|
return VIDEO_CONTENT_TYPE_MAP.get(content_type.lower())
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_descriptions(model_path: str):
|
def get_model_all_descriptions(model_path: str):
|
||||||
@@ -367,7 +467,7 @@ def rename_model(model_path: str, new_model_path: str):
|
|||||||
shutil.move(model_path, new_model_path)
|
shutil.move(model_path, new_model_path)
|
||||||
|
|
||||||
# move preview
|
# move preview
|
||||||
previews = get_model_all_images(model_path)
|
previews = get_model_all_previews(model_path)
|
||||||
for preview in previews:
|
for preview in previews:
|
||||||
preview_path = join_path(model_dirname, preview)
|
preview_path = join_path(model_dirname, preview)
|
||||||
preview_name = os.path.splitext(preview)[0]
|
preview_name = os.path.splitext(preview)[0]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-model-manager"
|
name = "comfyui-model-manager"
|
||||||
description = "Manage models: browsing, download and delete."
|
description = "Manage models: browsing, download and delete."
|
||||||
version = "2.5.2"
|
version = "2.8.2"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
|
|||||||
40
src/App.vue
40
src/App.vue
@@ -9,6 +9,8 @@
|
|||||||
import DialogDownload from 'components/DialogDownload.vue'
|
import DialogDownload from 'components/DialogDownload.vue'
|
||||||
import DialogExplorer from 'components/DialogExplorer.vue'
|
import DialogExplorer from 'components/DialogExplorer.vue'
|
||||||
import DialogManager from 'components/DialogManager.vue'
|
import DialogManager from 'components/DialogManager.vue'
|
||||||
|
import DialogScanning from 'components/DialogScanning.vue'
|
||||||
|
import DialogUpload from 'components/DialogUpload.vue'
|
||||||
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
||||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||||
import GlobalToast from 'components/GlobalToast.vue'
|
import GlobalToast from 'components/GlobalToast.vue'
|
||||||
@@ -35,6 +37,19 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openModelScanning = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-information-scanning',
|
||||||
|
title: t('batchScanModelInformation'),
|
||||||
|
content: DialogScanning,
|
||||||
|
modal: true,
|
||||||
|
defaultSize: {
|
||||||
|
width: 680,
|
||||||
|
height: 490,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openDownloadDialog = () => {
|
const openDownloadDialog = () => {
|
||||||
dialog.open({
|
dialog.open({
|
||||||
key: 'model-manager-download-list',
|
key: 'model-manager-download-list',
|
||||||
@@ -50,6 +65,21 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openUploadDialog = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-manager-upload',
|
||||||
|
title: t('uploadModel'),
|
||||||
|
content: DialogUpload,
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: 'pi pi-refresh',
|
||||||
|
command: refreshModelsAndConfig,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect, flat } = config
|
const { cardWidth, gutter, aspect, flat } = config
|
||||||
|
|
||||||
@@ -64,6 +94,11 @@ onMounted(() => {
|
|||||||
content: flat.value ? DialogManager : DialogExplorer,
|
content: flat.value ? DialogManager : DialogExplorer,
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
headerButtons: [
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'scanning',
|
||||||
|
icon: 'mdi mdi-folder-search-outline text-lg',
|
||||||
|
command: openModelScanning,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'refresh',
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
@@ -74,6 +109,11 @@ onMounted(() => {
|
|||||||
icon: 'pi pi-download',
|
icon: 'pi pi-download',
|
||||||
command: openDownloadDialog,
|
command: openDownloadDialog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
icon: 'pi pi-upload',
|
||||||
|
command: openUploadDialog,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
minWidth: cardWidth * 2 + gutter + 42,
|
minWidth: cardWidth * 2 + gutter + 42,
|
||||||
minHeight: (cardWidth / aspect) * 0.5 + 162,
|
minHeight: (cardWidth / aspect) * 0.5 + 162,
|
||||||
|
|||||||
@@ -31,12 +31,20 @@
|
|||||||
<KeepAlive>
|
<KeepAlive>
|
||||||
<ModelContent
|
<ModelContent
|
||||||
v-if="currentModel"
|
v-if="currentModel"
|
||||||
:key="currentModel.id"
|
:key="`${currentModel.id}-${currentModel.currentFileId}`"
|
||||||
:model="currentModel"
|
:model="currentModel"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
@submit="createDownTask"
|
@submit="createDownTask"
|
||||||
>
|
>
|
||||||
<template #action>
|
<template #action>
|
||||||
|
<div v-if="currentModel.files" class="flex-1">
|
||||||
|
<ResponseSelect
|
||||||
|
:model-value="currentModel.currentFileId"
|
||||||
|
:items="currentModel.selectionFiles"
|
||||||
|
:type="isMobile ? 'drop' : 'button'"
|
||||||
|
>
|
||||||
|
</ResponseSelect>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-download"
|
icon="pi pi-download"
|
||||||
:label="$t('download')"
|
:label="$t('download')"
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
>
|
>
|
||||||
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
||||||
<div class="h-18 preview-aspect">
|
<div class="h-18 preview-aspect">
|
||||||
<img :src="item.preview" />
|
<div v-if="isVideoUrl(item.preview)" class="h-full w-full">
|
||||||
|
<PreviewVideo :src="item.preview" />
|
||||||
|
</div>
|
||||||
|
<img v-else :src="item.preview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||||
@@ -72,11 +75,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
import { useContainerQueries } from 'hooks/container'
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import { useDownload } from 'hooks/download'
|
import { useDownload } from 'hooks/download'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ import Button from 'primevue/button'
|
|||||||
import ConfirmDialog from 'primevue/confirmdialog'
|
import ConfirmDialog from 'primevue/confirmdialog'
|
||||||
import ContextMenu from 'primevue/contextmenu'
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import { MenuItem } from 'primevue/menuitem'
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { genModelKey } from 'utils/model'
|
import { genModelKey } from 'utils/model'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|||||||
271
src/components/DialogScanning.vue
Normal file
271
src/components/DialogScanning.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full px-4">
|
||||||
|
<div v-show="batchScanningStep === 0" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full opacity-60">
|
||||||
|
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
v-show="batchScanningStep === 1"
|
||||||
|
v-model:value="stepValue"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
linear
|
||||||
|
>
|
||||||
|
<StepList>
|
||||||
|
<Step value="1">{{ $t('selectModelType') }}</Step>
|
||||||
|
<Step value="2">{{ $t('selectSubdirectory') }}</Step>
|
||||||
|
<Step value="3">{{ $t('scanModelInformation') }}</Step>
|
||||||
|
</StepList>
|
||||||
|
<StepPanels class="flex-1 overflow-hidden">
|
||||||
|
<StepPanel value="1" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
@click="item.command"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel value="2" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll class="flex-1">
|
||||||
|
<Tree
|
||||||
|
class="h-full"
|
||||||
|
v-model:selection-keys="selectedKey"
|
||||||
|
:value="pathOptions"
|
||||||
|
selectionMode="single"
|
||||||
|
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||||
|
></Tree>
|
||||||
|
</ResponseScroll>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6">
|
||||||
|
<Button
|
||||||
|
:label="$t('back')"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
:label="$t('next')"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
icon-pos="right"
|
||||||
|
:disabled="!enabledScan"
|
||||||
|
@click="handleConfirmSubdir"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel value="3" class="h-full">
|
||||||
|
<div class="overflow-hidden break-words py-8">
|
||||||
|
<div class="overflow-hidden px-8">
|
||||||
|
<div v-show="currentType === allType" class="text-center">
|
||||||
|
{{ $t('selectedAllPaths') }}
|
||||||
|
</div>
|
||||||
|
<div v-show="currentType !== allType" class="text-center">
|
||||||
|
<div class="pb-2">
|
||||||
|
{{ $t('selectedSpecialPath') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 opacity-60">
|
||||||
|
{{ selectedModelFolder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in scanActions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:icon="item.icon"
|
||||||
|
@click="item.command.call(item)"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
</StepPanels>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<div v-show="batchScanningStep === 2" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full">
|
||||||
|
<div v-show="scanProgress > -1">
|
||||||
|
<ProgressBar :value="scanProgress">
|
||||||
|
{{ scanCompleteCount }} / {{ scanTotalCount }}
|
||||||
|
</ProgressBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="scanProgress === -1" class="text-center">
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
:label="$t('back')"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<span class="pl-2">{{ $t('noModelsInCurrentPath') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import { configSetting } from 'hooks/config'
|
||||||
|
import { useModelFolder, useModels } from 'hooks/model'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ProgressBar from 'primevue/progressbar'
|
||||||
|
import Step from 'primevue/step'
|
||||||
|
import StepList from 'primevue/steplist'
|
||||||
|
import StepPanel from 'primevue/steppanel'
|
||||||
|
import StepPanels from 'primevue/steppanels'
|
||||||
|
import Stepper from 'primevue/stepper'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import { api, app } from 'scripts/comfyAPI'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const stepValue = ref('1')
|
||||||
|
|
||||||
|
const { folders } = useModels()
|
||||||
|
|
||||||
|
const allType = 'All'
|
||||||
|
const currentType = ref<string>()
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
|
||||||
|
configSetting.excludeScanTypes,
|
||||||
|
)
|
||||||
|
const customBlackList =
|
||||||
|
excludeScanTypes
|
||||||
|
?.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean) ?? []
|
||||||
|
return [
|
||||||
|
allType,
|
||||||
|
...Object.keys(folders.value).filter(
|
||||||
|
(folder) => !customBlackList.includes(folder),
|
||||||
|
),
|
||||||
|
].map((type) => {
|
||||||
|
return {
|
||||||
|
label: type,
|
||||||
|
value: type,
|
||||||
|
command: () => {
|
||||||
|
currentType.value = type
|
||||||
|
stepValue.value = currentType.value === allType ? '3' : '2'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pathOptions } = useModelFolder({ type: currentType })
|
||||||
|
|
||||||
|
const selectedModelFolder = ref<string>()
|
||||||
|
const selectedKey = computed({
|
||||||
|
get: () => {
|
||||||
|
const key = selectedModelFolder.value
|
||||||
|
return key ? { [key]: true } : {}
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const key = Object.keys(val)[0]
|
||||||
|
selectedModelFolder.value = key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enabledScan = computed(() => {
|
||||||
|
return currentType.value === allType || !!selectedModelFolder.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBackTypeSelect = () => {
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
currentType.value = undefined
|
||||||
|
stepValue.value = '1'
|
||||||
|
batchScanningStep.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmSubdir = () => {
|
||||||
|
stepValue.value = '3'
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchScanningStep = ref(0)
|
||||||
|
const scanModelsList = ref<Record<string, boolean>>({})
|
||||||
|
const scanTotalCount = computed(() => {
|
||||||
|
return Object.keys(scanModelsList.value).length
|
||||||
|
})
|
||||||
|
const scanCompleteCount = computed(() => {
|
||||||
|
return Object.keys(scanModelsList.value).filter(
|
||||||
|
(key) => scanModelsList.value[key],
|
||||||
|
).length
|
||||||
|
})
|
||||||
|
const scanProgress = computed(() => {
|
||||||
|
if (scanTotalCount.value === 0) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
const progress = scanCompleteCount.value / scanTotalCount.value
|
||||||
|
return Number(progress.toFixed(4)) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleScanModelInformation = async function () {
|
||||||
|
batchScanningStep.value = 0
|
||||||
|
const mode = this.value
|
||||||
|
const path = selectedModelFolder.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await request('/model-info/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode, path }),
|
||||||
|
})
|
||||||
|
scanModelsList.value = result?.models ?? {}
|
||||||
|
batchScanningStep.value = 2
|
||||||
|
} catch {
|
||||||
|
batchScanningStep.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanActions = ref([
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: t('back'),
|
||||||
|
icon: 'pi pi-arrow-left',
|
||||||
|
command: () => {
|
||||||
|
stepValue.value = currentType.value === allType ? '1' : '2'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: t('scanFullInformation'),
|
||||||
|
command: handleScanModelInformation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'diff',
|
||||||
|
label: t('scanMissInformation'),
|
||||||
|
command: handleScanModelInformation,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const refreshTaskContent = async () => {
|
||||||
|
const result = await request('/model-info/scan')
|
||||||
|
const listContent = result?.models ?? {}
|
||||||
|
scanModelsList.value = listContent
|
||||||
|
batchScanningStep.value = Object.keys(listContent).length ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshTaskContent()
|
||||||
|
|
||||||
|
api.addEventListener('update_scan_information_task', (event) => {
|
||||||
|
const content = event.detail
|
||||||
|
scanModelsList.value = content.models
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
274
src/components/DialogUpload.vue
Normal file
274
src/components/DialogUpload.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full px-4">
|
||||||
|
<!-- <div v-show="batchScanningStep === 0" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full opacity-60">
|
||||||
|
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<Stepper v-model:value="stepValue" class="flex h-full flex-col" linear>
|
||||||
|
<StepList>
|
||||||
|
<Step :value="1">{{ $t('selectModelType') }}</Step>
|
||||||
|
<Step :value="2">{{ $t('selectSubdirectory') }}</Step>
|
||||||
|
<Step :value="3">{{ $t('chooseFile') }}</Step>
|
||||||
|
</StepList>
|
||||||
|
<StepPanels class="flex-1 overflow-hidden">
|
||||||
|
<StepPanel :value="1" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
@click="item.command"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel :value="2" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll class="flex-1">
|
||||||
|
<Tree
|
||||||
|
class="h-full"
|
||||||
|
v-model:selection-keys="selectedKey"
|
||||||
|
:value="pathOptions"
|
||||||
|
selectionMode="single"
|
||||||
|
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||||
|
></Tree>
|
||||||
|
</ResponseScroll>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6">
|
||||||
|
<Button
|
||||||
|
:label="$t('back')"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
:label="$t('next')"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
icon-pos="right"
|
||||||
|
:disabled="!enabledUpload"
|
||||||
|
@click="handleConfirmSubdir"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel :value="3" class="h-full">
|
||||||
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
<template v-if="showUploadProgress">
|
||||||
|
<div class="w-4/5">
|
||||||
|
<ProgressBar
|
||||||
|
:value="uploadProgress"
|
||||||
|
:pt:value:style="{ transition: 'width .1s linear' }"
|
||||||
|
></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-hidden break-words py-8">
|
||||||
|
<div class="overflow-hidden px-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="pb-2">
|
||||||
|
{{ $t('selectedSpecialPath') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 opacity-60">
|
||||||
|
{{ selectedModelFolder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in uploadActions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:icon="item.icon"
|
||||||
|
@click="item.command.call(item)"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="h-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
</StepPanels>
|
||||||
|
</Stepper>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import { configSetting } from 'hooks/config'
|
||||||
|
import { useModelFolder, useModels } from 'hooks/model'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import { useToast } from 'hooks/toast'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ProgressBar from 'primevue/progressbar'
|
||||||
|
import Step from 'primevue/step'
|
||||||
|
import StepList from 'primevue/steplist'
|
||||||
|
import StepPanel from 'primevue/steppanel'
|
||||||
|
import StepPanels from 'primevue/steppanels'
|
||||||
|
import Stepper from 'primevue/stepper'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import { api, app } from 'scripts/comfyAPI'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, toValue } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const stepValue = ref(1)
|
||||||
|
|
||||||
|
const { folders } = useModels()
|
||||||
|
|
||||||
|
const currentType = ref<string>()
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
|
||||||
|
configSetting.excludeScanTypes,
|
||||||
|
)
|
||||||
|
const customBlackList =
|
||||||
|
excludeScanTypes
|
||||||
|
?.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean) ?? []
|
||||||
|
return Object.keys(folders.value)
|
||||||
|
.filter((folder) => !customBlackList.includes(folder))
|
||||||
|
.map((type) => {
|
||||||
|
return {
|
||||||
|
label: type,
|
||||||
|
value: type,
|
||||||
|
command: () => {
|
||||||
|
currentType.value = type
|
||||||
|
stepValue.value++
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pathOptions } = useModelFolder({ type: currentType })
|
||||||
|
|
||||||
|
const selectedModelFolder = ref<string>()
|
||||||
|
const selectedKey = computed({
|
||||||
|
get: () => {
|
||||||
|
const key = selectedModelFolder.value
|
||||||
|
return key ? { [key]: true } : {}
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const key = Object.keys(val)[0]
|
||||||
|
selectedModelFolder.value = key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enabledUpload = computed(() => {
|
||||||
|
return !!selectedModelFolder.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBackTypeSelect = () => {
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
currentType.value = undefined
|
||||||
|
stepValue.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmSubdir = () => {
|
||||||
|
stepValue.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadTotalSize = ref<number>()
|
||||||
|
const uploadSize = ref<number>()
|
||||||
|
const uploadProgress = computed(() => {
|
||||||
|
const total = toValue(uploadTotalSize)
|
||||||
|
const size = toValue(uploadSize)
|
||||||
|
if (typeof total === 'number' && typeof size === 'number') {
|
||||||
|
return Math.floor((size / total) * 100)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const showUploadProgress = computed(() => {
|
||||||
|
return typeof uploadProgress.value !== 'undefined'
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadActions = ref([
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: t('back'),
|
||||||
|
icon: 'pi pi-arrow-left',
|
||||||
|
command: () => {
|
||||||
|
stepValue.value--
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: t('chooseFile'),
|
||||||
|
command: () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = supportedExtensions.value.join(',')
|
||||||
|
input.onchange = async () => {
|
||||||
|
const files = input.files
|
||||||
|
const file = files?.item(0)
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uploadTotalSize.value = file.size
|
||||||
|
uploadSize.value = 0
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('folder', toValue(selectedModelFolder)!)
|
||||||
|
body.append('file', file)
|
||||||
|
|
||||||
|
await request('/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const supportedExtensions = ref([])
|
||||||
|
|
||||||
|
const fetchSupportedExtensions = async () => {
|
||||||
|
try {
|
||||||
|
const result = await request('/supported-extensions')
|
||||||
|
supportedExtensions.value = result ?? []
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update_process = (event: CustomEvent) => {
|
||||||
|
const detail = event.detail
|
||||||
|
uploadSize.value = detail.uploaded_size
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSupportedExtensions()
|
||||||
|
|
||||||
|
api.addEventListener('update_upload_progress', update_process)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
api.removeEventListener('update_upload_progress', update_process)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<ResponseDialog
|
<ResponseDialog
|
||||||
v-for="(item, index) in stack"
|
v-for="(item, index) in stack"
|
||||||
v-model:visible="item.visible"
|
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:keep-alive="item.keepAlive"
|
v-model:visible="item.visible"
|
||||||
:default-size="item.defaultSize"
|
v-bind="omitProps(item)"
|
||||||
:default-mobile-size="item.defaultMobileSize"
|
|
||||||
:resize-allow="item.resizeAllow"
|
|
||||||
:min-width="item.minWidth"
|
|
||||||
:max-width="item.maxWidth"
|
|
||||||
:min-height="item.minHeight"
|
|
||||||
:max-height="item.maxHeight"
|
|
||||||
:auto-z-index="false"
|
:auto-z-index="false"
|
||||||
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||||
:pt:root:onMousedown="() => rise(item)"
|
:pt:root:onMousedown="() => rise(item)"
|
||||||
@@ -42,7 +35,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ResponseDialog from 'components/ResponseDialog.vue'
|
import ResponseDialog from 'components/ResponseDialog.vue'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { type DialogItem, useDialog } from 'hooks/dialog'
|
||||||
|
import { omit } from 'lodash'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { usePrimeVue } from 'primevue/config'
|
import { usePrimeVue } from 'primevue/config'
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
@@ -55,4 +49,15 @@ const { config } = usePrimeVue()
|
|||||||
const baseZIndex = computed(() => {
|
const baseZIndex = computed(() => {
|
||||||
return config.zIndex?.modal ?? 1100
|
return config.zIndex?.modal ?? 1100
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const omitProps = (item: DialogItem) => {
|
||||||
|
return omit(item, [
|
||||||
|
'key',
|
||||||
|
'visible',
|
||||||
|
'title',
|
||||||
|
'headerButtons',
|
||||||
|
'content',
|
||||||
|
'contentProps',
|
||||||
|
])
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,6 +24,12 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="isVideoUrl(preview)"
|
||||||
|
class="h-full w-full p-1 hover:p-0"
|
||||||
|
>
|
||||||
|
<PreviewVideo :src="preview" />
|
||||||
|
</div>
|
||||||
<div v-else class="h-full w-full p-1 hover:p-0">
|
<div v-else class="h-full w-full p-1 hover:p-0">
|
||||||
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +72,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import { useModelNodeAction } from 'hooks/model'
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
import { BaseModel } from 'types/typings'
|
import { BaseModel } from 'types/typings'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
></ModelPreview>
|
></ModelPreview>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 overflow-hidden">
|
<div class="flex flex-col gap-4 overflow-hidden">
|
||||||
<div class="flex items-center justify-end gap-4">
|
<div class="flex h-10 items-center justify-end gap-4">
|
||||||
<slot name="action" :metadata="formInstance.metadata.value"></slot>
|
<slot name="action" :metadata="formInstance.metadata.value"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,24 @@
|
|||||||
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
||||||
:style="$sm({ width: `${cardWidth}px` })"
|
:style="$sm({ width: `${cardWidth}px` })"
|
||||||
>
|
>
|
||||||
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
|
<div
|
||||||
|
v-if="
|
||||||
|
preview &&
|
||||||
|
isVideoUrl(
|
||||||
|
preview,
|
||||||
|
currentType === 'local' ? localContentType : undefined,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="h-full w-full p-1 hover:p-0"
|
||||||
|
>
|
||||||
|
<PreviewVideo :src="preview" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponseImage
|
||||||
|
v-else
|
||||||
|
:src="preview"
|
||||||
|
:error="noPreviewContent"
|
||||||
|
></ResponseImage>
|
||||||
|
|
||||||
<Carousel
|
<Carousel
|
||||||
v-if="defaultContent.length > 1"
|
v-if="defaultContent.length > 1"
|
||||||
@@ -31,7 +48,14 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #item="slotProps">
|
<template #item="slotProps">
|
||||||
|
<div
|
||||||
|
v-if="isVideoUrl(slotProps.data)"
|
||||||
|
class="h-full w-full p-1 hover:p-0"
|
||||||
|
>
|
||||||
|
<PreviewVideo :src="slotProps.data" />
|
||||||
|
</div>
|
||||||
<ResponseImage
|
<ResponseImage
|
||||||
|
v-else
|
||||||
:src="slotProps.data"
|
:src="slotProps.data"
|
||||||
:error="noPreviewContent"
|
:error="noPreviewContent"
|
||||||
></ResponseImage>
|
></ResponseImage>
|
||||||
@@ -81,6 +105,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
||||||
import ResponseImage from 'components/ResponseImage.vue'
|
import ResponseImage from 'components/ResponseImage.vue'
|
||||||
import ResponseInput from 'components/ResponseInput.vue'
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
@@ -89,6 +114,7 @@ import { useContainerQueries } from 'hooks/container'
|
|||||||
import { useModelPreview } from 'hooks/model'
|
import { useModelPreview } from 'hooks/model'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Carousel from 'primevue/carousel'
|
import Carousel from 'primevue/carousel'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
|
|
||||||
const editable = defineModel<boolean>('editable')
|
const editable = defineModel<boolean>('editable')
|
||||||
const { cardWidth } = useConfig()
|
const { cardWidth } = useConfig()
|
||||||
@@ -102,6 +128,7 @@ const {
|
|||||||
networkContent,
|
networkContent,
|
||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
|
localContentType,
|
||||||
} = useModelPreview()
|
} = useModelPreview()
|
||||||
|
|
||||||
const { $sm, $xl } = useContainerQueries()
|
const { $sm, $xl } = useContainerQueries()
|
||||||
|
|||||||
25
src/components/PreviewVideo.vue
Normal file
25
src/components/PreviewVideo.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<video
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
playsinline
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
disablepictureinpicture
|
||||||
|
:preload="preload"
|
||||||
|
>
|
||||||
|
<source :src="src" type="video/mp4" />
|
||||||
|
<source :src="src" type="video/webm" />
|
||||||
|
</video>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
preload?: 'none' | 'metadata' | 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
preload: 'metadata',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:visible="true"
|
:visible="true"
|
||||||
@update:visible="updateVisible"
|
@update:visible="updateVisible"
|
||||||
|
:modal="modal"
|
||||||
:close-on-escape="false"
|
:close-on-escape="false"
|
||||||
:maximizable="!isMobile"
|
:maximizable="!isMobile"
|
||||||
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
||||||
@@ -91,6 +92,7 @@ interface Props {
|
|||||||
minHeight?: number
|
minHeight?: number
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
|
modal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const handleDropFile = (event: DragEvent) => {
|
|||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'image/*'
|
input.accept = 'image/*,video/*'
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
const files = input.files
|
const files = input.files
|
||||||
if (files) {
|
if (files) {
|
||||||
|
|||||||
69
src/components/SettingApiKey.vue
Normal file
69
src/components/SettingApiKey.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4">
|
||||||
|
<InputText
|
||||||
|
class="w-full"
|
||||||
|
v-model="content"
|
||||||
|
placeholder="Set New API Key"
|
||||||
|
autocomplete="off"
|
||||||
|
></InputText>
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span v-show="showError" class="text-red-400">
|
||||||
|
API Key Not Allow Empty
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button label="Save" autofocus @click="saveKeybinding"></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDialog } from 'hooks/dialog'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import { useToast } from 'hooks/toast'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import { ref, toValue } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keyField: string
|
||||||
|
setter: (val: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { close } = useDialog()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const content = ref<string>()
|
||||||
|
const showError = ref<boolean>(false)
|
||||||
|
|
||||||
|
const saveKeybinding = async () => {
|
||||||
|
const value = toValue(content)
|
||||||
|
if (!value) {
|
||||||
|
showError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showError.value = false
|
||||||
|
const key = toValue(props.keyField)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encodeValue = value ? btoa(value) : null
|
||||||
|
await request('/download/setting', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key, value: encodeValue }),
|
||||||
|
})
|
||||||
|
const desString = value ? value.slice(0, 4) + '****' + value.slice(-4) : ''
|
||||||
|
props.setter(desString)
|
||||||
|
close()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message,
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import SettingApiKey from 'components/SettingApiKey.vue'
|
||||||
import SettingCardSize from 'components/SettingCardSize.vue'
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
import { request } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
|
import { useToast } from 'hooks/toast'
|
||||||
|
import { $el, app } from 'scripts/comfyAPI'
|
||||||
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useToast } from './toast'
|
|
||||||
|
|
||||||
export const useConfig = defineStore('config', (store) => {
|
export const useConfig = defineStore('config', (store) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -67,6 +68,7 @@ export const useConfig = defineStore('config', (store) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
flat: flatLayout,
|
flat: flatLayout,
|
||||||
|
apiKeyInfo: ref<Record<string, string>>({}),
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(cardSizeFlag, (val) => {
|
watch(cardSizeFlag, (val) => {
|
||||||
@@ -98,57 +100,102 @@ export const configSetting = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
||||||
const { toast } = useToast()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { confirm } = useToast()
|
||||||
|
|
||||||
const confirm = (opts: {
|
const iconButton = (opt: {
|
||||||
message?: string
|
icon: string
|
||||||
accept?: () => void
|
onClick: () => void | Promise<void>
|
||||||
reject?: () => void
|
|
||||||
}) => {
|
}) => {
|
||||||
const dialog = new ComfyDialog('div', [])
|
return $el(
|
||||||
|
'span.h-4.cursor-pointer',
|
||||||
dialog.show(
|
{ onclick: opt.onClick },
|
||||||
$el('div', [
|
$el(`i.${opt.icon.replace(/\s/g, '.')}`),
|
||||||
$el('p', { textContent: opts.message }),
|
|
||||||
$el('div.flex.gap-4', [
|
|
||||||
$el('button.flex-1', {
|
|
||||||
textContent: 'Cancel',
|
|
||||||
onclick: () => {
|
|
||||||
opts.reject?.()
|
|
||||||
dialog.close()
|
|
||||||
document.body.removeChild(dialog.element)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
$el('button.flex-1', {
|
|
||||||
textContent: 'Continue',
|
|
||||||
onclick: () => {
|
|
||||||
opts.accept?.()
|
|
||||||
dialog.close()
|
|
||||||
document.body.removeChild(dialog.element)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setApiKey = async (key: string, setter: (val: string) => void) => {
|
||||||
|
store.dialog.open({
|
||||||
|
key: `setting.api_key.${key}`,
|
||||||
|
title: t(`setting.api_key.${key}`),
|
||||||
|
content: SettingApiKey,
|
||||||
|
modal: true,
|
||||||
|
defaultSize: {
|
||||||
|
width: 500,
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
contentProps: {
|
||||||
|
keyField: key,
|
||||||
|
setter: setter,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeApiKey = async (key: string) => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
confirm.require({
|
||||||
|
message: t('deleteAsk'),
|
||||||
|
header: 'Danger',
|
||||||
|
icon: 'pi pi-info-circle',
|
||||||
|
accept: () => resolve(true),
|
||||||
|
reject: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await request('/download/setting', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key, value: null }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderApiKey = (key: string) => {
|
||||||
|
return () => {
|
||||||
|
const apiKey = store.config.apiKeyInfo.value[key] || 'None'
|
||||||
|
const apiKeyDisplayEl = $el('div.text-sm.text-gray-500.flex-1', {
|
||||||
|
textContent: apiKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
const setter = (val: string) => {
|
||||||
|
store.config.apiKeyInfo.value[key] = val
|
||||||
|
apiKeyDisplayEl.textContent = val || 'None'
|
||||||
|
}
|
||||||
|
return $el('div.flex.gap-4', [
|
||||||
|
apiKeyDisplayEl,
|
||||||
|
iconButton({
|
||||||
|
icon: 'pi pi-pencil text-blue-400',
|
||||||
|
onClick: () => {
|
||||||
|
setApiKey(key, setter)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
iconButton({
|
||||||
|
icon: 'pi pi-trash text-red-400',
|
||||||
|
onClick: async () => {
|
||||||
|
const value = store.config.apiKeyInfo.value[key]
|
||||||
|
if (value) {
|
||||||
|
await removeApiKey(key)
|
||||||
|
setter('')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// API keys
|
// API keys
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.APIKey.HuggingFace',
|
id: 'ModelManager.APIKey.HuggingFace',
|
||||||
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
|
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
|
||||||
name: 'HuggingFace API Key',
|
name: 'HuggingFace API Key',
|
||||||
type: 'text',
|
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
|
type: renderApiKey('huggingface'),
|
||||||
})
|
})
|
||||||
|
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.APIKey.Civitai',
|
id: 'ModelManager.APIKey.Civitai',
|
||||||
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
|
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
|
||||||
name: 'Civitai API Key',
|
name: 'Civitai API Key',
|
||||||
type: 'text',
|
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
|
type: renderApiKey('civitai'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultCardSize = store.config.defaultCardSizeMap
|
const defaultCardSize = store.config.defaultCardSizeMap
|
||||||
@@ -187,101 +234,6 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Scan information
|
|
||||||
app.ui?.settings.addSetting({
|
|
||||||
id: 'ModelManager.ScanFiles.Full',
|
|
||||||
category: [t('modelManager'), t('setting.scan'), 'Full'],
|
|
||||||
name: t('setting.scanAll'),
|
|
||||||
defaultValue: '',
|
|
||||||
type: () => {
|
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
|
||||||
textContent: 'Full Scan',
|
|
||||||
onclick: () => {
|
|
||||||
confirm({
|
|
||||||
message: [
|
|
||||||
'This operation will override current files.',
|
|
||||||
'This may take a while and generate MANY server requests!',
|
|
||||||
'USE AT YOUR OWN RISK! Continue?',
|
|
||||||
].join('\n'),
|
|
||||||
accept: () => {
|
|
||||||
store.loading.loading.value = true
|
|
||||||
request('/model-info/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ scanMode: 'full' }),
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Complete download information',
|
|
||||||
life: 2000,
|
|
||||||
})
|
|
||||||
store.models.refresh()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: err.message ?? 'Failed to download information',
|
|
||||||
life: 15000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
store.loading.loading.value = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
app.ui?.settings.addSetting({
|
|
||||||
id: 'ModelManager.ScanFiles.Incremental',
|
|
||||||
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
|
|
||||||
name: t('setting.scanMissing'),
|
|
||||||
defaultValue: '',
|
|
||||||
type: () => {
|
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
|
||||||
textContent: 'Diff Scan',
|
|
||||||
onclick: () => {
|
|
||||||
confirm({
|
|
||||||
message: [
|
|
||||||
'Download missing information or preview.',
|
|
||||||
'This may take a while and generate MANY server requests!',
|
|
||||||
'USE AT YOUR OWN RISK! Continue?',
|
|
||||||
].join('\n'),
|
|
||||||
accept: () => {
|
|
||||||
store.loading.loading.value = true
|
|
||||||
request('/model-info/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ scanMode: 'diff' }),
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Complete download information',
|
|
||||||
life: 2000,
|
|
||||||
})
|
|
||||||
store.models.refresh()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: err.message ?? 'Failed to download information',
|
|
||||||
life: 15000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
store.loading.loading.value = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: configSetting.excludeScanTypes,
|
id: configSetting.excludeScanTypes,
|
||||||
category: [t('modelManager'), t('setting.scan'), 'ExcludeScanTypes'],
|
category: [t('modelManager'), t('setting.scan'), 'ExcludeScanTypes'],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface HeaderButton {
|
|||||||
command: () => void
|
command: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogItem {
|
export interface DialogItem {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
content: Component
|
content: Component
|
||||||
@@ -22,6 +22,7 @@ interface DialogItem {
|
|||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
minHeight?: number
|
minHeight?: number
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
|
modal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDialog = defineStore('dialog', () => {
|
export const useDialog = defineStore('dialog', () => {
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { useLoading } from 'hooks/loading'
|
|||||||
import { request } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
|
import { upperFirst } from 'lodash'
|
||||||
import { api } from 'scripts/comfyAPI'
|
import { api } from 'scripts/comfyAPI'
|
||||||
import {
|
import {
|
||||||
BaseModel,
|
|
||||||
DownloadTask,
|
DownloadTask,
|
||||||
DownloadTaskOptions,
|
DownloadTaskOptions,
|
||||||
SelectOptions,
|
SelectOptions,
|
||||||
VersionModel,
|
VersionModel,
|
||||||
|
VersionModelFile,
|
||||||
} from 'types/typings'
|
} from 'types/typings'
|
||||||
import { bytesToSize } from 'utils/common'
|
import { bytesToSize } from 'utils/common'
|
||||||
import { onBeforeMount, onMounted, ref, watch } from 'vue'
|
import { onBeforeMount, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import yaml from 'yaml'
|
||||||
|
|
||||||
export const useDownload = defineStore('download', (store) => {
|
export const useDownload = defineStore('download', (store) => {
|
||||||
const { toast, confirm, wrapperToastError } = useToast()
|
const { toast, confirm, wrapperToastError } = useToast()
|
||||||
@@ -84,7 +86,16 @@ export const useDownload = defineStore('download', (store) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initial download settings
|
||||||
|
// Migrate API keys from user settings to private key
|
||||||
|
const init = async () => {
|
||||||
|
const res = await request('/download/init', { method: 'POST' })
|
||||||
|
store.config.apiKeyInfo.value = res
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
|
init()
|
||||||
|
|
||||||
api.addEventListener('reconnected', () => {
|
api.addEventListener('reconnected', () => {
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
@@ -153,12 +164,60 @@ declare module 'hooks/store' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WithSelection<T> = SelectOptions & { item: T }
|
||||||
|
|
||||||
|
type FileSelectionVersionModel = VersionModel & {
|
||||||
|
currentFileId?: number
|
||||||
|
selectionFiles?: WithSelection<VersionModelFile>[]
|
||||||
|
}
|
||||||
|
|
||||||
export const useModelSearch = () => {
|
export const useModelSearch = () => {
|
||||||
const loading = useLoading()
|
const loading = useLoading()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const data = ref<(SelectOptions & { item: VersionModel })[]>([])
|
const data = ref<WithSelection<FileSelectionVersionModel>[]>([])
|
||||||
const current = ref<string | number>()
|
const current = ref<string | number>()
|
||||||
const currentModel = ref<BaseModel>()
|
const currentModel = ref<FileSelectionVersionModel>()
|
||||||
|
|
||||||
|
const genFileSelectionItem = (
|
||||||
|
item: VersionModel,
|
||||||
|
): FileSelectionVersionModel => {
|
||||||
|
const fileSelectionItem: FileSelectionVersionModel = { ...item }
|
||||||
|
fileSelectionItem.selectionFiles = fileSelectionItem.files
|
||||||
|
?.sort((file) => (file.type === 'Model' ? -1 : 1))
|
||||||
|
.map((file) => {
|
||||||
|
const parts = file.name.split('.')
|
||||||
|
const extension = `.${parts.pop()}`
|
||||||
|
const basename = parts.join('.')
|
||||||
|
|
||||||
|
const regexp = /---\n([\s\S]*?)\n---/
|
||||||
|
const yamlMetadataMatch = item.description.match(regexp)
|
||||||
|
const yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '')
|
||||||
|
yamlMetadata.hashes = file.hashes
|
||||||
|
yamlMetadata.metadata = file.metadata
|
||||||
|
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
|
||||||
|
const description = item.description.replace(regexp, yamlContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: file.type === 'Model' ? upperFirst(item.type) : file.type,
|
||||||
|
value: file.id,
|
||||||
|
item: file,
|
||||||
|
command() {
|
||||||
|
if (currentModel.value) {
|
||||||
|
currentModel.value.basename = basename
|
||||||
|
currentModel.value.extension = extension
|
||||||
|
currentModel.value.sizeBytes = file.sizeKB * 1024
|
||||||
|
currentModel.value.metadata = file.metadata
|
||||||
|
currentModel.value.downloadUrl = file.downloadUrl
|
||||||
|
currentModel.value.hashes = file.hashes
|
||||||
|
currentModel.value.description = description
|
||||||
|
currentModel.value.currentFileId = file.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fileSelectionItem.currentFileId = item.files?.[0]?.id
|
||||||
|
return fileSelectionItem
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchByUrl = async (url: string) => {
|
const handleSearchByUrl = async (url: string) => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -168,14 +227,17 @@ export const useModelSearch = () => {
|
|||||||
loading.show()
|
loading.show()
|
||||||
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
|
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
|
||||||
.then((resData: VersionModel[]) => {
|
.then((resData: VersionModel[]) => {
|
||||||
data.value = resData.map((item) => ({
|
data.value = resData.map((item) => {
|
||||||
label: item.shortname,
|
const resolvedItem = genFileSelectionItem(item)
|
||||||
value: item.id,
|
return {
|
||||||
item,
|
label: item.shortname,
|
||||||
command() {
|
value: item.id,
|
||||||
current.value = item.id
|
item: resolvedItem,
|
||||||
},
|
command() {
|
||||||
}))
|
current.value = item.id
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
current.value = data.value[0]?.value
|
current.value = data.value[0]?.value
|
||||||
currentModel.value = data.value[0]?.item
|
currentModel.value = data.value[0]?.item
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { defineStore } from 'hooks/store'
|
|||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
import { castArray, cloneDeep } from 'lodash'
|
import { castArray, cloneDeep } from 'lodash'
|
||||||
import { TreeNode } from 'primevue/treenode'
|
import { TreeNode } from 'primevue/treenode'
|
||||||
import { app } from 'scripts/comfyAPI'
|
import { api, app } from 'scripts/comfyAPI'
|
||||||
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
|
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
|
||||||
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
|
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
|
||||||
import { ModelGrid } from 'utils/legacy'
|
import { ModelGrid } from 'utils/legacy'
|
||||||
@@ -27,16 +27,18 @@ import {
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { configSetting } from './config'
|
import { configSetting } from './config'
|
||||||
|
|
||||||
|
const systemStat = ref()
|
||||||
|
|
||||||
type ModelFolder = Record<string, string[]>
|
type ModelFolder = Record<string, string[]>
|
||||||
|
|
||||||
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
|
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
|
||||||
Ref<ModelFolder>
|
Ref<ModelFolder>
|
||||||
>
|
>
|
||||||
|
|
||||||
export const genModelFullName = (model: BaseModel) => {
|
export const genModelFullName = (model: BaseModel, splitter = '/') => {
|
||||||
return [model.subFolder, `${model.basename}${model.extension}`]
|
return [model.subFolder, `${model.basename}${model.extension}`]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('/')
|
.join(splitter)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const genModelUrl = (model: BaseModel) => {
|
export const genModelUrl = (model: BaseModel) => {
|
||||||
@@ -234,6 +236,12 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
return [prefixPath, fullname].filter(Boolean).join('/')
|
return [prefixPath, fullname].filter(Boolean).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
api.getSystemStats().then((res) => {
|
||||||
|
systemStat.value = res
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialized: initialized,
|
initialized: initialized,
|
||||||
folders: folders,
|
folders: folders,
|
||||||
@@ -443,7 +451,7 @@ export const useModelBaseInfo = () => {
|
|||||||
|
|
||||||
export const useModelFolder = (
|
export const useModelFolder = (
|
||||||
option: {
|
option: {
|
||||||
type?: MaybeRefOrGetter<string>
|
type?: MaybeRefOrGetter<string | undefined>
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const { data: models, folders: modelFolders } = useModels()
|
const { data: models, folders: modelFolders } = useModels()
|
||||||
@@ -545,9 +553,11 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
* Local file url
|
* Local file url
|
||||||
*/
|
*/
|
||||||
const localContent = ref<string>()
|
const localContent = ref<string>()
|
||||||
|
const localContentType = ref<string>()
|
||||||
const updateLocalContent = async (event: SelectEvent) => {
|
const updateLocalContent = async (event: SelectEvent) => {
|
||||||
const { files } = event
|
const { files } = event
|
||||||
localContent.value = files[0].objectURL
|
localContent.value = files[0].objectURL
|
||||||
|
localContentType.value = files[0].type
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -585,6 +595,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
defaultContentPage.value = 0
|
defaultContentPage.value = 0
|
||||||
networkContent.value = undefined
|
networkContent.value = undefined
|
||||||
localContent.value = undefined
|
localContent.value = undefined
|
||||||
|
localContentType.value = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
registerSubmit((data) => {
|
registerSubmit((data) => {
|
||||||
@@ -603,6 +614,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
networkContent,
|
networkContent,
|
||||||
// local file
|
// local file
|
||||||
localContent,
|
localContent,
|
||||||
|
localContentType,
|
||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
// no preview
|
// no preview
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
@@ -712,11 +724,12 @@ export const useModelNodeAction = () => {
|
|||||||
// Use the legacy method instead
|
// Use the legacy method instead
|
||||||
const removeEmbeddingExtension = true
|
const removeEmbeddingExtension = true
|
||||||
const strictDragToAdd = false
|
const strictDragToAdd = false
|
||||||
|
const splitter = systemStat.value?.system.os === 'nt' ? '\\' : '/'
|
||||||
|
|
||||||
ModelGrid.dragAddModel(
|
ModelGrid.dragAddModel(
|
||||||
event,
|
event,
|
||||||
model.type,
|
model.type,
|
||||||
genModelFullName(model),
|
genModelFullName(model, splitter),
|
||||||
removeEmbeddingExtension,
|
removeEmbeddingExtension,
|
||||||
strictDragToAdd,
|
strictDragToAdd,
|
||||||
)
|
)
|
||||||
|
|||||||
131
src/i18n.ts
131
src/i18n.ts
@@ -1,133 +1,12 @@
|
|||||||
import { app } from 'scripts/comfyAPI'
|
import { app } from 'scripts/comfyAPI'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import en from './locales/en.json'
|
||||||
|
import zh from './locales/zh.json'
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
en: {
|
en: en,
|
||||||
model: 'Model',
|
zh: zh,
|
||||||
modelManager: 'Model Manager',
|
|
||||||
openModelManager: 'Open Model Manager',
|
|
||||||
searchModels: 'Search models',
|
|
||||||
modelCopied: 'Model Copied',
|
|
||||||
download: 'Download',
|
|
||||||
downloadList: 'Download List',
|
|
||||||
downloadTask: 'Download Task',
|
|
||||||
createDownloadTask: 'Create Download Task',
|
|
||||||
parseModelUrl: 'Parse Model URL',
|
|
||||||
pleaseInputModelUrl: 'Input a URL from civitai.com or huggingface.co',
|
|
||||||
cancel: 'Cancel',
|
|
||||||
save: 'Save',
|
|
||||||
delete: 'Delete',
|
|
||||||
deleteAsk: 'Confirm delete this {0}?',
|
|
||||||
modelType: 'Model Type',
|
|
||||||
default: 'Default',
|
|
||||||
network: 'Network',
|
|
||||||
local: 'Local',
|
|
||||||
none: 'None',
|
|
||||||
uploadFile: 'Upload File',
|
|
||||||
tapToChange: 'Tap description to change content',
|
|
||||||
name: 'Name',
|
|
||||||
width: 'Width',
|
|
||||||
height: 'Height',
|
|
||||||
reset: 'Reset',
|
|
||||||
sort: {
|
|
||||||
name: 'Name',
|
|
||||||
size: 'Largest',
|
|
||||||
created: 'Latest created',
|
|
||||||
modified: 'Latest modified',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
extraLarge: 'Extra Large Icons',
|
|
||||||
large: 'Large Icons',
|
|
||||||
medium: 'Medium Icons',
|
|
||||||
small: 'Small Icons',
|
|
||||||
custom: 'Custom Size',
|
|
||||||
customTip: 'Set in `Settings > Model Manager > UI`',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
type: 'Model Type',
|
|
||||||
pathIndex: 'Directory',
|
|
||||||
basename: 'File Name',
|
|
||||||
sizeBytes: 'File Size',
|
|
||||||
createdAt: 'Created At',
|
|
||||||
updatedAt: 'Updated At',
|
|
||||||
},
|
|
||||||
setting: {
|
|
||||||
apiKey: 'API Key',
|
|
||||||
cardHeight: 'Card Height',
|
|
||||||
cardWidth: 'Card Width',
|
|
||||||
scan: 'Scan',
|
|
||||||
scanMissing: 'Download missing information or preview',
|
|
||||||
scanAll: "Override all models' information and preview",
|
|
||||||
includeHiddenFiles: 'Include hidden files(start with .)',
|
|
||||||
excludeScanTypes: 'Exclude scan types (separate with commas)',
|
|
||||||
ui: 'UI',
|
|
||||||
cardSize: 'Card Size',
|
|
||||||
useFlatUI: 'Flat Layout',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
model: '模型',
|
|
||||||
modelManager: '模型管理器',
|
|
||||||
openModelManager: '打开模型管理器',
|
|
||||||
searchModels: '搜索模型',
|
|
||||||
modelCopied: '模型节点已拷贝',
|
|
||||||
download: '下载',
|
|
||||||
downloadList: '下载列表',
|
|
||||||
downloadTask: '下载任务',
|
|
||||||
createDownloadTask: '创建下载任务',
|
|
||||||
parseModelUrl: '解析模型URL',
|
|
||||||
pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL',
|
|
||||||
cancel: '取消',
|
|
||||||
save: '保存',
|
|
||||||
delete: '删除',
|
|
||||||
deleteAsk: '确定要删除此{0}?',
|
|
||||||
modelType: '模型类型',
|
|
||||||
default: '默认',
|
|
||||||
network: '网络',
|
|
||||||
local: '本地',
|
|
||||||
none: '无',
|
|
||||||
uploadFile: '上传文件',
|
|
||||||
tapToChange: '点击描述可更改内容',
|
|
||||||
name: '名称',
|
|
||||||
width: '宽度',
|
|
||||||
height: '高度',
|
|
||||||
reset: '重置',
|
|
||||||
sort: {
|
|
||||||
name: '名称',
|
|
||||||
size: '最大',
|
|
||||||
created: '最新创建',
|
|
||||||
modified: '最新修改',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
extraLarge: '超大图标',
|
|
||||||
large: '大图标',
|
|
||||||
medium: '中等图标',
|
|
||||||
small: '小图标',
|
|
||||||
custom: '自定义尺寸',
|
|
||||||
customTip: '在 `设置 > 模型管理器 > 外观` 中设置',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
type: '类型',
|
|
||||||
pathIndex: '目录',
|
|
||||||
basename: '文件名',
|
|
||||||
sizeBytes: '文件大小',
|
|
||||||
createdAt: '创建时间',
|
|
||||||
updatedAt: '更新时间',
|
|
||||||
},
|
|
||||||
setting: {
|
|
||||||
apiKey: '密钥',
|
|
||||||
cardHeight: '卡片高度',
|
|
||||||
cardWidth: '卡片宽度',
|
|
||||||
scan: '扫描',
|
|
||||||
scanMissing: '下载缺失的信息或预览图片',
|
|
||||||
scanAll: '覆盖所有模型信息和预览图片',
|
|
||||||
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
|
|
||||||
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
|
|
||||||
ui: '外观',
|
|
||||||
cardSize: '卡片尺寸',
|
|
||||||
useFlatUI: '展平布局',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLocalLanguage = () => {
|
const getLocalLanguage = () => {
|
||||||
|
|||||||
77
src/locales/en.json
Normal file
77
src/locales/en.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"model": "Model",
|
||||||
|
"modelManager": "Model Manager",
|
||||||
|
"openModelManager": "Open Model Manager",
|
||||||
|
"searchModels": "Search models",
|
||||||
|
"modelCopied": "Model Copied",
|
||||||
|
"download": "Download",
|
||||||
|
"downloadList": "Download List",
|
||||||
|
"downloadTask": "Download Task",
|
||||||
|
"createDownloadTask": "Create Download Task",
|
||||||
|
"parseModelUrl": "Parse Model URL",
|
||||||
|
"pleaseInputModelUrl": "Input a URL from civitai.com or huggingface.co",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteAsk": "Confirm delete this {0}?",
|
||||||
|
"modelType": "Model Type",
|
||||||
|
"default": "Default",
|
||||||
|
"network": "Network",
|
||||||
|
"local": "Local",
|
||||||
|
"none": "None",
|
||||||
|
"uploadFile": "Upload File",
|
||||||
|
"tapToChange": "Tap description to change content",
|
||||||
|
"name": "Name",
|
||||||
|
"width": "Width",
|
||||||
|
"height": "Height",
|
||||||
|
"reset": "Reset",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"batchScanModelInformation": "Batch scan model information",
|
||||||
|
"modelInformationScanning": "Scanning model information",
|
||||||
|
"selectModelType": "Select model type",
|
||||||
|
"selectSubdirectory": "Select subdirectory",
|
||||||
|
"scanModelInformation": "Scan model information",
|
||||||
|
"selectedAllPaths": "Selected all model paths",
|
||||||
|
"selectedSpecialPath": "Selected special path",
|
||||||
|
"scanMissInformation": "Download missing information",
|
||||||
|
"scanFullInformation": "Override full information",
|
||||||
|
"noModelsInCurrentPath": "There are no models available in the current path",
|
||||||
|
"uploadModel": "Upload Model",
|
||||||
|
"chooseFile": "Choose File",
|
||||||
|
"sort": {
|
||||||
|
"name": "Name",
|
||||||
|
"size": "Largest",
|
||||||
|
"created": "Latest created",
|
||||||
|
"modified": "Latest modified"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"extraLarge": "Extra Large Icons",
|
||||||
|
"large": "Large Icons",
|
||||||
|
"medium": "Medium Icons",
|
||||||
|
"small": "Small Icons",
|
||||||
|
"custom": "Custom Size",
|
||||||
|
"customTip": "Set in `Settings > Model Manager > UI`"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"type": "Model Type",
|
||||||
|
"pathIndex": "Directory",
|
||||||
|
"basename": "File Name",
|
||||||
|
"sizeBytes": "File Size",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"updatedAt": "Updated At"
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"cardHeight": "Card Height",
|
||||||
|
"cardWidth": "Card Width",
|
||||||
|
"scan": "Scan",
|
||||||
|
"scanMissing": "Download missing information or preview",
|
||||||
|
"scanAll": "Override all models' information and preview",
|
||||||
|
"includeHiddenFiles": "Include hidden files(start with .)",
|
||||||
|
"excludeScanTypes": "Exclude scan types (separate with commas)",
|
||||||
|
"ui": "UI",
|
||||||
|
"cardSize": "Card Size",
|
||||||
|
"useFlatUI": "Flat Layout"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/locales/zh.json
Normal file
77
src/locales/zh.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"model": "模型",
|
||||||
|
"modelManager": "模型管理器",
|
||||||
|
"openModelManager": "打开模型管理器",
|
||||||
|
"searchModels": "搜索模型",
|
||||||
|
"modelCopied": "模型节点已拷贝",
|
||||||
|
"download": "下载",
|
||||||
|
"downloadList": "下载列表",
|
||||||
|
"downloadTask": "下载任务",
|
||||||
|
"createDownloadTask": "创建下载任务",
|
||||||
|
"parseModelUrl": "解析模型URL",
|
||||||
|
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteAsk": "确定要删除此{0}?",
|
||||||
|
"modelType": "模型类型",
|
||||||
|
"default": "默认",
|
||||||
|
"network": "网络",
|
||||||
|
"local": "本地",
|
||||||
|
"none": "无",
|
||||||
|
"uploadFile": "上传文件",
|
||||||
|
"tapToChange": "点击描述可更改内容",
|
||||||
|
"name": "名称",
|
||||||
|
"width": "宽度",
|
||||||
|
"height": "高度",
|
||||||
|
"reset": "重置",
|
||||||
|
"back": "返回",
|
||||||
|
"next": "下一步",
|
||||||
|
"batchScanModelInformation": "批量扫描模型信息",
|
||||||
|
"modelInformationScanning": "扫描模型信息",
|
||||||
|
"selectModelType": "选择模型类型",
|
||||||
|
"selectSubdirectory": "选择子目录",
|
||||||
|
"scanModelInformation": "扫描模型信息",
|
||||||
|
"selectedAllPaths": "已选所有模型路径",
|
||||||
|
"selectedSpecialPath": "已选指定路径",
|
||||||
|
"scanMissInformation": "下载缺失信息",
|
||||||
|
"scanFullInformation": "覆盖所有信息",
|
||||||
|
"noModelsInCurrentPath": "当前路径中没有可用的模型",
|
||||||
|
"uploadModel": "上传模型",
|
||||||
|
"chooseFile": "选择文件",
|
||||||
|
"sort": {
|
||||||
|
"name": "名称",
|
||||||
|
"size": "最大",
|
||||||
|
"created": "最新创建",
|
||||||
|
"modified": "最新修改"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"extraLarge": "超大图标",
|
||||||
|
"large": "大图标",
|
||||||
|
"medium": "中等图标",
|
||||||
|
"small": "小图标",
|
||||||
|
"custom": "自定义尺寸",
|
||||||
|
"customTip": "在 `设置 > 模型管理器 > 外观` 中设置"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"type": "类型",
|
||||||
|
"pathIndex": "目录",
|
||||||
|
"basename": "文件名",
|
||||||
|
"sizeBytes": "文件大小",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"updatedAt": "更新时间"
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"apiKey": "密钥",
|
||||||
|
"cardHeight": "卡片高度",
|
||||||
|
"cardWidth": "卡片宽度",
|
||||||
|
"scan": "扫描",
|
||||||
|
"scanMissing": "下载缺失的信息或预览图片",
|
||||||
|
"scanAll": "覆盖所有模型信息和预览图片",
|
||||||
|
"includeHiddenFiles": "包含隐藏文件(以 . 开头的文件或文件夹)",
|
||||||
|
"excludeScanTypes": "排除扫描类型(使用英文逗号隔开)",
|
||||||
|
"ui": "外观",
|
||||||
|
"cardSize": "卡片尺寸",
|
||||||
|
"useFlatUI": "展平布局"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,3 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comfy-modal {
|
|
||||||
z-index: 3000;
|
|
||||||
}
|
|
||||||
|
|||||||
11
src/types/global.d.ts
vendored
11
src/types/global.d.ts
vendored
@@ -1,6 +1,10 @@
|
|||||||
declare namespace ComfyAPI {
|
declare namespace ComfyAPI {
|
||||||
namespace api {
|
namespace api {
|
||||||
class ComfyApi {
|
class ComfyApiEvent {
|
||||||
|
getSystemStats: () => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComfyApi extends ComfyApiEvent {
|
||||||
socket: WebSocket
|
socket: WebSocket
|
||||||
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
|
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
|
||||||
addEventListener: (
|
addEventListener: (
|
||||||
@@ -8,6 +12,11 @@ declare namespace ComfyAPI {
|
|||||||
callback: (event: CustomEvent) => void,
|
callback: (event: CustomEvent) => void,
|
||||||
options?: AddEventListenerOptions,
|
options?: AddEventListenerOptions,
|
||||||
) => void
|
) => void
|
||||||
|
removeEventListener: (
|
||||||
|
type: string,
|
||||||
|
callback: (event: CustomEvent) => void,
|
||||||
|
options?: AddEventListenerOptions,
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const api: ComfyApi
|
const api: ComfyApi
|
||||||
|
|||||||
11
src/types/typings.d.ts
vendored
11
src/types/typings.d.ts
vendored
@@ -21,11 +21,22 @@ export interface Model extends BaseModel {
|
|||||||
children?: Model[]
|
children?: Model[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionModelFile {
|
||||||
|
id: number
|
||||||
|
sizeKB: number
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
metadata: Record<string, string>
|
||||||
|
hashes: Record<string, string>
|
||||||
|
downloadUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface VersionModel extends BaseModel {
|
export interface VersionModel extends BaseModel {
|
||||||
shortname: string
|
shortname: string
|
||||||
downloadPlatform: string
|
downloadPlatform: string
|
||||||
downloadUrl: string
|
downloadUrl: string
|
||||||
hashes?: Record<string, string>
|
hashes?: Record<string, string>
|
||||||
|
files?: VersionModelFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WithResolved<T> = Omit<T, 'preview'> & {
|
export type WithResolved<T> = Omit<T, 'preview'> & {
|
||||||
|
|||||||
53
src/utils/media.ts
Normal file
53
src/utils/media.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Media file utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = [
|
||||||
|
'.mp4',
|
||||||
|
'.webm',
|
||||||
|
'.mov',
|
||||||
|
'.avi',
|
||||||
|
'.mkv',
|
||||||
|
'.flv',
|
||||||
|
'.wmv',
|
||||||
|
'.m4v',
|
||||||
|
'.ogv',
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIDEO_HOST_PATTERNS = [
|
||||||
|
'/video', // Civitai video URLs often end with /video
|
||||||
|
'type=video', // URLs with video type parameter
|
||||||
|
'format=video', // URLs with video format parameter
|
||||||
|
'video.civitai.com', // Civitai video domain
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a URL points to a video based on extension or URL patterns
|
||||||
|
* @param url - The URL to check
|
||||||
|
* @param localContentType - Optional MIME type for local files
|
||||||
|
*/
|
||||||
|
export const isVideoUrl = (url: string, localContentType?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
// For local files with known MIME type
|
||||||
|
if (localContentType && localContentType.startsWith('video/')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlLower = url.toLowerCase()
|
||||||
|
|
||||||
|
// First check if URL ends with a video extension
|
||||||
|
for (const ext of VIDEO_EXTENSIONS) {
|
||||||
|
if (urlLower.endsWith(ext)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL contains a video extension anywhere (for complex URLs like Civitai)
|
||||||
|
if (VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific video hosting patterns
|
||||||
|
return VIDEO_HOST_PATTERNS.some((pattern) => urlLower.includes(pattern))
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": false,
|
"strict": false,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default defineConfig({
|
|||||||
outDir: 'web',
|
outDir: 'web',
|
||||||
minify: 'esbuild',
|
minify: 'esbuild',
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// Disabling tree-shaking
|
// Disabling tree-shaking
|
||||||
// Prevent vite remove unused exports
|
// Prevent vite remove unused exports
|
||||||
|
|||||||
Reference in New Issue
Block a user