Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea0ddbc54 | ||
|
|
4b405d77d2 | ||
|
|
39e4581558 | ||
|
|
c55ee62ac9 | ||
|
|
33c43085c4 | ||
|
|
dc1d12649a | ||
|
|
0fdbc5d7be | ||
|
|
60837ae35f | ||
|
|
e88a77f224 | ||
|
|
f3de2006ef | ||
|
|
0295dd6288 | ||
|
|
4f9a437725 |
@@ -11,7 +11,7 @@ jobs:
|
|||||||
publish-node:
|
publish-node:
|
||||||
name: Release and Publish Custom Node to registry
|
name: Release and Publish Custom Node to registry
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository_owner == 'hayden-fr' }}
|
if: ${{ github.repository_owner == 'hayden-cn' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ Designed to support desktop, mobile and multi-screen devices.
|
|||||||
|
|
||||||
There are three installation methods, choose one
|
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
|
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-fr/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it 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`
|
3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@primevue/themes": "^4.0.7",
|
"@primevue/themes": "^4.0.7",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markdown-it-metadata-block": "^1.0.6",
|
"markdown-it-metadata-block": "^1.0.6",
|
||||||
|
|||||||
Generated
+3
-9
@@ -17,6 +17,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
|
js-yaml:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
lodash:
|
lodash:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -405,55 +408,46 @@ packages:
|
|||||||
resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==}
|
resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.22.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.22.0':
|
||||||
resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==}
|
resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.22.0':
|
'@rollup/rollup-linux-arm64-gnu@4.22.0':
|
||||||
resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==}
|
resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.22.0':
|
'@rollup/rollup-linux-arm64-musl@4.22.0':
|
||||||
resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==}
|
resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.22.0':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.22.0':
|
||||||
resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==}
|
resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.22.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.22.0':
|
||||||
resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==}
|
resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.22.0':
|
'@rollup/rollup-linux-s390x-gnu@4.22.0':
|
||||||
resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==}
|
resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.22.0':
|
'@rollup/rollup-linux-x64-gnu@4.22.0':
|
||||||
resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==}
|
resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.22.0':
|
'@rollup/rollup-linux-x64-musl@4.22.0':
|
||||||
resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==}
|
resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.22.0':
|
'@rollup/rollup-win32-arm64-msvc@4.22.0':
|
||||||
resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==}
|
resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==}
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
for version in model_versions:
|
for version in model_versions:
|
||||||
version_files: list[dict] = version.get("files", [])
|
version_files: list[dict] = version.get("files", [])
|
||||||
model_files = utils.filter_with(version_files, {"type": "Model"})
|
model_files = utils.filter_with(version_files, {"type": "Model"})
|
||||||
# issue: https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/188
|
# issue: https://github.com/hayden-cn/ComfyUI-Model-Manager/issues/188
|
||||||
# Some Embeddings do not have Model file, but Negative
|
# Some Embeddings do not have Model file, but Negative
|
||||||
# Make sure there are at least downloadable files
|
# Make sure there are at least downloadable files
|
||||||
model_files = version_files if len(model_files) == 0 else model_files
|
model_files = version_files if len(model_files) == 0 else model_files
|
||||||
|
|||||||
+29
-20
@@ -131,12 +131,11 @@ class ModelManager:
|
|||||||
basename = os.path.splitext(filename)[0] if is_file else filename
|
basename = os.path.splitext(filename)[0] if is_file else filename
|
||||||
extension = os.path.splitext(filename)[1] if is_file else ""
|
extension = os.path.splitext(filename)[1] if is_file else ""
|
||||||
|
|
||||||
if is_file and extension not in folder_paths.supported_pt_extensions:
|
model_preview = None
|
||||||
return None
|
if is_file:
|
||||||
|
preview_name = utils.get_model_preview_name(entry.path)
|
||||||
preview_name = utils.get_model_preview_name(entry.path)
|
preview_ext = f".{preview_name.split('.')[-1]}"
|
||||||
preview_ext = f".{preview_name.split('.')[-1]}"
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
||||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
|
||||||
|
|
||||||
if not os.path.exists(entry.path):
|
if not os.path.exists(entry.path):
|
||||||
utils.print_error(f"{entry.path} is not file or directory.")
|
utils.print_error(f"{entry.path} is not file or directory.")
|
||||||
@@ -151,35 +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,
|
||||||
"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
|
||||||
|
|
||||||
|
|||||||
+57
-61
@@ -21,6 +21,9 @@ from . import config
|
|||||||
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
|
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
|
||||||
IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
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
|
# Content type mappings
|
||||||
VIDEO_CONTENT_TYPE_MAP = {
|
VIDEO_CONTENT_TYPE_MAP = {
|
||||||
'video/mp4': '.mp4',
|
'video/mp4': '.mp4',
|
||||||
@@ -123,7 +126,7 @@ def download_web_distribution(version: str):
|
|||||||
try:
|
try:
|
||||||
print_info(f"current version {version}, web version {web_version}")
|
print_info(f"current version {version}, web version {web_version}")
|
||||||
print_info("Downloading web distribution...")
|
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 = requests.get(download_url, stream=True)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -189,6 +192,10 @@ def get_full_path(model_type: str, path_index: int, filename: str):
|
|||||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
||||||
base_path = folders[path_index]
|
base_path = folders[path_index]
|
||||||
full_path = join_path(base_path, filename)
|
full_path = join_path(base_path, filename)
|
||||||
|
real_base = os.path.realpath(base_path)
|
||||||
|
real_full = os.path.realpath(full_path)
|
||||||
|
if not (real_full == real_base or real_full.startswith(real_base + os.sep)):
|
||||||
|
raise RuntimeError(f"Path traversal detected: filename escapes model directory")
|
||||||
return full_path
|
return full_path
|
||||||
|
|
||||||
|
|
||||||
@@ -196,11 +203,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
|
|||||||
"""
|
"""
|
||||||
Like get_full_path but it will check whether the file is valid.
|
Like get_full_path but it will check whether the file is valid.
|
||||||
"""
|
"""
|
||||||
folders = resolve_model_base_paths().get(model_type, [])
|
full_path = get_full_path(model_type, path_index, filename)
|
||||||
if not path_index < len(folders):
|
|
||||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
|
||||||
base_path = folders[path_index]
|
|
||||||
full_path = join_path(base_path, filename)
|
|
||||||
if os.path.isfile(full_path):
|
if os.path.isfile(full_path):
|
||||||
return full_path
|
return full_path
|
||||||
elif os.path.islink(full_path):
|
elif os.path.islink(full_path):
|
||||||
@@ -268,40 +271,52 @@ def get_model_metadata(filename: str):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_previews(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, ["video", "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:
|
||||||
"""
|
"""Get the first available preview file or 'no-preview.png' if none found"""
|
||||||
Get the preview file name for a model. Checks for images and videos in all supported formats.
|
|
||||||
Returns the first available preview file or 'no-preview.png' if none found.
|
|
||||||
"""
|
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
# Prefer previews with these extensions in this order
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
preview_extensions = ['.webm', '.mp4', '.webp']
|
# Check direct match first
|
||||||
for ext in preview_extensions:
|
|
||||||
preview_name = f"{basename}{ext}"
|
preview_name = f"{basename}{ext}"
|
||||||
if os.path.exists(join_path(base_dirname, preview_name)):
|
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 preview_name
|
||||||
|
|
||||||
# Fallback to any available preview files
|
return "no-preview.png"
|
||||||
all_previews = get_model_all_previews(model_path)
|
|
||||||
return all_previews[0] if len(all_previews) > 0 else "no-preview.png"
|
|
||||||
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -309,34 +324,19 @@ from io import BytesIO
|
|||||||
|
|
||||||
|
|
||||||
def remove_model_preview(model_path: str):
|
def remove_model_preview(model_path: str):
|
||||||
"""
|
"""Remove all preview files for a model"""
|
||||||
Remove preview files for a model.
|
|
||||||
"""
|
|
||||||
basename = os.path.splitext(model_path)[0]
|
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
# Remove all preview files
|
previews = _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||||
for ext in VIDEO_EXTENSIONS + IMAGE_EXTENSIONS:
|
for preview in previews:
|
||||||
preview_path = f"{basename}{ext}"
|
preview_path = join_path(base_dirname, preview)
|
||||||
if os.path.exists(preview_path):
|
if os.path.exists(preview_path):
|
||||||
os.remove(preview_path)
|
os.remove(preview_path)
|
||||||
|
|
||||||
# Also check for .preview variants
|
|
||||||
files = search_files(base_dirname)
|
|
||||||
model_name = os.path.splitext(os.path.basename(model_path))[0]
|
|
||||||
for file in files:
|
|
||||||
if file.startswith(f"{model_name}.preview"):
|
|
||||||
file_path = join_path(base_dirname, file)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
|
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"""
|
||||||
Save a preview file for a model.
|
|
||||||
Images are converted to WebP, videos are saved in their original format.
|
|
||||||
"""
|
|
||||||
basename = os.path.splitext(model_path)[0]
|
|
||||||
|
|
||||||
# Download file if it is a URL
|
# Download file if it is a URL
|
||||||
if type(file_or_url) is str:
|
if type(file_or_url) is str:
|
||||||
@@ -357,15 +357,13 @@ def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str
|
|||||||
if content_type.startswith("video/"):
|
if content_type.startswith("video/"):
|
||||||
# Save video in original format
|
# Save video in original format
|
||||||
# Try to get extension from URL or content-type
|
# Try to get extension from URL or content-type
|
||||||
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type)
|
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type) or '.mp4'
|
||||||
if not ext:
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
ext = '.mp4' # Default fallback
|
|
||||||
preview_path = f"{basename}{ext}"
|
|
||||||
with open(preview_path, 'wb') as f:
|
with open(preview_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
else:
|
else:
|
||||||
# Default to image processing for unknown or image types
|
# Default to image processing for unknown or image types
|
||||||
preview_path = f"{basename}.webp"
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
image = Image.open(BytesIO(content))
|
image = Image.open(BytesIO(content))
|
||||||
image.save(preview_path, "WEBP")
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
@@ -384,17 +382,15 @@ def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str
|
|||||||
|
|
||||||
if content_type.startswith("video/"):
|
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
|
# 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]
|
ext = os.path.splitext(filename.lower())[1] or '.mp4'
|
||||||
if not ext:
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
ext = '.mp4' # Default fallback
|
|
||||||
preview_path = f"{basename}{ext}"
|
|
||||||
file_obj.file.seek(0)
|
file_obj.file.seek(0)
|
||||||
content = file_obj.file.read()
|
content = file_obj.file.read()
|
||||||
with open(preview_path, 'wb') as f:
|
with open(preview_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
elif content_type.startswith("image/"):
|
elif content_type.startswith("image/"):
|
||||||
# Convert image to webp
|
# Convert image to webp
|
||||||
preview_path = f"{basename}.webp"
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
image = Image.open(file_obj.file)
|
image = Image.open(file_obj.file)
|
||||||
image.save(preview_path, "WEBP")
|
image.save(preview_path, "WEBP")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
[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.8.1"
|
version = "2.8.5"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
[project.urls]
|
[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
|
# Used by Comfy Registry https://comfyregistry.org
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
|
|||||||
+42
@@ -80,9 +80,30 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect, flat } = config
|
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) {
|
if (firstOpenManager.value) {
|
||||||
models.refresh(true)
|
models.refresh(true)
|
||||||
firstOpenManager.value = false
|
firstOpenManager.value = false
|
||||||
@@ -99,6 +120,27 @@ 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: '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',
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
|
|||||||
@@ -14,6 +14,27 @@
|
|||||||
</template>
|
</template>
|
||||||
</ResponseInput>
|
</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">
|
<div v-show="data.length > 0">
|
||||||
<ResponseSelect
|
<ResponseSelect
|
||||||
v-model="current"
|
v-model="current"
|
||||||
@@ -79,8 +100,13 @@ import { request } from 'hooks/request'
|
|||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { VersionModel, WithResolved } from 'types/typings'
|
import { VersionModel, WithResolved } from 'types/typings'
|
||||||
import { previewUrlToFile } from 'utils/common'
|
import {
|
||||||
import { ref } from 'vue'
|
getFilenameFromUrl,
|
||||||
|
getModelTypeFromFilename,
|
||||||
|
isDirectFileUrl,
|
||||||
|
previewUrlToFile,
|
||||||
|
} from 'utils/common'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const { isMobile } = useConfig()
|
const { isMobile } = useConfig()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -89,14 +115,153 @@ const dialog = useDialog()
|
|||||||
|
|
||||||
const modelUrl = ref<string>()
|
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 { current, currentModel, data, search } = useModelSearch()
|
||||||
|
|
||||||
const searchModelsByUrl = async () => {
|
const searchModelsByUrl = async () => {
|
||||||
if (modelUrl.value) {
|
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>) => {
|
const createDownTask = async (data: WithResolved<VersionModel>) => {
|
||||||
loading.show()
|
loading.show()
|
||||||
|
|
||||||
|
|||||||
@@ -225,16 +225,32 @@ const list = computed(() => {
|
|||||||
return !item.isFolder
|
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 filterList = pureModels.filter((model) => {
|
||||||
const showAllModel = currentType.value === allType
|
const showAllModel = currentType.value === allType
|
||||||
|
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
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
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<Button
|
<Button
|
||||||
v-for="action in item.headerButtons"
|
v-for="action in item.headerButtons"
|
||||||
:key="action.key"
|
:key="action.key"
|
||||||
|
v-tooltip.top="action.tooltip"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
:text="true"
|
:text="true"
|
||||||
:rounded="true"
|
:rounded="true"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface HeaderButton {
|
|||||||
key: string
|
key: string
|
||||||
icon: string
|
icon: string
|
||||||
command: () => void
|
command: () => void
|
||||||
|
tooltip?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogItem {
|
export interface DialogItem {
|
||||||
|
|||||||
+171
-3
@@ -11,7 +11,12 @@ import {
|
|||||||
VersionModel,
|
VersionModel,
|
||||||
VersionModelFile,
|
VersionModelFile,
|
||||||
} from 'types/typings'
|
} from 'types/typings'
|
||||||
import { bytesToSize } from 'utils/common'
|
import {
|
||||||
|
bytesToSize,
|
||||||
|
getFilenameFromUrl,
|
||||||
|
getModelTypeFromFilename,
|
||||||
|
isDirectFileUrl,
|
||||||
|
} 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'
|
import yaml from 'yaml'
|
||||||
@@ -191,7 +196,13 @@ export const useModelSearch = () => {
|
|||||||
|
|
||||||
const regexp = /---\n([\s\S]*?)\n---/
|
const regexp = /---\n([\s\S]*?)\n---/
|
||||||
const yamlMetadataMatch = item.description.match(regexp)
|
const yamlMetadataMatch = item.description.match(regexp)
|
||||||
const yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '')
|
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.hashes = file.hashes
|
||||||
yamlMetadata.metadata = file.metadata
|
yamlMetadata.metadata = file.metadata
|
||||||
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
|
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
|
||||||
@@ -219,12 +230,169 @@ export const useModelSearch = () => {
|
|||||||
return fileSelectionItem
|
return fileSelectionItem
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchByUrl = async (url: string) => {
|
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) {
|
if (!url) {
|
||||||
return Promise.resolve([])
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.show()
|
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)}`, {})
|
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
|
||||||
.then((resData: VersionModel[]) => {
|
.then((resData: VersionModel[]) => {
|
||||||
data.value = resData.map((item) => {
|
data.value = resData.map((item) => {
|
||||||
|
|||||||
+7
-2
@@ -9,7 +9,8 @@
|
|||||||
"downloadTask": "Download Task",
|
"downloadTask": "Download Task",
|
||||||
"createDownloadTask": "Create Download Task",
|
"createDownloadTask": "Create Download Task",
|
||||||
"parseModelUrl": "Parse Model URL",
|
"parseModelUrl": "Parse Model URL",
|
||||||
"pleaseInputModelUrl": "Input a URL from civitai.com or huggingface.co",
|
"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",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -73,5 +74,9 @@
|
|||||||
"ui": "UI",
|
"ui": "UI",
|
||||||
"cardSize": "Card Size",
|
"cardSize": "Card Size",
|
||||||
"useFlatUI": "Flat Layout"
|
"useFlatUI": "Flat Layout"
|
||||||
}
|
},
|
||||||
|
"switchToFolderView": "Switch to Folder View",
|
||||||
|
"switchToFlatView": "Switch to Flat View",
|
||||||
|
"hideHiddenFiles": "Hide hidden files",
|
||||||
|
"showHiddenFiles": "Show hidden files"
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -9,7 +9,8 @@
|
|||||||
"downloadTask": "下载任务",
|
"downloadTask": "下载任务",
|
||||||
"createDownloadTask": "创建下载任务",
|
"createDownloadTask": "创建下载任务",
|
||||||
"parseModelUrl": "解析模型URL",
|
"parseModelUrl": "解析模型URL",
|
||||||
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
|
"pleaseInputModelUrl": "输入 civitai.com, huggingface.co 的 URL 或直接文件链接 (.safetensors, .ckpt 等)",
|
||||||
|
"selectModelTypeForDirect": "为此模型选择文件夹/类型",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
@@ -73,5 +74,9 @@
|
|||||||
"ui": "外观",
|
"ui": "外观",
|
||||||
"cardSize": "卡片尺寸",
|
"cardSize": "卡片尺寸",
|
||||||
"useFlatUI": "展平布局"
|
"useFlatUI": "展平布局"
|
||||||
}
|
},
|
||||||
|
"switchToFolderView": "切换到文件夹视图",
|
||||||
|
"switchToFlatView": "切换到平铺视图",
|
||||||
|
"hideHiddenFiles": "隐藏隐藏文件",
|
||||||
|
"showHiddenFiles": "显示隐藏文件"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,103 @@ export const previewUrlToFile = async (url: string) => {
|
|||||||
return file
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user