9 Commits

Author SHA1 Message Date
Hayden
811f1bc352 Support optional in py3.9 (#165)
* fix: support optional in py3.9

* prepare release 2.5.4
2025-03-14 17:02:54 +08:00
Hayden
5342b7ec92 fix: miss property (#163) 2025-03-06 10:34:30 +08:00
Hayden
30e1714397 159 python version compatible (#160)
* fix: double quotes nest in f-strings

* prepare release 2.5.3
2025-03-04 15:17:20 +08:00
Hayden
384a106917 pref: optimize dialog property (#158) 2025-03-03 17:02:03 +08:00
Hayden
7378a7deae Feat optimize preview (#156)
* pref: change code structure

* feat(information): support gif preview

* feat(information): support video preview
2025-03-03 14:50:06 +08:00
Hayden
1975e2056d 152 cant click through some nested dirs in tree view (#157)
* fix: basename error

* prepare release 2.5.2
2025-03-03 14:36:13 +08:00
Hayden
8877c1599b prepare release 2.5.1 2025-02-24 11:09:08 +08:00
Hayden
965905305e fix: find subfolder incorrect (#154) 2025-02-24 11:07:43 +08:00
Hayden
312138f981 fix: auto open root folder (#151) 2025-02-22 18:30:29 +08:00
13 changed files with 228 additions and 48 deletions

View File

@@ -1,14 +1,22 @@
import os
import re
import math
import yaml
import requests
import markdownify
import folder_paths
from aiohttp import web
from abc import ABC, abstractmethod
from urllib.parse import urlparse, parse_qs
from PIL import Image
from io import BytesIO
from . import utils
from . import config
class ModelSearcher(ABC):
@@ -282,25 +290,6 @@ class HuggingfaceModelSearcher(ModelSearcher):
return _filter_tree_files
def get_model_searcher_by_url(url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()
import folder_paths
from . import config
from aiohttp import web
class Information:
def add_routes(self, routes):
@@ -347,18 +336,30 @@ class Information:
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
content_type = utils.resolve_file_content_type(filename)
if content_type == "video":
abs_path = utils.get_full_path(model_type, index, filename)
return web.FileResponse(abs_path)
extension_uri = config.extension_uri
try:
folders = folder_paths.get_folder_paths(model_type)
base_path = folders[index]
abs_path = utils.join_path(base_path, filename)
preview_name = utils.get_model_preview_name(abs_path)
if preview_name:
dir_name = os.path.dirname(abs_path)
abs_path = utils.join_path(dir_name, preview_name)
except:
abs_path = extension_uri
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(abs_path)
image_data = self.get_image_preview_data(abs_path)
return web.Response(body=image_data.getvalue(), content_type="image/webp")
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
@@ -373,11 +374,69 @@ class Information:
return web.FileResponse(preview_path)
def get_image_preview_data(self, filename: str):
with Image.open(filename) as img:
max_size = 1024
original_format = img.format
exif_data = img.info.get("exif")
icc_profile = img.info.get("icc_profile")
if getattr(img, "is_animated", False) and img.n_frames > 1:
total_frames = img.n_frames
step = max(1, math.ceil(total_frames / 30))
frames, durations = [], []
for frame_idx in range(0, total_frames, step):
img.seek(frame_idx)
frame = img.copy()
frame.thumbnail((max_size, max_size), Image.Resampling.NEAREST)
frames.append(frame)
durations.append(img.info.get("duration", 100) * step)
save_args = {
"format": "WEBP",
"save_all": True,
"append_images": frames[1:],
"duration": durations,
"loop": 0,
"quality": 80,
"method": 0,
"allow_mixed": False,
}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img_byte_arr = BytesIO()
frames[0].save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
img_byte_arr = BytesIO()
save_args = {"format": "WEBP", "quality": 80}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img.save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
def fetch_model_info(self, model_page: str):
if not model_page:
return []
model_searcher = get_model_searcher_by_url(model_page)
model_searcher = self.get_model_searcher_by_url(model_page)
result = model_searcher.search_by_url(model_page)
return result
@@ -435,3 +494,12 @@ class Information:
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
utils.print_debug("Completed scan model information.")
def get_model_searcher_by_url(self, url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()

View File

@@ -124,17 +124,29 @@ class ModelManager:
if not prefix_path.endswith("/"):
prefix_path = f"{prefix_path}/"
is_file = entry.is_file()
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0]
extension = os.path.splitext(filename)[1]
basename = os.path.splitext(filename)[0] if is_file else filename
extension = os.path.splitext(filename)[1] if is_file else ""
is_file = entry.is_file()
if is_file and extension not in folder_paths.supported_pt_extensions:
return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
preview_type = "image"
preview_ext = ".webp"
preview_images = utils.get_model_all_images(entry.path)
if len(preview_images) > 0:
preview_type = "image"
preview_ext = ".webp"
else:
preview_videos = utils.get_model_all_videos(entry.path)
if len(preview_videos) > 0:
preview_type = "video"
preview_ext = f".{preview_videos[0].split('.')[-1]}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
stat = entry.stat()
return {
@@ -146,6 +158,7 @@ class ModelManager:
"pathIndex": path_index,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"previewType": preview_type,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}

View File

@@ -8,12 +8,13 @@ import requests
import traceback
import configparser
import functools
import mimetypes
import comfy.utils
import folder_paths
from aiohttp import web
from typing import Any
from typing import Any, Optional
from . import config
@@ -149,6 +150,20 @@ def resolve_model_base_paths() -> dict[str, list[str]]:
return model_base_paths
def resolve_file_content_type(filename: str):
extension_mimetypes_cache = folder_paths.extension_mimetypes_cache
extension = filename.split(".")[-1]
if extension not in extension_mimetypes_cache:
mime_type, _ = mimetypes.guess_type(filename, strict=False)
if not mime_type:
return None
content_type = mime_type.split("/")[0]
extension_mimetypes_cache[extension] = content_type
else:
content_type = extension_mimetypes_cache[extension]
return content_type
def get_full_path(model_type: str, path_index: int, filename: str):
"""
Get the absolute path in the model type through string concatenation.
@@ -266,6 +281,22 @@ def get_model_preview_name(model_path: str):
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)
files = search_files(base_dirname)
files = folder_paths.filter_files_content_types(files, ["video"])
basename = os.path.splitext(os.path.basename(model_path))[0]
output: list[str] = []
for file in files:
file_basename = os.path.splitext(file)[0]
if file_basename == basename:
output.append(file)
if file_basename == f"{basename}.preview":
output.append(file)
return output
from PIL import Image
from io import BytesIO
@@ -277,7 +308,7 @@ def remove_model_preview_image(model_path: str):
os.remove(preview_path)
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: Optional[str] = None):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
# Download image file if it is url

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete."
version = "2.5.0"
version = "2.5.4"
license = { file = "LICENSE" }
dependencies = ["markdownify"]

View File

@@ -193,7 +193,10 @@ const sortOrderOptions = ref(
const currentDataList = computed(() => {
let renderedList = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(renderedList, folderItem.name)
const found = findFolder(renderedList, {
basename: folderItem.name,
pathIndex: folderItem.pathIndex,
})
renderedList = found?.children || []
}

View File

@@ -1,16 +1,9 @@
<template>
<ResponseDialog
v-for="(item, index) in stack"
v-model:visible="item.visible"
:key="item.key"
:keep-alive="item.keepAlive"
:default-size="item.defaultSize"
:default-mobile-size="item.defaultMobileSize"
:resize-allow="item.resizeAllow"
:min-width="item.minWidth"
:max-width="item.maxWidth"
:min-height="item.minHeight"
:max-height="item.maxHeight"
v-model:visible="item.visible"
v-bind="omitProps(item)"
:auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:pt:root:onMousedown="() => rise(item)"
@@ -42,7 +35,8 @@
<script setup lang="ts">
import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog'
import { type DialogItem, useDialog } from 'hooks/dialog'
import { omit } from 'lodash'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
@@ -55,4 +49,15 @@ const { config } = usePrimeVue()
const baseZIndex = computed(() => {
return config.zIndex?.modal ?? 1100
})
const omitProps = (item: DialogItem) => {
return omit(item, [
'key',
'visible',
'title',
'headerButtons',
'content',
'contentProps',
])
}
</script>

View File

@@ -24,6 +24,21 @@
></path>
</svg>
</div>
<div
v-else-if="model.previewType === 'video'"
class="h-full w-full p-1 hover:p-0"
>
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
disablepictureinpicture
preload="none"
>
<source :src="preview" />
</video>
</div>
<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" />
</div>

View File

@@ -5,7 +5,24 @@
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
:style="$sm({ width: `${cardWidth}px` })"
>
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
disablepictureinpicture
preload="none"
>
<source :src="preview" />
</video>
</div>
<ResponseImage
v-else
:src="preview"
:error="noPreviewContent"
></ResponseImage>
<Carousel
v-if="defaultContent.length > 1"
@@ -95,6 +112,7 @@ const { cardWidth } = useConfig()
const {
preview,
previewType,
typeOptions,
currentType,
defaultContent,

View File

@@ -3,6 +3,7 @@
ref="dialogRef"
:visible="true"
@update:visible="updateVisible"
:modal="modal"
:close-on-escape="false"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
@@ -91,6 +92,7 @@ interface Props {
minHeight?: number
maxHeight?: number
zIndex?: number
modal?: boolean
}
const props = withDefaults(defineProps<Props>(), {

View File

@@ -8,7 +8,7 @@ interface HeaderButton {
command: () => void
}
interface DialogItem {
export interface DialogItem {
key: string
title: string
content: Component
@@ -22,6 +22,7 @@ interface DialogItem {
maxWidth?: number
minHeight?: number
maxHeight?: number
modal?: boolean
}
export const useDialog = defineStore('dialog', () => {

View File

@@ -1,10 +1,11 @@
import { genModelFullName, useModels } from 'hooks/model'
import { cloneDeep, filter, find } from 'lodash'
import { BaseModel, Model, SelectOptions } from 'types/typings'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
export interface FolderPathItem {
name: string
pathIndex: number
icon?: string
onClick: () => void
children: SelectOptions[]
@@ -26,7 +27,7 @@ export type TreeItemNode = ModelTreeNode & {
}
export const useModelExplorer = () => {
const { data, folders, ...modelRest } = useModels()
const { data, folders, initialized, ...modelRest } = useModels()
const folderPaths = ref<FolderPathItem[]>([])
@@ -45,6 +46,7 @@ export const useModelExplorer = () => {
description: '',
metadata: {},
preview: '',
previewType: 'image',
type: folder ?? '',
isFolder: true,
children: [],
@@ -91,10 +93,11 @@ export const useModelExplorer = () => {
return [root]
})
function findFolder(list: ModelTreeNode[], name: string) {
return find(list, { isFolder: true, basename: name }) as
| ModelFolder
| undefined
function findFolder(
list: ModelTreeNode[],
feature: { basename: string; pathIndex: number },
) {
return find(list, { ...feature, isFolder: true }) as ModelFolder | undefined
}
function findFolders(list: ModelTreeNode[]) {
@@ -118,7 +121,12 @@ export const useModelExplorer = () => {
let levelFolders = findFolders(dataTreeList.value)
for (const [index, part] of pathParts.entries()) {
const currentFolder = findFolder(levelFolders, part)
const pathIndex = index < 2 ? 0 : item.pathIndex
const currentFolder = findFolder(levelFolders, {
basename: part,
pathIndex: pathIndex,
})
if (!currentFolder) {
break
}
@@ -126,6 +134,7 @@ export const useModelExplorer = () => {
levelFolders = findFolders(currentFolder.children ?? [])
folderItems.push({
name: currentFolder.basename,
pathIndex: pathIndex,
icon: index === 0 ? 'pi pi-desktop' : '',
onClick: () => {
openFolder(currentFolder)
@@ -144,6 +153,12 @@ export const useModelExplorer = () => {
folderPaths.value = folderItems
}
watch(initialized, (val) => {
if (val) {
openFolder(dataTreeList.value[0])
}
})
return {
folders,
folderPaths,

View File

@@ -50,10 +50,12 @@ export const useModels = defineStore('models', (store) => {
const loading = useLoading()
const folders = ref<ModelFolder>({})
const initialized = ref(false)
const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
initialized.value = true
})
}
@@ -233,6 +235,7 @@ export const useModels = defineStore('models', (store) => {
}
return {
initialized: initialized,
folders: folders,
data: models,
refresh: refreshAllModels,
@@ -576,6 +579,10 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
return content
})
const previewType = computed(() => {
return model.value.previewType
})
onMounted(() => {
registerReset(() => {
currentType.value = 'default'
@@ -591,6 +598,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
const result = {
preview,
previewType,
typeOptions,
currentType,
// default value

View File

@@ -11,6 +11,7 @@ export interface BaseModel {
pathIndex: number
isFolder: boolean
preview: string | string[]
previewType: string
description: string
metadata: Record<string, string>
}