Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88a77f224 | ||
|
|
f3de2006ef | ||
|
|
0295dd6288 | ||
|
|
4f9a437725 | ||
|
|
815a483cf0 | ||
|
|
ae37765017 | ||
|
|
ebef300279 | ||
|
|
38cd328e57 | ||
|
|
71a200ed5c | ||
|
|
c96a164f68 | ||
|
|
0ae0716272 | ||
|
|
b692270f87 | ||
|
|
a9675a5d83 | ||
|
|
ac4a168f13 | ||
|
|
8b9f3a0e65 | ||
|
|
8d7e32eaf6 | ||
|
|
e964f26798 | ||
|
|
3cfbb5ac0e | ||
|
|
4472357537 | ||
|
|
aabf3f99b3 | ||
|
|
6bd6b19c1d |
2
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -20,10 +20,10 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
[Operating System]:
|
[Operating System]:
|
||||||
[Python Version]:
|
[Python Version]:
|
||||||
[Is Electron]:
|
|
||||||
[ComfyUI Version]:
|
[ComfyUI Version]:
|
||||||
[ComfyUI Frontend Version]:
|
[ComfyUI Frontend Version]:
|
||||||
[ComfyUI-Model-Manager Version]:
|
[ComfyUI-Model-Manager Version]:
|
||||||
|
[Browser Version]:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -61,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,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"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -69,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
|
||||||
|
|
||||||
@@ -108,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,
|
||||||
@@ -122,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)
|
||||||
|
|
||||||
@@ -350,23 +355,17 @@ 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))
|
||||||
filename = request.match_info.get("filename", None)
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
content_type = utils.resolve_file_content_type(filename)
|
|
||||||
|
|
||||||
if content_type == "video":
|
|
||||||
abs_path = utils.get_full_path(model_type, index, filename)
|
|
||||||
return web.FileResponse(abs_path)
|
|
||||||
|
|
||||||
extension_uri = config.extension_uri
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -383,8 +382,16 @@ class Information:
|
|||||||
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")
|
||||||
|
|
||||||
image_data = self.get_image_preview_data(abs_path)
|
# Determine content type from the actual file
|
||||||
return web.Response(body=image_data.getvalue(), content_type="image/webp")
|
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):
|
||||||
@@ -541,10 +548,10 @@ class Information:
|
|||||||
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:
|
||||||
|
|||||||
@@ -131,23 +131,16 @@ 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
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
if not os.path.exists(entry.path):
|
||||||
|
utils.print_error(f"{entry.path} is not file or directory.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
preview_type = "image"
|
|
||||||
preview_ext = ".webp"
|
|
||||||
preview_images = utils.get_model_all_images(entry.path)
|
|
||||||
if len(preview_images) > 0:
|
|
||||||
preview_type = "image"
|
|
||||||
preview_ext = ".webp"
|
|
||||||
else:
|
|
||||||
preview_videos = utils.get_model_all_videos(entry.path)
|
|
||||||
if len(preview_videos) > 0:
|
|
||||||
preview_type = "video"
|
|
||||||
preview_ext = f".{preview_videos[0].split('.')[-1]}"
|
|
||||||
|
|
||||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
|
||||||
|
|
||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
return {
|
return {
|
||||||
"type": folder,
|
"type": folder,
|
||||||
@@ -157,36 +150,45 @@ 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,
|
||||||
"previewType": preview_type,
|
|
||||||
"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),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_all_files_entry(directory: str):
|
def get_all_files_entry(directory: str):
|
||||||
entries: list[os.DirEntry[str]] = []
|
entries: list[os.DirEntry[str]] = []
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
return []
|
||||||
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
|
||||||
|
|
||||||
@@ -211,10 +213,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"]
|
||||||
@@ -236,7 +239,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)
|
||||||
205
py/utils.py
205
py/utils.py
@@ -17,6 +17,25 @@ from aiohttp import web
|
|||||||
from typing import Any, Optional
|
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)
|
||||||
@@ -27,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())
|
||||||
|
|
||||||
|
|
||||||
@@ -252,95 +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"""
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
|
||||||
|
|
||||||
for image in images:
|
|
||||||
image_name = os.path.splitext(image)[0]
|
|
||||||
image_ext = os.path.splitext(image)[1]
|
|
||||||
if image_name == basename and image_ext.lower() == ".webp":
|
|
||||||
return image
|
|
||||||
|
|
||||||
return images[0] if len(images) > 0 else "no-preview.png"
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_videos(model_path: str):
|
|
||||||
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, ["video"])
|
|
||||||
|
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
output: list[str] = []
|
|
||||||
for file in files:
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
file_basename = os.path.splitext(file)[0]
|
# Check direct match first
|
||||||
if file_basename == basename:
|
preview_name = f"{basename}{ext}"
|
||||||
output.append(file)
|
if os.path.isfile(join_path(base_dirname, preview_name)):
|
||||||
if file_basename == f"{basename}.preview":
|
return preview_name
|
||||||
output.append(file)
|
|
||||||
return output
|
# 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: Optional[str] = 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):
|
||||||
@@ -398,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.6.0"
|
version = "2.8.3"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
|
|||||||
46
src/App.vue
46
src/App.vue
@@ -10,6 +10,7 @@ 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 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'
|
||||||
@@ -64,8 +65,40 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openUploadDialog = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-manager-upload',
|
||||||
|
title: t('uploadModel'),
|
||||||
|
content: DialogUpload,
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: 'pi pi-refresh',
|
||||||
|
command: refreshModelsAndConfig,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLayout = () => {
|
||||||
|
// flip the flat setting
|
||||||
|
const newValue = !config.flat.value
|
||||||
|
config.flat.value = newValue
|
||||||
|
|
||||||
|
// persist so it survives reloads
|
||||||
|
app.ui?.settings.setSettingValue('ModelManager.UI.Flat', newValue)
|
||||||
|
|
||||||
|
// close the current dialog (because it is keepAlive)
|
||||||
|
dialog.closeAll()
|
||||||
|
|
||||||
|
// reopen with the new layout
|
||||||
|
openManagerDialog()
|
||||||
|
}
|
||||||
|
|
||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect, flat } = config
|
const { cardWidth, gutter, aspect, flat } = config
|
||||||
|
// choose icon depending on current layout
|
||||||
|
const layoutIcon = flat.value ? 'pi pi-folder-open' : 'pi pi-th-large'
|
||||||
|
|
||||||
if (firstOpenManager.value) {
|
if (firstOpenManager.value) {
|
||||||
models.refresh(true)
|
models.refresh(true)
|
||||||
@@ -83,6 +116,14 @@ onMounted(() => {
|
|||||||
icon: 'mdi mdi-folder-search-outline text-lg',
|
icon: 'mdi mdi-folder-search-outline text-lg',
|
||||||
command: openModelScanning,
|
command: openModelScanning,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'toggle-layout',
|
||||||
|
icon: layoutIcon,
|
||||||
|
command: toggleLayout,
|
||||||
|
tooltip: flat.value
|
||||||
|
? t('switchToFolderView')
|
||||||
|
: t('switchToFlatView'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'refresh',
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
@@ -93,6 +134,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'
|
||||||
|
|
||||||
|
|||||||
@@ -225,17 +225,33 @@ const list = computed(() => {
|
|||||||
return !item.isFolder
|
return !item.isFolder
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterList = pureModels.filter((model) => {
|
function buildRegex(raw: string): RegExp {
|
||||||
const showAllModel = currentType.value === allType
|
try {
|
||||||
|
// Escape regex specials, then restore * wildcards as .*
|
||||||
|
const escaped = raw
|
||||||
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\\\*/g, '.*')
|
||||||
|
return new RegExp(escaped, 'i') // case-insensitive
|
||||||
|
} catch (e) {
|
||||||
|
return new RegExp(raw, 'i')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
const filterList = pureModels.filter((model) => {
|
||||||
|
const showAllModel = currentType.value === allType
|
||||||
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
|
|
||||||
const filter = searchContent.value?.toLowerCase() ?? ''
|
const rawFilter = searchContent.value ?? ''
|
||||||
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
|
const tokens = rawFilter.split(/\s+/).filter(Boolean)
|
||||||
const matchName = model.basename.toLowerCase().includes(filter)
|
const regexes = tokens.map(buildRegex)
|
||||||
|
|
||||||
return matchType && (matchSubFolder || matchName)
|
// Require every token to match either the folder or the name
|
||||||
})
|
const matchesAll = regexes.every((re) =>
|
||||||
|
re.test(model.subFolder) || re.test(model.basename)
|
||||||
|
)
|
||||||
|
|
||||||
|
return matchType && matchesAll
|
||||||
|
})
|
||||||
|
|
||||||
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
||||||
switch (sortOrder.value) {
|
switch (sortOrder.value) {
|
||||||
@@ -262,6 +278,7 @@ const list = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const contentStyle = computed(() => ({
|
const contentStyle = computed(() => ({
|
||||||
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
||||||
gap: `${gutter}px`,
|
gap: `${gutter}px`,
|
||||||
|
|||||||
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>
|
||||||
@@ -25,19 +25,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="model.previewType === 'video'"
|
v-else-if="isVideoUrl(preview)"
|
||||||
class="h-full w-full p-1 hover:p-0"
|
class="h-full w-full p-1 hover:p-0"
|
||||||
>
|
>
|
||||||
<video
|
<PreviewVideo :src="preview" />
|
||||||
class="h-full w-full object-cover"
|
|
||||||
playsinline
|
|
||||||
autoplay
|
|
||||||
loop
|
|
||||||
disablepictureinpicture
|
|
||||||
preload="none"
|
|
||||||
>
|
|
||||||
<source :src="preview" />
|
|
||||||
</video>
|
|
||||||
</div>
|
</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" />
|
||||||
@@ -81,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,17 +5,17 @@
|
|||||||
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` })"
|
||||||
>
|
>
|
||||||
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
|
<div
|
||||||
<video
|
v-if="
|
||||||
class="h-full w-full object-cover"
|
preview &&
|
||||||
playsinline
|
isVideoUrl(
|
||||||
autoplay
|
preview,
|
||||||
loop
|
currentType === 'local' ? localContentType : undefined,
|
||||||
disablepictureinpicture
|
)
|
||||||
preload="none"
|
"
|
||||||
>
|
class="h-full w-full p-1 hover:p-0"
|
||||||
<source :src="preview" />
|
>
|
||||||
</video>
|
<PreviewVideo :src="preview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResponseImage
|
<ResponseImage
|
||||||
@@ -48,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>
|
||||||
@@ -98,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'
|
||||||
@@ -106,13 +114,13 @@ 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()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
preview,
|
preview,
|
||||||
previewType,
|
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
defaultContent,
|
defaultContent,
|
||||||
@@ -120,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>
|
||||||
@@ -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,6 +1,9 @@
|
|||||||
|
import SettingApiKey from 'components/SettingApiKey.vue'
|
||||||
import SettingCardSize from 'components/SettingCardSize.vue'
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { app } 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'
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ export const useConfig = defineStore('config', (store) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
flat: flatLayout,
|
flat: flatLayout,
|
||||||
|
apiKeyInfo: ref<Record<string, string>>({}),
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(cardSizeFlag, (val) => {
|
watch(cardSizeFlag, (val) => {
|
||||||
@@ -97,6 +101,84 @@ export const configSetting = {
|
|||||||
|
|
||||||
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { confirm } = useToast()
|
||||||
|
|
||||||
|
const iconButton = (opt: {
|
||||||
|
icon: string
|
||||||
|
onClick: () => void | Promise<void>
|
||||||
|
}) => {
|
||||||
|
return $el(
|
||||||
|
'span.h-4.cursor-pointer',
|
||||||
|
{ onclick: opt.onClick },
|
||||||
|
$el(`i.${opt.icon.replace(/\s/g, '.')}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -104,16 +186,16 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const useModelExplorer = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
metadata: {},
|
metadata: {},
|
||||||
preview: '',
|
preview: '',
|
||||||
previewType: 'image',
|
|
||||||
type: folder ?? '',
|
type: folder ?? '',
|
||||||
isFolder: true,
|
isFolder: true,
|
||||||
children: [],
|
children: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -579,16 +589,13 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
return content
|
return content
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewType = computed(() => {
|
|
||||||
return model.value.previewType
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerReset(() => {
|
registerReset(() => {
|
||||||
currentType.value = 'default'
|
currentType.value = 'default'
|
||||||
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) => {
|
||||||
@@ -598,7 +605,6 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
preview,
|
preview,
|
||||||
previewType,
|
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
// default value
|
// default value
|
||||||
@@ -608,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,
|
||||||
@@ -717,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,
|
||||||
)
|
)
|
||||||
|
|||||||
155
src/i18n.ts
155
src/i18n.ts
@@ -1,157 +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',
|
|
||||||
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',
|
|
||||||
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: '重置',
|
|
||||||
back: '返回',
|
|
||||||
next: '下一步',
|
|
||||||
batchScanModelInformation: '批量扫描模型信息',
|
|
||||||
modelInformationScanning: '扫描模型信息',
|
|
||||||
selectModelType: '选择模型类型',
|
|
||||||
selectSubdirectory: '选择子目录',
|
|
||||||
scanModelInformation: '扫描模型信息',
|
|
||||||
selectedAllPaths: '已选所有模型路径',
|
|
||||||
selectedSpecialPath: '已选指定路径',
|
|
||||||
scanMissInformation: '下载缺失信息',
|
|
||||||
scanFullInformation: '覆盖所有信息',
|
|
||||||
noModelsInCurrentPath: '当前路径中没有可用的模型',
|
|
||||||
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
|
||||||
|
|||||||
12
src/types/typings.d.ts
vendored
12
src/types/typings.d.ts
vendored
@@ -11,7 +11,6 @@ export interface BaseModel {
|
|||||||
pathIndex: number
|
pathIndex: number
|
||||||
isFolder: boolean
|
isFolder: boolean
|
||||||
preview: string | string[]
|
preview: string | string[]
|
||||||
previewType: string
|
|
||||||
description: string
|
description: string
|
||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
}
|
}
|
||||||
@@ -22,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user