Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_gallery_detail.js
T
DemonGatanjieuandClaude Opus 5.5 8c044d0d5a Keep very large pictures from freezing the gallery
A 20480x13312 upscale is past Pillow's pixel limit, so its card image
failed and the catch-all fallback served the 324 MB original as the
thumbnail: the gallery and the AI workbench decoded it every time.

- A picture past the limit is shrunk once by a child process
  (huge_picture.py, low priority, one at a time) and cached; a card is
  never a large original, one with no card image gets an SVG with its size.
- /anomalous/output_preview gives the viewers the picture or a cached
  4096 px copy of a very large one; the viewer (moved to
  ui_gallery_viewer.js), the image workbench's stage and the AI pages'
  larger pictures use it, with a note and "Open the original".
- The listing carries each file's size (and big ones' pixel size); such a
  card says "Very large".
- The browser-side PNG metadata read stops at the image data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:23:22 +08:00

593 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ui_gallery_detail.js
* Professional Image Detail Studio Workbench for ComfyUI.
* Features:
* - Studio modal workspace covering most of viewport with blurred backdrop
* - Prev / Next image navigation (keyboard arrows, floating glass arrows, counter jump)
* - Collapsible left thumbnail rail with smooth vertical auto-centering
* - High-speed in-memory LRU metadata cache (imageMetadataCache)
* - Preloading of adjacent images and AbortController request cancellation
* - Inspector: specs, prompts, models and LoRAs, nodes; load the workflow, keep the image
* as a workflow, a combo or its prompts (ui_keep_menu.js), or keep selected nodes' values
*/
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { openKeepMenu } from './ui_keep_menu.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import {
buildWorkbenchHeader,
setupStagePanZoom,
buildFilmstripRail,
extendFilmstripRail,
preloadAdjacentImages,
} from './ui_image_stage.js';
import { renderImageInspectorContent } from './ui_image_inspector.js';
import { openParamCompare } from './ui_param_compare.js';
import { handImageToAi } from './ui_ai_handoff.js';
import {
fileBaseName,
extractWorkflowDetails,
materialNodeHeading,
} from './material_inspector.js';
import { parsePngMetadataFromUrl } from './png_metadata.js';
import { shownUrl } from './ui_gallery_viewer.js';
const t = (key, params) => translate(key, params);
// Inputs that keep the focus after a click but take no typing: the arrows and A / D still
// switch pictures after a node's checkbox in All parameters was ticked.
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file']);
function isTyping(target) {
if (!target) return false;
if (target.isContentEditable || target.tagName === 'TEXTAREA') return true;
return target.tagName === 'INPUT' && !NON_TEXT_INPUTS.has(target.type);
}
// In-memory LRU metadata cache: key -> { inspectPayload, clientWorkflow, clientDetails, params }
const imageMetadataCache = new Map();
// A workflow can contain large prompt strings and hundreds of nodes. Keep only
// a small navigation window rather than retaining an entire long gallery.
const MAX_METADATA_CACHE = 16;
function getCachedMetadata(key) {
if (!key || !imageMetadataCache.has(key)) return null;
const val = imageMetadataCache.get(key);
// Refresh LRU order
imageMetadataCache.delete(key);
imageMetadataCache.set(key, val);
return val;
}
function setCachedMetadata(key, val) {
if (!key || !val) return;
if (imageMetadataCache.size >= MAX_METADATA_CACHE) {
const oldestKey = imageMetadataCache.keys().next().value;
imageMetadataCache.delete(oldestKey);
}
imageMetadataCache.set(key, val);
}
function compactInspectPayload(payload, hasClientWorkflow) {
if (!payload || typeof payload !== 'object' || !hasClientWorkflow) return payload || {};
const compact = { ...payload };
delete compact.workflow;
// Older running backends may still return exact values here. The client
// workflow already owns them, so discard the duplicate before LRU caching.
if (Array.isArray(compact.node_blocks)) {
compact.node_blocks = compact.node_blocks.map(block => ({
node_id: block?.node_id,
type: block?.type,
title: block?.title,
occurrence: block?.occurrence,
widget_count: block?.widget_count,
volatile_widget_indexes: block?.volatile_widget_indexes,
}));
}
return compact;
}
// Active singleton workbench state
let wb = null;
/**
* Copy text to clipboard with button feedback
*/
async function copyToClipboard(str, btn, successLabel, defaultLabel) {
try {
await navigator.clipboard.writeText(str);
if (btn) {
btn.textContent = successLabel;
btn.classList.add('is-copied');
setTimeout(() => {
btn.textContent = defaultLabel;
btn.classList.remove('is-copied');
}, 1500);
}
} catch (e) {
console.warn('Clipboard write failed:', e);
}
}
function materialSourceImage(item) {
return item.sourceImage || {
type: 'output',
filename: item.filename,
subfolder: item.subfolder || '',
};
}
async function saveImageMaterial(item, name, selectedNodeIds) {
const owner = wb?.owner;
const promptRoleOverrides = wb?.promptRoleOverrides;
const body = {
source_image: materialSourceImage(item), name: String(name || '').trim().slice(0, 120), selected_node_ids: selectedNodeIds,
...(owner?.recipeDetailFilename ? { recipe_filename: owner.recipeDetailFilename } : {}),
...(promptRoleOverrides && typeof promptRoleOverrides === 'object' ? { promptRoleOverrides } : {}),
};
const send = () => fetch('/anomalous/save_image_material', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
let response = await send();
if (response.status === 409) {
const duplicate = await response.json();
if (duplicate.status !== 'duplicate') throw new Error('material save conflict');
if (!await anomalousConfirm(t('materialDuplicateConfirm', { name: duplicate.name }))) return null;
body.allow_duplicate = true;
response = await send();
}
const payload = await jsonResponse(response, 'material save failed');
if (payload.status !== 'success') throw new Error(payload.message || 'material save failed');
showWorkbenchToast(t('nodeValuesSaved'));
return payload;
}
function selectedMaterialName(baseName, blocks) {
const base = String(baseName || '').trim();
const cleanBase = base.replace(/ · (?:快照|Snapshot)$/i, '').trim();
const suffix = blocks.length === 1
? materialNodeHeading(blocks[0])
: t('materialSelectedNodesName', { count: blocks.length });
return `${cleanBase ? `${cleanBase} · ` : ''}${suffix}`.slice(0, 120);
}
async function saveSelectedBlocks(item, suggestedName, blocks, button) {
if (!blocks.length || !button || button.disabled) return;
const defaultLabel = button.dataset.defaultLabel || (blocks.length === 1
? t('materialSaveNode')
: t('materialSaveSelectedAction', { count: blocks.length }));
button.disabled = true;
button.textContent = t('materialSaving');
try {
const saved = await saveImageMaterial(
item,
selectedMaterialName(suggestedName || fileBaseName(item.filename), blocks),
blocks.map(block => String(block.node_id)),
);
if (!saved) { button.textContent = defaultLabel; button.disabled = false; return; }
button.textContent = t('materialSaved');
window.setTimeout(() => {
if (!button.isConnected) return;
button.textContent = defaultLabel;
button.disabled = false;
}, 1400);
} catch (error) {
console.error('Could not save selected material nodes:', error);
button.textContent = defaultLabel;
button.disabled = false;
await anomalousAlert(t('materialSaveError'));
}
}
/** The header's ⇄: this image's parameters beside the canvas's. */
function compareWithCanvas(item) {
const source = item.sourceImage || { type: 'output', filename: item.filename, subfolder: item.subfolder || '' };
openParamCompare({ image: source }, { canvas: true });
}
/**
* Dismiss active workbench
*/
function dismissWorkbench() {
if (!wb) return;
if (wb.panZoom) wb.panZoom.cleanup();
if (wb.abortController) wb.abortController.abort();
if (wb.overlay) wb.overlay.remove();
if (wb.onKeyDown) window.removeEventListener('keydown', wb.onKeyDown);
wb = null;
}
/**
* Load into ComfyUI canvas
*/
async function loadWorkflowToComfyCanvas(workflow) {
if (!workflow || typeof app.loadGraphData !== 'function') {
await anomalousAlert(t('materialOpenError'));
return;
}
try {
const cloned = JSON.parse(JSON.stringify(workflow));
await app.loadGraphData(cloned);
await anomalousAlert(t('workbenchLoadSuccess') || '工作流已成功加载到 ComfyUI 画布!');
} catch (e) {
console.error('Failed to load workflow to canvas:', e);
await anomalousAlert(`${t('workbenchLoadFailed') || '无法加载工作流到画布:'}${e.message || e}`);
}
}
/**
* Load and render a specific image in the workbench
*/
async function loadWorkbenchImage(index) {
if (!wb || !wb.items || index < 0 || index >= wb.items.length) return;
wb.currentIndex = index;
wb.selectedNodeIds = new Set();
const item = wb.items[index];
const total = wb.items.length;
// Abort any ongoing fetch
if (wb.abortController) wb.abortController.abort();
wb.abortController = new AbortController();
const signal = wb.abortController.signal;
// 1. Update Header
if (wb.headerEl) {
const newHeader = buildWorkbenchHeader(
item,
index,
total,
(newIdx) => loadWorkbenchImage(newIdx),
{ workbench: wb, onDismiss: dismissWorkbench, onCompare: compareWithCanvas, onHandToAi: item => handImageToAi(wb.owner, item, dismissWorkbench) },
);
wb.headerEl.replaceWith(newHeader);
wb.headerEl = newHeader;
}
// 2. Update Image & reset pan/zoom
if (wb.stageImg) {
wb.stageImg.src = shownUrl(item.url);
if (wb.panZoom) wb.panZoom.reset();
}
// 3. Update Filmstrip Active item
if (wb.filmstripEl) {
const thumbs = wb.filmstripEl.querySelectorAll('.anomalous-workbench-filmstrip-thumb');
thumbs.forEach((el, i) => {
el.classList.toggle('is-active', i === index);
if (i === index) {
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}
});
}
// 4. Preload adjacent images
preloadAdjacentImages(wb.items, index);
// 5. Check if infinite load more needed
if (index >= wb.items.length - 4 && typeof wb.loadMore === 'function') {
try {
wb.loadMore().then(newItems => {
if (Array.isArray(newItems) && newItems.length > wb.items.length) {
wb.items = newItems;
// Only the new thumbnails are added, so the rail keeps its place.
if (wb.filmstripEl) extendFilmstripRail(wb.filmstripEl, wb.items, (idx) => loadWorkbenchImage(idx), wb);
}
}).catch(() => {});
} catch (_) {}
}
// 6. Check cache or load metadata
const cacheKey = item.url || (item.subfolder ? `${item.subfolder}/${item.filename}` : item.filename);
const cached = getCachedMetadata(cacheKey);
if (cached) {
renderInspectorContent(cached, item);
return;
}
// Render loading state in side body
if (wb.sideBodyEl) {
wb.sideBodyEl.replaceChildren();
const loadingBox = document.createElement('div');
loadingBox.className = 'anomalous-workbench-loading-box';
loadingBox.innerHTML = `
<div class="anomalous-workbench-spinner"></div>
<span>${t('materialInspecting') || '正在读取图片中的工作流与参数…'}</span>
`;
wb.sideBodyEl.appendChild(loadingBox);
}
try {
const sourceImage = item.sourceImage || {
type: 'output',
filename: item.filename,
subfolder: item.subfolder || '',
};
const inspectPayload = await fetch('/anomalous/inspect_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_image: sourceImage }),
signal,
}).then(r => jsonResponse(r, 'material inspection failed')).catch(() => ({}));
if (signal.aborted) return;
// Current backends return the already validated workflow, avoiding a
// second full-image ArrayBuffer in the browser. Direct PNG parsing is
// retained only as a compatibility fallback before a server restart.
const clientWorkflow = inspectPayload.workflow
|| await parsePngMetadataFromUrl(item.url, { signal }).catch(() => null);
if (signal.aborted) return;
const compactPayload = compactInspectPayload(inspectPayload, Boolean(clientWorkflow));
const clientDetails = extractWorkflowDetails(clientWorkflow);
const params = {
...(clientDetails.params || {}),
...(compactPayload.params || {}),
};
const metadataBundle = {
inspectPayload: compactPayload,
clientWorkflow,
clientDetails,
params,
};
setCachedMetadata(cacheKey, metadataBundle);
renderInspectorContent(metadataBundle, item);
} catch (err) {
if (signal.aborted) return;
console.warn('Workbench metadata fetch error:', err);
if (wb.sideBodyEl) {
wb.sideBodyEl.replaceChildren();
text(wb.sideBodyEl, 'div', t('materialInspectError') || '无法解析此图片的生图参数。', 'anomalous-workbench-error-box');
}
}
}
function renderInspectorContent(data, item) {
return renderImageInspectorContent({
workbench: wb,
saveSelectedBlocks,
copyToClipboard,
loadWorkflowToComfyCanvas,
openMaterialLocalModel,
openKeep,
}, data, item);
}
/** Keep: the keep menu for the image shown (ui_keep_menu.js); what is kept is named after its prompt. */
function openKeep(anchor, item) {
void openKeepMenu(wb?.owner, anchor, materialSourceImage(item), { beforeOpen: dismissWorkbench });
}
/**
* Open local model detail safely from workbench
*/
function openMaterialLocalModel(model) {
if (!model || !wb || typeof wb.owner?.showDetail !== 'function') return;
const owner = wb.owner;
wb.overlay.classList.add('is-suspended');
wb.inspectingModel = true;
const previousReturn = owner.recipeModelReturn;
owner.recipeModelReturn = () => {
owner.recipeModelReturn = previousReturn;
if (wb) {
wb.inspectingModel = false;
wb.overlay.classList.remove('is-suspended');
}
if (owner.detailPanel) {
owner.detailPanel.style.display = 'none';
owner.stopMediaInContainer?.(owner.detailPanel);
owner.detailPanel.replaceChildren();
}
owner.modal?.classList.add('visible');
};
owner.historyStack = [];
owner.currentType = model.type || owner.currentType;
owner.currentPathIdx = model.path_idx ?? model.path_index ?? 0;
owner.currentSubfolder = model.subfolder || '/';
owner.currentDetailModel = model;
owner.modal?.classList.add('visible');
for (const panel of [owner.grid, owner.galleryPanel, owner.doctorPanel, owner.assistantPanel, owner.paramPanel, owner.nbPanel]) {
if (panel) panel.style.display = 'none';
}
owner.showDetail(model);
}
/**
* Primary public entry point: Show Image Detail Workbench
* @param {Object} owner - AnomalousBrowser instance
* @param {Object} sourceImage - { filename, subfolder, type }
* @param {string} imageUrl - direct URL to output image
* @param {Object} [options] - { items: Array, currentIndex: number, loadMore: Function }
*/
export async function showImageWorkbench(owner, sourceImage, imageUrl, options = {}) {
// If existing workbench open, dismiss it first
dismissWorkbench();
// Prepare items list
let items = Array.isArray(options.items) && options.items.length ? [...options.items] : [];
let currentIndex = typeof options.currentIndex === 'number' ? options.currentIndex : 0;
// Normalization: Ensure each item has url, filename, subfolder
if (!items.length) {
items = [{
filename: sourceImage?.filename || 'image.png',
subfolder: sourceImage?.subfolder || '',
url: imageUrl,
sourceImage,
}];
currentIndex = 0;
} else {
// If current index was not specified or out of bounds, locate by url or filename
if (currentIndex < 0 || currentIndex >= items.length) {
const foundIdx = items.findIndex(it => (it.url && it.url === imageUrl) || (it.filename && it.filename === sourceImage?.filename));
currentIndex = foundIdx >= 0 ? foundIdx : 0;
}
}
// Initialize workbench singleton
wb = {
owner,
items,
currentIndex,
loadMore: options.loadMore || null,
activeTab: 'specs',
isFilmstripVisible: true,
inspectingModel: false,
abortController: null,
selectedNodeIds: new Set(),
};
// 1. Overlay container
const overlay = document.createElement('div');
overlay.className = 'anomalous-workbench-overlay';
overlay.id = 'anomalous-workbench-overlay';
wb.overlay = overlay;
// 2. Dialog box
const dialog = document.createElement('div');
dialog.className = 'anomalous-workbench-dialog';
wb.dialog = dialog;
// 3. Header bar (Initial placeholder)
const headerEl = buildWorkbenchHeader(
items[currentIndex],
currentIndex,
items.length,
(idx) => loadWorkbenchImage(idx),
{ workbench: wb, onDismiss: dismissWorkbench, onCompare: compareWithCanvas, onHandToAi: item => handImageToAi(wb.owner, item, dismissWorkbench) },
);
wb.headerEl = headerEl;
dialog.appendChild(headerEl);
// 4. Main Body: Left Stage + Right Inspector
const bodyContainer = document.createElement('div');
bodyContainer.className = 'anomalous-workbench-body';
// 4A. Left Stage Area
const stageArea = document.createElement('div');
stageArea.className = 'anomalous-workbench-stage-area';
const stage = document.createElement('div');
stage.className = 'anomalous-workbench-stage';
wb.stage = stage;
const img = document.createElement('img');
img.className = 'anomalous-workbench-stage-img';
img.src = shownUrl(items[currentIndex].url);
img.alt = 'Workbench Stage';
wb.stageImg = img;
stage.appendChild(img);
// Floating Nav Arrows on canvas edges
const floatPrev = document.createElement('button');
floatPrev.type = 'button';
floatPrev.className = 'anomalous-workbench-float-nav is-prev';
floatPrev.innerHTML = '‹';
floatPrev.title = t('workbenchPrev') || '上一张 (←)';
floatPrev.onclick = (e) => {
e.stopPropagation();
if (wb.currentIndex > 0) loadWorkbenchImage(wb.currentIndex - 1);
};
const floatNext = document.createElement('button');
floatNext.type = 'button';
floatNext.className = 'anomalous-workbench-float-nav is-next';
floatNext.innerHTML = '›';
floatNext.title = t('workbenchNext') || '下一张 (→)';
floatNext.onclick = (e) => {
e.stopPropagation();
if (wb.currentIndex < wb.items.length - 1) loadWorkbenchImage(wb.currentIndex + 1);
};
stageArea.append(floatPrev, stage, floatNext);
// Floating Canvas Zoom Toolbar (Bottom-Right of stage)
const panZoom = setupStagePanZoom(stage, img);
wb.panZoom = panZoom;
const zoomBar = document.createElement('div');
zoomBar.className = 'anomalous-workbench-zoom-bar';
const fitBtn = document.createElement('button');
fitBtn.type = 'button';
fitBtn.textContent = t('workbenchZoomFit') || '适应窗口';
fitBtn.onclick = () => panZoom.reset();
const actualBtn = document.createElement('button');
actualBtn.type = 'button';
actualBtn.textContent = t('workbenchZoomReset') || '1:1';
actualBtn.onclick = () => { panZoom.reset(); panZoom.zoomIn(); };
const zoomInBtn = document.createElement('button');
zoomInBtn.type = 'button';
zoomInBtn.textContent = '+';
zoomInBtn.onclick = () => panZoom.zoomIn();
const zoomOutBtn = document.createElement('button');
zoomOutBtn.type = 'button';
zoomOutBtn.textContent = '−';
zoomOutBtn.onclick = () => panZoom.zoomOut();
zoomBar.append(fitBtn, actualBtn, zoomOutBtn, zoomInBtn);
stageArea.appendChild(zoomBar);
// 4A. Left Vertical Filmstrip Rail
const filmstripRail = buildFilmstripRail(items, currentIndex, (idx) => loadWorkbenchImage(idx), wb);
wb.filmstripEl = filmstripRail;
bodyContainer.appendChild(filmstripRail);
// 4B. Center Canvas Stage Area
bodyContainer.appendChild(stageArea);
// 4C. Right Inspector Panel
const inspector = document.createElement('div');
inspector.className = 'anomalous-workbench-inspector';
const sideBody = document.createElement('div');
sideBody.className = 'anomalous-workbench-inspector-body';
wb.sideBodyEl = sideBody;
inspector.appendChild(sideBody);
bodyContainer.appendChild(inspector);
dialog.appendChild(bodyContainer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Keyboard navigation listener
const onKeyDown = (e) => {
if (wb.inspectingModel) return;
if (document.querySelector('.anomalous-dialog-overlay')) return;
if (isTyping(e.target)) return;
if (e.key === 'Escape') {
e.preventDefault();
dismissWorkbench();
} else if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
e.preventDefault();
if (wb.currentIndex > 0) loadWorkbenchImage(wb.currentIndex - 1);
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
e.preventDefault();
if (wb.currentIndex < wb.items.length - 1) loadWorkbenchImage(wb.currentIndex + 1);
}
};
wb.onKeyDown = onKeyDown;
window.addEventListener('keydown', onKeyDown);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) dismissWorkbench();
});
// Start loading current item metadata
loadWorkbenchImage(currentIndex);
}