Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88a77f224 | ||
|
|
f3de2006ef | ||
|
|
0295dd6288 | ||
|
|
4f9a437725 | ||
|
|
815a483cf0 | ||
|
|
ae37765017 | ||
|
|
ebef300279 | ||
|
|
38cd328e57 | ||
|
|
71a200ed5c |
@@ -326,7 +326,7 @@ class ModelDownload:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
preview_file = task_data.pop("previewFile", None)
|
preview_file = task_data.pop("previewFile", None)
|
||||||
utils.save_model_preview_image(task_path, preview_file, download_platform)
|
utils.save_model_preview(task_path, preview_file, download_platform)
|
||||||
self.set_task_content(task_id, task_data)
|
self.set_task_content(task_id, task_data)
|
||||||
task_status = TaskStatus(
|
task_status = TaskStatus(
|
||||||
taskId=task_id,
|
taskId=task_id,
|
||||||
|
|||||||
@@ -355,23 +355,17 @@ class Information:
|
|||||||
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
||||||
async def read_model_preview(request):
|
async def read_model_preview(request):
|
||||||
"""
|
"""
|
||||||
Get the file stream of the specified image.
|
Get the file stream of the specified preview
|
||||||
If the file does not exist, no-preview.png is returned.
|
If the file does not exist, no-preview.png is returned.
|
||||||
|
|
||||||
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
||||||
:param index: The index of the model folders.
|
:param index: The index of the model folders.
|
||||||
:param filename: The filename of the image.
|
:param filename: The filename of the preview.
|
||||||
"""
|
"""
|
||||||
model_type = request.match_info.get("type", None)
|
model_type = request.match_info.get("type", None)
|
||||||
index = int(request.match_info.get("index", None))
|
index = int(request.match_info.get("index", None))
|
||||||
filename = request.match_info.get("filename", None)
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
content_type = utils.resolve_file_content_type(filename)
|
|
||||||
|
|
||||||
if content_type == "video":
|
|
||||||
abs_path = utils.get_full_path(model_type, index, filename)
|
|
||||||
return web.FileResponse(abs_path)
|
|
||||||
|
|
||||||
extension_uri = config.extension_uri
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -388,8 +382,16 @@ class Information:
|
|||||||
if not os.path.isfile(abs_path):
|
if not os.path.isfile(abs_path):
|
||||||
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
|
|
||||||
image_data = self.get_image_preview_data(abs_path)
|
# Determine content type from the actual file
|
||||||
return web.Response(body=image_data.getvalue(), content_type="image/webp")
|
content_type = utils.resolve_file_content_type(abs_path)
|
||||||
|
|
||||||
|
if content_type == "video":
|
||||||
|
# Serve video files directly
|
||||||
|
return web.FileResponse(abs_path)
|
||||||
|
else:
|
||||||
|
# Serve image files (WebP or fallback images)
|
||||||
|
image_data = self.get_image_preview_data(abs_path)
|
||||||
|
return web.Response(body=image_data.getvalue(), content_type="image/webp")
|
||||||
|
|
||||||
@routes.get("/model-manager/preview/download/{filename}")
|
@routes.get("/model-manager/preview/download/{filename}")
|
||||||
async def read_download_preview(request):
|
async def read_download_preview(request):
|
||||||
@@ -546,10 +548,10 @@ class Information:
|
|||||||
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
|
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
|
||||||
|
|
||||||
preview_url_list = model_info.get("preview", [])
|
preview_url_list = model_info.get("preview", [])
|
||||||
preview_image_url = preview_url_list[0] if preview_url_list else None
|
preview_url = preview_url_list[0] if preview_url_list else None
|
||||||
if preview_image_url:
|
if preview_url:
|
||||||
utils.print_debug(f"Save preview image to {abs_image_path}")
|
utils.print_debug(f"Save preview to {abs_model_path}")
|
||||||
utils.save_model_preview_image(abs_model_path, preview_image_url)
|
utils.save_model_preview(abs_model_path, preview_url)
|
||||||
|
|
||||||
description = model_info.get("description", None)
|
description = model_info.get("description", None)
|
||||||
if description:
|
if description:
|
||||||
|
|||||||
@@ -131,23 +131,16 @@ class ModelManager:
|
|||||||
basename = os.path.splitext(filename)[0] if is_file else filename
|
basename = os.path.splitext(filename)[0] if is_file else filename
|
||||||
extension = os.path.splitext(filename)[1] if is_file else ""
|
extension = os.path.splitext(filename)[1] if is_file else ""
|
||||||
|
|
||||||
if is_file and extension not in folder_paths.supported_pt_extensions:
|
model_preview = None
|
||||||
|
if is_file:
|
||||||
|
preview_name = utils.get_model_preview_name(entry.path)
|
||||||
|
preview_ext = f".{preview_name.split('.')[-1]}"
|
||||||
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
||||||
|
|
||||||
|
if not os.path.exists(entry.path):
|
||||||
|
utils.print_error(f"{entry.path} is not file or directory.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
preview_type = "image"
|
|
||||||
preview_ext = ".webp"
|
|
||||||
preview_images = utils.get_model_all_images(entry.path)
|
|
||||||
if len(preview_images) > 0:
|
|
||||||
preview_type = "image"
|
|
||||||
preview_ext = ".webp"
|
|
||||||
else:
|
|
||||||
preview_videos = utils.get_model_all_videos(entry.path)
|
|
||||||
if len(preview_videos) > 0:
|
|
||||||
preview_type = "video"
|
|
||||||
preview_ext = f".{preview_videos[0].split('.')[-1]}"
|
|
||||||
|
|
||||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
|
||||||
|
|
||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
return {
|
return {
|
||||||
"type": folder,
|
"type": folder,
|
||||||
@@ -157,36 +150,45 @@ class ModelManager:
|
|||||||
"extension": extension,
|
"extension": extension,
|
||||||
"pathIndex": path_index,
|
"pathIndex": path_index,
|
||||||
"sizeBytes": stat.st_size if is_file else 0,
|
"sizeBytes": stat.st_size if is_file else 0,
|
||||||
"preview": model_preview if is_file else None,
|
"preview": model_preview,
|
||||||
"previewType": preview_type,
|
|
||||||
"createdAt": round(stat.st_ctime_ns / 1000000),
|
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||||
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_all_files_entry(directory: str):
|
def get_all_files_entry(directory: str):
|
||||||
entries: list[os.DirEntry[str]] = []
|
entries: list[os.DirEntry[str]] = []
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
return []
|
||||||
with os.scandir(directory) as it:
|
with os.scandir(directory) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
# Skip hidden files
|
if not include_hidden_files and entry.name.startswith("."):
|
||||||
if not include_hidden_files:
|
continue
|
||||||
if entry.name.startswith("."):
|
|
||||||
continue
|
if entry.is_file():
|
||||||
entries.append(entry)
|
extension = os.path.splitext(entry.name)[1]
|
||||||
if entry.is_dir():
|
if extension in folder_paths.supported_pt_extensions:
|
||||||
|
entries.append(entry)
|
||||||
|
else:
|
||||||
|
entries.append(entry)
|
||||||
entries.extend(get_all_files_entry(entry.path))
|
entries.extend(get_all_files_entry(entry.path))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
BATCH_SIZE = 200
|
||||||
|
MAX_WORKERS = min(4, os.cpu_count() or 1)
|
||||||
|
|
||||||
for path_index, base_path in enumerate(folders):
|
for path_index, base_path in enumerate(folders):
|
||||||
if not os.path.exists(base_path):
|
if not os.path.exists(base_path):
|
||||||
continue
|
continue
|
||||||
file_entries = get_all_files_entry(base_path)
|
file_entries = get_all_files_entry(base_path)
|
||||||
with ThreadPoolExecutor() as executor:
|
|
||||||
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
|
for i in range(0, len(file_entries), BATCH_SIZE):
|
||||||
for future in as_completed(futures):
|
batch = file_entries[i:i + BATCH_SIZE]
|
||||||
file_info = future.result()
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
if file_info is None:
|
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in batch}
|
||||||
continue
|
for future in as_completed(futures):
|
||||||
result.append(file_info)
|
file_info = future.result()
|
||||||
|
if file_info is not None:
|
||||||
|
result.append(file_info)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -211,10 +213,11 @@ class ModelManager:
|
|||||||
|
|
||||||
if "previewFile" in model_data:
|
if "previewFile" in model_data:
|
||||||
previewFile = model_data["previewFile"]
|
previewFile = model_data["previewFile"]
|
||||||
if type(previewFile) is str and previewFile == "undefined":
|
# Always remove existing preview files first in case the file extension has changed
|
||||||
utils.remove_model_preview_image(model_path)
|
utils.remove_model_preview(model_path)
|
||||||
else:
|
# Nothing else to do if the preview file was being removed
|
||||||
utils.save_model_preview_image(model_path, previewFile)
|
if not (type(previewFile) is str and previewFile == "undefined"):
|
||||||
|
utils.save_model_preview(model_path, previewFile)
|
||||||
|
|
||||||
if "description" in model_data:
|
if "description" in model_data:
|
||||||
description = model_data["description"]
|
description = model_data["description"]
|
||||||
@@ -236,7 +239,7 @@ class ModelManager:
|
|||||||
model_dirname = os.path.dirname(model_path)
|
model_dirname = os.path.dirname(model_path)
|
||||||
os.remove(model_path)
|
os.remove(model_path)
|
||||||
|
|
||||||
model_previews = utils.get_model_all_images(model_path)
|
model_previews = utils.get_model_all_previews(model_path)
|
||||||
for preview in model_previews:
|
for preview in model_previews:
|
||||||
os.remove(utils.join_path(model_dirname, preview))
|
os.remove(utils.join_path(model_dirname, preview))
|
||||||
|
|
||||||
|
|||||||
207
py/utils.py
207
py/utils.py
@@ -17,6 +17,25 @@ from aiohttp import web
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
# Media file extensions
|
||||||
|
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
|
||||||
|
IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
# Preview extensions in priority order (videos first, then images)
|
||||||
|
PREVIEW_EXTENSIONS = ['.webm', '.mp4', '.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
# Content type mappings
|
||||||
|
VIDEO_CONTENT_TYPE_MAP = {
|
||||||
|
'video/mp4': '.mp4',
|
||||||
|
'video/webm': '.webm',
|
||||||
|
'video/quicktime': '.mov',
|
||||||
|
'video/x-msvideo': '.avi',
|
||||||
|
'video/x-matroska': '.mkv',
|
||||||
|
'video/x-flv': '.flv',
|
||||||
|
'video/x-ms-wmv': '.wmv',
|
||||||
|
'video/ogg': '.ogv',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_info(msg, *args, **kwargs):
|
def print_info(msg, *args, **kwargs):
|
||||||
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
||||||
@@ -27,7 +46,7 @@ def print_warning(msg, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def print_error(msg, *args, **kwargs):
|
def print_error(msg, *args, **kwargs):
|
||||||
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
logging.error(f"[{config.extension_tag}][ERROR] {msg}", *args, **kwargs)
|
||||||
logging.debug(traceback.format_exc())
|
logging.debug(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
@@ -252,95 +271,145 @@ def get_model_metadata(filename: str):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_images(model_path: str):
|
def _check_preview_variants(base_dirname: str, basename: str, extensions: list[str]) -> list[str]:
|
||||||
|
"""Check for preview files with given extensions and return found files"""
|
||||||
|
found = []
|
||||||
|
for ext in extensions:
|
||||||
|
# Direct match (basename.ext)
|
||||||
|
preview_file = f"{basename}{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_file)):
|
||||||
|
found.append(preview_file)
|
||||||
|
|
||||||
|
# Preview variant (basename.preview.ext)
|
||||||
|
preview_file = f"{basename}.preview{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_file)):
|
||||||
|
found.append(preview_file)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _get_preview_path(model_path: str, extension: str) -> str:
|
||||||
|
"""Generate preview file path with given extension"""
|
||||||
|
basename = os.path.splitext(model_path)[0]
|
||||||
|
return f"{basename}{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_all_previews(model_path: str) -> list[str]:
|
||||||
|
"""Get all preview files for a model"""
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
files = search_files(base_dirname)
|
|
||||||
files = folder_paths.filter_files_content_types(files, ["image"])
|
|
||||||
|
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
output: list[str] = []
|
return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||||
for file in files:
|
|
||||||
file_basename = os.path.splitext(file)[0]
|
|
||||||
if file_basename == basename:
|
|
||||||
output.append(file)
|
|
||||||
if file_basename == f"{basename}.preview":
|
|
||||||
output.append(file)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_preview_name(model_path: str):
|
def get_model_preview_name(model_path: str) -> str:
|
||||||
images = get_model_all_images(model_path)
|
"""Get the first available preview file or 'no-preview.png' if none found"""
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
|
||||||
|
|
||||||
for image in images:
|
|
||||||
image_name = os.path.splitext(image)[0]
|
|
||||||
image_ext = os.path.splitext(image)[1]
|
|
||||||
if image_name == basename and image_ext.lower() == ".webp":
|
|
||||||
return image
|
|
||||||
|
|
||||||
return images[0] if len(images) > 0 else "no-preview.png"
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_videos(model_path: str):
|
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
files = search_files(base_dirname)
|
|
||||||
files = folder_paths.filter_files_content_types(files, ["video"])
|
|
||||||
|
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
output: list[str] = []
|
|
||||||
for file in files:
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
file_basename = os.path.splitext(file)[0]
|
# Check direct match first
|
||||||
if file_basename == basename:
|
preview_name = f"{basename}{ext}"
|
||||||
output.append(file)
|
if os.path.isfile(join_path(base_dirname, preview_name)):
|
||||||
if file_basename == f"{basename}.preview":
|
return preview_name
|
||||||
output.append(file)
|
|
||||||
return output
|
# Check preview variant
|
||||||
|
preview_name = f"{basename}.preview{ext}"
|
||||||
|
if os.path.isfile(join_path(base_dirname, preview_name)):
|
||||||
|
return preview_name
|
||||||
|
|
||||||
|
return "no-preview.png"
|
||||||
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
def remove_model_preview_image(model_path: str):
|
def remove_model_preview(model_path: str):
|
||||||
basename = os.path.splitext(model_path)[0]
|
"""Remove all preview files for a model"""
|
||||||
preview_path = f"{basename}.webp"
|
base_dirname = os.path.dirname(model_path)
|
||||||
if os.path.exists(preview_path):
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
os.remove(preview_path)
|
|
||||||
|
previews = _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||||
|
for preview in previews:
|
||||||
|
preview_path = join_path(base_dirname, preview)
|
||||||
|
if os.path.exists(preview_path):
|
||||||
|
os.remove(preview_path)
|
||||||
|
|
||||||
|
|
||||||
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: Optional[str] = None):
|
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
|
||||||
basename = os.path.splitext(model_path)[0]
|
"""Save a preview file for a model. Images -> WebP, videos -> original format"""
|
||||||
preview_path = f"{basename}.webp"
|
|
||||||
# Download image file if it is url
|
# Download file if it is a URL
|
||||||
if type(image_file_or_url) is str:
|
if type(file_or_url) is str:
|
||||||
image_url = image_file_or_url
|
url = file_or_url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
image_response = requests.get(image_url)
|
response = requests.get(url)
|
||||||
image_response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
image = Image.open(BytesIO(image_response.content))
|
# Determine content type from response headers or URL extension
|
||||||
image.save(preview_path, "WEBP")
|
content_type = response.headers.get('content-type', '')
|
||||||
|
if not content_type:
|
||||||
|
# Fallback to URL extension detection
|
||||||
|
content_type = resolve_file_content_type(url) or ''
|
||||||
|
|
||||||
|
content = response.content
|
||||||
|
|
||||||
|
if content_type.startswith("video/"):
|
||||||
|
# Save video in original format
|
||||||
|
# Try to get extension from URL or content-type
|
||||||
|
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type) or '.mp4'
|
||||||
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
else:
|
||||||
|
# Default to image processing for unknown or image types
|
||||||
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
|
image = Image.open(BytesIO(content))
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(f"Failed to download image: {e}")
|
print_error(f"Failed to download preview: {e}")
|
||||||
|
|
||||||
|
# Handle uploaded file
|
||||||
else:
|
else:
|
||||||
# Assert image as file
|
file_obj = file_or_url
|
||||||
image_file = image_file_or_url
|
|
||||||
|
|
||||||
if not isinstance(image_file, web.FileField):
|
if not isinstance(file_obj, web.FileField):
|
||||||
raise RuntimeError("Invalid image file")
|
raise RuntimeError("Invalid file")
|
||||||
|
|
||||||
content_type: str = image_file.content_type
|
content_type: str = file_obj.content_type
|
||||||
if not content_type.startswith("image/"):
|
filename: str = getattr(file_obj, 'filename', '')
|
||||||
if platform == "huggingface":
|
|
||||||
# huggingface previewFile content_type='text/plain', not startswith("image/")
|
if content_type.startswith("video/"):
|
||||||
return
|
# Save video in original format for now, consider transcoding to webm to follow the pattern for images converting to webp
|
||||||
else:
|
ext = os.path.splitext(filename.lower())[1] or '.mp4'
|
||||||
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
preview_path = _get_preview_path(model_path, ext)
|
||||||
image = Image.open(image_file.file)
|
file_obj.file.seek(0)
|
||||||
image.save(preview_path, "WEBP")
|
content = file_obj.file.read()
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
elif content_type.startswith("image/"):
|
||||||
|
# Convert image to webp
|
||||||
|
preview_path = _get_preview_path(model_path, ".webp")
|
||||||
|
image = Image.open(file_obj.file)
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"FileTypeError: expected image or video, got {content_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_video_extension_from_url(url: str) -> Optional[str]:
|
||||||
|
"""Extract video extension from URL."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
path = urlparse(url).path.lower()
|
||||||
|
for ext in VIDEO_EXTENSIONS:
|
||||||
|
if path.endswith(ext):
|
||||||
|
return ext
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_extension_from_content_type(content_type: str) -> Optional[str]:
|
||||||
|
"""Map content-type to file extension."""
|
||||||
|
return VIDEO_CONTENT_TYPE_MAP.get(content_type.lower())
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_descriptions(model_path: str):
|
def get_model_all_descriptions(model_path: str):
|
||||||
@@ -398,7 +467,7 @@ def rename_model(model_path: str, new_model_path: str):
|
|||||||
shutil.move(model_path, new_model_path)
|
shutil.move(model_path, new_model_path)
|
||||||
|
|
||||||
# move preview
|
# move preview
|
||||||
previews = get_model_all_images(model_path)
|
previews = get_model_all_previews(model_path)
|
||||||
for preview in previews:
|
for preview in previews:
|
||||||
preview_path = join_path(model_dirname, preview)
|
preview_path = join_path(model_dirname, preview)
|
||||||
preview_name = os.path.splitext(preview)[0]
|
preview_name = os.path.splitext(preview)[0]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-model-manager"
|
name = "comfyui-model-manager"
|
||||||
description = "Manage models: browsing, download and delete."
|
description = "Manage models: browsing, download and delete."
|
||||||
version = "2.7.0"
|
version = "2.8.3"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
|
|||||||
25
src/App.vue
25
src/App.vue
@@ -80,8 +80,25 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLayout = () => {
|
||||||
|
// flip the flat setting
|
||||||
|
const newValue = !config.flat.value
|
||||||
|
config.flat.value = newValue
|
||||||
|
|
||||||
|
// persist so it survives reloads
|
||||||
|
app.ui?.settings.setSettingValue('ModelManager.UI.Flat', newValue)
|
||||||
|
|
||||||
|
// close the current dialog (because it is keepAlive)
|
||||||
|
dialog.closeAll()
|
||||||
|
|
||||||
|
// reopen with the new layout
|
||||||
|
openManagerDialog()
|
||||||
|
}
|
||||||
|
|
||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect, flat } = config
|
const { cardWidth, gutter, aspect, flat } = config
|
||||||
|
// choose icon depending on current layout
|
||||||
|
const layoutIcon = flat.value ? 'pi pi-folder-open' : 'pi pi-th-large'
|
||||||
|
|
||||||
if (firstOpenManager.value) {
|
if (firstOpenManager.value) {
|
||||||
models.refresh(true)
|
models.refresh(true)
|
||||||
@@ -99,6 +116,14 @@ onMounted(() => {
|
|||||||
icon: 'mdi mdi-folder-search-outline text-lg',
|
icon: 'mdi mdi-folder-search-outline text-lg',
|
||||||
command: openModelScanning,
|
command: openModelScanning,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'toggle-layout',
|
||||||
|
icon: layoutIcon,
|
||||||
|
command: toggleLayout,
|
||||||
|
tooltip: flat.value
|
||||||
|
? t('switchToFolderView')
|
||||||
|
: t('switchToFlatView'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'refresh',
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
>
|
>
|
||||||
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
||||||
<div class="h-18 preview-aspect">
|
<div class="h-18 preview-aspect">
|
||||||
<img :src="item.preview" />
|
<div v-if="isVideoUrl(item.preview)" class="h-full w-full">
|
||||||
|
<PreviewVideo :src="item.preview" />
|
||||||
|
</div>
|
||||||
|
<img v-else :src="item.preview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||||
@@ -72,11 +75,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
import { useContainerQueries } from 'hooks/container'
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import { useDownload } from 'hooks/download'
|
import { useDownload } from 'hooks/download'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
|||||||
@@ -225,17 +225,33 @@ const list = computed(() => {
|
|||||||
return !item.isFolder
|
return !item.isFolder
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterList = pureModels.filter((model) => {
|
function buildRegex(raw: string): RegExp {
|
||||||
const showAllModel = currentType.value === allType
|
try {
|
||||||
|
// Escape regex specials, then restore * wildcards as .*
|
||||||
|
const escaped = raw
|
||||||
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\\\*/g, '.*')
|
||||||
|
return new RegExp(escaped, 'i') // case-insensitive
|
||||||
|
} catch (e) {
|
||||||
|
return new RegExp(raw, 'i')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
const filterList = pureModels.filter((model) => {
|
||||||
|
const showAllModel = currentType.value === allType
|
||||||
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
|
|
||||||
const filter = searchContent.value?.toLowerCase() ?? ''
|
const rawFilter = searchContent.value ?? ''
|
||||||
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
|
const tokens = rawFilter.split(/\s+/).filter(Boolean)
|
||||||
const matchName = model.basename.toLowerCase().includes(filter)
|
const regexes = tokens.map(buildRegex)
|
||||||
|
|
||||||
return matchType && (matchSubFolder || matchName)
|
// Require every token to match either the folder or the name
|
||||||
})
|
const matchesAll = regexes.every((re) =>
|
||||||
|
re.test(model.subFolder) || re.test(model.basename)
|
||||||
|
)
|
||||||
|
|
||||||
|
return matchType && matchesAll
|
||||||
|
})
|
||||||
|
|
||||||
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
||||||
switch (sortOrder.value) {
|
switch (sortOrder.value) {
|
||||||
@@ -262,6 +278,7 @@ const list = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const contentStyle = computed(() => ({
|
const contentStyle = computed(() => ({
|
||||||
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
||||||
gap: `${gutter}px`,
|
gap: `${gutter}px`,
|
||||||
|
|||||||
@@ -25,19 +25,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="model.previewType === 'video'"
|
v-else-if="isVideoUrl(preview)"
|
||||||
class="h-full w-full p-1 hover:p-0"
|
class="h-full w-full p-1 hover:p-0"
|
||||||
>
|
>
|
||||||
<video
|
<PreviewVideo :src="preview" />
|
||||||
class="h-full w-full object-cover"
|
|
||||||
playsinline
|
|
||||||
autoplay
|
|
||||||
loop
|
|
||||||
disablepictureinpicture
|
|
||||||
preload="none"
|
|
||||||
>
|
|
||||||
<source :src="preview" />
|
|
||||||
</video>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="h-full w-full p-1 hover:p-0">
|
<div v-else class="h-full w-full p-1 hover:p-0">
|
||||||
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
||||||
@@ -81,8 +72,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import { useModelNodeAction } from 'hooks/model'
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
import { BaseModel } from 'types/typings'
|
import { BaseModel } from 'types/typings'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -5,17 +5,17 @@
|
|||||||
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
||||||
:style="$sm({ width: `${cardWidth}px` })"
|
:style="$sm({ width: `${cardWidth}px` })"
|
||||||
>
|
>
|
||||||
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
|
<div
|
||||||
<video
|
v-if="
|
||||||
class="h-full w-full object-cover"
|
preview &&
|
||||||
playsinline
|
isVideoUrl(
|
||||||
autoplay
|
preview,
|
||||||
loop
|
currentType === 'local' ? localContentType : undefined,
|
||||||
disablepictureinpicture
|
)
|
||||||
preload="none"
|
"
|
||||||
>
|
class="h-full w-full p-1 hover:p-0"
|
||||||
<source :src="preview" />
|
>
|
||||||
</video>
|
<PreviewVideo :src="preview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResponseImage
|
<ResponseImage
|
||||||
@@ -48,7 +48,14 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #item="slotProps">
|
<template #item="slotProps">
|
||||||
|
<div
|
||||||
|
v-if="isVideoUrl(slotProps.data)"
|
||||||
|
class="h-full w-full p-1 hover:p-0"
|
||||||
|
>
|
||||||
|
<PreviewVideo :src="slotProps.data" />
|
||||||
|
</div>
|
||||||
<ResponseImage
|
<ResponseImage
|
||||||
|
v-else
|
||||||
:src="slotProps.data"
|
:src="slotProps.data"
|
||||||
:error="noPreviewContent"
|
:error="noPreviewContent"
|
||||||
></ResponseImage>
|
></ResponseImage>
|
||||||
@@ -98,6 +105,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||||
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
||||||
import ResponseImage from 'components/ResponseImage.vue'
|
import ResponseImage from 'components/ResponseImage.vue'
|
||||||
import ResponseInput from 'components/ResponseInput.vue'
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
@@ -106,13 +114,13 @@ import { useContainerQueries } from 'hooks/container'
|
|||||||
import { useModelPreview } from 'hooks/model'
|
import { useModelPreview } from 'hooks/model'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Carousel from 'primevue/carousel'
|
import Carousel from 'primevue/carousel'
|
||||||
|
import { isVideoUrl } from 'utils/media'
|
||||||
|
|
||||||
const editable = defineModel<boolean>('editable')
|
const editable = defineModel<boolean>('editable')
|
||||||
const { cardWidth } = useConfig()
|
const { cardWidth } = useConfig()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
preview,
|
preview,
|
||||||
previewType,
|
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
defaultContent,
|
defaultContent,
|
||||||
@@ -120,6 +128,7 @@ const {
|
|||||||
networkContent,
|
networkContent,
|
||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
|
localContentType,
|
||||||
} = useModelPreview()
|
} = useModelPreview()
|
||||||
|
|
||||||
const { $sm, $xl } = useContainerQueries()
|
const { $sm, $xl } = useContainerQueries()
|
||||||
|
|||||||
25
src/components/PreviewVideo.vue
Normal file
25
src/components/PreviewVideo.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<video
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
playsinline
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
disablepictureinpicture
|
||||||
|
:preload="preload"
|
||||||
|
>
|
||||||
|
<source :src="src" type="video/mp4" />
|
||||||
|
<source :src="src" type="video/webm" />
|
||||||
|
</video>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
preload?: 'none' | 'metadata' | 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
preload: 'metadata',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -46,7 +46,7 @@ const handleDropFile = (event: DragEvent) => {
|
|||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'image/*'
|
input.accept = 'image/*,video/*'
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
const files = input.files
|
const files = input.files
|
||||||
if (files) {
|
if (files) {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const useModelExplorer = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
metadata: {},
|
metadata: {},
|
||||||
preview: '',
|
preview: '',
|
||||||
previewType: 'image',
|
|
||||||
type: folder ?? '',
|
type: folder ?? '',
|
||||||
isFolder: true,
|
isFolder: true,
|
||||||
children: [],
|
children: [],
|
||||||
|
|||||||
@@ -553,9 +553,11 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
* Local file url
|
* Local file url
|
||||||
*/
|
*/
|
||||||
const localContent = ref<string>()
|
const localContent = ref<string>()
|
||||||
|
const localContentType = ref<string>()
|
||||||
const updateLocalContent = async (event: SelectEvent) => {
|
const updateLocalContent = async (event: SelectEvent) => {
|
||||||
const { files } = event
|
const { files } = event
|
||||||
localContent.value = files[0].objectURL
|
localContent.value = files[0].objectURL
|
||||||
|
localContentType.value = files[0].type
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,16 +589,13 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
return content
|
return content
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewType = computed(() => {
|
|
||||||
return model.value.previewType
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerReset(() => {
|
registerReset(() => {
|
||||||
currentType.value = 'default'
|
currentType.value = 'default'
|
||||||
defaultContentPage.value = 0
|
defaultContentPage.value = 0
|
||||||
networkContent.value = undefined
|
networkContent.value = undefined
|
||||||
localContent.value = undefined
|
localContent.value = undefined
|
||||||
|
localContentType.value = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
registerSubmit((data) => {
|
registerSubmit((data) => {
|
||||||
@@ -606,7 +605,6 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
preview,
|
preview,
|
||||||
previewType,
|
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
// default value
|
// default value
|
||||||
@@ -616,6 +614,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
networkContent,
|
networkContent,
|
||||||
// local file
|
// local file
|
||||||
localContent,
|
localContent,
|
||||||
|
localContentType,
|
||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
// no preview
|
// no preview
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
|
|||||||
1
src/types/typings.d.ts
vendored
1
src/types/typings.d.ts
vendored
@@ -11,7 +11,6 @@ export interface BaseModel {
|
|||||||
pathIndex: number
|
pathIndex: number
|
||||||
isFolder: boolean
|
isFolder: boolean
|
||||||
preview: string | string[]
|
preview: string | string[]
|
||||||
previewType: string
|
|
||||||
description: string
|
description: string
|
||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/utils/media.ts
Normal file
53
src/utils/media.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Media file utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = [
|
||||||
|
'.mp4',
|
||||||
|
'.webm',
|
||||||
|
'.mov',
|
||||||
|
'.avi',
|
||||||
|
'.mkv',
|
||||||
|
'.flv',
|
||||||
|
'.wmv',
|
||||||
|
'.m4v',
|
||||||
|
'.ogv',
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIDEO_HOST_PATTERNS = [
|
||||||
|
'/video', // Civitai video URLs often end with /video
|
||||||
|
'type=video', // URLs with video type parameter
|
||||||
|
'format=video', // URLs with video format parameter
|
||||||
|
'video.civitai.com', // Civitai video domain
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a URL points to a video based on extension or URL patterns
|
||||||
|
* @param url - The URL to check
|
||||||
|
* @param localContentType - Optional MIME type for local files
|
||||||
|
*/
|
||||||
|
export const isVideoUrl = (url: string, localContentType?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
// For local files with known MIME type
|
||||||
|
if (localContentType && localContentType.startsWith('video/')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlLower = url.toLowerCase()
|
||||||
|
|
||||||
|
// First check if URL ends with a video extension
|
||||||
|
for (const ext of VIDEO_EXTENSIONS) {
|
||||||
|
if (urlLower.endsWith(ext)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL contains a video extension anywhere (for complex URLs like Civitai)
|
||||||
|
if (VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific video hosting patterns
|
||||||
|
return VIDEO_HOST_PATTERNS.some((pattern) => urlLower.includes(pattern))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user