4 Commits
v2.8.1 ... main

Author SHA1 Message Date
Ainaemaet
e88a77f224 feat(search): add multi-token regex and wildcard support (#211)
* feat(search): add multi-token regex and wildcard support

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

* increate the version
2025-09-05 16:45:51 +08:00
5 changed files with 131 additions and 84 deletions

View File

@@ -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

View File

@@ -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',
@@ -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:

View File

@@ -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.8.1" version = "2.8.3"
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = ["markdownify"] dependencies = ["markdownify"]

View File

@@ -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',

View File

@@ -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`,