From 0d200498bf645a0a1a3ca8678652f34f3cf063dd Mon Sep 17 00:00:00 2001 From: "l.y@live.cn" Date: Sun, 15 Jun 2025 14:41:58 +0800 Subject: [PATCH] AIIA Media Browser --- .DS_Store | Bin 0 -> 6148 bytes aiia_browser_node.py | 263 +++++++++++++++++++ js/aiia_browser.js | 474 +++++++++++++++++++++++++++++++++++ js/aiia_browser_styles.js | 176 +++++++++++++ js/aiia_fullscreen_viewer.js | 374 +++++++++++++++++++++++++++ node.zip | Bin 0 -> 37841 bytes package-lock.json | 6 + pyproject.toml | 4 +- 8 files changed, 1295 insertions(+), 2 deletions(-) create mode 100644 .DS_Store create mode 100644 aiia_browser_node.py create mode 100644 js/aiia_browser.js create mode 100644 js/aiia_browser_styles.js create mode 100644 js/aiia_fullscreen_viewer.js create mode 100644 node.zip create mode 100644 package-lock.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1a2f77b681e894ba77c4535327a5a06b6369028f GIT binary patch literal 6148 zcmeHKQA@)x5Kgw~GKSCx1$_(nI&gF17`~J`|9};JP?;?qTCL4kH~TOKeb+zaAMy8i zm!#ly&m!&)lJ72emoy(VmoUb-H;X!qIgBv@4Uwa=LU4Gct73x@InEI#Dg^yK94E2$ z{dK@^udqj!g?Tpq`u*9o-TvT}YHee)F6yEwZv6+9`_o`L%e-K6jn;)yNm%MZco~oK zp|gFe(rFN40<*(1cJ&4EzEEpGWOT*Z=?k literal 0 HcmV?d00001 diff --git a/aiia_browser_node.py b/aiia_browser_node.py new file mode 100644 index 0000000..d6f9a07 --- /dev/null +++ b/aiia_browser_node.py @@ -0,0 +1,263 @@ +# --- START OF FILE aiia_browser_node.py (V19 - 健壮性终极修复) --- + +import os +import server +from aiohttp import web +import json +from pathlib import Path +import time +from PIL import Image, ImageOps +import asyncio +import shutil +import traceback +from io import BytesIO +import re +import tempfile + +print("--- [AIIA] Loading Media Browser API Endpoints (V19 - Robustness Fixes) ---") + +# --- START OF FIX: Path and Cache Logic Refinement --- +try: + # Attempt to find the ComfyUI root directory robustly + comfyui_root_path = Path(__file__).resolve().parents[2] + if not (comfyui_root_path / "main.py").exists(): + current_dir = Path(__file__).resolve().parent + while not (current_dir / "main.py").exists() and current_dir != current_dir.parent: + current_dir = current_dir.parent + if (current_dir / "main.py").exists(): + comfyui_root_path = current_dir + else: + raise FileNotFoundError("Could not find ComfyUI root directory.") + output_dir = comfyui_root_path / "output" + print(f"--- [AIIA] Successfully located ComfyUI root. Output directory set to: {output_dir}") + +except Exception as e: + # Fallback path if auto-detection fails + print(f"--- [AIIA] Warning: Could not auto-detect ComfyUI root path ({e}). Falling back to default './ComfyUI/output'.") + output_dir = Path("./ComfyUI/output") + +# Define cache directories based on the final output_dir. This ensures consistency. +cache_main_dir = output_dir / ".aiia_cache" +image_thumb_dir = cache_main_dir / "thumbnails" +video_poster_dir = cache_main_dir / "posters" + +# Create all necessary directories +try: + output_dir.mkdir(parents=True, exist_ok=True) + cache_main_dir.mkdir(exist_ok=True) + image_thumb_dir.mkdir(exist_ok=True) + video_poster_dir.mkdir(exist_ok=True) + print(f"--- [AIIA] Image thumbnail cache: {image_thumb_dir}") + print(f"--- [AIIA] Video poster cache: {video_poster_dir}") +except Exception as e: + print(f"--- [AIIA] CRITICAL ERROR: Could not create output or cache directories at {output_dir}. Please check permissions. Error: {e}") +# --- END OF FIX: Path and Cache Logic Refinement --- + + +CONCURRENT_LIMIT = 16 +semaphore = asyncio.Semaphore(CONCURRENT_LIMIT) + +def get_safe_path(base_dir, relative_path_str): + try: + safe_base = base_dir.resolve() + clean_path = os.path.normpath(relative_path_str) + if '..' in clean_path.split(os.sep): + raise PermissionError("Path traversal attempt detected.") + target_path = (safe_base / Path(clean_path)).resolve() + target_path.relative_to(safe_base) + return target_path + except ValueError: + raise PermissionError("Forbidden path, not within the base directory.") + + +async def _get_file_metadata(file_path: Path): + async with semaphore: + metadata = {} + try: + if not file_path.exists() or file_path.stat().st_size == 0: + return metadata + except FileNotFoundError: + return metadata + + ext = file_path.suffix.lower() + try: + if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}: + with Image.open(file_path) as img: + metadata["width"], metadata["height"] = img.size + 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)] + proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + ffprobe_data = json.loads(stdout) + if 'format' in ffprobe_data and 'duration' in ffprobe_data['format']: + metadata['duration'] = float(ffprobe_data['format']['duration']) + video_stream = next((s for s in ffprobe_data.get('streams', []) if s.get('codec_type') == 'video'), None) + if video_stream: + metadata['width'] = video_stream.get('width') + metadata['height'] = video_stream.get('height') + except Exception as e: + pass + return metadata + +async def list_items(request): + relative_path_str = request.query.get("path", "") + try: + target_path = get_safe_path(output_dir, relative_path_str) + if not target_path.is_dir(): return web.Response(status=404, text="Directory not found") + + directories, files = [], [] + for item in os.scandir(target_path): + # --- START OF FIX: Precise filtering --- + # Only filter our specific cache directory, not all dotfiles. + if item.name == '.aiia_cache': + continue + # --- END OF FIX: Precise filtering --- + try: + if item.is_dir(): + try: item_count = len([name for name in os.listdir(item.path) if name != '.aiia_cache']) # Also exclude from count + except OSError: item_count = 0 + directories.append({"name": item.name, "type": "directory", "mtime": item.stat().st_mtime, "item_count": item_count}) + elif item.is_file(): + ext = Path(item.name).suffix.lower() + if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.wav', '.mp3', '.ogg'}: + stat_info = item.stat() + file_data = {"name": item.name, "type": "file", "size": stat_info.st_size, "mtime": stat_info.st_mtime, "extension": ext} + files.append(file_data) + except Exception: + continue + + def natural_sort_key(s, _re=re.compile(r'([0-9]+)')): + return [int(text) if text.isdigit() else text.lower() for text in _re.split(s['name'])] + + directories.sort(key=natural_sort_key) + files.sort(key=natural_sort_key) + + return web.json_response({"directories": directories, "files": files}) + except Exception as e: + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_thumbnail(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: + 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") + + cache_sub_dir = get_safe_path(image_thumb_dir, relative_path_str) + cache_sub_dir.mkdir(parents=True, exist_ok=True) + 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'}) + + with Image.open(original_file_path) as img: + img = ImageOps.exif_transpose(img) + if img.mode in ("RGBA", "P"): img = img.convert("RGB") + img.thumbnail((256, 256), Image.Resampling.LANCZOS) + + buffer = BytesIO() + img.save(buffer, format="JPEG", quality=85, optimize=True) + buffer.seek(0) + + async def write_cache(): + try: + with open(cached_thumb_path, 'wb') as f: f.write(buffer.getvalue()) + except Exception as e: print(f"[AIIA] Error writing thumbnail cache for {filename}: {e}") + + asyncio.create_task(write_cache()) + + return web.Response(body=buffer.getvalue(), content_type='image/jpeg') + + except Exception as e: + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_video_poster(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.") + + if not shutil.which("ffmpeg"): + return web.Response(status=501, text="ffmpeg not found on server.") + + 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") + + 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'}) + + # 使用临时文件确保 ffmpeg 操作的原子性 + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmpfile: + tmp_poster_path = tmpfile.name + + command = [ + "ffmpeg", + "-ss", "00:00:01.00", + "-i", str(original_file_path), + "-frames:v", "1", + "-vf", "thumbnail,scale=256:-1", + "-y", # Overwrite output file if it exists + str(tmp_poster_path) + ] + proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + + if proc.returncode != 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.") + + # 检查生成的文件是否有效 + 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'}) + except Exception as e: + if 'tmp_poster_path' in locals() and os.path.exists(tmp_poster_path): + os.remove(tmp_poster_path) + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_batch_metadata(request): + data = await request.json() + path = data.get("path", "") + filenames = data.get("filenames", []) + try: + safe_base = output_dir.resolve() + tasks = [] + valid_filenames = [] + for filename in filenames: + file_path = (safe_base / Path(path) / filename).resolve() + if safe_base in file_path.parents and file_path.is_file(): + tasks.append(_get_file_metadata(file_path)) + valid_filenames.append(filename) + results = await asyncio.gather(*tasks) + response_data = {valid_filenames[i]: results[i] for i in range(len(valid_filenames))} + return web.json_response(response_data) + 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) + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +print("--- [AIIA] Media Browser API Endpoints (V19) loaded successfully. ---") \ No newline at end of file diff --git a/js/aiia_browser.js b/js/aiia_browser.js new file mode 100644 index 0000000..25ce4cb --- /dev/null +++ b/js/aiia_browser.js @@ -0,0 +1,474 @@ +// 文件: aiia_browser.js (V75 - 设置面板终极修复) + +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; +import { $el, ComfyDialog } from "/scripts/ui.js"; +import { AIIAFullscreenViewer } from "./aiia_fullscreen_viewer.js"; +import { browserStyles } from "./aiia_browser_styles.js"; + +const DEBUG = false; +const log = (...args) => DEBUG && console.log("[AIIA Browser Debug]", ...args); + +const SETTINGS_PREFIX = "aiia-browser-settings:"; +function getSetting(key, defaultValue) { + try { + const data = localStorage.getItem(SETTINGS_PREFIX + key); + return data !== null ? JSON.parse(data) : defaultValue; + } catch (e) { + return defaultValue; + } +} +function setSetting(key, value) { + try { + localStorage.setItem(SETTINGS_PREFIX + key, JSON.stringify(value)); + } catch (e) { console.error(`[AIIA] Error setting localStorage for key ${key}:`, e); } +} + +const CACHE_PREFIX = "aiia-cache:"; +function getCacheKey(path, filename) { return `${CACHE_PREFIX}${path ? `${path}/` : ''}${filename}`; } +function getCache(key) { try { const data = localStorage.getItem(key); return data ? JSON.parse(data) : null; } catch (e) { console.error(`[AIIA] Error parsing cache for key ${key}:`, e); return null; } } +function setCache(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.error(`[AIIA] Error setting cache for key ${key}:`, e); } } + +function getFileUrl(filename, path = "") { const subfolder = path || ""; return api.apiURL(`/view?type=output&subfolder=${encodeURIComponent(subfolder)}&filename=${encodeURIComponent(filename)}`); } +function getThumbnailUrl(filename, path = "", mtime = 0) { return `/api/aiia/v1/browser/thumbnail?path=${encodeURIComponent(path)}&filename=${encodeURIComponent(filename)}&mtime=${mtime}`; } +function getVideoPosterUrl(filename, path = "", mtime = 0) { return `/api/aiia/v1/browser/poster?path=${encodeURIComponent(path)}&filename=${encodeURIComponent(filename)}&mtime=${mtime}`; } + +function getMimeType(filename) { if (typeof filename !== 'string') return 'unknown'; const extension = filename.split('.').pop().toLowerCase(); if (['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) return 'image'; if (['mp4', 'webm', 'mov', 'avi'].includes(extension)) return 'video'; if (['mp3', 'wav', 'ogg'].includes(extension)) return 'audio'; return 'unknown'; } +function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } +function formatDate(timestamp) { const date = new Date(timestamp * 1000); return date.toLocaleString(); } +function formatDuration(seconds) { if (isNaN(seconds) || seconds < 0) return '—'; const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } + +class AIIABrowserDialog extends ComfyDialog { + constructor() { + super(); + this.currentPath = null; + this.history = []; + this.itemsData = []; + this.itemsDataMap = new Map(); + this.isNavigating = false; + + this.viewMode = getSetting('viewMode', 'icons'); + this.iconSize = getSetting('iconSize', 120); + this.sortKey = getSetting('sortKey', 'name'); + this.sortDir = getSetting('sortDir', 'asc'); + this.hideFolders = getSetting('hideFolders', false); + this.enableVideoPreviews = getSetting('enableVideoPreviews', false); + + this.metadataLoader = { controller: null, isLoading: false, path: null, isPaused: false }; + this.loadQueue = new Set(); + this.isProcessingQueue = false; + this.scrollDebounceHandle = null; + this.isScrolling = false; + + this.lastMouseEvent = null; + this.isTooltipVisible = false; + this.tooltipTimeout = null; + this.isPointerDownOnSettings = false; + + this.fullscreenViewer = null; + this.visibleItems = []; + this.directoryItems = []; + this.currentFocusIndex = -1; + this.tooltipItemName = null; + this.isKeyboardNavigating = false; + this.lastFocus = { dir: -1, file: -1 }; + this.virtualList = { viewport: null, content: null, scroller: null, rowHeight: 31, buffer: 10, lastRendered: { start: -1, end: -1 }, scrollDebounce: null }; + this.applyFocusRaf = null; + + this.tooltipImage = $el("img.aiia-tooltip-image"); + this.tooltipVideo = $el("video.aiia-tooltip-video", { autoplay: true, muted: true, loop: true, controls: false, volume: 0.8 }); + this.tooltipAudio = $el("audio", { autoplay: true }); + this.tooltipMediaContainer = $el("div.aiia-tooltip-media", [this.tooltipImage, this.tooltipVideo]); + this.tooltipMetadata = $el("div.aiia-tooltip-metadata"); + this.tooltipElement = $el("div.aiia-custom-tooltip", [this.tooltipMediaContainer, this.tooltipMetadata]); + + this.directoryList = $el("div.aiia-directory-list"); + this.directoryPanel = $el("div.aiia-directory-panel", [ $el("div.aiia-directory-panel-header", { textContent: "Folders" }), this.directoryList ]); + this.contentArea = $el("div.aiia-browser-content"); + this.contentPanel = $el("div.aiia-content-panel", [this.contentArea]); + + const splitViewContainer = $el("div.aiia-split-view-container", [this.directoryPanel, this.contentPanel, this.tooltipElement]); + + this.iconViewObserver = new IntersectionObserver(this.handleIconIntersection.bind(this), { root: this.contentArea, rootMargin: '300px 0px 300px 0px' }); + + this.titleElement = $el("span", { textContent: "AIIA Media Browser" }); + this.closeButton = $el("button.close", { textContent: "✖", title: "Close" }); + const titleBar = $el("div.aiia-browser-titlebar", [this.titleElement, this.closeButton]); + this.backButton = $el("button.aiia-browser-nav-button", { textContent: "◀️", disabled: true, title: "Back" }); + this.upButton = $el("button.aiia-browser-nav-button", { textContent: "🔼", disabled: true, title: "Up to parent directory" }); + this.breadcrumbs = $el("div.aiia-browser-breadcrumbs"); + this.pathInputContainer = $el("div.aiia-browser-path-input-container"); + this.pathPrefix = $el("span.aiia-browser-path-prefix", { textContent: "output / " }); + this.pathInput = $el("input.aiia-browser-path-input", { type: "text", dataset: { escapeToList: "true" } }); + this.pathInputContainer.append(this.pathPrefix, this.pathInput); + const navContainer = $el("div.aiia-browser-nav-container", [this.backButton, this.upButton, this.breadcrumbs, this.pathInputContainer]); + + this.hideFoldersCheckbox = $el("input", { type: "checkbox", id: "aiia-hide-folders-cb", checked: this.hideFolders }); + const hideFoldersLabel = $el("label.aiia-settings-label", { htmlFor: "aiia-hide-folders-cb" }, [this.hideFoldersCheckbox, $el("span", {textContent: "Hide Folders"})]); + + this.videoPreviewsCheckbox = $el("input", { type: "checkbox", id: "aiia-video-previews-cb", checked: this.enableVideoPreviews }); + const videoPreviewsLabel = $el("label.aiia-settings-label", { htmlFor: "aiia-video-previews-cb" }, [this.videoPreviewsCheckbox, $el("span", {textContent: "Enable Video Previews"})]); + + this.settingsPanel = $el("div.aiia-settings-panel", {style: {display: "none"}, tabindex: -1}, [ + $el("div", [$el("strong", {textContent: "Display"})]), + hideFoldersLabel, + videoPreviewsLabel, + ]); + this.settingsButton = $el("button.aiia-browser-settings-button", {textContent: "⚙️"}); + this.settingsContainer = $el("div.aiia-settings-container", [this.settingsButton, this.settingsPanel]); + + this.sortKeySelect = $el("select", [ $el("option", { value: "name", textContent: "Sort by Name" }), $el("option", { value: "mtime", textContent: "Sort by Date" }), $el("option", { value: "type", textContent: "Sort by Type" }), $el("option", { value: "size", textContent: "Sort by Size" }), $el("option", { value: "dimensions", textContent: "Sort by Dimensions" }), $el("option", { value: "duration", textContent: "Sort by Duration" }) ]); + this.sortDirButton = $el("button", { textContent: "▲" }); + const sortControls = $el("div.aiia-browser-sort-controls", [this.sortKeySelect, this.sortDirButton]); + this.iconViewButton = $el("button", { textContent: "🖼️", dataset: { tooltipText: "Icon View" } }); + this.listViewButton = $el("button", { textContent: "📄", dataset: { tooltipText: "List View" } }); + this.sizeSlider = $el("input", { type: "range", min: 64, max: 256, value: this.iconSize, step: 8, dataset: { tooltipText: "Change icon size", escapeToList: "true" } }); + const viewControls = $el("div.aiia-browser-view-controls", [this.settingsContainer, sortControls, this.iconViewButton, this.listViewButton, this.sizeSlider]); + + this.progressBar = $el("div.aiia-progress-bar", [$el("div")]); + this.progressText = $el("div.aiia-progress-text"); + this.progressContainer = $el("div.aiia-progress-container", [this.progressText, this.progressBar]); + this.progressContainer.style.display = 'none'; + const headerControls = $el("div.aiia-browser-header-controls", [navContainer, viewControls]); + const mainContainer = $el("div.aiia-browser-main-container", [headerControls, this.progressContainer, splitViewContainer]); + this.element.replaceChildren(titleBar, mainContainer); + this.element.classList.add("aiia-browser-dialog-root"); + this.element.setAttribute('tabindex', -1); + this.bindEvents(); + this.applySettings(); + this.updateSortControls(); + this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); if(this.viewMode === 'list') this.renderVisibleRows(); }); + this.resizeObserver.observe(this.contentPanel); + } + + applySettings() { + this.iconViewButton.classList.toggle('active', this.viewMode === 'icons'); + this.listViewButton.classList.toggle('active', this.viewMode === 'list'); + this.sizeSlider.style.display = this.viewMode === 'icons' ? 'inline-block' : 'none'; + document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`); + this.directoryPanel.classList.toggle('hidden', this.hideFolders); + } + + bindEvents() { + this.closeButton.onclick = () => this.close(); + this.backButton.onclick = () => this.goBack(); + this.upButton.onclick = () => this.goUp(); + this.breadcrumbs.addEventListener('click', (e) => { this.showPathInput(); e.stopPropagation(); }); + this.pathInput.onkeydown = (e) => { if (e.key === 'Enter') { this.navigateTo(this.pathInput.value.trim()); this.hidePathInput(); } else if (e.key === 'Escape') { this.hidePathInput(); } }; + this.pathInput.onblur = () => this.hidePathInput(); + + this.element.addEventListener('mousedown', (e) => { + if (this.settingsContainer.contains(e.target)) { + this.isPointerDownOnSettings = true; + } + }); + + this.element.addEventListener('mouseup', (e) => { + this.isPointerDownOnSettings = false; + }); + + this.element.addEventListener('click', (e) => { + if (!this.isPointerDownOnSettings && !this.settingsContainer.contains(e.target)) { + this.settingsPanel.style.display = 'none'; + } + if (!e.target.closest('input, select, button, .aiia-item-icon, .aiia-list-row, .aiia-browser-breadcrumb-link, .aiia-directory-item, .aiia-settings-container')) { + this.element.focus({ preventScroll: true }); + } + }); + + this.element.addEventListener('focusout', (e) => { + if (!this.element.contains(e.relatedTarget)) this.hideTooltip(); + }); + + this.element.addEventListener("dragover", (e) => { e.preventDefault(); }); + this.element.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); }); + + this.settingsButton.onclick = (e) => { + e.stopPropagation(); + const newDisplay = this.settingsPanel.style.display === 'block' ? 'none' : 'block'; + this.settingsPanel.style.display = newDisplay; + if (newDisplay === 'block') { + this.settingsPanel.focus(); + } + }; + + this.hideFoldersCheckbox.onchange = (e) => { this.hideFolders = e.target.checked; setSetting('hideFolders', this.hideFolders); this.render(); }; + this.videoPreviewsCheckbox.onchange = (e) => { this.enableVideoPreviews = e.target.checked; setSetting('enableVideoPreviews', this.enableVideoPreviews); this.render(); }; + + this.sortKeySelect.onchange = (e) => this.handleSortChange(e.target.value, this.sortDir); + this.sortDirButton.onclick = () => this.handleSortChange(this.sortKey, this.sortDir === 'asc' ? 'desc' : 'asc'); + this.iconViewButton.onclick = () => this.setViewMode('icons'); + this.listViewButton.onclick = () => this.setViewMode('list'); + this.sizeSlider.oninput = (e) => { this.iconSize = e.target.value; setSetting('iconSize', this.iconSize); document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`); }; + + const splitViewContainer = this.element.querySelector('.aiia-split-view-container'); + splitViewContainer.addEventListener('mouseover', this.handleTooltipMouseOver); + splitViewContainer.addEventListener('mouseout', this.handleTooltipMouseOut); + splitViewContainer.addEventListener('mousemove', (e) => { if (this.isKeyboardNavigating) this.isKeyboardNavigating = false; this.lastMouseEvent = e; if (this.isTooltipVisible) this.updateTooltipPosition(e); }); + + this.element.addEventListener('dblclick', (e) => { const itemEl = e.target.closest('[data-item-name]'); if (itemEl) { const item = this.itemsDataMap.get(itemEl.dataset.itemName); if(item && item.type === 'file' && item.size > 0) this.openFullscreen(item.name); } }); + + this.element.addEventListener('keydown', this.handleKeyDown.bind(this)); + } + + openFullscreen(itemName) { if (!this.fullscreenViewer) { this.fullscreenViewer = new AIIAFullscreenViewer(); } const filesOnly = this.itemsData.filter(i => i.type === 'file' && i.size > 0); this.sortItems(filesOnly); const currentIndex = filesOnly.findIndex(item => item.name === itemName); if (currentIndex !== -1) { this.fullscreenViewer.show(filesOnly, currentIndex, this.currentPath, () => { this.element.focus({ preventScroll: true }); }); } } + updateTooltipSizeLimits() { const rect = this.contentPanel.getBoundingClientRect(); const maxSize = Math.min(rect.width, rect.height) * 0.30; this.tooltipElement.style.setProperty('--aiia-tooltip-media-max-size', `${maxSize}px`); } + updateSortControls() { this.sortKeySelect.value = this.sortKey; this.sortDirButton.textContent = this.sortDir === 'asc' ? '▲' : '▼'; this.sortDirButton.dataset.tooltipText = `Sort ${this.sortDir === 'asc' ? 'Descending' : 'Ascending'}`; } + showPathInput() { this.breadcrumbs.style.display = 'none'; this.pathInputContainer.style.display = 'flex'; this.pathInput.value = this.currentPath; this.pathInput.focus(); this.pathInput.select(); } + hidePathInput() { this.pathInputContainer.style.display = 'none'; this.breadcrumbs.style.display = 'flex'; if (!this.element.contains(document.activeElement)) { this.element.focus({ preventScroll: true }); } } + + hideTooltip() { clearTimeout(this.tooltipTimeout); if (this.isTooltipVisible) { this.tooltipElement.style.display = 'none'; this.isTooltipVisible = false; this.tooltipItemName = null; this.tooltipImage.src = ""; this.tooltipVideo.src = ""; this.tooltipAudio.src = ""; if (this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; } } + + setFocus(item, element, scrollIntoView = true, trigger = 'mouse') { + if(this.applyFocusRaf) cancelAnimationFrame(this.applyFocusRaf); + clearTimeout(this.tooltipTimeout); + this.hideTooltip(); + + const oldEl = this.element.querySelector('.focused'); + if (oldEl) oldEl.classList.remove('focused'); + + if (!item) { this.currentFocusIndex = -1; return; } + + this.currentFocusIndex = this.getAllNavigableItems().findIndex(i => i.name === item.name); + if(this.currentFocusIndex > -1) { + if(item.type === 'directory') this.lastFocus.dir = this.currentFocusIndex; + else this.lastFocus.file = this.currentFocusIndex; + } + + if (scrollIntoView) this.ensureFocusedItemVisible(); + + const applyFocus = () => { + let elToFocus = element || this.element.querySelector(`[data-item-name="${CSS.escape(item.name)}"]`); + if (this.viewMode === 'list' && !elToFocus) { + this.renderVisibleRows(); + elToFocus = this.element.querySelector(`[data-item-name="${CSS.escape(item.name)}"]`); + } + if (elToFocus) { + const currentlyFocused = this.element.querySelector('.focused'); + if (currentlyFocused) currentlyFocused.classList.remove('focused'); + + elToFocus.classList.add('focused'); + this.tooltipTimeout = setTimeout(() => { + const currentFocusedEl = this.element.querySelector('.focused'); + if (currentFocusedEl && currentFocusedEl.dataset.itemName === item.name) { + const eventForPositioning = (trigger === 'keyboard' || !this.lastMouseEvent) ? { clientX: currentFocusedEl.getBoundingClientRect().right, clientY: currentFocusedEl.getBoundingClientRect().top } : this.lastMouseEvent; + this.updateTooltipContent(item, eventForPositioning); + } + }, 200); + } + this.applyFocusRaf = null; + }; + + if ((this.viewMode === 'list' || !element) && trigger === 'keyboard') { + this.applyFocusRaf = requestAnimationFrame(applyFocus); + } else { + applyFocus(); + } + } + + handleTooltipMouseOver = (e) => { if (this.isKeyboardNavigating) return; const itemEl = e.target.closest('[data-item-name]'); if (itemEl) { const itemData = this.itemsDataMap.get(itemEl.dataset.itemName); if (itemData) this.setFocus(itemData, itemEl, false, 'mouse'); } } + handleTooltipMouseOut = (e) => { if (!e.currentTarget.contains(e.relatedTarget)) this.setFocus(null, null); } + + updateTooltipPosition(event) { if (!event) return; const xOffset = 20, yOffset = 20; const tooltipRect = this.tooltipElement.getBoundingClientRect(); const containerRect = this.element.querySelector('.aiia-split-view-container').getBoundingClientRect(); const mouseX_inContainer = event.clientX - containerRect.left, mouseY_inContainer = event.clientY - containerRect.top; let targetX = mouseX_inContainer + xOffset; if (targetX + tooltipRect.width > containerRect.width) targetX = mouseX_inContainer - tooltipRect.width - xOffset; if (targetX < 0) targetX = 0; let targetY = mouseY_inContainer + yOffset; if (targetY + tooltipRect.height > containerRect.height) targetY = mouseY_inContainer - tooltipRect.height - yOffset; if (targetY < 0) targetY = 0; this.tooltipElement.style.left = `${targetX}px`; this.tooltipElement.style.top = `${targetY}px`; } + + showTooltip(event) { this.tooltipElement.style.display = 'flex'; this.tooltipElement.style.visibility = 'hidden'; requestAnimationFrame(() => { this.updateTooltipPosition(event); this.tooltipElement.style.visibility = 'visible'; this.isTooltipVisible = true; }); } + updateTooltipContent(itemData, event) { this.tooltipItemName = itemData.name; this.tooltipMetadata.innerHTML = ''; const createMetaRow = (label, value) => { if (value === undefined || value === null || value === '—' || value === '') return; const row = $el("div.aiia-tooltip-row", [$el("span.aiia-tooltip-label", { textContent: `${label}:` }), $el("span.aiia-tooltip-value", { textContent: value })]); this.tooltipMetadata.appendChild(row); return row.querySelector('.aiia-tooltip-value'); }; createMetaRow("Name", itemData.name); const onMediaLoad = () => { if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; if (this.tooltipItemName !== itemData.name) return; this.showTooltip(event); this.tooltipImage.onload = null; this.tooltipImage.onerror = null; this.tooltipVideo.onloadedmetadata = null; this.tooltipVideo.onerror = null; this.tooltipAudio.onloadedmetadata = null; this.tooltipAudio.onerror = null; }; const onMediaError = (e) => { if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; onMediaLoad(); }; const mime = getMimeType(itemData.name); if (this.viewMode === 'list' && this.metadataLoader.isLoading && (mime === 'image' || mime === 'video')) this.metadataLoader.isPaused = true; if (itemData.type === 'directory') { this.tooltipMediaContainer.style.display = 'none'; createMetaRow("Date", formatDate(itemData.mtime)); createMetaRow("Type", "Folder"); createMetaRow("Contains", `${itemData.item_count} items`); this.showTooltip(event); } else { if (itemData.size === 0) { this.tooltipMediaContainer.style.display = 'none'; createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); createMetaRow("Size", formatBytes(0)); createMetaRow("Status", "Empty File 🚫"); this.showTooltip(event); return; } createMetaRow("Date", formatDate(itemData.mtime)); createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); createMetaRow("Size", formatBytes(itemData.size)); this.tooltipImage.style.display = 'none'; this.tooltipVideo.style.display = 'none'; if (mime === 'image') { this.tooltipMediaContainer.style.display = 'block'; const url = getThumbnailUrl(itemData.name, this.currentPath, itemData.mtime); if (itemData.width && itemData.height) createMetaRow("Dimensions", `${itemData.width} x ${itemData.height}`); this.tooltipImage.onload = onMediaLoad; this.tooltipImage.onerror = onMediaError; this.tooltipImage.src = url; this.tooltipImage.style.display = 'block'; } else if (mime === 'video') { this.tooltipMediaContainer.style.display = 'block'; const url = getFileUrl(itemData.name, this.currentPath); const dimText = itemData.width && itemData.height ? `${itemData.width} x ${itemData.height}` : '...'; const durText = itemData.duration != null ? formatDuration(itemData.duration) : '...'; const dimEl = createMetaRow("Dimensions", dimText); const durEl = createMetaRow("Duration", durText); this.tooltipVideo.onloadedmetadata = () => { if (dimEl) dimEl.textContent = `${this.tooltipVideo.videoWidth} x ${this.tooltipVideo.videoHeight}`; if (durEl) durEl.textContent = formatDuration(this.tooltipVideo.duration); onMediaLoad(); }; this.tooltipVideo.onerror = onMediaError; this.tooltipVideo.src = url; this.tooltipVideo.style.display = 'block'; this.tooltipImage.style.display = 'none'; } else if (mime === 'audio') { this.tooltipMediaContainer.style.display = 'none'; const url = getFileUrl(itemData.name, this.currentPath); const durText = itemData.duration != null ? formatDuration(itemData.duration) : '...'; const durationEl = createMetaRow("Duration", durText); this.tooltipAudio.onloadedmetadata = () => { if (durationEl) durationEl.textContent = formatDuration(this.tooltipAudio.duration); this.showTooltip(event); }; this.tooltipAudio.onerror = onMediaError; this.tooltipAudio.src = url; } else { this.tooltipMediaContainer.style.display = 'none'; this.showTooltip(event); } } } + async navigateTo(path) { if (this.isNavigating || this.currentPath === path) return; this.isNavigating = true; if (this.metadataLoader.isLoading) this.metadataLoader.controller.abort(); this.lastFocus = { dir: -1, file: -1 }; this.history.push(path); this.currentPath = path; this.contentArea.innerHTML = '
Loading...
'; this.directoryList.innerHTML = '
...
'; try { const response = await api.fetchApi(`/aiia/v1/browser/list_items?path=${encodeURIComponent(path)}`); if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`); const data = await response.json(); this.itemsData = [...data.directories, ...data.files]; this.render(); } catch (error) { this.contentArea.innerHTML = `
Error: ${error.message}
`; } finally { this.isNavigating = false; } } + goBack() { if (this.history.length > 1) { this.history.pop(); const path = this.history.pop(); this.navigateTo(path); } } + goUp() { if (this.currentPath) { const parentPath = this.currentPath.substring(0, this.currentPath.lastIndexOf('/')); this.navigateTo(parentPath); } } + render() { this.backButton.disabled = this.history.length <= 1; this.upButton.disabled = !this.currentPath; this.loadQueue.clear(); clearTimeout(this.scrollDebounceHandle); this.isProcessingQueue = false; this.isScrolling = false; this.iconViewObserver.disconnect(); this.applySettings(); this.updateSortControls(); this.itemsDataMap.clear(); for (const item of this.itemsData) this.itemsDataMap.set(item.name, item); this.updateBreadcrumbs(this.currentPath); const directories = this.itemsData.filter(i => i.type === 'directory'); const files = this.itemsData.filter(i => i.type !== 'directory'); if (this.hideFolders) { this.sortItems(files); this.directoryItems = []; this.renderDirectoryPanel([]); this.renderContentPanel(files); } else { this.sortItems(directories); this.sortItems(files); this.directoryItems = directories; this.renderDirectoryPanel(directories); this.renderContentPanel(files); } if (this.viewMode === 'list') this.startMetadataBatchLoading(); } + sortItems(items) { const key = this.sortKey; const dir = this.sortDir === 'asc' ? 1 : -1; items.sort((a, b) => { let valA, valB; if (key === 'type') { valA = a.extension || ''; valB = b.extension || ''; } else if (key === 'dimensions') { valA = (a.width || 0) * (a.height || 0); valB = (b.width || 0) * (b.height || 0); } else if (key === 'duration') { valA = a.duration ?? -1; valB = b.duration ?? -1; } else { valA = a[key]; valB = b[key]; } let comparison = 0; if (typeof valA === 'string' && typeof valB === 'string') { let strA = valA, strB = valB; if (key === 'name') { strA = valA.toLowerCase(); strB = valB.toLowerCase(); } comparison = strA.localeCompare(strB, undefined, { numeric: true }); } else if (typeof valA === 'number' && typeof valB === 'number') { comparison = valA - valB; } return comparison * dir; }); } + handleSortChange(newSortKey, newSortDir) { this.sortKey = newSortKey; this.sortDir = newSortDir; setSetting('sortKey', this.sortKey); setSetting('sortDir', this.sortDir); this.render(); } + updateBreadcrumbs(path) { this.breadcrumbs.innerHTML = ''; const rootLink = $el('span.aiia-browser-breadcrumb-link', { textContent: 'output', dataset: { path: '' } }); rootLink.onclick = (e) => { e.stopPropagation(); this.navigateTo(''); }; this.breadcrumbs.appendChild(rootLink); if (path) { let cumulativePath = ''; path.split('/').filter(p => p).forEach(part => { cumulativePath += (cumulativePath ? '/' : '') + part; this.breadcrumbs.appendChild($el('span', { textContent: ' / ' })); const link = $el('span.aiia-browser-breadcrumb-link', { textContent: part, dataset: { path: cumulativePath } }); link.onclick = (e) => { e.stopPropagation(); this.navigateTo(link.dataset.path); }; this.breadcrumbs.appendChild(link); }); } this.hidePathInput(); } + setViewMode(mode) { if (this.viewMode === mode) return; this.viewMode = mode; setSetting('viewMode', this.viewMode); this.render(); } + renderDirectoryPanel(directories) { this.directoryList.innerHTML = ""; if (directories.length === 0 && !this.hideFolders) { this.directoryList.appendChild($el("div.aiia-browser-placeholder", { style: { fontSize: "12px", padding: "10px" }, textContent: "No sub-folders." })); return; } directories.forEach(dir => { const dirEl = $el("div.aiia-directory-item", { dataset: { itemName: dir.name }, textContent: dir.name, onclick: () => this.navigateTo(this.currentPath ? `${this.currentPath}/${dir.name}` : dir.name) }); this.directoryList.appendChild(dirEl); }); } + renderContentPanel(files) { this.virtualList.lastRendered = { start: -1, end: -1 }; log("[Virtual List] State reset."); this.contentArea.innerHTML = ''; this.visibleItems = files; this.setFocus(null, null); if (this.viewMode === 'icons') this.renderIconsView(files); else this.renderListView(files); } + renderIconsView(files) { if (files.length === 0) { this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); return; } const gridInnerWrapper = $el('div.aiia-grid-inner-wrapper'); const gridWrapper = $el('div.aiia-grid-wrapper', [gridInnerWrapper]); gridWrapper.onscroll = () => { this.isScrolling = true; clearTimeout(this.scrollDebounceHandle); this.scrollDebounceHandle = setTimeout(() => { this.isScrolling = false; log("[Scroll] Scroll ended. Triggering queue processing."); this.processIconQueue(); }, 250); }; const createIcon = (item) => { const preview = $el('div.aiia-icon-preview.media-placeholder', { dataset: { filename: item.name, path: this.currentPath, mtime: item.mtime } }); const itemIcon = $el('div.aiia-item.aiia-item-icon', { dataset: { itemName: item.name } }, [ preview, $el('div.aiia-item-label', { textContent: item.name }) ]); return itemIcon; }; files.forEach(file => gridInnerWrapper.appendChild(createIcon(file))); this.contentArea.appendChild(gridWrapper); const placeholdersToObserve = gridWrapper.querySelectorAll('.media-placeholder'); for (const placeholder of placeholdersToObserve) this.iconViewObserver.observe(placeholder); } + renderListView(files) { if (files.length === 0) { this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); return; } const handleHeaderClick = (e) => { const cell = e.target.closest('.aiia-list-header-cell'); if (!cell || (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(cell.dataset.sort))) return; const key = cell.dataset.sort; if (!key) return; this.handleSortChange(key, this.sortKey === key ? (this.sortDir === 'asc' ? 'desc' : 'asc') : 'asc'); }; const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; const headerContainer = $el("div.aiia-list-header", { onclick: handleHeaderClick }); this.updateListHeader(headerContainer, headers); const totalHeight = files.length * this.virtualList.rowHeight; log(`[Virtual List] Initializing. Items: ${files.length}, RowHeight: ${this.virtualList.rowHeight}px, TotalHeight: ${totalHeight}px`); this.virtualList.scroller = $el("div", { style: { height: `${totalHeight}px`, position: 'relative', width: '100%' } }); this.virtualList.content = $el("div.aiia-list-body-content", { style: { position: 'absolute', top: 0, left: 0, width: '100%' }}); this.virtualList.viewport = $el("div.aiia-list-body", [this.virtualList.scroller, this.virtualList.content]); this.virtualList.viewport.onscroll = () => { clearTimeout(this.virtualList.scrollDebounce); this.virtualList.scrollDebounce = setTimeout(() => this.renderVisibleRows(), 16); }; const container = $el("div.aiia-list-container", [headerContainer, this.virtualList.viewport]); this.contentArea.innerHTML = ''; this.contentArea.appendChild(container); this.renderVisibleRows(); } + updateListHeader(headerContainer, headers) { headerContainer.replaceChildren($el("div.aiia-list-header-cell"), ...headers.map(h => { let headerText = h.text; if (h.key === this.sortKey && !(this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key))) headerText += this.sortDir === 'asc' ? ' ▲' : ' ▼'; const cell = $el("div.aiia-list-header-cell", { textContent: headerText, dataset: { sort: h.key } }); if (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key)) { cell.style.opacity = 0.5; cell.style.cursor = 'not-allowed'; } return cell; })); } + renderVisibleRows() { if (!this.virtualList.viewport || !this.visibleItems) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight, buffer } = this.virtualList; const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); const endIndex = Math.min(this.visibleItems.length - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); log(`[Virtual List] Rendering rows. ScrollTop: ${scrollTop.toFixed(0)}, Visible range: ${startIndex} - ${endIndex}`); const fragment = document.createDocumentFragment(); for(let i = startIndex; i <= endIndex; i++) { const item = this.visibleItems[i]; if (!item) continue; const row = $el("div.aiia-list-row", { dataset: { itemName: item.name }, style: { position: 'absolute', top: `${i * rowHeight}px`, height: `${rowHeight}px`, width: '100%', boxSizing: 'border-box', }}); const globalIndex = this.directoryItems.length + i; if(globalIndex === this.currentFocusIndex) row.classList.add('focused'); const mime = getMimeType(item.name); let icon = '📄'; if (mime === 'image') icon = '🖼️'; else if (mime === 'video') icon = '🎬'; else if (mime === 'audio') icon = '🎵'; let dimText = item.width && item.height ? `${item.width} x ${item.height}` : '—'; let durText = item.duration != null ? formatDuration(item.duration) : '—'; row.append( $el("div.aiia-list-cell-icon", { textContent: icon }), $el("div", { textContent: item.name }), $el("div", { textContent: formatDate(item.mtime) }), $el("div", { textContent: item.extension.replace('.','') }), $el("div", { textContent: formatBytes(item.size) }), $el("div", { textContent: dimText, dataset: { field: 'dimensions' } }), $el("div", { textContent: durText, dataset: { field: 'duration' } }) ); fragment.appendChild(row); } this.virtualList.content.replaceChildren(fragment); } + getAllNavigableItems() { const directories = this.hideFolders ? [] : this.directoryItems; return [...directories, ...this.visibleItems]; } + async startMetadataBatchLoading() { if (this.metadataLoader.isLoading) return; const filesToProcess = this.itemsData.filter(item => { const mime = getMimeType(item.name); const needsMeta = (mime === 'video' || mime === 'audio'); return item.type === 'file' && needsMeta && (!item.hasOwnProperty('width') || !item.hasOwnProperty('duration')); }); const filesToFetch = []; for (const item of filesToProcess) { const cacheKey = getCacheKey(this.currentPath, item.name); const cachedData = getCache(cacheKey); if (cachedData && cachedData.mtime === item.mtime && cachedData.size === item.size) Object.assign(item, cachedData.metadata); else filesToFetch.push(item); } this.renderVisibleRows(); if (filesToFetch.length === 0) return; this.metadataLoader.controller = new AbortController(); this.metadataLoader.isLoading = true; this.metadataLoader.path = this.currentPath; const headerContainer = this.element.querySelector('.aiia-list-header'); if (headerContainer) { const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; this.updateListHeader(headerContainer, headers); } const total = filesToFetch.length; let loaded = 0; this.updateProgressBar(loaded, total); const BATCH_SIZE = 50; for (let i = 0; i < filesToFetch.length; i += BATCH_SIZE) { if (this.metadataLoader.controller.signal.aborted) break; while (this.metadataLoader.isPaused) { await new Promise(resolve => setTimeout(resolve, 200)); if (this.metadataLoader.controller.signal.aborted) break; } if (this.metadataLoader.controller.signal.aborted) break; const batch = filesToFetch.slice(i, i + BATCH_SIZE); const filenames = batch.map(f => f.name); try { const response = await api.fetchApi('/aiia/v1/browser/get_batch_metadata', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: this.currentPath, filenames }), signal: this.metadataLoader.controller.signal }); if (!response.ok) continue; const metadataBatch = await response.json(); for (const filename in metadataBatch) { const item = this.itemsDataMap.get(filename); if (item) { Object.assign(item, metadataBatch[filename]); this.updateListItem(item); const cacheKey = getCacheKey(this.currentPath, item.name); setCache(cacheKey, { mtime: item.mtime, size: item.size, metadata: metadataBatch[filename] }); } } loaded += batch.length; this.updateProgressBar(loaded, total); } catch (err) { if (err.name === 'AbortError') break; console.error('[AIIA] Batch metadata fetch failed:', err); } } this.metadataLoader.isLoading = false; this.updateProgressBar(total, total, true); } + updateProgressBar(loaded, total, finished = false) { if (!this.progressContainer) return; if (total === 0 || loaded >= total || finished) { this.progressContainer.style.display = 'none'; if(finished) { this.metadataLoader.isLoading = false; const headerContainer = this.element.querySelector('.aiia-list-header'); if(headerContainer) { const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; this.updateListHeader(headerContainer, headers); } this.renderVisibleRows(); } return; } this.progressContainer.style.display = 'flex'; const percent = (loaded / total) * 100; this.progressBar.firstChild.style.width = `${percent}%`; this.progressText.textContent = `Analyzing metadata... (${loaded}/${total})`; } + updateListItem(item) { const row = this.element.querySelector(`.aiia-list-row[data-item-name="${CSS.escape(item.name)}"]`); if (!row) return; const dimCell = row.querySelector('[data-field="dimensions"]'); const durCell = row.querySelector('[data-field="duration"]'); if (dimCell && item.width && item.height) { dimCell.textContent = `${item.width} x ${item.height}`; } if (durCell && item.duration != null) { durCell.textContent = formatDuration(item.duration); } } + handleIconIntersection(entries) { let needsProcessing = false; for (const entry of entries) { const placeholder = entry.target; if (entry.isIntersecting) { if (!this.loadQueue.has(placeholder)) { this.loadQueue.add(placeholder); needsProcessing = true; } } else { if (this.loadQueue.has(placeholder)) { this.loadQueue.delete(placeholder); } } } if (needsProcessing && !this.isScrolling) this.processIconQueue(); } + processIconQueue() { if (this.isProcessingQueue || this.loadQueue.size === 0) return; const viewport = this.contentArea.querySelector('.aiia-grid-wrapper'); if (!viewport) { this.isProcessingQueue = false; this.loadQueue.clear(); return; } this.isProcessingQueue = true; const viewportRect = viewport.getBoundingClientRect(); const visiblePlaceholders = []; const preloadPlaceholders = []; for (const placeholder of this.loadQueue) { const rect = placeholder.getBoundingClientRect(); if (rect.top < viewportRect.bottom && rect.bottom > viewportRect.top) visiblePlaceholders.push(placeholder); else preloadPlaceholders.push(placeholder); } const sortedQueue = [...visiblePlaceholders, ...preloadPlaceholders]; const batchSize = 8; const itemsToLoad = sortedQueue.slice(0, batchSize); for(const placeholder of itemsToLoad) { this.loadQueue.delete(placeholder); this.iconViewObserver.unobserve(placeholder); const filename = placeholder.dataset.filename; if (!filename) continue; const fileData = this.itemsDataMap.get(filename); if (fileData && fileData.size === 0) { const emptyFileEl = $el("div.aiia-empty-file-placeholder", [ $el("span", { textContent: "🚫" }), $el("span", { textContent: "Empty File" }) ]); placeholder.replaceChildren(emptyFileEl); continue; } const path = placeholder.dataset.path; const mtime = placeholder.dataset.mtime; const mime = getMimeType(filename); let mediaElement; if (mime === 'image') { const url = getThumbnailUrl(filename, path, mtime); mediaElement = $el("img.aiia-image-preview", { src: url }); } else if (mime === 'video') { const url = this.enableVideoPreviews ? getFileUrl(filename, path) : getVideoPosterUrl(filename, path, mtime); if(this.enableVideoPreviews) { mediaElement = $el("video.aiia-video-preview", { src: url, autoplay: true, muted: true, loop: true, playsinline: true }); } else { mediaElement = $el("img.aiia-image-preview", { src: url }); placeholder.classList.add("aiia-video-poster-container"); } } else if (mime === 'audio') { const url = getFileUrl(filename, path); const audio = $el("audio", { src: url, controls: true }); const durationEl = $el("div.aiia-audio-duration", { textContent: "..." }); audio.onloadedmetadata = () => { durationEl.textContent = formatDuration(audio.duration); }; mediaElement = $el("div.aiia-audio-container", [audio, durationEl]); } else { mediaElement = $el("div.aiia-empty-file-placeholder", [ $el("span", { textContent: "?" }), $el("span", { textContent: "Unknown" }) ]); } /* --- START OF FIX: Use replaceChildren to keep the container --- */ placeholder.replaceChildren(mediaElement); /* --- END OF FIX --- */ } this.isProcessingQueue = false; if (this.loadQueue.size > 0 && !this.isScrolling) requestAnimationFrame(() => this.processIconQueue()); } + + ensureFocusedItemVisible() { const item = this.getAllNavigableItems()[this.currentFocusIndex]; if (!item) return; if (item.type === 'directory') { const el = this.element.querySelector(`.aiia-directory-item[data-item-name="${CSS.escape(item.name)}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else { if (this.viewMode === 'list' && this.virtualList.viewport) { const fileIndex = this.visibleItems.findIndex(f => f.name === item.name); if (fileIndex === -1) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight } = this.virtualList; const itemTop = fileIndex * rowHeight, itemBottom = itemTop + rowHeight; if (itemTop < scrollTop) this.virtualList.viewport.scrollTop = itemTop; else if (itemBottom > scrollTop + clientHeight) this.virtualList.viewport.scrollTop = itemBottom - clientHeight; } else if (this.viewMode === 'icons') { const el = this.element.querySelector(`.aiia-item-icon[data-item-name="${CSS.escape(item.name)}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } } + + moveFocus(key) { + const allNavigableItems = this.getAllNavigableItems(); + if (allNavigableItems.length === 0) return; + + let newIndex = this.currentFocusIndex; + const originalIndex = newIndex; + + const dirCount = this.directoryItems.length; + const fileCount = this.visibleItems.length; + + if (newIndex === -1) { + newIndex = 0; + } else { + const isDir = newIndex < dirCount; + const fileIndex = isDir ? -1 : newIndex - dirCount; + + let columns = 1; + if (this.viewMode === 'icons') { + const grid = this.contentArea.querySelector('.aiia-grid-inner-wrapper'); + if (grid) columns = window.getComputedStyle(grid).getPropertyValue('grid-template-columns').split(' ').length; + } + + switch (key) { + case 'ArrowUp': + if (isDir) newIndex = (newIndex > 0) ? newIndex - 1 : dirCount - 1; + else { + if (this.viewMode === 'list') newIndex = (fileIndex > 0) ? newIndex - 1 : allNavigableItems.length - 1; + else { + if (fileIndex >= columns) newIndex -= columns; + else { + const lastRowCol = fileIndex % columns; + const lastRowFileCount = fileCount % columns || columns; + const lastRowFirstFileIndex = fileCount - lastRowFileCount; + newIndex = dirCount + lastRowFirstFileIndex + Math.min(lastRowCol, lastRowFileCount - 1); + } + } + } + break; + + case 'ArrowDown': + if (isDir) newIndex = (newIndex < dirCount - 1) ? newIndex + 1 : 0; + else { + if (this.viewMode === 'list') newIndex = (fileIndex < fileCount - 1) ? newIndex + 1 : dirCount; + else { + const targetIndex = newIndex + columns; + if (targetIndex < allNavigableItems.length) { + newIndex = targetIndex; + } else { + const currentRow = Math.floor(fileIndex / columns); + const totalRows = Math.ceil(fileCount / columns); + if (currentRow < totalRows - 1) { + newIndex = allNavigableItems.length - 1; + } else { + newIndex = dirCount + (fileIndex % columns); + if(newIndex >= allNavigableItems.length) newIndex = allNavigableItems.length - 1; + } + } + } + } + break; + + case 'ArrowLeft': + if (isDir) newIndex = 0; + else { + if (this.viewMode === 'list') { + if(dirCount > 0) newIndex = this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0; + } else { + if (fileIndex > 0) newIndex--; + else if(dirCount > 0) newIndex = this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0; + else newIndex = allNavigableItems.length - 1; + } + } + break; + + case 'ArrowRight': + if (isDir) { + if(fileCount > 0) newIndex = this.lastFocus.file !== -1 && this.lastFocus.file >= dirCount ? this.lastFocus.file : dirCount; + } else { + if (this.viewMode === 'list') { + newIndex = allNavigableItems.length - 1; + } else { + if (fileIndex < fileCount - 1) newIndex++; + else newIndex = dirCount; + } + } + break; + } + } + + newIndex = Math.max(0, Math.min(allNavigableItems.length - 1, newIndex)); + + if (newIndex !== originalIndex) { + const newItem = allNavigableItems[newIndex]; + this.setFocus(newItem, null, true, 'keyboard'); + } + } + + activateFocusedItem() { const item = this.getAllNavigableItems()[this.currentFocusIndex]; if(!item) return; if(item.type === 'directory') { const el = this.element.querySelector('.focused'); if(el) el.click(); } else { const itemEl = this.element.querySelector('.focused'); if (itemEl) itemEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })); } } + toggleTooltipForFocusedItem() { if (this.currentFocusIndex === -1) return; const allNavigableItems = this.getAllNavigableItems(); const itemData = allNavigableItems[this.currentFocusIndex]; if (this.isTooltipVisible && this.tooltipItemName === itemData.name) { this.hideTooltip(); this.setFocus(null, null); return; } clearTimeout(this.tooltipTimeout); this.hideTooltip(); const itemEl = this.element.querySelector('.focused'); if (itemEl) { const rect = itemEl.getBoundingClientRect(); const fakeEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; this.updateTooltipContent(itemData, fakeEvent); } } + + handleKeyDown(e) { + const target = e.target; + + if (target.dataset.escapeToList) { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + + if(target === this.pathInput) this.hidePathInput(); + + const focusIndex = this.lastFocus.file !== -1 ? this.lastFocus.file : (this.directoryItems.length > 0 ? (this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0) : -1); + + if (focusIndex !== -1) { + const item = this.getAllNavigableItems()[focusIndex]; + if (item) this.setFocus(item, null, true, 'keyboard'); + } else if (this.getAllNavigableItems().length > 0) { + this.setFocus(this.getAllNavigableItems()[0], null, true, 'keyboard'); + } + + this.element.focus({ preventScroll: true }); + return; + } + + if (target === this.sizeSlider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + e.preventDefault(); + e.stopPropagation(); + const step = parseInt(this.sizeSlider.step) || 1; + const dir = e.key === 'ArrowLeft' ? -1 : 1; + this.sizeSlider.value = parseInt(this.sizeSlider.value) + (step * dir); + this.sizeSlider.dispatchEvent(new Event('input', { bubbles:true, cancelable:true })); + return; + } + return; + } + + if ((target.tagName === 'INPUT' && target !== this.pathInput) || target.tagName === 'SELECT') return; + + const keyMap = { 'ArrowUp': 'moveFocus', 'ArrowDown': 'moveFocus', 'ArrowLeft': 'moveFocus', 'ArrowRight': 'moveFocus', 'Enter': 'activateFocusedItem', ' ': 'toggleTooltipForFocusedItem' }; + if (keyMap[e.key]) { + e.preventDefault(); e.stopPropagation(); + this.isKeyboardNavigating = true; + if(keyMap[e.key] === 'moveFocus') this.moveFocus(e.key); + else this[keyMap[e.key]](); + } + } + + show() { this.currentPath = null; this.history = []; this.navigateTo(""); super.show(); this.updateTooltipSizeLimits(); this.element.focus({ preventScroll: true }); } +} + +app.registerExtension({ + name: "Comfy.AIIA.Browser", + setup() { + document.head.appendChild($el("style", { textContent: browserStyles })); + let browserDialog = null; + const createDialog = () => { if (!browserDialog) browserDialog = new AIIABrowserDialog(); browserDialog.show(); }; + document.addEventListener('keydown', (e) => { + if (!browserDialog || browserDialog.element.style.display === 'none') return; + if (browserDialog.fullscreenViewer && browserDialog.fullscreenViewer.element.style.display !== 'none') return; + if (!browserDialog.element.contains(document.activeElement)) { + const isNavKey = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' '].includes(e.key); + if (isNavKey) { + e.preventDefault(); + browserDialog.element.focus({ preventScroll: true }); + if(browserDialog.currentFocusIndex === -1 && e.key !== ' ') { + const allItems = browserDialog.getAllNavigableItems(); + if (allItems.length > 0) browserDialog.setFocus(allItems[0], browserDialog.element.querySelector('[data-item-name]'), true, 'keyboard'); + } else { + browserDialog.handleKeyDown(e); + } + } + } + }); + const menuButton = $el("button", { id: "aiia-browser-menu-button", textContent: "AIIA Browser", onclick: createDialog }); + app.menu.element.appendChild(menuButton); + } +}); \ No newline at end of file diff --git a/js/aiia_browser_styles.js b/js/aiia_browser_styles.js new file mode 100644 index 0000000..71ffd7a --- /dev/null +++ b/js/aiia_browser_styles.js @@ -0,0 +1,176 @@ +// aiia_browser_styles.js (V67 - 设置面板样式修复) +export const browserStyles = ` + /* Main Browser Styles */ + #aiia-browser-menu-button { margin-left: 10px; } + .comfy-modal.aiia-browser-dialog-root { top: 50%; left: 50%; transform: translate(-50%, -50%); width: 80vw; height: 85vh; max-width: 1400px; max-height: 1000px; min-width: 800px; min-height: 500px; display: flex; flex-direction: column; padding: 0; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); resize: both; overflow: hidden; } + .aiia-browser-titlebar { background: var(--comfy-box-bg); padding: 4px 8px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); flex-shrink: 0; color: #F9FAFB; cursor: default; } + .aiia-browser-main-container { display: flex; flex-direction: column; flex-grow: 1; padding: 8px; overflow: hidden; } + .aiia-browser-header-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-shrink: 0; gap: 8px; flex-wrap: wrap; } + .aiia-browser-nav-container { display: flex; flex-grow: 1; align-items: center; gap: 4px; border: 1px solid var(--border-color); border-radius: 4px; padding-right: 4px; } + .aiia-browser-nav-button { background: none; border: none; font-size: 18px; cursor: pointer; color: #E5E7EB; padding: 4px 8px; line-height: 1; } + .aiia-browser-nav-button:disabled { color: #555; cursor: not-allowed; } + .aiia-browser-nav-button:hover:not(:disabled) { background-color: var(--comfy-hover-bg); border-radius: 2px; } + .aiia-browser-breadcrumbs { flex-grow: 1; padding: 4px 8px; border: none; display: flex; flex-wrap: nowrap; gap: 4px; color: #D1D5DB; overflow: hidden; cursor: text; } + .aiia-browser-breadcrumb-link { cursor: pointer; color: var(--link-color); white-space: nowrap; } + .aiia-browser-path-input-container { flex-grow: 1; display: none; align-items: center; background-color: var(--comfy-input-bg); border: 1px solid var(--input-active-border); border-radius: 4px; padding: 0 8px; } + .aiia-browser-path-prefix { color: var(--descrip-text); margin-right: 4px; white-space: nowrap; } + .aiia-browser-path-input { flex-grow: 1; background: transparent; border: none; outline: none; color: var(--input-text); padding: 4px 0; font-family: monospace; } + + .aiia-browser-view-controls { display: flex; align-items: center; gap: 4px; } + .aiia-browser-view-controls button, .aiia-browser-view-controls select, .aiia-browser-view-controls input { padding: 4px 8px; border: 1px solid var(--border-color); background: var(--comfy-input-bg); color: #E5E7EB; border-radius: 4px; cursor: pointer; vertical-align: middle; } + .aiia-browser-view-controls button.active { border-color: var(--input-active-border); background: var(--comfy-menu-bg); } + .aiia-browser-sort-controls { display: flex; align-items: center; gap: 4px; } + .aiia-browser-sort-controls button { font-size: 12px; padding: 4px 6px; } + + /* --- Settings Panel Styles --- */ + .aiia-settings-container { position: relative; } + .aiia-browser-settings-button { font-size: 16px; padding: 4px 6px; line-height: 1; } + .aiia-settings-panel { + display: none; + position: absolute; + top: 100%; + left: 0; + background-color: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px 12px; + z-index: 1003; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + color: #E5E7EB; + font-size: 13px; + min-width: 220px; + } + .aiia-settings-panel > div:first-of-type { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + margin-bottom: 4px; + } + .aiia-settings-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + white-space: nowrap; + padding: 6px 4px; + border-radius: 3px; + } + .aiia-settings-label:hover { + background-color: var(--comfy-hover-bg); + color: var(--link-color); + } + + .aiia-split-view-container { display: flex; flex-grow: 1; gap: 8px; overflow: hidden; position: relative; } + .aiia-directory-panel { width: 220px; min-width: 150px; max-width: 50%; flex-shrink: 0; display: flex; flex-direction: column; border: 1px solid var(--border-color); resize: horizontal; overflow: hidden; background-color: var(--comfy-box-bg); } + .aiia-directory-panel.hidden { display: none; } + .aiia-directory-panel-header { padding: 8px; font-weight: bold; border-bottom: 1px solid var(--border-color); flex-shrink: 0; text-align: center; color: #E5E7EB; } + .aiia-directory-list { overflow-y: auto; padding: 4px; } + .aiia-directory-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 4px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #D1D5DB; } + .aiia-directory-item:before { content: '📁'; font-size: 16px; } + .aiia-directory-item:hover { background-color: var(--comfy-hover-bg); color: #F9FAFB; } + .aiia-directory-item.focused, .aiia-item-icon.focused, .aiia-list-row.focused { outline: 2px solid var(--accent-color, #4a90e2) !important; outline-offset: -2px; background-color: var(--comfy-hover-bg); } + + .aiia-content-panel { flex-grow: 1; border: 1px solid var(--border-color); background-color: var(--comfy-box-bg); overflow: hidden; display: flex; } + .aiia-browser-content { width: 100%; height: 100%; position: relative; overflow: hidden; display: flex; } + + .aiia-grid-wrapper::-webkit-scrollbar, .aiia-list-body::-webkit-scrollbar, .aiia-custom-tooltip::-webkit-scrollbar, .aiia-filmstrip-container::-webkit-scrollbar, .aiia-directory-list::-webkit-scrollbar { width: 16px; height: 16px; } + .aiia-grid-wrapper::-webkit-scrollbar-track, .aiia-list-body::-webkit-scrollbar-track, .aiia-custom-tooltip::-webkit-scrollbar-track, .aiia-filmstrip-container::-webkit-scrollbar-track, .aiia-directory-list::-webkit-scrollbar-track { background: transparent; } + .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-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-video-poster-container::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 30%; + height: 30%; + max-width: 48px; + max-height: 48px; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 50%; + -webkit-mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black"%3E%3Cpath d="M8 5v14l11-7z"/%3E%3C/svg%3E'); + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black"%3E%3Cpath d="M8 5v14l11-7z"/%3E%3C/svg%3E'); + -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; + pointer-events: none; + } + .aiia-item-icon:hover .aiia-video-poster-container::after { + background-color: white; + transform: translate(-50%, -50%) scale(1.1); + } + + .aiia-custom-tooltip { position: absolute; display: none; padding: 12px; background-color: rgba(30, 30, 30, 0.95); border: 1px solid var(--border-color); color: white; border-radius: 8px; z-index: 1002; font-size: 13px; pointer-events: none; backdrop-filter: blur(4px); box-sizing: border-box; flex-direction: column; align-items: flex-start; } + .aiia-tooltip-media { width: 100%; margin-bottom: 10px; max-width: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-metadata { display: block; max-width: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-media:not(:has(> :not([style*="display: none"]))) { display: none; margin-bottom: 0; } + .aiia-tooltip-image, .aiia-tooltip-video { width: 100%; height: auto; object-fit: contain; display: none; border-radius: 4px; max-width: var(--aiia-tooltip-media-max-size, 250px); max-height: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; margin-top: 4px; } + .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-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-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-icon { font-size: 16px; text-align: center; } + + .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-progress-container { display: none; flex-direction: column; gap: 4px; margin-bottom: 8px; } + .aiia-progress-text { font-size: 12px; color: var(--descrip-text); } + .aiia-progress-bar { width: 100%; height: 4px; background-color: var(--comfy-menu-bg); border-radius: 2px; } + .aiia-progress-bar > div { width: 0%; height: 100%; background-color: var(--accent-color); border-radius: 2px; transition: width 0.2s ease-out; } + + .aiia-fullscreen-viewer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(10, 10, 10, 0.9); backdrop-filter: blur(8px); z-index: 1001; display: none; flex-direction: column; } + .aiia-fullscreen-main-content { display: flex; flex-grow: 1; align-items: center; position: relative; width: 100%; overflow: hidden; } + .aiia-fullscreen-media-container { flex-grow: 1; display: flex; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; width: 100%; height: 100%; } + .aiia-fullscreen-media-container > * { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; } + .aiia-fullscreen-audio-wrapper { display: flex; flex-direction: column; align-items: center; gap: 20px; color: white; } + .aiia-fullscreen-audio-icon { font-size: 128px; } + .aiia-fullscreen-close-button, .aiia-fullscreen-nav { background: rgba(0,0,0,0.4); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 24px; width: 44px; height: 44px; line-height: 44px; text-align: center; transition: all 0.2s; z-index: 12; } + .aiia-fullscreen-close-button:hover, .aiia-fullscreen-nav:hover { background: rgba(0,0,0,0.7); } + .aiia-fullscreen-close-button { position: absolute; top: 20px; right: 20px; } + .aiia-fullscreen-nav { position: absolute; top: 50%; transform: translateY(-50%); } + .aiia-fullscreen-nav.prev { left: 20px; } + .aiia-fullscreen-nav.next { right: 20px; } + .aiia-fullscreen-nav:disabled { opacity: 0.3; cursor: not-allowed; } + .aiia-filmstrip-container { z-index: 11; flex-shrink: 0; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); padding: 15px; display: flex; gap: 10px; overflow-x: auto; width: 100%; box-sizing: border-box; } + .aiia-filmstrip-thumb { height: 80px; width: 80px; flex-shrink: 0; background: #222; border-radius: 4px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s, opacity 0.2s; overflow: hidden; display: flex; align-items: center; justify-content: center; } + .aiia-filmstrip-thumb:hover { border-color: #555; } + .aiia-filmstrip-thumb.active { border-color: var(--accent-color, #007BFF); } + .aiia-filmstrip-thumb-content { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } + .aiia-filmstrip-thumb img { width: 100%; height: 100%; object-fit: cover; } + .aiia-filmstrip-thumb video { width: 100%; height: 100%; object-fit: contain; background-color: #000; } + .aiia-filmstrip-placeholder, .aiia-filmstrip-placeholder-icon { font-size: 32px; color: #888; background-color: #2a2a2a; width:100%; height:100%; display:flex; align-items:center; justify-content:center; } + .aiia-info-panel { position: absolute; bottom: 120px; right: 20px; width: auto; max-width: 350px; height: auto; background: rgba(15,15,15,0.8); padding: 10px 15px; box-sizing: border-box; color: #eee; border-radius: 6px; transition: all 0.2s ease-in-out; z-index: 11; pointer-events: none; backdrop-filter: blur(4px); } + .aiia-info-panel:hover { pointer-events: auto; } + .aiia-info-panel-row { display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 8px; font-size: 13px; } + .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; } +`; \ No newline at end of file diff --git a/js/aiia_fullscreen_viewer.js b/js/aiia_fullscreen_viewer.js new file mode 100644 index 0000000..449e3a8 --- /dev/null +++ b/js/aiia_fullscreen_viewer.js @@ -0,0 +1,374 @@ +// 文件: aiia_fullscreen_viewer.js (V2 - 增加滑动和滚动导航) + +import { $el } from "/scripts/ui.js"; +import { api } from "/scripts/api.js"; + +// Helper functions +function getFileUrl(filename, path = "") { const subfolder = path || ""; return api.apiURL(`/view?type=output&subfolder=${encodeURIComponent(subfolder)}&filename=${encodeURIComponent(filename)}`); } +function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } +function formatDate(timestamp) { const date = new Date(timestamp * 1000); return date.toLocaleString(); } +function formatDuration(seconds) { if (isNaN(seconds)) return '00:00'; const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } +function getMimeType(filename) { if (typeof filename !== 'string') return 'unknown'; const extension = filename.split('.').pop().toLowerCase(); if (['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) return 'image'; if (['mp4', 'webm', 'mov', 'avi'].includes(extension)) return 'video'; if (['mp3', 'wav', 'ogg'].includes(extension)) return 'audio'; return 'unknown'; } + +export class AIIAFullscreenViewer { + constructor() { + this.items = []; + this.currentIndex = -1; + this.currentPath = ""; + this.onCloseCallback = null; + this.filmstripObserver = null; + this.loadMediaTimeout = null; + + // V2 优化: 增加滑动和滚动相关的状态 + this.longPressTimeout = null; + this.longPressInterval = null; + this.swipeState = { + isDragging: false, + startX: 0, + threshold: 50, // 至少滑动50px才算有效 + }; + this.scrollDebounce = { + timeout: null, + delay: 150, // 滚动事件的防抖延迟 + }; + + this.element = $el("div.aiia-fullscreen-viewer"); + this.element.setAttribute('tabindex', -1); + + this.mediaContainer = $el("div.aiia-fullscreen-media-container"); + this.closeButton = $el("button.aiia-fullscreen-close-button", { textContent: "✖" }); + this.prevButton = $el("button.aiia-fullscreen-nav.prev", { textContent: "❮" }); + this.nextButton = $el("button.aiia-fullscreen-nav.next", { textContent: "❯" }); + this.filmstripContainer = $el("div.aiia-filmstrip-container"); + this.infoPanel = $el("div.aiia-info-panel"); + + const mainContent = $el("div.aiia-fullscreen-main-content", [ this.prevButton, this.mediaContainer, this.nextButton ]); + this.element.append(mainContent, this.infoPanel, this.filmstripContainer, this.closeButton); + document.body.appendChild(this.element); + + this.bindEvents(); + } + + bindEvents() { + this.closeButton.onclick = () => this.hide(); + + const setupNavButtonEvents = (button, direction) => { + const clearTimers = () => { + clearTimeout(this.longPressTimeout); + clearInterval(this.longPressInterval); + this.longPressTimeout = null; + this.longPressInterval = null; + }; + + button.addEventListener("mousedown", (e) => { + e.preventDefault(); + + if (direction === 'prev') this.prev(); + else if (direction === 'next') this.next(); + + this.longPressTimeout = setTimeout(() => { + this.longPressInterval = setInterval(() => { + if (direction === 'prev') this.prev(); + else if (direction === 'next') this.next(); + }, 150); + }, 500); + }); + + button.addEventListener("mouseup", clearTimers); + button.addEventListener("mouseleave", clearTimers); + + window.addEventListener("blur", clearTimers); + }; + + setupNavButtonEvents(this.prevButton, 'prev'); + setupNavButtonEvents(this.nextButton, 'next'); + + // V2 优化: 增加滑动事件监听 + const handleSwipeStart = (e) => { + if (e.target.tagName === 'VIDEO' || e.target.tagName === 'AUDIO') return; // 不在视频/音频控件上触发滑动 + e.preventDefault(); + this.swipeState.isDragging = true; + this.swipeState.startX = e.clientX; + this.mediaContainer.style.cursor = 'grabbing'; + }; + + const handleSwipeEnd = (e) => { + if (!this.swipeState.isDragging) return; + this.swipeState.isDragging = false; + this.mediaContainer.style.cursor = 'grab'; + + const deltaX = e.clientX - this.swipeState.startX; + if (Math.abs(deltaX) > this.swipeState.threshold) { + if (deltaX > 0) { + this.prev(); + } else { + this.next(); + } + } + }; + + this.mediaContainer.addEventListener('mousedown', handleSwipeStart); + this.mediaContainer.addEventListener('mouseup', handleSwipeEnd); + this.mediaContainer.addEventListener('mouseleave', () => { + if (this.swipeState.isDragging) { + this.swipeState.isDragging = false; + this.mediaContainer.style.cursor = 'grab'; + } + }); + + // V2 优化: 增加滚轮事件监听 + this.element.addEventListener('wheel', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.scrollDebounce.timeout) return; + + this.scrollDebounce.timeout = setTimeout(() => { + this.scrollDebounce.timeout = null; + }, this.scrollDebounce.delay); + + if (e.deltaY < 0) { + this.navigateVertical('up'); + } else if (e.deltaY > 0) { + this.navigateVertical('down'); + } + }); + + this.handleKeyDown = (e) => { + if (this.element.style.display === 'none') return; + switch (e.key) { + case "Escape": e.preventDefault(); e.stopPropagation(); this.hide(); break; + case "ArrowLeft": e.preventDefault(); this.prev(); break; + case "ArrowRight": e.preventDefault(); this.next(); break; + // V2 优化: 增加上下方向键导航 + case "ArrowUp": e.preventDefault(); this.navigateVertical('up'); break; + case "ArrowDown": e.preventDefault(); this.navigateVertical('down'); break; + case " ": e.preventDefault(); this.infoPanel.style.display = this.infoPanel.style.display === 'none' ? '' : 'none'; break; + } + }; + this.element.addEventListener('keydown', this.handleKeyDown); + } + + // V2 优化: 新增垂直导航方法 + navigateVertical(direction) { + const thumb = this.filmstripContainer.querySelector('.aiia-filmstrip-thumb'); + if (!thumb) return; + + // 计算filmstrip一行可以显示多少个缩略图 + const thumbStyle = getComputedStyle(thumb); + const containerStyle = getComputedStyle(this.filmstripContainer); + const thumbWidth = thumb.offsetWidth + parseFloat(thumbStyle.marginLeft) + parseFloat(thumbStyle.marginRight); + const gap = parseFloat(containerStyle.gap) || 10; + const totalThumbWidth = thumbWidth + gap; + const columns = Math.max(1, Math.floor(this.filmstripContainer.clientWidth / totalThumbWidth)); + + let newIndex = this.currentIndex; + if (direction === 'up') { + newIndex -= columns; + } else if (direction === 'down') { + newIndex += columns; + } + + // 确保新索引在有效范围内 + newIndex = Math.max(0, Math.min(this.items.length - 1, newIndex)); + + if (newIndex !== this.currentIndex) { + this.jumpTo(newIndex); + } + } + + show(items, currentIndex, currentPath, onCloseCallback = null) { + this.items = items; + this.currentIndex = currentIndex; + this.currentPath = currentPath; + this.onCloseCallback = onCloseCallback; + if (this.currentIndex === -1) return; + + this.mediaContainer.style.cursor = 'grab'; // V2 优化: 设置初始鼠标手势 + + this.renderFilmstrip(); + this.element.style.display = "flex"; + this.element.focus({ preventScroll: true }); + + this.loadMedia(); + } + + hide() { + if (this.onCloseCallback) { + this.onCloseCallback(); + } + this.element.style.display = "none"; + this.mediaContainer.innerHTML = ""; + this.filmstripContainer.innerHTML = ""; + if (this.filmstripObserver) this.filmstripObserver.disconnect(); + clearTimeout(this.loadMediaTimeout); + clearTimeout(this.longPressTimeout); + clearInterval(this.longPressInterval); + clearTimeout(this.scrollDebounce.timeout); // V2 优化: 清除滚动计时器 + } + + renderFilmstrip() { + this.filmstripContainer.innerHTML = ""; + if(this.filmstripObserver) this.filmstripObserver.disconnect(); + this.filmstripObserver = new IntersectionObserver(this.handleFilmstripIntersection.bind(this), { + root: this.filmstripContainer, + threshold: 0.1 + }); + + this.items.forEach((item, index) => { + const thumb = $el("div.aiia-filmstrip-thumb", { + dataset: { index, filename: item.name }, + onclick: () => this.jumpTo(index) + }); + const mime = getMimeType(item.name); + const thumbContent = $el("div.aiia-filmstrip-thumb-content"); + thumb.appendChild(thumbContent); + + if (mime === 'video') { + thumbContent.innerHTML = '
🎬
'; + thumb.dataset.mediaType = 'video'; + } else if (mime === 'image') { + thumbContent.innerHTML = '
'; + thumb.dataset.mediaType = 'image'; + } else { + thumbContent.innerHTML = '
🎵
'; + thumb.dataset.mediaType = 'audio'; + } + + this.filmstripContainer.appendChild(thumb); + if (mime === 'image' || mime === 'video') { + this.filmstripObserver.observe(thumb); + } + }); + } + + handleFilmstripIntersection(entries) { + for (const entry of entries) { + if (entry.isIntersecting) { + const thumb = entry.target; + const mediaType = thumb.dataset.mediaType; + const filename = thumb.dataset.filename; + const thumbContent = thumb.querySelector('.aiia-filmstrip-thumb-content'); + const url = getFileUrl(filename, this.currentPath); + let mediaElement; + + if (mediaType === 'image') { + mediaElement = $el("img", { src: url }); + } else if (mediaType === 'video') { + mediaElement = $el("video", { + src: url, autoplay: true, muted: true, loop: true, playsinline: true + }); + } + + if (mediaElement) { + thumbContent.replaceChildren(mediaElement); + this.filmstripObserver.unobserve(thumb); + } + } + } + } + + updateInfoPanel(item, extra = {}) { + this.infoPanel.innerHTML = ""; + const createMetaRow = (label, value) => { + if (value === undefined || value === null || value === '—' || value === '') return; + this.infoPanel.appendChild($el("div.aiia-info-panel-row", [ + $el("span.aiia-info-panel-label", { textContent: label }), + $el("span.aiia-info-panel-value", { textContent: value }) + ])); + }; + + createMetaRow("Name", item.name); + createMetaRow("Date", formatDate(item.mtime)); + createMetaRow("Type", item.extension.replace('.', '').toUpperCase()); + createMetaRow("Size", formatBytes(item.size)); + + const dimensions = extra.dimensions || (item.width && item.height ? `${item.width} x ${item.height}` : null); + if (dimensions) createMetaRow("Dimensions", dimensions); + + if (extra.duration) createMetaRow("Duration", extra.duration); + } + + loadMedia() { + if (this.currentIndex < 0 || this.currentIndex >= this.items.length) return; + const item = this.items[this.currentIndex]; + this.mediaContainer.innerHTML = ""; + + const url = getFileUrl(item.name, this.currentPath); + const mime = getMimeType(item.name); + + this.updateInfoPanel(item); + + let mediaElement; + if (mime === 'image') { + mediaElement = $el("img", { src: url }); + } else if (mime === 'video') { + mediaElement = $el("video", { src: url, controls: true, autoplay: true, loop: true }); + mediaElement.onloadedmetadata = () => { + const dimensions = `${mediaElement.videoWidth} x ${mediaElement.videoHeight}`; + this.updateInfoPanel(item, { dimensions, duration: formatDuration(mediaElement.duration) }); + }; + } else if (mime === 'audio') { + mediaElement = $el("div.aiia-fullscreen-audio-wrapper", [ + $el("div.aiia-fullscreen-audio-icon", { textContent: "🎵" }), + $el("audio", { + src: url, controls: true, autoplay: true, + onloadedmetadata: (e) => this.updateInfoPanel(item, { duration: formatDuration(e.target.duration) }) + }) + ]); + } else { + mediaElement = $el("div", { textContent: `Unsupported file type: ${item.name}` }); + } + + this.mediaContainer.appendChild(mediaElement); + this.updateNavButtons(); + this.updateFilmstripHighlight(); + } + + updateFilmstripHighlight() { + const thumbs = this.filmstripContainer.children; + for (let i = 0; i < thumbs.length; i++) { + thumbs[i].classList.toggle("active", i === this.currentIndex); + } + const activeThumb = this.filmstripContainer.querySelector('.active'); + if (activeThumb) { + activeThumb.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); + } + } + + jumpTo(index) { + if (index >= 0 && index < this.items.length) { + this.currentIndex = index; + clearTimeout(this.loadMediaTimeout); + this.loadMedia(); + } + } + + navigate(direction) { + let newIndex = this.currentIndex; + if (direction === 'prev' && this.currentIndex > 0) newIndex--; + else if (direction === 'next' && this.currentIndex < this.items.length - 1) newIndex++; + else return; + + if (newIndex === this.currentIndex) return; + this.currentIndex = newIndex; + + this.updateNavButtons(); + this.updateFilmstripHighlight(); + clearTimeout(this.loadMediaTimeout); + this.loadMediaTimeout = setTimeout(() => this.loadMedia(), 150); + } + + prev() { this.navigate('prev'); } + next() { this.navigate('next'); } + + updateNavButtons() { + this.prevButton.disabled = this.currentIndex <= 0; + this.nextButton.disabled = this.currentIndex >= this.items.length - 1; + } + + destroy() { + this.element.removeEventListener('keydown', this.handleKeyDown); + this.element.remove(); + } +} \ No newline at end of file diff --git a/node.zip b/node.zip new file mode 100644 index 0000000000000000000000000000000000000000..07a655bd9cb79307f00c11916bb2bff16cb7365f GIT binary patch literal 37841 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 MKR!;a#{GHqFEEOa9{>OV literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..57fd5d4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ComfyUI_AIIA", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pyproject.toml b/pyproject.toml index f5edda9..8f45ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aiia" -description = "AIIA Nodes for ComfyUI is a comprehensive suite of advanced nodes designed to streamline and supercharge your video and audio generation workflows. It centrally addresses the critical Out-Of-Memory (OOM) issues encountered when processing long sequences by intelligently handling frames on disk. The suite includes: a powerful Video Combiner with an extensible format system; memory-efficient FLOAT Talking Head Animator nodes with both in-memory (for speed) and on-disk (for stability) modes; and a sophisticated Speaker Diarization toolkit powered by NeMo for identifying who spoke when. Utility nodes like the OOM-safe Image Sequence Concatenator are also included to create comparison or multi-panel videos effortlessly." -version = "1.0.4" +description = "Tired of OOM errors and tedious file management? The AIIA Node Suite delivers the ultimate workflow overhaul, headlined by the AIIA Media Browser: a high-performance file hub fully integrated into ComfyUI. Instantly preview images, videos, and audio, and experience ultimate smoothness thanks to smart caching and virtual scrolling, even with tens of thousands of files. It also features a powerful fullscreen viewer, rich tooltip previews, and flexible sorting. Alongside the browser, our Video Combine node fundamentally solves memory bottlenecks in long video generation. By streaming frames from disk, you can create videos of virtually unlimited length, making OOM errors a thing of the past. The suite also includes OOM-safe FLOAT video generation, advanced audio processing, and other utilities, providing a complete end-to-end solution from content creation to asset management." +version = "1.1.0" license = {file = "LICENSE"} [project.urls]