Compare commits

...
21 Commits
Author SHA1 Message Date
Hayden 39e4581558 Prepare release 2.8.4
update repository references from 'hayden-fr' to 'hayden-cn'
2026-05-27 13:04:38 +08:00
hayden c55ee62ac9 fix: update repository references from 'hayden-fr' to 'hayden-cn' 2026-05-27 13:01:33 +08:00
Hayden 33c43085c4 [feat]dialog header action tooltip support (#219)
* fix: remove duplicate variables resulting from merging

* feat(dialog): add tooltip support for header buttons
2026-02-26 15:23:40 +08:00
AinaemaetandHayden dc1d12649a feat(ui): add hidden-files toggle and i18n updates (#214)
* feat(search): add multi-token regex and wildcard support

* feat(ui): add layout toggle button in Model Manager header

* feat(ui): add hidden-files toggle and i18n updates

- Added a new header button in the Model Manager to toggle display of
hidden files/folders (dot-prefixed). This updates the existing
`ModelManager.Scan.IncludeHiddenFiles` setting, persists it, and
refreshes the dialog so changes apply immediately.
- Button shows dynamic icon + tooltip:
  - 👁 "Show hidden files"
  - 👁‍🗨 "Hide hidden files"

- Updated i18n locale files:
  - en.json: added strings for both the layout toggle and hidden-files toggle
  - zh.json: added translations for the same strings

Note: normally these i18n keys would have been split between the layout
commit and this one, but they are grouped here for simplicity so all UI
strings for the new header buttons are introduced together.

---------

Co-authored-by: Hayden <48267247+hayden-fr@users.noreply.github.com>
2026-02-26 15:09:26 +08:00
moldwebs 0fdbc5d7be Direct file download (#215) 2026-02-26 14:37:51 +08:00
Ainaemaet 60837ae35f feat(ui): add layout toggle + hidden toggle, cleanup and formatting (#213) 2026-02-26 14:24:50 +08:00
Ainaemaet e88a77f224 feat(search): add multi-token regex and wildcard support (#211)
* feat(search): add multi-token regex and wildcard support

* feat(ui): add layout toggle button in Model Manager header
2025-09-24 15:18:25 +08:00
Hayden f3de2006ef Prepare release 2.8.3 2025-09-05 16:53:05 +08:00
Hayden 0295dd6288 fix: Validate existence of entry path after improvements previews (#205) 2025-09-05 16:51:54 +08:00
Koro 4f9a437725 Improvements in previews reading (#204)
* Improve model preview handling and optimize file processing

* increate the version
2025-09-05 16:45:51 +08:00
Hayden 815a483cf0 Prepare release 2.8.1 2025-09-03 15:14:07 +08:00
Hayden ae37765017 fix: Add error message tag (#203) 2025-09-03 15:13:08 +08:00
Hayden ebef300279 fix: Validate existence of entry path in model preview generation (#202) 2025-09-03 15:06:50 +08:00
Hayden 38cd328e57 Prepare release 2.8.0 2025-08-15 10:13:10 +08:00
Kevin Lewis 71a200ed5c add support for video previews (#197)
* add support for video previews

* fix two cases where video previews did not show
2025-08-15 10:12:13 +08:00
Hayden c96a164f68 Prepare release 2.7.0 2025-08-11 16:59:45 +08:00
Hayden 0ae0716272 fix: Ensure downloadable files are available for model versions without model files (#199) 2025-08-11 10:51:18 +08:00
Hayden b692270f87 perf: Reconstruct the i18n directory structure (#198) 2025-08-11 10:38:40 +08:00
Hayden a9675a5d83 feat: Add model upload functionality (#194) 2025-08-11 09:10:39 +08:00
Hayden ac4a168f13 feat: support download multiple actual files (#196) 2025-08-11 09:10:20 +08:00
Hayden 8b9f3a0e65 191 windows path is wrong when dragging to create lora node from manager (#195)
* fix: match system path style

* fix: only match dragToAddModelNode
2025-08-11 09:09:51 +08:00
37 changed files with 1492 additions and 349 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
publish-node:
name: Release and Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hayden-fr' }}
if: ${{ github.repository_owner == 'hayden-cn' }}
steps:
- name: Check out code
uses: actions/checkout@v4
+2 -1
View File
@@ -1,5 +1,6 @@
{
"recommendations": [
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"lokalise.i18n-ally"
]
}
+6 -1
View File
@@ -43,5 +43,10 @@
"editor.quickSuggestions": {
"strings": "on"
},
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"i18n-ally.localesPaths": [
"src/locales"
],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.keystyle": "nested"
}
+2 -2
View File
@@ -8,8 +8,8 @@ Designed to support desktop, mobile and multi-screen devices.
There are three installation methods, choose one
1. Clone the repository: `git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
2. Download the [latest release](https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
1. Clone the repository: `git clone https://github.com/hayden-cn/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
2. Download the [latest release](https://github.com/hayden-cn/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
## Features
+2
View File
@@ -41,12 +41,14 @@ utils.download_web_distribution(version)
from .py import manager
from .py import download
from .py import information
from .py import upload
routes = config.routes
manager.ModelManager().add_routes(routes)
download.ModelDownload().add_routes(routes)
information.Information().add_routes(routes)
upload.ModelUploader().add_routes(routes)
WEB_DIRECTORY = "web"
+1
View File
@@ -35,6 +35,7 @@
"@primevue/themes": "^4.0.7",
"@vueuse/core": "^11.3.0",
"dayjs": "^1.11.13",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6",
+3 -9
View File
@@ -17,6 +17,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.13
js-yaml:
specifier: ^4.1.0
version: 4.1.0
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -405,55 +408,46 @@ packages:
resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.22.0':
resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.22.0':
resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.22.0':
resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.22.0':
resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.22.0':
resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.22.0':
resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.22.0':
resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.22.0':
resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.22.0':
resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==}
+1 -1
View File
@@ -326,7 +326,7 @@ class ModelDownload:
try:
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)
task_status = TaskStatus(
taskId=task_id,
+24 -17
View File
@@ -69,8 +69,12 @@ class CivitaiModelSearcher(ModelSearcher):
models: list[dict] = []
for version in model_versions:
model_files: list[dict] = version.get("files", [])
model_files = utils.filter_with(model_files, {"type": "Model"})
version_files: list[dict] = version.get("files", [])
model_files = utils.filter_with(version_files, {"type": "Model"})
# issue: https://github.com/hayden-cn/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
@@ -108,7 +112,7 @@ class CivitaiModelSearcher(ModelSearcher):
description_parts.append("")
model = {
"id": file.get("id"),
"id": version.get("id"),
"shortname": shortname or basename,
"basename": basename,
"extension": extension,
@@ -122,6 +126,7 @@ class CivitaiModelSearcher(ModelSearcher):
"downloadPlatform": "civitai",
"downloadUrl": file.get("downloadUrl"),
"hashes": file.get("hashes"),
"files": version_files if len(version_files) > 1 else None,
}
models.append(model)
@@ -350,23 +355,17 @@ class Information:
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
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.
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
: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)
index = int(request.match_info.get("index", 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
try:
@@ -383,8 +382,16 @@ class Information:
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
image_data = self.get_image_preview_data(abs_path)
return web.Response(body=image_data.getvalue(), content_type="image/webp")
# Determine content type from the actual file
content_type = utils.resolve_file_content_type(abs_path)
if content_type == "video":
# Serve video files directly
return web.FileResponse(abs_path)
else:
# Serve image files (WebP or fallback images)
image_data = self.get_image_preview_data(abs_path)
return web.Response(body=image_data.getvalue(), content_type="image/webp")
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
@@ -541,10 +548,10 @@ class Information:
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
preview_url_list = model_info.get("preview", [])
preview_image_url = preview_url_list[0] if preview_url_list else None
if preview_image_url:
utils.print_debug(f"Save preview image to {abs_image_path}")
utils.save_model_preview_image(abs_model_path, preview_image_url)
preview_url = preview_url_list[0] if preview_url_list else None
if preview_url:
utils.print_debug(f"Save preview to {abs_model_path}")
utils.save_model_preview(abs_model_path, preview_url)
description = model_info.get("description", None)
if description:
+38 -35
View File
@@ -131,23 +131,16 @@ class ModelManager:
basename = os.path.splitext(filename)[0] if is_file else filename
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
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()
return {
"type": folder,
@@ -157,36 +150,45 @@ class ModelManager:
"extension": extension,
"pathIndex": path_index,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"previewType": preview_type,
"preview": model_preview,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
entries: list[os.DirEntry[str]] = []
if not os.path.exists(directory):
return []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
entries.append(entry)
if entry.is_dir():
if not include_hidden_files and entry.name.startswith("."):
continue
if entry.is_file():
extension = os.path.splitext(entry.name)[1]
if extension in folder_paths.supported_pt_extensions:
entries.append(entry)
else:
entries.append(entry)
entries.extend(get_all_files_entry(entry.path))
return entries
BATCH_SIZE = 200
MAX_WORKERS = min(4, os.cpu_count() or 1)
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):
continue
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 future in as_completed(futures):
file_info = future.result()
if file_info is None:
continue
result.append(file_info)
for i in range(0, len(file_entries), BATCH_SIZE):
batch = file_entries[i:i + BATCH_SIZE]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in batch}
for future in as_completed(futures):
file_info = future.result()
if file_info is not None:
result.append(file_info)
return result
@@ -211,10 +213,11 @@ class ModelManager:
if "previewFile" in model_data:
previewFile = model_data["previewFile"]
if type(previewFile) is str and previewFile == "undefined":
utils.remove_model_preview_image(model_path)
else:
utils.save_model_preview_image(model_path, previewFile)
# Always remove existing preview files first in case the file extension has changed
utils.remove_model_preview(model_path)
# Nothing else to do if the preview file was being removed
if not (type(previewFile) is str and previewFile == "undefined"):
utils.save_model_preview(model_path, previewFile)
if "description" in model_data:
description = model_data["description"]
@@ -236,7 +239,7 @@ class ModelManager:
model_dirname = os.path.dirname(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:
os.remove(utils.join_path(model_dirname, preview))
+79
View 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)
+139 -70
View File
@@ -17,6 +17,25 @@ from aiohttp import web
from typing import Any, Optional
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):
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):
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
logging.error(f"[{config.extension_tag}][ERROR] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc())
@@ -107,7 +126,7 @@ def download_web_distribution(version: str):
try:
print_info(f"current version {version}, web version {web_version}")
print_info("Downloading web distribution...")
download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz"
download_url = f"https://github.com/hayden-cn/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz"
response = requests.get(download_url, stream=True)
response.raise_for_status()
@@ -252,95 +271,145 @@ def get_model_metadata(filename: str):
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)
files = search_files(base_dirname)
files = folder_paths.filter_files_content_types(files, ["image"])
basename = os.path.splitext(os.path.basename(model_path))[0]
output: list[str] = []
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
return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
def get_model_preview_name(model_path: str):
images = get_model_all_images(model_path)
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):
def get_model_preview_name(model_path: str) -> str:
"""Get the first available preview file or 'no-preview.png' if none found"""
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]
output: list[str] = []
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
for ext in PREVIEW_EXTENSIONS:
# Check direct match first
preview_name = f"{basename}{ext}"
if os.path.isfile(join_path(base_dirname, preview_name)):
return preview_name
# 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 io import BytesIO
def remove_model_preview_image(model_path: str):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
if os.path.exists(preview_path):
os.remove(preview_path)
def remove_model_preview(model_path: str):
"""Remove all preview files for a model"""
base_dirname = os.path.dirname(model_path)
basename = os.path.splitext(os.path.basename(model_path))[0]
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):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
# Download image file if it is url
if type(image_file_or_url) is str:
image_url = image_file_or_url
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
"""Save a preview file for a model. Images -> WebP, videos -> original format"""
# Download file if it is a URL
if type(file_or_url) is str:
url = file_or_url
try:
image_response = requests.get(image_url)
image_response.raise_for_status()
image = Image.open(BytesIO(image_response.content))
image.save(preview_path, "WEBP")
response = requests.get(url)
response.raise_for_status()
# Determine content type from response headers or URL extension
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:
print_error(f"Failed to download image: {e}")
print_error(f"Failed to download preview: {e}")
# Handle uploaded file
else:
# Assert image as file
image_file = image_file_or_url
file_obj = file_or_url
if not isinstance(image_file, web.FileField):
raise RuntimeError("Invalid image file")
if not isinstance(file_obj, web.FileField):
raise RuntimeError("Invalid file")
content_type: str = image_file.content_type
if not content_type.startswith("image/"):
if platform == "huggingface":
# huggingface previewFile content_type='text/plain', not startswith("image/")
return
else:
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
image = Image.open(image_file.file)
image.save(preview_path, "WEBP")
content_type: str = file_obj.content_type
filename: str = getattr(file_obj, 'filename', '')
if content_type.startswith("video/"):
# Save video in original format for now, consider transcoding to webm to follow the pattern for images converting to webp
ext = os.path.splitext(filename.lower())[1] or '.mp4'
preview_path = _get_preview_path(model_path, ext)
file_obj.file.seek(0)
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):
@@ -398,7 +467,7 @@ def rename_model(model_path: str, new_model_path: str):
shutil.move(model_path, new_model_path)
# move preview
previews = get_model_all_images(model_path)
previews = get_model_all_previews(model_path)
for preview in previews:
preview_path = join_path(model_dirname, preview)
preview_name = os.path.splitext(preview)[0]
+2 -2
View File
@@ -1,12 +1,12 @@
[project]
name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete."
version = "2.6.3"
version = "2.8.4"
license = { file = "LICENSE" }
dependencies = ["markdownify"]
[project.urls]
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
Repository = "https://github.com/hayden-cn/ComfyUI-Model-Manager"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
+63
View File
@@ -10,6 +10,7 @@ import DialogDownload from 'components/DialogDownload.vue'
import DialogExplorer from 'components/DialogExplorer.vue'
import DialogManager from 'components/DialogManager.vue'
import DialogScanning from 'components/DialogScanning.vue'
import DialogUpload from 'components/DialogUpload.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
import GlobalToast from 'components/GlobalToast.vue'
@@ -64,9 +65,45 @@ 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 = () => {
const newValue = !config.flat.value
config.flat.value = newValue
app.ui?.settings.setSettingValue('ModelManager.UI.Flat', newValue)
dialog.closeAll()
openManagerDialog()
}
const openManagerDialog = () => {
const { cardWidth, gutter, aspect, flat } = config
const layoutIcon = flat.value ? 'pi pi-th-large' : 'pi pi-folder-open'
const includeHidden =
app.ui?.settings.getSettingValue(
'ModelManager.Scan.IncludeHiddenFiles',
) ?? false
const hiddenIcon = includeHidden ? 'pi pi-eye' : 'pi pi-eye-slash'
const hiddenTooltip = includeHidden
? t('hideHiddenFiles')
: t('showHiddenFiles')
if (firstOpenManager.value) {
models.refresh(true)
firstOpenManager.value = false
@@ -83,6 +120,27 @@ onMounted(() => {
icon: 'mdi mdi-folder-search-outline text-lg',
command: openModelScanning,
},
{
key: 'toggle-layout',
icon: layoutIcon,
command: toggleLayout,
tooltip: flat.value ? t('switchToFolderView') : t('switchToFlatView'),
},
{
key: 'toggle-hidden',
icon: hiddenIcon,
command: async () => {
const newValue = !includeHidden
app.ui?.settings.setSettingValue(
'ModelManager.Scan.IncludeHiddenFiles',
newValue,
)
await refreshModelsAndConfig() // ensure updated model list
dialog.closeAll()
openManagerDialog()
},
tooltip: hiddenTooltip,
},
{
key: 'refresh',
icon: 'pi pi-refresh',
@@ -93,6 +151,11 @@ onMounted(() => {
icon: 'pi pi-download',
command: openDownloadDialog,
},
{
key: 'upload',
icon: 'pi pi-upload',
command: openUploadDialog,
},
],
minWidth: cardWidth * 2 + gutter + 42,
minHeight: (cardWidth / aspect) * 0.5 + 162,
+177 -4
View File
@@ -14,6 +14,27 @@
</template>
</ResponseInput>
<!-- Direct file URL indicator with folder selection -->
<div v-if="isDirectFile && modelUrl" class="flex flex-col gap-2">
<div
class="flex items-center gap-2 rounded bg-green-50 p-2 text-sm text-green-600"
>
<i class="pi pi-check-circle"></i>
<span>Direct file download detected</span>
</div>
<!-- Model Type/Folder Selection for direct downloads -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium">{{ $t('modelType') }}:</label>
<ResponseSelect
v-model="selectedModelType"
:items="modelTypeOptions"
:type="'drop'"
class="flex-1"
/>
</div>
</div>
<div v-show="data.length > 0">
<ResponseSelect
v-model="current"
@@ -31,12 +52,20 @@
<KeepAlive>
<ModelContent
v-if="currentModel"
:key="currentModel.id"
:key="`${currentModel.id}-${currentModel.currentFileId}`"
:model="currentModel"
:editable="true"
@submit="createDownTask"
>
<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
icon="pi pi-download"
:label="$t('download')"
@@ -71,8 +100,13 @@ import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { VersionModel, WithResolved } from 'types/typings'
import { previewUrlToFile } from 'utils/common'
import { ref } from 'vue'
import {
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
previewUrlToFile,
} from 'utils/common'
import { computed, ref, watch } from 'vue'
const { isMobile } = useConfig()
const { toast } = useToast()
@@ -81,14 +115,153 @@ const dialog = useDialog()
const modelUrl = ref<string>()
// Model type selection for direct downloads
const selectedModelType = ref<string>('checkpoints')
const modelTypeOptions = computed(() => [
{
label: 'Checkpoints',
value: 'checkpoints',
command: () => {
selectedModelType.value = 'checkpoints'
},
},
{
label: 'LoRA',
value: 'loras',
command: () => {
selectedModelType.value = 'loras'
},
},
{
label: 'ControlNet',
value: 'controlnet',
command: () => {
selectedModelType.value = 'controlnet'
},
},
{
label: 'VAE',
value: 'vae',
command: () => {
selectedModelType.value = 'vae'
},
},
{
label: 'Embeddings',
value: 'embeddings',
command: () => {
selectedModelType.value = 'embeddings'
},
},
{
label: 'Upscale Models',
value: 'upscale_models',
command: () => {
selectedModelType.value = 'upscale_models'
},
},
{
label: 'Diffusers',
value: 'diffusers',
command: () => {
selectedModelType.value = 'diffusers'
},
},
{
label: 'CLIP',
value: 'clip',
command: () => {
selectedModelType.value = 'clip'
},
},
{
label: 'CLIP Vision',
value: 'clip_vision',
command: () => {
selectedModelType.value = 'clip_vision'
},
},
{
label: 'UNet/Diffusion Models',
value: 'diffusion_models',
command: () => {
selectedModelType.value = 'diffusion_models'
},
},
{
label: 'Style Models',
value: 'style_models',
command: () => {
selectedModelType.value = 'style_models'
},
},
{
label: 'Hypernetworks',
value: 'hypernetworks',
command: () => {
selectedModelType.value = 'hypernetworks'
},
},
{
label: 'GLIGEN',
value: 'gligen',
command: () => {
selectedModelType.value = 'gligen'
},
},
{
label: 'PhotoMaker',
value: 'photomaker',
command: () => {
selectedModelType.value = 'photomaker'
},
},
{
label: 'VAE Approx',
value: 'vae_approx',
command: () => {
selectedModelType.value = 'vae_approx'
},
},
{
label: 'Classifiers',
value: 'classifiers',
command: () => {
selectedModelType.value = 'classifiers'
},
},
])
const isDirectFile = computed(() =>
modelUrl.value ? isDirectFileUrl(modelUrl.value) : false,
)
const { current, currentModel, data, search } = useModelSearch()
const searchModelsByUrl = async () => {
if (modelUrl.value) {
await search(modelUrl.value)
const modelType = isDirectFile.value ? selectedModelType.value : undefined
await search(modelUrl.value, modelType)
}
}
// Watch for direct file URL changes and set intelligent default
watch(modelUrl, (newUrl) => {
if (newUrl && isDirectFileUrl(newUrl)) {
const filename = getFilenameFromUrl(newUrl)
const suggestedType = getModelTypeFromFilename(filename)
selectedModelType.value = suggestedType
}
})
// Watch for model type changes on direct files and refresh the model
watch(selectedModelType, async () => {
if (isDirectFile.value && modelUrl.value) {
await search(modelUrl.value, selectedModelType.value)
}
})
const createDownTask = async (data: WithResolved<VersionModel>) => {
loading.show()
+6 -1
View File
@@ -20,7 +20,10 @@
>
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
<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 class="flex flex-1 flex-col gap-3 overflow-hidden">
@@ -72,11 +75,13 @@
<script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue'
import PreviewVideo from 'components/PreviewVideo.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import { useContainerQueries } from 'hooks/container'
import { useDialog } from 'hooks/dialog'
import { useDownload } from 'hooks/download'
import Button from 'primevue/button'
import { isVideoUrl } from 'utils/media'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
+22 -5
View File
@@ -225,16 +225,32 @@ const list = computed(() => {
return !item.isFolder
})
function buildRegex(raw: string): RegExp {
try {
// Escape regex specials, then restore * wildcards as .*
const escaped = raw
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\*/g, '.*')
return new RegExp(escaped, 'i') // case-insensitive
} catch {
return new RegExp(raw, 'i')
}
}
const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const filter = searchContent.value?.toLowerCase() ?? ''
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
const matchName = model.basename.toLowerCase().includes(filter)
const rawFilter = searchContent.value ?? ''
const tokens = rawFilter.split(/\s+/).filter(Boolean)
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
@@ -262,6 +278,7 @@ const list = computed(() => {
})
})
const contentStyle = computed(() => ({
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
gap: `${gutter}px`,
+274
View File
@@ -0,0 +1,274 @@
<template>
<div class="h-full px-4">
<!-- <div v-show="batchScanningStep === 0" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full opacity-60">
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
</div>
</div>
</div> -->
<Stepper v-model:value="stepValue" class="flex h-full flex-col" linear>
<StepList>
<Step :value="1">{{ $t('selectModelType') }}</Step>
<Step :value="2">{{ $t('selectSubdirectory') }}</Step>
<Step :value="3">{{ $t('chooseFile') }}</Step>
</StepList>
<StepPanels class="flex-1 overflow-hidden">
<StepPanel :value="1" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll>
<div class="flex flex-wrap gap-4">
<Button
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
@click="item.command"
></Button>
</div>
</ResponseScroll>
</div>
</StepPanel>
<StepPanel :value="2" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll class="flex-1">
<Tree
class="h-full"
v-model:selection-keys="selectedKey"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
<div class="flex justify-between pt-6">
<Button
:label="$t('back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<Button
:label="$t('next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="!enabledUpload"
@click="handleConfirmSubdir"
></Button>
</div>
</div>
</StepPanel>
<StepPanel :value="3" class="h-full">
<div class="flex h-full flex-col items-center justify-center">
<template v-if="showUploadProgress">
<div class="w-4/5">
<ProgressBar
:value="uploadProgress"
:pt:value:style="{ transition: 'width .1s linear' }"
></ProgressBar>
</div>
</template>
<template v-else>
<div class="overflow-hidden break-words py-8">
<div class="overflow-hidden px-8">
<div class="text-center">
<div class="pb-2">
{{ $t('selectedSpecialPath') }}
</div>
<div class="leading-5 opacity-60">
{{ selectedModelFolder }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4">
<Button
v-for="item in uploadActions"
:key="item.value"
:label="item.label"
:icon="item.icon"
@click="item.command.call(item)"
></Button>
</div>
</template>
<div class="h-1/4"></div>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import ResponseScroll from 'components/ResponseScroll.vue'
import { configSetting } from 'hooks/config'
import { useModelFolder, useModels } from 'hooks/model'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import Tree from 'primevue/tree'
import { api, app } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, ref, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { toast } = useToast()
const stepValue = ref(1)
const { folders } = useModels()
const currentType = ref<string>()
const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
.map((type) => {
return {
label: type,
value: type,
command: () => {
currentType.value = type
stepValue.value++
},
}
})
})
const { pathOptions } = useModelFolder({ type: currentType })
const selectedModelFolder = ref<string>()
const selectedKey = computed({
get: () => {
const key = selectedModelFolder.value
return key ? { [key]: true } : {}
},
set: (val) => {
const key = Object.keys(val)[0]
selectedModelFolder.value = key
},
})
const enabledUpload = computed(() => {
return !!selectedModelFolder.value
})
const handleBackTypeSelect = () => {
selectedModelFolder.value = undefined
currentType.value = undefined
stepValue.value--
}
const handleConfirmSubdir = () => {
stepValue.value++
}
const uploadTotalSize = ref<number>()
const uploadSize = ref<number>()
const uploadProgress = computed(() => {
const total = toValue(uploadTotalSize)
const size = toValue(uploadSize)
if (typeof total === 'number' && typeof size === 'number') {
return Math.floor((size / total) * 100)
}
return undefined
})
const showUploadProgress = computed(() => {
return typeof uploadProgress.value !== 'undefined'
})
const uploadActions = ref([
{
value: 'back',
label: t('back'),
icon: 'pi pi-arrow-left',
command: () => {
stepValue.value--
},
},
{
value: 'full',
label: t('chooseFile'),
command: () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = supportedExtensions.value.join(',')
input.onchange = async () => {
const files = input.files
const file = files?.item(0)
if (!file) {
return
}
try {
uploadTotalSize.value = file.size
uploadSize.value = 0
const body = new FormData()
body.append('folder', toValue(selectedModelFolder)!)
body.append('file', file)
await request('/upload', {
method: 'POST',
body: body,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 5000,
})
}
}
input.click()
},
},
])
const supportedExtensions = ref([])
const fetchSupportedExtensions = async () => {
try {
const result = await request('/supported-extensions')
supportedExtensions.value = result ?? []
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 5000,
})
}
}
const update_process = (event: CustomEvent) => {
const detail = event.detail
uploadSize.value = detail.uploaded_size
}
onMounted(() => {
fetchSupportedExtensions()
api.addEventListener('update_upload_progress', update_process)
})
onUnmounted(() => {
api.removeEventListener('update_upload_progress', update_process)
})
</script>
+1
View File
@@ -16,6 +16,7 @@
<Button
v-for="action in item.headerButtons"
:key="action.key"
v-tooltip.top="action.tooltip"
severity="secondary"
:text="true"
:rounded="true"
+4 -11
View File
@@ -25,19 +25,10 @@
</svg>
</div>
<div
v-else-if="model.previewType === 'video'"
v-else-if="isVideoUrl(preview)"
class="h-full w-full p-1 hover:p-0"
>
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
disablepictureinpicture
preload="none"
>
<source :src="preview" />
</video>
<PreviewVideo :src="preview" />
</div>
<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" />
@@ -81,8 +72,10 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import PreviewVideo from 'components/PreviewVideo.vue'
import { useModelNodeAction } from 'hooks/model'
import { BaseModel } from 'types/typings'
import { isVideoUrl } from 'utils/media'
import { computed, ref } from 'vue'
interface Props {
+1 -1
View File
@@ -17,7 +17,7 @@
></ModelPreview>
<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>
</div>
+21 -12
View File
@@ -5,17 +5,17 @@
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
:style="$sm({ width: `${cardWidth}px` })"
>
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
disablepictureinpicture
preload="none"
>
<source :src="preview" />
</video>
<div
v-if="
preview &&
isVideoUrl(
preview,
currentType === 'local' ? localContentType : undefined,
)
"
class="h-full w-full p-1 hover:p-0"
>
<PreviewVideo :src="preview" />
</div>
<ResponseImage
@@ -48,7 +48,14 @@
}"
>
<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
v-else
:src="slotProps.data"
:error="noPreviewContent"
></ResponseImage>
@@ -98,6 +105,7 @@
</template>
<script setup lang="ts">
import PreviewVideo from 'components/PreviewVideo.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue'
@@ -106,13 +114,13 @@ import { useContainerQueries } from 'hooks/container'
import { useModelPreview } from 'hooks/model'
import Button from 'primevue/button'
import Carousel from 'primevue/carousel'
import { isVideoUrl } from 'utils/media'
const editable = defineModel<boolean>('editable')
const { cardWidth } = useConfig()
const {
preview,
previewType,
typeOptions,
currentType,
defaultContent,
@@ -120,6 +128,7 @@ const {
networkContent,
updateLocalContent,
noPreviewContent,
localContentType,
} = useModelPreview()
const { $sm, $xl } = useContainerQueries()
+25
View 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>
+1 -1
View File
@@ -46,7 +46,7 @@ const handleDropFile = (event: DragEvent) => {
const handleClick = (event: MouseEvent) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.accept = 'image/*,video/*'
input.onchange = () => {
const files = input.files
if (files) {
+1
View File
@@ -6,6 +6,7 @@ interface HeaderButton {
key: string
icon: string
command: () => void
tooltip?: string
}
export interface DialogItem {
+234 -13
View File
@@ -2,17 +2,24 @@ import { useLoading } from 'hooks/loading'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { upperFirst } from 'lodash'
import { api } from 'scripts/comfyAPI'
import {
BaseModel,
DownloadTask,
DownloadTaskOptions,
SelectOptions,
VersionModel,
VersionModelFile,
} from 'types/typings'
import { bytesToSize } from 'utils/common'
import {
bytesToSize,
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
} from 'utils/common'
import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import yaml from 'yaml'
export const useDownload = defineStore('download', (store) => {
const { toast, confirm, wrapperToastError } = useToast()
@@ -162,29 +169,243 @@ declare module 'hooks/store' {
}
}
type WithSelection<T> = SelectOptions & { item: T }
type FileSelectionVersionModel = VersionModel & {
currentFileId?: number
selectionFiles?: WithSelection<VersionModelFile>[]
}
export const useModelSearch = () => {
const loading = useLoading()
const { toast } = useToast()
const data = ref<(SelectOptions & { item: VersionModel })[]>([])
const data = ref<WithSelection<FileSelectionVersionModel>[]>([])
const current = ref<string | number>()
const currentModel = ref<BaseModel>()
const currentModel = ref<FileSelectionVersionModel>()
const handleSearchByUrl = async (url: string) => {
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)
let yamlMetadata: any = {}
try {
yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '') || {}
} catch (e) {
console.warn('Failed to parse YAML metadata:', e)
yamlMetadata = {}
}
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 createDirectFileModel = (
url: string,
modelType?: string,
): VersionModel => {
try {
const filename = getFilenameFromUrl(url)
const parts = filename.split('.')
const extension = `.${parts.pop()}`
const basename = parts.join('.') || 'model'
const detectedModelType = modelType || getModelTypeFromFilename(filename)
// Create a proper YAML metadata structure for direct files
const yamlMetadata = {
source: 'direct-link',
original_url: url,
filename: filename,
modelType: detectedModelType,
downloadPlatform: 'Direct Link',
}
const description = `---
${Object.entries(yamlMetadata)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')}
---
# Direct File Download
This is a direct download link to a model file. The file size will be determined during download.
**Source:** ${url}
**Filename:** ${filename}
**Type:** ${detectedModelType}`
return {
id: `direct-${Date.now()}`,
basename,
extension,
sizeBytes: 0, // Will be determined during download
type: detectedModelType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: basename,
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0, // Unknown until download starts
name: filename,
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
} catch (error) {
console.error('Error creating direct file model:', error)
// Return a fallback model
const fallbackType = modelType || 'checkpoints'
return {
id: `direct-${Date.now()}`,
basename: 'model',
extension: '.bin',
sizeBytes: 0,
type: fallbackType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description: `---
source: direct-link
original_url: ${url}
filename: model.bin
modelType: ${fallbackType}
downloadPlatform: Direct Link
---
# Direct File Download
This is a direct download link to a model file.`,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: 'model',
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0,
name: 'model.bin',
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
}
}
const handleSearchByUrl = async (url: string, modelType?: string) => {
if (!url) {
return Promise.resolve([])
}
loading.show()
// Check if this is a direct file URL
if (isDirectFileUrl(url)) {
try {
// Create a mock model for direct file download
const directModel = createDirectFileModel(url, modelType)
const resolvedItem = genFileSelectionItem(directModel)
data.value = [
{
label: directModel.shortname,
value: directModel.id,
item: resolvedItem,
command() {
current.value = directModel.id
},
},
]
current.value = data.value[0]?.value
currentModel.value = data.value[0]?.item
loading.hide()
return [directModel]
} catch (error) {
console.error('Error processing direct file URL:', error)
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: `Failed to process direct file URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
life: 5000,
})
return []
}
}
// Original logic for model page URLs
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
.then((resData: VersionModel[]) => {
data.value = resData.map((item) => ({
label: item.shortname,
value: item.id,
item,
command() {
current.value = item.id
},
}))
data.value = resData.map((item) => {
const resolvedItem = genFileSelectionItem(item)
return {
label: item.shortname,
value: item.id,
item: resolvedItem,
command() {
current.value = item.id
},
}
})
current.value = data.value[0]?.value
currentModel.value = data.value[0]?.item
-1
View File
@@ -46,7 +46,6 @@ export const useModelExplorer = () => {
description: '',
metadata: {},
preview: '',
previewType: 'image',
type: folder ?? '',
isFolder: true,
children: [],
+17 -9
View File
@@ -6,7 +6,7 @@ import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { castArray, cloneDeep } from 'lodash'
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 { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
import { ModelGrid } from 'utils/legacy'
@@ -27,16 +27,18 @@ import {
import { useI18n } from 'vue-i18n'
import { configSetting } from './config'
const systemStat = ref()
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
Ref<ModelFolder>
>
export const genModelFullName = (model: BaseModel) => {
export const genModelFullName = (model: BaseModel, splitter = '/') => {
return [model.subFolder, `${model.basename}${model.extension}`]
.filter(Boolean)
.join('/')
.join(splitter)
}
export const genModelUrl = (model: BaseModel) => {
@@ -234,6 +236,12 @@ export const useModels = defineStore('models', (store) => {
return [prefixPath, fullname].filter(Boolean).join('/')
}
onMounted(() => {
api.getSystemStats().then((res) => {
systemStat.value = res
})
})
return {
initialized: initialized,
folders: folders,
@@ -545,9 +553,11 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* Local file url
*/
const localContent = ref<string>()
const localContentType = ref<string>()
const updateLocalContent = async (event: SelectEvent) => {
const { files } = event
localContent.value = files[0].objectURL
localContentType.value = files[0].type
}
/**
@@ -579,16 +589,13 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
return content
})
const previewType = computed(() => {
return model.value.previewType
})
onMounted(() => {
registerReset(() => {
currentType.value = 'default'
defaultContentPage.value = 0
networkContent.value = undefined
localContent.value = undefined
localContentType.value = undefined
})
registerSubmit((data) => {
@@ -598,7 +605,6 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
const result = {
preview,
previewType,
typeOptions,
currentType,
// default value
@@ -608,6 +614,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
networkContent,
// local file
localContent,
localContentType,
updateLocalContent,
// no preview
noPreviewContent,
@@ -717,11 +724,12 @@ export const useModelNodeAction = () => {
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
const splitter = systemStat.value?.system.os === 'nt' ? '\\' : '/'
ModelGrid.dragAddModel(
event,
model.type,
genModelFullName(model),
genModelFullName(model, splitter),
removeEmbeddingExtension,
strictDragToAdd,
)
+5 -150
View File
@@ -1,157 +1,12 @@
import { app } from 'scripts/comfyAPI'
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import zh from './locales/zh.json'
const messages = {
en: {
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',
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: '展平布局',
},
},
en: en,
zh: zh,
}
const getLocalLanguage = () => {
+82
View File
@@ -0,0 +1,82 @@
{
"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, huggingface.co, or direct file link (.safetensors, .ckpt, etc.)",
"selectModelTypeForDirect": "Select the folder/type for this model",
"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"
},
"switchToFolderView": "Switch to Folder View",
"switchToFlatView": "Switch to Flat View",
"hideHiddenFiles": "Hide hidden files",
"showHiddenFiles": "Show hidden files"
}
+82
View File
@@ -0,0 +1,82 @@
{
"model": "模型",
"modelManager": "模型管理器",
"openModelManager": "打开模型管理器",
"searchModels": "搜索模型",
"modelCopied": "模型节点已拷贝",
"download": "下载",
"downloadList": "下载列表",
"downloadTask": "下载任务",
"createDownloadTask": "创建下载任务",
"parseModelUrl": "解析模型URL",
"pleaseInputModelUrl": "输入 civitai.com, huggingface.co 的 URL 或直接文件链接 (.safetensors, .ckpt 等)",
"selectModelTypeForDirect": "为此模型选择文件夹/类型",
"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": "展平布局"
},
"switchToFolderView": "切换到文件夹视图",
"switchToFlatView": "切换到平铺视图",
"hideHiddenFiles": "隐藏隐藏文件",
"showHiddenFiles": "显示隐藏文件"
}
+10 -1
View File
@@ -1,6 +1,10 @@
declare namespace ComfyAPI {
namespace api {
class ComfyApi {
class ComfyApiEvent {
getSystemStats: () => Promise<any>
}
class ComfyApi extends ComfyApiEvent {
socket: WebSocket
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
addEventListener: (
@@ -8,6 +12,11 @@ declare namespace ComfyAPI {
callback: (event: CustomEvent) => void,
options?: AddEventListenerOptions,
) => void
removeEventListener: (
type: string,
callback: (event: CustomEvent) => void,
options?: AddEventListenerOptions,
) => void
}
const api: ComfyApi
+11 -1
View File
@@ -11,7 +11,6 @@ export interface BaseModel {
pathIndex: number
isFolder: boolean
preview: string | string[]
previewType: string
description: string
metadata: Record<string, string>
}
@@ -22,11 +21,22 @@ export interface Model extends BaseModel {
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 {
shortname: string
downloadPlatform: string
downloadUrl: string
hashes?: Record<string, string>
files?: VersionModelFile[]
}
export type WithResolved<T> = Omit<T, 'preview'> & {
+100
View File
@@ -37,3 +37,103 @@ export const previewUrlToFile = async (url: string) => {
return file
})
}
// Model file extensions that are supported for direct download
export const MODEL_FILE_EXTENSIONS = [
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.onnx',
'.tflite',
'.pb',
'.h5',
'.pkl',
'.pickle',
'.json', // for configuration files
'.yaml',
'.yml',
]
/**
* Checks if a URL points directly to a downloadable model file
*/
export const isDirectFileUrl = (url: string): boolean => {
if (!url || typeof url !== 'string') {
return false
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
// Check if the URL ends with a model file extension
return MODEL_FILE_EXTENSIONS.some((ext) => pathname.endsWith(ext))
} catch {
return false
}
}
/**
* Extracts filename from a URL
*/
export const getFilenameFromUrl = (url: string): string => {
if (!url || typeof url !== 'string') {
return 'model.bin'
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.split('/').pop() || ''
// If no filename with extension found, generate one
if (!filename || !filename.includes('.')) {
const extension =
MODEL_FILE_EXTENSIONS.find((ext) =>
pathname.toLowerCase().endsWith(ext),
) || '.bin'
return `model${extension}`
}
return filename
} catch {
return 'model.bin'
}
}
/**
* Determines model type based on file extension (fallback only)
* Note: This is now primarily used as a fallback when no manual selection is made
*/
export const getModelTypeFromFilename = (filename: string): string => {
if (!filename || typeof filename !== 'string') {
return 'checkpoints'
}
const extension = filename.toLowerCase().split('.').pop()
switch (extension) {
case 'safetensors':
case 'ckpt':
case 'pt':
case 'pth':
return 'checkpoints' // Default for these extensions, but user can override
case 'bin':
return 'diffusers'
case 'onnx':
return 'onnx'
case 'tflite':
return 'tflite'
case 'pb':
return 'tensorflow'
case 'h5':
return 'keras'
case 'pkl':
case 'pickle':
return 'embeddings'
default:
return 'checkpoints'
}
}
+53
View 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))
}
+1
View File
@@ -11,6 +11,7 @@
"moduleResolution": "Node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
/* Linting */
"strict": false,
File diff suppressed because one or more lines are too long