feat(browser): Enhance UI/UX and fix multiple bugs

This commit introduces a major overhaul to the AIIA Browser, addressing several UI/UX issues and bugs to create a more robust and intuitive experience.

Key changes include:

1.  **Fix List View Button Rendering**: Resolved a race condition where the "Load Workflow" button wouldn't appear in list view. The browser now waits for all metadata to be loaded before the initial render, ensuring UI consistency.

2.  **Fix Workflow Name on Load**: Correctly sets the workflow name in the ComfyUI title bar upon loading by using the appropriate API for both standard and API-formatted workflows.

3.  **Fix Icon Sizing Slider**: The icon size slider now updates the view instantly and correctly by triggering a full, clean re-render of the icon view, preventing display artifacts and state conflicts.

4.  **Implement Directory Refresh**:
    - Added a manual refresh button to the navigation bar.
    - The browser now automatically refreshes the directory content when switching between icon and list views. This ensures that newly created or deleted files are correctly reflected in the UI.

5.  **Refactor Icon Button Layout**: The download and load workflow buttons in the icon view are now a hover-activated overlay at the top of the thumbnail, improving the visual layout.

6.  **Backend Enhancements**: Includes various improvements to the Python backend for more robust thumbnail generation and metadata extraction.

---

feat(浏览器): 全面优化UI/UX并修复多个Bug

本次提交对AIIA浏览器进行了一次重大的更新,解决了一系列UI/UX问题和Bug,旨在提供一个更稳定、更直观的用户体验。

主要变更包括:

1.  **修复列表视图按钮渲染问题**: 解决了一个因竞态条件导致的“加载工作流”按钮在列表视图中不显示的Bug。浏览器现在会等待所有元数据加载完毕后才进行初次渲染,确保了UI的一致性。

2.  **修复加载工作流时的名称设置**: 通过为标准格式和API格式的工作流调用正确的API,现在加载工作流后能正确地在ComfyUI标题栏设置其名称。

3.  **修复图标缩���滑块**: 图标大小滑块现在可以通过触发一次完整的、干净的视图重绘来即时、正确地更新视图,避免了显示错位和状态冲突。

4.  **实现目录刷新功能**:
    - 在导航栏添加了手动刷新按钮。
    - 现在当在图标和列表视图之间切换时,浏览器会自动刷新目录内容,确保新建或删除的文件能被正确地反映在界面上。

5.  **重构图标按钮布局**: 图标视图中的下载和加载工作流按钮现在改为在鼠标悬停时,在缩略图顶部显示为一个浮层,优化了视觉布局。

6.  **后端增强**: 包含对Python后端的若干改进,使缩略图生成和元数据提取更加健壮。
This commit is contained in:
Hawk Lee
2025-07-07 19:16:46 +08:00
parent 1d5bce844f
commit 57ea267454
3 changed files with 1152 additions and 72 deletions
+72 -21
View File
@@ -75,7 +75,10 @@ async def _get_file_metadata(file_path: Path):
try:
if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}:
with Image.open(file_path) as img:
img.load() # Force loading of metadata chunks
metadata["width"], metadata["height"] = img.size
if 'prompt' in img.info or 'workflow' in img.info:
metadata['has_workflow'] = True
elif ext in {'.mp4', '.mov', '.avi', '.wav', '.mp3', '.ogg'}:
if not shutil.which("ffprobe"): return metadata
command = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", str(file_path)]
@@ -146,7 +149,7 @@ async def get_thumbnail(request):
cached_thumb_path = cache_sub_dir / f"{Path(filename).stem}.jpg"
if cached_thumb_path.exists() and cached_thumb_path.stat().st_mtime > original_file_path.stat().st_mtime:
return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg'})
return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
with Image.open(original_file_path) as img:
img = ImageOps.exif_transpose(img)
@@ -164,7 +167,7 @@ async def get_thumbnail(request):
asyncio.create_task(write_cache())
return web.Response(body=buffer.getvalue(), content_type='image/jpeg')
return web.Response(body=buffer.getvalue(), content_type='image/jpeg', headers={'Cache-Control': 'public, must-revalidate'})
except Exception as e:
traceback.print_exc()
@@ -180,45 +183,49 @@ async def get_video_poster(request):
try:
original_file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename))
if not original_file_path.is_file(): return web.Response(status=404, text="File not found")
if not original_file_path.is_file() or original_file_path.stat().st_size == 0:
return web.Response(status=404, text="File not found or is empty")
cache_sub_dir = get_safe_path(video_poster_dir, relative_path_str)
cache_sub_dir.mkdir(parents=True, exist_ok=True)
cached_poster_path = cache_sub_dir / f"{Path(filename).stem}.jpg"
if cached_poster_path.exists() and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime:
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'})
if cached_poster_path.exists():
# If the cached file is valid and up-to-date, serve it.
if cached_poster_path.stat().st_size > 0 and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime:
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
else:
# Delete invalid (0-byte) or outdated cache files.
print(f"--- [AIIA Browser Debug] Deleting invalid or outdated cache file: {cached_poster_path}")
os.remove(cached_poster_path)
# 使用临时文件确保 ffmpeg 操作的原子性
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmpfile:
tmp_poster_path = tmpfile.name
# Use the 'thumbnail' filter with the 'update' flag, which is robust for both
# normal videos and special cases like single-frame videos.
command = [
"ffmpeg",
"-ss", "00:00:01.00",
"-i", str(original_file_path),
"-vf", "thumbnail,scale=256:-1:force_original_aspect_ratio=decrease",
"-frames:v", "1",
"-vf", "thumbnail,scale=256:-1",
"-y", # Overwrite output file if it exists
"-update", "1",
"-q:v", "3",
"-y",
str(tmp_poster_path)
]
proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
_, stderr = await proc.communicate()
if proc.returncode != 0:
if proc.returncode != 0 or not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0:
if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path)
print(f"[AIIA] FFmpeg error for {filename}: {stderr.decode()}")
return web.Response(status=500, text="Failed to extract frame from video.")
print(f"[AIIA] FFmpeg failed to extract thumbnail for {filename}. Error: {stderr.decode()}")
return web.Response(status=500, text="Failed to extract thumbnail from video.")
# 检查生成的文件是否有效
if not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0:
if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path)
return web.Response(status=500, text="FFmpeg produced an empty file.")
# 原子性地移动/重命名临时文件到缓存位置
shutil.move(tmp_poster_path, cached_poster_path)
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'})
return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'})
except Exception as e:
if 'tmp_poster_path' in locals() and os.path.exists(tmp_poster_path):
os.remove(tmp_poster_path)
@@ -245,10 +252,54 @@ async def get_batch_metadata(request):
traceback.print_exc()
return web.Response(status=500, text=str(e))
import math
def replace_nan_with_null(obj):
"""
Recursively traverses a Python object (dicts, lists) and replaces
float('nan') with None, which serializes to JSON 'null'.
"""
if isinstance(obj, dict):
return {k: replace_nan_with_null(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [replace_nan_with_null(elem) for elem in obj]
# The core of the fix: only replace actual float NaN values
elif isinstance(obj, float) and math.isnan(obj):
return None
else:
return obj
async def get_workflow(request):
relative_path_str = request.query.get("path", "")
filename = request.query.get("filename", "")
if not filename:
return web.Response(status=400, text="Filename is required.")
try:
file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename))
if not file_path.is_file():
return web.Response(status=404, text="File not found")
with Image.open(file_path) as img:
# Prioritize the full 'workflow' data if it exists, otherwise fall back to 'prompt'.
workflow_data_str = img.info.get('workflow') or img.info.get('prompt')
if workflow_data_str:
workflow_obj = json.loads(workflow_data_str)
sanitized_workflow_obj = replace_nan_with_null(workflow_obj)
return web.json_response(sanitized_workflow_obj)
else:
return web.Response(status=404, text="No workflow data found in the image.")
except Exception as e:
traceback.print_exc()
return web.Response(status=500, text=str(e))
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/list_items', list_items)
server.PromptServer.instance.app.router.add_post('/api/aiia/v1/browser/get_batch_metadata', get_batch_metadata)
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/thumbnail', get_thumbnail)
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/poster', get_video_poster)
server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/get_workflow', get_workflow)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+939 -33
View File
File diff suppressed because it is too large Load Diff
+141 -18
View File
@@ -1,4 +1,4 @@
// aiia_browser_styles.js (V67 - 设置面板样式修复)
// aiia_browser_styles.js (V82 - 虚拟滚动引擎重构)
export const browserStyles = `
/* Main Browser Styles */
#aiia-browser-menu-button { margin-left: 10px; }
@@ -77,16 +77,65 @@ export const browserStyles = `
.aiia-grid-wrapper::-webkit-scrollbar-thumb, .aiia-list-body::-webkit-scrollbar-thumb, .aiia-custom-tooltip::-webkit-scrollbar-thumb, .aiia-filmstrip-container::-webkit-scrollbar-thumb, .aiia-directory-list::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 8px; border: 4px solid transparent; background-clip: content-box; }
.aiia-grid-wrapper:hover::-webkit-scrollbar-thumb, .aiia-list-container:hover .aiia-list-body::-webkit-scrollbar-thumb, .aiia-filmstrip-container:hover::-webkit-scrollbar-thumb, .aiia-directory-list:hover::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.8); }
.aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: auto; overflow-x: hidden; }
.aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; }
.aiia-item-icon { width: 100%; display: flex; flex-direction: column; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
.aiia-icon-preview { position: relative; height: var(--aiia-icon-size, 120px); min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; overflow: hidden; }
.aiia-icon-preview.media-placeholder { background-color: var(--comfy-menu-bg); border: 1px solid var(--border-color); }
.aiia-image-preview, .aiia-video-preview { max-width: 100%; max-height: 100%; object-fit: contain; }
.aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: scroll; overflow-x: hidden; position: relative; }
.aiia-virtual-scroller { position: absolute; top: 0; left: 0; width: 1px; opacity: 0; pointer-events: none; }
.aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; position: absolute; top: 0; left: 0; right: 0; }
.aiia-item-icon {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 5px;
cursor: pointer;
position: relative;
background-color: var(--comfy-menu-bg);
overflow: hidden;
}
.aiia-item-icon:hover .aiia-icon-button-overlay {
opacity: 1;
transform: translateY(0);
}
.aiia-icon-preview {
position: relative;
flex-grow: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
overflow: hidden;
}
.aiia-icon-preview.media-placeholder { background-color: var(--comfy-box-bg); }
.aiia-image-preview, .aiia-video-preview {
max-width: 100%;
max-height: 100%;
object-fit: contain;
width: calc(100% - 16px);
height: calc(100% - 16px);
border-radius: 4px;
}
.aiia-audio-container { display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; gap: 8px; }
.aiia-audio-container audio { width: 90%; }
.aiia-audio-duration { font-size: 11px; color: var(--descrip-text); }
.aiia-item-label { flex-shrink: 0; width: 100%; text-align: center; font-size: 12px; padding: 4px; white-space: normal; word-break: break-all; color: #E5E7EB; }
.aiia-item-label {
flex-shrink: 0;
width: 100%;
text-align: center;
font-size: 12px;
padding: 8px 4px;
white-space: normal;
word-break: break-all;
color: #E5E7EB;
line-height: 1.3;
background-color: rgba(0,0,0,0.2);
}
.aiia-item-label.has-workflow {
background-color: rgba(59, 130, 246, 0.25);
border-top: 1px solid rgba(96, 165, 250, 0.3);
color: #eff6ff;
}
.aiia-video-poster-container::after {
content: '';
@@ -105,7 +154,6 @@ export const browserStyles = `
-webkit-mask-size: 60%;
mask-size: 60%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -125,21 +173,95 @@ export const browserStyles = `
.aiia-tooltip-label { color: #aaa; text-align: right; white-space: nowrap; }
.aiia-tooltip-value { color: #eee; font-weight: bold; white-space: normal; overflow-wrap: break-word; min-width: 0; }
.aiia-tooltip-metadata audio {
width: 100%;
margin-bottom: 8px;
}
.aiia-tooltip-metadata audio::-webkit-media-controls-play-button,
.aiia-tooltip-metadata audio::-webkit-media-controls-mute-button,
.aiia-tooltip-metadata audio::-webkit-media-controls-volume-slider {
display: none;
}
.aiia-list-container { display: flex; flex-direction: column; height: 100%; width: 100%; }
.aiia-list-body { flex-grow: 1; overflow-y: auto; position: relative; }
.aiia-list-body-content { }
.aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr); align-items: center; gap: 8px; padding: 0 8px; }
.aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; }
.aiia-list-body { flex-grow: 1; overflow-y: scroll; position: relative; }
.aiia-list-body-content { position: relative; width: 100%; height: 100%; }
.aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr) 110px; align-items: center; gap: 8px; padding: 0 8px; }
.aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; position: absolute; left: 0; right: 0; }
.aiia-list-header { position: sticky; top: 0; z-index: 10; background: var(--comfy-box-bg); border-bottom: 2px solid var(--border-color); }
.aiia-list-header-cell { padding: 8px 0; font-weight: bold; color: #F9FAFB; cursor: pointer; user-select: none; }
.aiia-list-header-cell:hover { color: var(--link-color); }
.aiia-list-row > div { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; }
.aiia-list-cell { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; }
.aiia-list-cell-icon { font-size: 16px; text-align: center; }
.aiia-list-cell-actions { display: flex; justify-content: center; align-items: center; gap: 4px; }
.aiia-icon-button-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: 8px;
box-sizing: border-box;
display: flex;
justify-content: flex-end;
gap: 6px;
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
pointer-events: none;
}
.aiia-item-icon:hover .aiia-icon-button-overlay,
.aiia-item-icon.actions-visible .aiia-icon-button-overlay {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
.aiia-icon-button-overlay > button {
pointer-events: all;
position: static;
padding: 4px 8px;
font-size: 14px;
line-height: 1;
background-color: rgba(30, 30, 30, 0.8);
color: white;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 4px;
cursor: pointer;
backdrop-filter: blur(2px);
transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out;
}
.aiia-icon-button-overlay > button:hover {
background-color: rgba(0, 123, 255, 0.9);
transform: scale(1.05);
}
.aiia-icon-button-overlay > button:active {
transform: scale(1);
}
/* Hide old, now-unused absolute buttons in icon view */
.aiia-item-icon > .aiia-load-workflow-button,
.aiia-item-icon > .aiia-download-button {
display: none;
}
/* Keep original styles for list view */
.aiia-list-row .aiia-load-workflow-button, .aiia-list-row .aiia-download-button {
position: static;
background-color: transparent;
border: none;
color: #E5E7EB;
font-size: 18px;
}
.aiia-list-row .aiia-load-workflow-button:hover, .aiia-list-row .aiia-download-button:hover {
color: var(--accent-color);
background-color: transparent;
}
.aiia-browser-error, .aiia-browser-placeholder { padding: 20px; text-align: center; font-size: 16px; color: var(--descrip-text); width: 100%; }
.aiia-empty-file-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); }
.aiia-empty-file-placeholder span:first-child { font-size: 24px; }
.aiia-empty-file-placeholder span:last-child { font-size: 11px; margin-top: 4px; }
.aiia-empty-file-placeholder, .aiia-no-preview-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); }
.aiia-empty-file-placeholder span:first-child, .aiia-no-preview-placeholder span:first-child { font-size: 24px; }
.aiia-empty-file-placeholder span:last-child, .aiia-no-preview-placeholder span:last-child { font-size: 11px; margin-top: 4px; }
.aiia-progress-container { display: none; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.aiia-progress-text { font-size: 12px; color: var(--descrip-text); }
@@ -173,4 +295,5 @@ export const browserStyles = `
.aiia-info-panel-row:last-child { margin-bottom: 0; }
.aiia-info-panel-label { color: #aaa; text-align: right; }
.aiia-info-panel-value { font-weight: 500; overflow-wrap: break-word; min-width: 0; }
`;
`;