diff --git a/aiia_browser_node.py b/aiia_browser_node.py
index 5b27a99..d2c0330 100644
--- a/aiia_browser_node.py
+++ b/aiia_browser_node.py
@@ -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 = {}
diff --git a/js/aiia_browser.js b/js/aiia_browser.js
index 087cce3..70f9e82 100644
--- a/js/aiia_browser.js
+++ b/js/aiia_browser.js
@@ -1,4 +1,4 @@
-// 文件: aiia_browser.js (V75 - 设置面板终极修复)
+// 文件: aiia_browser.js (V82 - 虚拟滚动引擎重构)
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
@@ -24,7 +24,7 @@ function setSetting(key, value) {
} catch (e) { console.error(`[AIIA] Error setting localStorage for key ${key}:`, e); }
}
-const CACHE_PREFIX = "aiia-cache:";
+const CACHE_PREFIX = "aiia-cache:v2:";
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); } }
@@ -46,6 +46,8 @@ class AIIABrowserDialog extends ComfyDialog {
this.itemsData = [];
this.itemsDataMap = new Map();
this.isNavigating = false;
+ this.lastActionClickTimestamp = 0;
+ this.downloadLock = new Set();
this.viewMode = getSetting('viewMode', 'icons');
this.iconSize = getSetting('iconSize', 120);
@@ -55,10 +57,6 @@ class AIIABrowserDialog extends ComfyDialog {
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;
@@ -72,8 +70,12 @@ class AIIABrowserDialog extends ComfyDialog {
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.virtualList = { viewport: null, content: null, scroller: null, buffer: 10, lastRendered: { start: -1, end: -1 }, renderRaf: null };
+ this.listView = { rowHeight: 31, activeNodes: new Map(), nodePool: [] };
+ this.iconView = { resizeObserver: null, columns: 0, rowHeight: 0, activeNodes: new Map(), nodePool: [], mediaCache: new Map() };
this.applyFocusRaf = null;
+ this.iconLoadTimers = new Map();
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 });
@@ -96,12 +98,13 @@ class AIIABrowserDialog extends ComfyDialog {
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.refreshButton = $el("button.aiia-browser-nav-button", { textContent: "🔄", title: "Refresh current 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]);
+ const navContainer = $el("div.aiia-browser-nav-container", [this.backButton, this.upButton, this.refreshButton, 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"})]);
@@ -137,7 +140,7 @@ class AIIABrowserDialog extends ComfyDialog {
this.bindEvents();
this.applySettings();
this.updateSortControls();
- this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); if(this.viewMode === 'list') this.renderVisibleRows(); });
+ this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); });
this.resizeObserver.observe(this.contentPanel);
}
@@ -153,6 +156,7 @@ class AIIABrowserDialog extends ComfyDialog {
this.closeButton.onclick = () => this.close();
this.backButton.onclick = () => this.goBack();
this.upButton.onclick = () => this.goUp();
+ this.refreshButton.onclick = () => this.refreshCurrentDirectory();
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();
@@ -199,25 +203,129 @@ class AIIABrowserDialog extends ComfyDialog {
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`); };
+ this.sizeSlider.oninput = (e) => {
+ this.iconSize = e.target.value;
+ setSetting('iconSize', this.iconSize);
+ document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`);
+ if (this.viewMode === 'icons') {
+ // A full re-render is the most robust way to handle the size change,
+ // as it correctly re-initializes the virtual scroller and all its states,
+ // mimicking a view switch.
+ this.render();
+ }
+ };
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.contentArea.addEventListener('click', (e) => {
+ const button = e.target.closest('.aiia-load-workflow-button, .aiia-download-button');
+ if (button) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.lastActionClickTimestamp = Date.now();
+ const itemName = button.closest('[data-item-name]').dataset.itemName;
+
+ if (button.classList.contains('aiia-load-workflow-button')) {
+ this.handleLoadWorkflow(itemName);
+ } else if (button.classList.contains('aiia-download-button')) {
+ if (this.downloadLock.has(itemName)) {
+ console.log(`[AIIA] Download for ${itemName} is already in progress or recently triggered. Ignoring.`);
+ return;
+ }
+ this.downloadLock.add(itemName);
+ const url = getFileUrl(itemName, this.currentPath);
+ const link = $el('a', { href: url, download: itemName });
+ link.click();
+ // Remove lock after 2 seconds
+ setTimeout(() => {
+ this.downloadLock.delete(itemName);
+ }, 2000);
+ }
+ }
+ });
+
+ this.element.addEventListener('dblclick', (e) => {
+ if (Date.now() - this.lastActionClickTimestamp < 300) {
+ return;
+ }
+ 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 }); }); } }
+
+ async handleLoadWorkflow(itemName) {
+ if (confirm(`Load workflow from "${itemName}"? This will replace your current workflow.`)) {
+ try {
+ const res = await fetch(window.location.origin + `/api/aiia/v1/browser/get_workflow?path=${encodeURIComponent(this.currentPath)}&filename=${encodeURIComponent(itemName)}`);
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}: ${await res.text()}`);
+ }
+ const data = await res.json();
+
+ // The response can be in two formats: a direct workflow JSON,
+ // or an object containing the workflow under a 'workflow' key.
+ const workflowData = data.workflow || data;
+
+ // The ComfyUI API can return a graph in two formats. We need to detect which one we have.
+ // The API format is a flat object where keys are node IDs and values have `class_type`.
+ // The standard format has a `nodes` array.
+ const isApiFormat = workflowData && !workflowData.nodes && Object.values(workflowData).every(v => v.class_type);
+
+ if (isApiFormat) {
+ // Handle API format: loadApiJson(apiData, fileName)
+ app.loadApiJson(workflowData, itemName);
+ } else {
+ // Handle standard graph format: loadGraphData(graphData, clean, restore_view, workflowName)
+ app.loadGraphData(workflowData, true, true, itemName);
+ }
+
+ this.close();
+ } catch (e) {
+ alert(`Error loading workflow: ${e.message}`);
+ console.error(e);
+ }
+ }
+ }
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; } }
+ hideTooltip() {
+ clearTimeout(this.tooltipTimeout);
+ if (this.isTooltipVisible) {
+ this.tooltipElement.style.display = 'none';
+ this.isTooltipVisible = false;
+ this.tooltipItemName = null;
+
+ this.tooltipVideo.pause();
+ this.tooltipAudio.pause();
+ this.tooltipImage.src = "";
+ this.tooltipVideo.src = "";
+ this.tooltipAudio.src = "";
+
+ // Detach the audio element if it was added
+ if (this.tooltipAudio.parentNode) {
+ this.tooltipAudio.parentNode.removeChild(this.tooltipAudio);
+ }
+
+ if (this.metadataLoader.isPaused) {
+ this.metadataLoader.isPaused = false;
+ }
+ }
+ }
setFocus(item, element, scrollIntoView = true, trigger = 'mouse') {
if(this.applyFocusRaf) cancelAnimationFrame(this.applyFocusRaf);
@@ -225,7 +333,10 @@ class AIIABrowserDialog extends ComfyDialog {
this.hideTooltip();
const oldEl = this.element.querySelector('.focused');
- if (oldEl) oldEl.classList.remove('focused');
+ if (oldEl) {
+ oldEl.classList.remove('focused');
+ oldEl.classList.remove('actions-visible');
+ }
if (!item) { this.currentFocusIndex = -1; return; }
@@ -272,29 +383,768 @@ class AIIABrowserDialog extends ComfyDialog {
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; } }
+ 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;
+ e.target.style.display = 'none'; // Hide the broken element
+ onMediaLoad(); // Still show the tooltip with metadata
+ };
+
+ const mime = getMimeType(itemData.name);
+ if (this.viewMode === 'list' && this.metadataLoader.isLoading && (mime === 'image' || mime === 'video')) this.metadataLoader.isPaused = true;
+
+ // Reset media elements
+ this.tooltipMediaContainer.style.display = 'block';
+ this.tooltipImage.style.display = 'none';
+ this.tooltipVideo.style.display = 'none';
+ if (this.tooltipAudio.parentNode) this.tooltipAudio.parentNode.removeChild(this.tooltipAudio);
+ this.tooltipAudio.removeAttribute('controls');
+
+
+ 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));
+
+ // Re-add the persistent elements to the container
+ this.tooltipMediaContainer.replaceChildren(this.tooltipImage, this.tooltipVideo);
+
+ if (mime === 'image') {
+ this.tooltipImage.src = getFileUrl(itemData.name, this.currentPath);
+ this.tooltipImage.style.display = 'block';
+ this.tooltipImage.onload = onMediaLoad;
+ this.tooltipImage.onerror = onMediaError;
+ } else if (mime === 'video') {
+ this.tooltipVideo.src = getFileUrl(itemData.name, this.currentPath);
+ this.tooltipVideo.style.display = 'block';
+ this.tooltipVideo.onloadedmetadata = onMediaLoad;
+ this.tooltipVideo.onerror = onMediaError;
+ } else if (mime === 'audio') {
+ this.tooltipMediaContainer.style.display = 'none';
+ this.tooltipAudio.src = getFileUrl(itemData.name, this.currentPath);
+ this.tooltipAudio.setAttribute('controls', 'true');
+ this.tooltipMetadata.insertBefore(this.tooltipAudio, this.tooltipMetadata.firstChild); // Prepend audio player
+ this.tooltipAudio.onloadedmetadata = onMediaLoad;
+ this.tooltipAudio.onerror = onMediaError;
+ } else {
+ this.tooltipMediaContainer.style.display = 'none';
+ this.showTooltip(event);
+ }
+
+ const cachedMeta = getCache(getCacheKey(this.currentPath, itemData.name));
+ if (cachedMeta && cachedMeta.metadata) {
+ if (cachedMeta.metadata.width) createMetaRow("Dimensions", `${cachedMeta.metadata.width} x ${cachedMeta.metadata.height}`);
+ if (cachedMeta.metadata.duration) createMetaRow("Duration", formatDuration(cachedMeta.metadata.duration));
+ if (cachedMeta.metadata.has_workflow) createMetaRow("Workflow", "Yes");
+ } else {
+ if (itemData.width) createMetaRow("Dimensions", `${itemData.width} x ${itemData.height}`);
+ if (itemData.duration) createMetaRow("Duration", formatDuration(itemData.duration));
+ if (itemData.has_workflow) createMetaRow("Workflow", "Yes");
+ }
+ }
+ }
+
+ async navigateTo(path, isRefresh = false) {
+ if (this.isNavigating) return;
+ if (!isRefresh && this.currentPath === path) return;
+
+ this.isNavigating = true;
+ if (this.metadataLoader.isLoading) this.metadataLoader.controller.abort();
+
+ if (!isRefresh) {
+ 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()}`);
+
+ this.itemsData = await response.json().then(data => [...data.directories, ...data.files]);
+
+ // Wait for all metadata to load before the initial render.
+ // This prevents race conditions and ensures the view is always consistent.
+ await this.startMetadataBatchLoading();
+
+ this.render();
+
+ } catch (error) {
+ this.contentArea.innerHTML = `Error: ${error.message}
`;
+ } finally {
+ this.isNavigating = false;
+ }
+ }
+
+ refreshCurrentDirectory() {
+ if (this.isNavigating) return;
+ log(`Refreshing directory: ${this.currentPath}`);
+ this.navigateTo(this.currentPath, true);
+ }
+
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); } this.startMetadataBatchLoading(); }
+
+ render() {
+ this.backButton.disabled = this.history.length <= 1;
+ this.upButton.disabled = !this.currentPath;
+ if (this.virtualList.renderRaf) {
+ cancelAnimationFrame(this.virtualList.renderRaf);
+ this.virtualList.renderRaf = null;
+ }
+ 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);
+ }
+ }
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(); }
+ setViewMode(mode) {
+ if (this.viewMode === mode) return;
+ this.viewMode = mode;
+ setSetting('viewMode', this.viewMode);
+ this.refreshCurrentDirectory();
+ }
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' || mime === 'image'); 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' }); } } }
+ 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.iconView.resizeObserver) {
+ this.iconView.resizeObserver.disconnect();
+ this.iconView.resizeObserver = null;
+ }
+
+ this.iconView.activeNodes.forEach(node => this.iconView.nodePool.push(node));
+ this.iconView.activeNodes.clear();
+ this.listView.activeNodes.forEach(node => this.listView.nodePool.push(node));
+ this.listView.activeNodes.clear();
+
+ 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;
+ }
+
+ // By wrapping the entire setup in a setTimeout, we push it to the next event
+ // loop cycle. This ensures the browser has finished painting the dialog and the
+ // container dimensions are stable before any layout calculations are performed.
+ // This is a robust way to solve race conditions.
+ setTimeout(() => {
+ if (this.viewMode !== 'icons') return; // In case the view changed while waiting
+
+ this.virtualList.lastRendered = { start: -1, end: -1 };
+
+ const scroller = $el("div.aiia-virtual-scroller");
+ this.virtualList.scroller = scroller;
+
+ const content = $el("div.aiia-grid-inner-wrapper", { style: { position: 'relative' }});
+ this.virtualList.content = content;
+
+ const viewport = $el("div.aiia-grid-wrapper", [scroller, content]);
+ this.virtualList.viewport = viewport;
+
+ this.iconView.resizeObserver = new ResizeObserver(() => {
+ this.updateIconViewGeometry(files);
+ this.renderVisibleIcons(files, true);
+ });
+ this.iconView.resizeObserver.observe(viewport);
+
+ viewport.onscroll = () => {
+ if (this.virtualList.renderRaf) cancelAnimationFrame(this.virtualList.renderRaf);
+ this.virtualList.renderRaf = requestAnimationFrame(() => this.renderVisibleIcons(files));
+ };
+
+ this.contentArea.replaceChildren(viewport); // Use replaceChildren to be safe
+ }, 0);
+ }
+
+ updateIconViewGeometry(files) {
+ if (!this.virtualList.viewport) return;
+ const { clientWidth } = this.virtualList.viewport;
+
+ // Defensive guard against erroneous zero-width calculations during layout shifts.
+ if (clientWidth === 0) return;
+
+ const iconSize = Number(this.iconSize);
+ const minMargin = 10;
+ this.iconView.columns = Math.max(1, Math.floor(clientWidth / (iconSize + minMargin)));
+
+ this.iconView.rowHeight = iconSize + 40;
+
+ const totalIconWidth = this.iconView.columns * iconSize;
+ const totalMarginWidth = clientWidth - totalIconWidth;
+ this.iconView.actualMargin = this.iconView.columns > 1 ? totalMarginWidth / (this.iconView.columns - 1) : 0;
+
+ const totalRows = Math.ceil(files.length / this.iconView.columns);
+ const totalHeight = totalRows * this.iconView.rowHeight;
+ this.virtualList.scroller.style.height = `${totalHeight}px`;
+ }
+
+ renderVisibleIcons(files, force = false) {
+ if (!this.virtualList.viewport || !files || !this.iconView.columns) return;
+
+ const { scrollTop, clientHeight } = this.virtualList.viewport;
+ const { rowHeight, columns, colWidth } = this.iconView;
+ const { buffer } = this.virtualList;
+
+ const totalRows = Math.ceil(files.length / columns);
+ const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer);
+ const lastVisibleRow = Math.min(totalRows - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer);
+
+ if (!force && firstVisibleRow === this.virtualList.lastRendered.start && lastVisibleRow === this.virtualList.lastRendered.end) {
+ return;
+ }
+
+ const firstItem = firstVisibleRow * columns;
+ const lastItem = Math.min(files.length - 1, ((lastVisibleRow + 1) * columns) - 1);
+
+ const newVisibleNames = new Set();
+ for(let i = firstItem; i <= lastItem; i++) {
+ if(files[i]) newVisibleNames.add(files[i].name);
+ }
+
+ // Recycle nodes that are no longer visible
+ for (const [name, node] of this.iconView.activeNodes.entries()) {
+ if (!newVisibleNames.has(name)) {
+ if (this.iconLoadTimers.has(name)) {
+ clearTimeout(this.iconLoadTimers.get(name));
+ this.iconLoadTimers.delete(name);
+ }
+ const mediaContainer = node.querySelector('.aiia-icon-preview');
+ if(mediaContainer) this.iconViewObserver.unobserve(mediaContainer);
+ node.remove();
+ this.iconView.nodePool.push(node);
+ this.iconView.activeNodes.delete(name);
+ }
+ }
+
+ // Render new visible items
+ for (let i = firstItem; i <= lastItem; i++) {
+ const item = files[i];
+ if (!item || this.iconView.activeNodes.has(item.name)) continue;
+
+ const node = this.iconView.nodePool.pop() || (() => {
+ const newNode = $el('div.aiia-item-icon', { style: { position: 'absolute' }});
+ const preview = $el('div.aiia-icon-preview');
+ const buttonOverlay = $el('div.aiia-icon-button-overlay');
+ const label = $el('div.aiia-item-label');
+ newNode.replaceChildren(preview, buttonOverlay, label);
+ return newNode;
+ })();
+
+ // Cleanup and setup
+ const preview = node.querySelector('.aiia-icon-preview');
+ preview.innerHTML = '';
+ preview.className = 'aiia-icon-preview';
+
+ // Positioning
+ const row = Math.floor(i / columns);
+ const col = i % columns;
+ node.style.top = `${row * rowHeight}px`;
+ node.style.left = `${col * (Number(this.iconSize) + this.iconView.actualMargin)}px`;
+ node.style.width = `${this.iconSize}px`;
+ node.style.height = `${this.iconView.rowHeight}px`;
+
+ // Content
+ node.dataset.itemName = item.name;
+ const label = node.querySelector('.aiia-item-label');
+ label.textContent = item.name;
+ label.classList.toggle('has-workflow', !!item.has_workflow);
+
+ let cachedEntry = this.iconView.mediaCache.get(item.name);
+
+ // Invalidate cache if mtime is different
+ if (cachedEntry && cachedEntry.mtime !== item.mtime) {
+ cachedEntry = null;
+ this.iconView.mediaCache.delete(item.name);
+ }
+
+ if (cachedEntry) {
+ const mediaElement = cachedEntry.element.cloneNode(true);
+ preview.appendChild(mediaElement);
+ if (mediaElement.classList.contains('aiia-video-poster-container-flag')) {
+ preview.classList.add("aiia-video-poster-container");
+ }
+ } else {
+ preview.classList.add('media-placeholder');
+ preview.dataset.filename = item.name;
+ preview.dataset.path = this.currentPath;
+ preview.dataset.mtime = item.mtime;
+ this.iconViewObserver.observe(preview);
+ }
+
+ const buttonOverlay = node.querySelector('.aiia-icon-button-overlay');
+ const downloadButton = $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' });
+
+ if (item.has_workflow) {
+ const loadButton = $el('button.aiia-load-workflow-button', { textContent: '🚀', title: 'Load Workflow' });
+ buttonOverlay.replaceChildren(loadButton, downloadButton);
+ } else {
+ buttonOverlay.replaceChildren(downloadButton);
+ }
+
+ this.virtualList.content.appendChild(node);
+ this.iconView.activeNodes.set(item.name, node);
+ }
+
+ this.virtualList.lastRendered = { start: firstVisibleRow, end: lastVisibleRow };
+ }
+
+ 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.listView.rowHeight;
+ const scroller = $el("div.aiia-virtual-scroller", { style: { height: `${totalHeight}px` } });
+ this.virtualList.scroller = scroller;
+
+ const content = $el("div.aiia-list-body-content");
+ this.virtualList.content = content;
+
+ const viewport = $el("div.aiia-list-body", [scroller, content]);
+ this.virtualList.viewport = viewport;
+
+ // --- START: Fixed Pool Implementation ---
+ this.listView.nodePool = [];
+ this.listView.activeNodes.clear(); // Clear any previous state
+ const poolSize = Math.ceil(viewport.clientHeight / this.listView.rowHeight) + (this.virtualList.buffer * 2);
+
+ for (let i = 0; i < poolSize; i++) {
+ const row = $el("div.aiia-list-row");
+ row.replaceChildren(
+ $el("div.aiia-list-cell.aiia-list-cell-icon"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell.aiia-list-cell-actions", [
+ $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' })
+ ])
+ );
+ this.listView.nodePool.push(row);
+ }
+ // --- END: Fixed Pool Implementation ---
+
+ viewport.onscroll = () => {
+ if (this.virtualList.renderRaf) cancelAnimationFrame(this.virtualList.renderRaf);
+ this.virtualList.renderRaf = requestAnimationFrame(() => this.renderVisibleRows());
+ };
+
+ const container = $el("div.aiia-list-container", [headerContainer, 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(force = false) {
+ if (!this.virtualList.viewport || !this.visibleItems) return;
+
+ const { scrollTop, clientHeight } = this.virtualList.viewport;
+ const { rowHeight, nodePool, activeNodes } = this.listView;
+ const { buffer } = this.virtualList;
+
+ const totalRows = this.visibleItems.length;
+ const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer);
+ const lastVisibleRow = Math.min(totalRows - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer);
+
+ if (!force && firstVisibleRow === this.virtualList.lastRendered.start && lastVisibleRow === this.virtualList.lastRendered.end) {
+ return;
+ }
+
+ const newActiveNodes = new Map();
+ const fragment = document.createDocumentFragment();
+
+ for (let i = firstVisibleRow; i <= lastVisibleRow; i++) {
+ const item = this.visibleItems[i];
+ if (!item) continue;
+
+ let node = activeNodes.get(item.name);
+ if (node) {
+ activeNodes.delete(item.name);
+ } else {
+ node = nodePool.pop() || (() => {
+ const newNode = $el("div.aiia-list-row");
+ newNode.replaceChildren(
+ $el("div.aiia-list-cell.aiia-list-cell-icon"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell"),
+ $el("div.aiia-list-cell.aiia-list-cell-actions", [
+ $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' })
+ ])
+ );
+ return newNode;
+ })();
+ }
+
+ node.dataset.itemName = item.name;
+ node.style.position = 'relative';
+ node.style.transform = '';
+
+ 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) : '—';
+
+ const cells = node.children;
+ cells[0].textContent = icon;
+ cells[1].textContent = item.name;
+ cells[2].textContent = formatDate(item.mtime);
+ cells[3].textContent = item.extension.replace('.', '');
+ cells[4].textContent = formatBytes(item.size);
+ cells[5].textContent = dimText;
+ cells[6].textContent = durText;
+
+ const actionsCell = cells[7];
+ actionsCell.innerHTML = ''; // Clear previous buttons
+
+ const downloadButton = $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' });
+
+ if (item.has_workflow) {
+ const loadButton = $el('button.aiia-load-workflow-button', { textContent: '🚀', title: 'Load Workflow' });
+ actionsCell.append(loadButton, downloadButton);
+ } else {
+ actionsCell.appendChild(downloadButton);
+ }
+
+ const globalIndex = this.directoryItems.length + i;
+ if(globalIndex === this.currentFocusIndex) node.classList.add('focused');
+ else node.classList.remove('focused');
+
+ fragment.appendChild(node);
+ newActiveNodes.set(item.name, node);
+ }
+
+ for (const node of activeNodes.values()) {
+ nodePool.push(node);
+ }
+
+ this.virtualList.content.replaceChildren(fragment);
+ this.listView.activeNodes = newActiveNodes;
+
+ this.virtualList.content.style.transform = `translateY(${firstVisibleRow * rowHeight}px)`;
+ this.virtualList.lastRendered = { start: firstVisibleRow, end: lastVisibleRow };
+ }
+ getAllNavigableItems() { const directories = this.hideFolders ? [] : this.directoryItems; return [...directories, ...this.visibleItems]; }
+ async startMetadataBatchLoading() {
+ return new Promise(async (resolve) => {
+ if (this.metadataLoader.isLoading) {
+ resolve();
+ return;
+ }
+
+ const filesToProcess = this.itemsData.filter(item => {
+ const mime = getMimeType(item.name);
+ let needsMeta = false;
+ if (mime === 'image') {
+ needsMeta = !item.hasOwnProperty('width');
+ } else if (mime === 'video' || mime === 'audio') {
+ needsMeta = !item.hasOwnProperty('duration');
+ }
+ return item.type === 'file' && needsMeta;
+ });
+
+ 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) {
+ const { mtime, size, ...rest } = cachedData.metadata;
+ Object.assign(item, rest);
+ } else {
+ filesToFetch.push(item);
+ }
+ }
+
+ if (filesToFetch.length === 0) {
+ resolve();
+ 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(resolveWait => setTimeout(resolveWait, 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 fetch(window.location.origin + '/api/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) throw new Error(`HTTP ${response.status}`);
+
+ const data = await response.json();
+ for (const [filename, metadata] of Object.entries(data)) {
+ const f = this.itemsDataMap.get(filename);
+ if (f) {
+ const { mtime, size, ...rest } = metadata;
+ Object.assign(f, rest);
+ const cacheKey = getCacheKey(this.currentPath, f.name);
+ setCache(cacheKey, { mtime: f.mtime, size: f.size, metadata: rest });
+ }
+ }
+
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ console.error('[AIIA] Error fetching batch metadata:', error);
+ }
+ }
+
+ loaded += batch.length;
+ this.updateProgressBar(loaded, total);
+ }
+ this.updateProgressBar(loaded, total, true);
+ resolve();
+ });
+ }
+ 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);
+ }
+ }
+ return;
+ }
+ this.progressContainer.style.display = 'flex';
+ const percent = (loaded / total) * 100;
+ this.progressBar.firstChild.style.width = `${percent}%`;
+ this.progressText.textContent = `Analyzing metadata... (${loaded}/${total})`;
+ }
+
+ handleIconIntersection(entries) {
+ const LOAD_DELAY = 100;
+ for (const entry of entries) {
+ const placeholder = entry.target;
+ const filename = placeholder.dataset.filename;
+ if (!filename) continue;
+
+ if (entry.isIntersecting) {
+ if (this.iconLoadTimers.has(filename)) continue;
+
+ const timer = setTimeout(() => {
+ this.iconLoadTimers.delete(filename);
+
+ const fileData = this.itemsDataMap.get(filename);
+ if (!fileData) return;
+
+ let cachedEntry = this.iconView.mediaCache.get(filename);
+ let mediaElement;
+
+ // If the cached entry exists but the modification time is different, invalidate it.
+ if (cachedEntry && cachedEntry.mtime !== fileData.mtime) {
+ cachedEntry = null;
+ this.iconView.mediaCache.delete(filename);
+ }
+
+ if (cachedEntry) {
+ mediaElement = cachedEntry.element;
+ } else {
+ const createPlaceholder = (icon, text) => $el(`div.${text === "Empty File" ? "aiia-empty-file-placeholder" : "aiia-no-preview-placeholder"}`, [ $el("span", { textContent: icon }), $el("span", { textContent: text }) ]);
+
+ if (fileData.size === 0) {
+ mediaElement = createPlaceholder("🚫", "Empty File");
+ } else {
+ const path = placeholder.dataset.path;
+ const mtime = placeholder.dataset.mtime;
+ const mime = getMimeType(filename);
+
+ const handleMediaError = (e) => {
+ const newPlaceholder = createPlaceholder("🖼️🚫", "No Preview");
+ if(e.target.parentNode) e.target.parentNode.replaceChild(newPlaceholder, e.target);
+ this.iconView.mediaCache.set(filename, { element: newPlaceholder.cloneNode(true), mtime: fileData.mtime });
+ };
+
+ if (mime === 'image') {
+ const url = getThumbnailUrl(filename, path, mtime);
+ mediaElement = $el("img.aiia-image-preview", { src: url, onerror: handleMediaError });
+ } 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, onerror: handleMediaError });
+ } else {
+ mediaElement = $el("img.aiia-image-preview", { src: url, onerror: handleMediaError });
+ mediaElement.classList.add("aiia-video-poster-container-flag");
+ }
+ } else if (mime === 'audio') {
+ mediaElement = createPlaceholder("🎵", fileData.duration ? formatDuration(fileData.duration) : 'Audio');
+ } else {
+ mediaElement = createPlaceholder("❓", "Unknown");
+ }
+ }
+ this.iconView.mediaCache.set(filename, { element: mediaElement.cloneNode(true), mtime: fileData.mtime });
+ }
+
+ if (placeholder.parentNode) {
+ placeholder.className = 'aiia-icon-preview';
+ if (mediaElement.classList.contains('aiia-video-poster-container-flag')) {
+ placeholder.classList.add("aiia-video-poster-container");
+ }
+ placeholder.replaceChildren(mediaElement);
+ }
+
+ }, LOAD_DELAY);
+ this.iconLoadTimers.set(filename, timer);
+ } else {
+ if (this.iconLoadTimers.has(filename)) {
+ clearTimeout(this.iconLoadTimers.get(filename));
+ this.iconLoadTimers.delete(filename);
+ }
+ }
+ }
+ }
+
+ 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.listView; 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' && 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, columns } = this.iconView; const itemRow = Math.floor(fileIndex / columns); const itemTop = itemRow * rowHeight; const itemBottom = itemTop + rowHeight; if (itemTop < scrollTop) { this.virtualList.viewport.scrollTop = itemTop; } else if (itemBottom > scrollTop + clientHeight) { this.virtualList.viewport.scrollTop = itemBottom - clientHeight; } } } }
moveFocus(key) {
const allNavigableItems = this.getAllNavigableItems();
@@ -314,8 +1164,7 @@ class AIIABrowserDialog extends ComfyDialog {
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;
+ columns = this.iconView.columns || 1;
}
switch (key) {
@@ -394,7 +1243,33 @@ class AIIABrowserDialog extends ComfyDialog {
}
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); } }
+ toggleTooltipForFocusedItem() {
+ if (this.currentFocusIndex === -1) return;
+
+ const allNavigableItems = this.getAllNavigableItems();
+ const itemData = allNavigableItems[this.currentFocusIndex];
+ const itemEl = this.element.querySelector('.focused');
+
+ if (!itemEl) return;
+
+ // Toggle the actions-visible class for keyboard users
+ const isActionsVisible = itemEl.classList.toggle('actions-visible');
+
+ if (this.isTooltipVisible && this.tooltipItemName === itemData.name) {
+ this.hideTooltip();
+ // If actions are not visible, it means we are closing everything, so remove focus.
+ if (!isActionsVisible) {
+ itemEl.classList.remove('focused');
+ this.currentFocusIndex = -1;
+ }
+ } else {
+ clearTimeout(this.tooltipTimeout);
+ this.hideTooltip();
+ 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;
@@ -442,12 +1317,43 @@ class AIIABrowserDialog extends ComfyDialog {
}
}
- show() { this.currentPath = null; this.history = []; this.navigateTo(""); super.show(); this.updateTooltipSizeLimits(); this.element.focus({ preventScroll: true }); }
+ show() {
+ this.currentPath = null;
+ this.history = [];
+ this.navigateTo("");
+ super.show();
+ this.updateTooltipSizeLimits();
+ this.element.focus({ preventScroll: true });
+ }
}
app.registerExtension({
name: "Comfy.AIIA.Browser",
setup() {
+ // --- START: One-time cache cleanup ---
+ try {
+ const migrationKey = "aiia-cache-v2-migrated";
+ if (!localStorage.getItem(migrationKey)) {
+ console.log("[AIIA Browser] Checking for old cache entries to migrate...");
+ const keysToRemove = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key.startsWith("aiia-cache:") && !key.startsWith("aiia-cache:v2:")) {
+ keysToRemove.push(key);
+ }
+ }
+ if (keysToRemove.length > 0) {
+ console.log(`[AIIA Browser] Removing ${keysToRemove.length} old cache entries...`);
+ keysToRemove.forEach(key => localStorage.removeItem(key));
+ }
+ localStorage.setItem(migrationKey, "true");
+ console.log("[AIIA Browser] Cache migration check complete.");
+ }
+ } catch (e) {
+ console.error("[AIIA Browser] Error during cache migration:", e);
+ }
+ // --- END: One-time cache cleanup ---
+
document.head.appendChild($el("style", { textContent: browserStyles }));
let browserDialog = null;
const createDialog = () => { if (!browserDialog) browserDialog = new AIIABrowserDialog(); browserDialog.show(); };
diff --git a/js/aiia_browser_styles.js b/js/aiia_browser_styles.js
index 71ffd7a..6ec0a35 100644
--- a/js/aiia_browser_styles.js
+++ b/js/aiia_browser_styles.js
@@ -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; }
-`;
\ No newline at end of file
+`;
+