Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e4581558 | ||
|
|
c55ee62ac9 | ||
|
|
33c43085c4 | ||
|
|
dc1d12649a | ||
|
|
0fdbc5d7be | ||
|
|
60837ae35f | ||
|
|
e88a77f224 | ||
|
|
f3de2006ef | ||
|
|
0295dd6288 | ||
|
|
4f9a437725 | ||
|
|
815a483cf0 | ||
|
|
ae37765017 | ||
|
|
ebef300279 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
-9
@@ -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
@@ -71,7 +71,7 @@ class CivitaiModelSearcher(ModelSearcher):
|
||||
for version in model_versions:
|
||||
version_files: list[dict] = version.get("files", [])
|
||||
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
|
||||
# Make sure there are at least downloadable files
|
||||
model_files = version_files if len(model_files) == 0 else model_files
|
||||
|
||||
+32
-19
@@ -131,12 +131,15 @@ 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:
|
||||
return None
|
||||
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)}"
|
||||
|
||||
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
|
||||
|
||||
stat = entry.stat()
|
||||
return {
|
||||
@@ -147,35 +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,
|
||||
"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
|
||||
|
||||
|
||||
+53
-57
@@ -21,6 +21,9 @@ from . import config
|
||||
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',
|
||||
@@ -43,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())
|
||||
|
||||
|
||||
@@ -123,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()
|
||||
|
||||
@@ -268,40 +271,52 @@ def get_model_metadata(filename: str):
|
||||
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)
|
||||
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]
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
# Prefer previews with these extensions in this order
|
||||
preview_extensions = ['.webm', '.mp4', '.webp']
|
||||
for ext in preview_extensions:
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
# Check direct match first
|
||||
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
|
||||
|
||||
# Fallback to any available preview files
|
||||
all_previews = get_model_all_previews(model_path)
|
||||
return all_previews[0] if len(all_previews) > 0 else "no-preview.png"
|
||||
return "no-preview.png"
|
||||
|
||||
|
||||
from PIL import Image
|
||||
@@ -309,34 +324,19 @@ from io import BytesIO
|
||||
|
||||
|
||||
def remove_model_preview(model_path: str):
|
||||
"""
|
||||
Remove preview files for a model.
|
||||
"""
|
||||
basename = os.path.splitext(model_path)[0]
|
||||
"""Remove all preview files for a model"""
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
# Remove all preview files
|
||||
for ext in VIDEO_EXTENSIONS + IMAGE_EXTENSIONS:
|
||||
preview_path = f"{basename}{ext}"
|
||||
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)
|
||||
|
||||
# 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):
|
||||
"""
|
||||
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]
|
||||
"""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:
|
||||
@@ -357,15 +357,13 @@ def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str
|
||||
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)
|
||||
if not ext:
|
||||
ext = '.mp4' # Default fallback
|
||||
preview_path = f"{basename}{ext}"
|
||||
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 = f"{basename}.webp"
|
||||
preview_path = _get_preview_path(model_path, ".webp")
|
||||
image = Image.open(BytesIO(content))
|
||||
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/"):
|
||||
# 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]
|
||||
if not ext:
|
||||
ext = '.mp4' # Default fallback
|
||||
preview_path = f"{basename}{ext}"
|
||||
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 = f"{basename}.webp"
|
||||
preview_path = _get_preview_path(model_path, ".webp")
|
||||
image = Image.open(file_obj.file)
|
||||
image.save(preview_path, "WEBP")
|
||||
else:
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "comfyui-model-manager"
|
||||
description = "Manage models: browsing, download and delete."
|
||||
version = "2.8.0"
|
||||
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]
|
||||
|
||||
+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 { 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
|
||||
@@ -99,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',
|
||||
|
||||
@@ -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"
|
||||
@@ -79,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()
|
||||
@@ -89,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()
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ interface HeaderButton {
|
||||
key: string
|
||||
icon: string
|
||||
command: () => void
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface DialogItem {
|
||||
|
||||
+171
-3
@@ -11,7 +11,12 @@ import {
|
||||
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'
|
||||
@@ -191,7 +196,13 @@ export const useModelSearch = () => {
|
||||
|
||||
const regexp = /---\n([\s\S]*?)\n---/
|
||||
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.metadata = file.metadata
|
||||
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
|
||||
@@ -219,12 +230,169 @@ export const useModelSearch = () => {
|
||||
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) {
|
||||
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) => {
|
||||
|
||||
+7
-2
@@ -9,7 +9,8 @@
|
||||
"downloadTask": "Download Task",
|
||||
"createDownloadTask": "Create Download Task",
|
||||
"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",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
@@ -73,5 +74,9 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
+7
-2
@@ -9,7 +9,8 @@
|
||||
"downloadTask": "下载任务",
|
||||
"createDownloadTask": "创建下载任务",
|
||||
"parseModelUrl": "解析模型URL",
|
||||
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
|
||||
"pleaseInputModelUrl": "输入 civitai.com, huggingface.co 的 URL 或直接文件链接 (.safetensors, .ckpt 等)",
|
||||
"selectModelTypeForDirect": "为此模型选择文件夹/类型",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
@@ -73,5 +74,9 @@
|
||||
"ui": "外观",
|
||||
"cardSize": "卡片尺寸",
|
||||
"useFlatUI": "展平布局"
|
||||
}
|
||||
},
|
||||
"switchToFolderView": "切换到文件夹视图",
|
||||
"switchToFlatView": "切换到平铺视图",
|
||||
"hideHiddenFiles": "隐藏隐藏文件",
|
||||
"showHiddenFiles": "显示隐藏文件"
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user