Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_gallery_detail.js
T

1454 lines
60 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.
import { showMaterialSaved } from './material_feedback.js';
/**
* 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
* - Segmented Bento Inspector: Key Specs Bento grid, Prompts Station, Models & LoRA with weight pills, Node structure
* - One-click actions: load Workflow and save a full or selected-node Material
*/
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import {
sectionLabel,
fileBaseName,
appendLocalPreview,
modelCustomNotes,
parsePngMetadataFromUrl,
extractWorkflowDetails,
resolveLocalModels,
lookupLocalModel,
detailedBlocksFromWorkflow,
materialNodeHeading,
renderDetailedNodeCards,
applyPromptRolesToBlocks,
mergePromptRoleOverrides,
} from './material_inspector.js';
const t = (key, params) => translate(key, params);
// 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 = null, tags = []) {
const owner = wb?.owner;
const promptRoleOverrides = wb?.promptRoleOverrides;
const body = {
source_image: materialSourceImage(item), name: String(name || '').trim().slice(0, 120), tags,
...(selectedNodeIds?.length ? { 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');
showMaterialSaved(owner, payload.material, dismissWorkbench);
await owner?.refreshMaterials?.();
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') || '素材快照保存失败。');
}
}
/**
* Preload adjacent images for smooth instant transitions
*/
function preloadAdjacentImages(items, currentIndex) {
const indices = [currentIndex - 1, currentIndex + 1];
for (const idx of indices) {
if (idx >= 0 && idx < items.length) {
const url = items[idx]?.url;
if (url) {
const img = new Image();
img.src = url;
}
}
}
}
/**
* Build Workbench Header
*/
function buildWorkbenchHeader(item, index, total, onNavigate) {
const header = document.createElement('div');
header.className = 'anomalous-workbench-header';
// Left info
const leftWrap = document.createElement('div');
leftWrap.className = 'anomalous-workbench-header-left';
const backBtn = document.createElement('button');
backBtn.type = 'button';
backBtn.className = 'anomalous-workbench-back-btn';
backBtn.innerHTML = '‹';
backBtn.title = `${t('close') || '关闭'} (Esc)`;
backBtn.onclick = () => dismissWorkbench();
leftWrap.appendChild(backBtn);
const titleBox = document.createElement('div');
titleBox.className = 'anomalous-workbench-title-box';
const filenameEl = document.createElement('span');
filenameEl.className = 'anomalous-workbench-title-filename';
filenameEl.textContent = fileBaseName(item.filename) || 'Image';
filenameEl.title = item.filename || '';
titleBox.appendChild(filenameEl);
const metaTags = document.createElement('div');
metaTags.className = 'anomalous-workbench-title-tags';
if (item.subfolder) {
const folderTag = text(metaTags, 'span', item.subfolder, 'anomalous-workbench-tag');
folderTag.title = item.subfolder;
}
const ext = (item.filename || '').split('.').pop()?.toUpperCase();
if (ext) text(metaTags, 'span', ext, 'anomalous-workbench-tag is-ext');
titleBox.appendChild(metaTags);
leftWrap.appendChild(titleBox);
header.appendChild(leftWrap);
// Center image counter & quick switcher
const centerWrap = document.createElement('div');
centerWrap.className = 'anomalous-workbench-header-center';
const prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'anomalous-workbench-nav-btn';
prevBtn.innerHTML = '‹';
prevBtn.title = t('workbenchPrev') || '上一张 (←)';
prevBtn.disabled = index <= 0;
prevBtn.onclick = () => onNavigate(index - 1);
const counter = document.createElement('span');
counter.className = 'anomalous-workbench-counter';
counter.textContent = `${index + 1} / ${total}`;
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'anomalous-workbench-nav-btn';
nextBtn.innerHTML = '›';
nextBtn.title = t('workbenchNext') || '下一张 (→)';
nextBtn.disabled = index >= total - 1;
nextBtn.onclick = () => onNavigate(index + 1);
centerWrap.append(prevBtn, counter, nextBtn);
header.appendChild(centerWrap);
// Right Action Buttons
const rightWrap = document.createElement('div');
rightWrap.className = 'anomalous-workbench-header-right';
// Toggle Filmstrip button
const filmstripToggle = document.createElement('button');
filmstripToggle.type = 'button';
filmstripToggle.className = wb?.isFilmstripVisible
? 'anomalous-workbench-tool-btn is-active'
: 'anomalous-workbench-tool-btn';
filmstripToggle.innerHTML = '🎞️';
filmstripToggle.title = t('workbenchFilmstrip') || (window.anomalous_browser_lang === 'zh' ? '侧栏缩略图' : 'Thumbnail Rail');
filmstripToggle.onclick = () => {
wb.isFilmstripVisible = !wb.isFilmstripVisible;
filmstripToggle.classList.toggle('is-active', wb.isFilmstripVisible);
if (wb.filmstripEl) wb.filmstripEl.classList.toggle('is-hidden', !wb.isFilmstripVisible);
};
rightWrap.appendChild(filmstripToggle);
// Close button
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'anomalous-workbench-close-btn';
closeBtn.innerHTML = '×';
closeBtn.title = `${t('close') || '关闭'} (Esc)`;
closeBtn.onclick = () => dismissWorkbench();
rightWrap.appendChild(closeBtn);
header.appendChild(rightWrap);
return header;
}
/**
* Setup pan & zoom interactions on the stage image
*/
function setupStagePanZoom(stage, img) {
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0, startY = 0;
const applyTransform = () => {
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
img.style.cursor = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default';
};
const resetTransform = () => {
scale = 1;
translateX = 0;
translateY = 0;
applyTransform();
};
stage.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.15 : -0.15;
const newScale = Math.min(Math.max(0.2, scale + delta), 8);
scale = newScale;
if (scale <= 1) { translateX = 0; translateY = 0; }
applyTransform();
}, { passive: false });
img.addEventListener('mousedown', (e) => {
if (scale <= 1) return;
e.preventDefault();
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
applyTransform();
});
const onMouseMove = (e) => {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
applyTransform();
};
const onMouseUp = () => {
if (!isDragging) return;
isDragging = false;
applyTransform();
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
// Double click to toggle 1:1 vs fit
stage.addEventListener('dblclick', (e) => {
if (e.target !== img) return;
if (scale === 1) {
scale = 2;
translateX = 0;
translateY = 0;
} else {
resetTransform();
}
applyTransform();
});
return {
reset: resetTransform,
zoomIn: () => { scale = Math.min(8, scale + 0.3); applyTransform(); },
zoomOut: () => { scale = Math.max(0.2, scale - 0.3); applyTransform(); },
cleanup: () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
};
}
/**
* Build Left Vertical Filmstrip Rail
*/
function buildFilmstripRail(items, currentIndex, onNavigate) {
const rail = document.createElement('div');
rail.className = 'anomalous-workbench-filmstrip';
if (wb && wb.isFilmstripVisible === false) {
rail.classList.add('is-hidden');
}
const track = document.createElement('div');
track.className = 'anomalous-workbench-filmstrip-track';
items.forEach((item, idx) => {
const thumbWrap = document.createElement('div');
thumbWrap.className = idx === currentIndex
? 'anomalous-workbench-filmstrip-thumb is-active'
: 'anomalous-workbench-filmstrip-thumb';
thumbWrap.title = `${idx + 1}. ${fileBaseName(item.filename)}`;
const thumbImg = document.createElement('img');
thumbImg.src = item.url;
thumbImg.loading = 'lazy';
thumbImg.alt = '';
thumbWrap.appendChild(thumbImg);
const badge = document.createElement('span');
badge.className = 'anomalous-workbench-thumb-index';
badge.textContent = String(idx + 1);
thumbWrap.appendChild(badge);
thumbWrap.onclick = (e) => {
e.stopPropagation();
if (idx !== currentIndex) onNavigate(idx);
};
track.appendChild(thumbWrap);
// Auto-center active thumbnail vertically
if (idx === currentIndex) {
requestAnimationFrame(() => {
setTimeout(() => {
thumbWrap.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}, 40);
});
}
});
rail.appendChild(track);
return rail;
}
/**
* Build Specs Bento Grid
*/
function buildSpecsGrid(params, blocks, item, suggestedName) {
const grid = document.createElement('div');
grid.className = 'anomalous-workbench-specs-grid';
const samplerBlock = (blocks || []).find(block => /^(ksampler|ksampleradvanced)$/i.test(block.type || ''));
const sizeBlock = (blocks || []).find(block => /^(emptylatentimage|emptylatentimage.*)$/i.test(block.type || ''));
const addTile = (labelStr, val, options = {}) => {
if (val == null || val === '') return;
const card = document.createElement('div');
card.className = options.wide ? 'anomalous-workbench-spec-card is-wide' : 'anomalous-workbench-spec-card';
if (options.accent) card.classList.add(`is-${options.accent}`);
const headerRow = document.createElement('div');
headerRow.className = 'anomalous-workbench-spec-header';
text(headerRow, 'span', labelStr, 'anomalous-workbench-spec-label');
let copyIndicator = null;
if (options.copyable) {
card.classList.add('is-copyable');
card.setAttribute('role', 'button');
card.tabIndex = 0;
card.title = t('materialClickToCopy') || '点击复制数值';
copyIndicator = document.createElement('span');
copyIndicator.className = 'anomalous-workbench-copy-indicator';
const renderNormalCopyIcon = () => {
copyIndicator.innerHTML = `
<svg class="anomalous-workbench-spec-copy-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5"></rect>
<path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"></path>
</svg>
`;
};
renderNormalCopyIcon();
card.appendChild(copyIndicator);
const handleCopy = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(String(val));
card.classList.remove('is-copy-failed');
card.classList.add('is-copied');
if (copyIndicator) {
copyIndicator.innerHTML = `
<svg class="anomalous-workbench-spec-copy-icon is-success" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3.5 8.5 6.5 11.5 12.5 4.5"></polyline>
</svg>
<span class="anomalous-workbench-copy-badge">${t('materialCopySuccess')}</span>
`;
}
setTimeout(() => {
card.classList.remove('is-copied');
if (copyIndicator) renderNormalCopyIcon();
}, 1500);
} catch (err) {
console.warn('Clipboard copy error:', err);
card.classList.remove('is-copied');
card.classList.add('is-copy-failed');
if (copyIndicator) {
copyIndicator.innerHTML = `
<svg class="anomalous-workbench-spec-copy-icon is-error" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="6"></circle>
<line x1="8" y1="5" x2="8" y2="8.5"></line>
<line x1="8" y1="11" x2="8.01" y2="11"></line>
</svg>
<span class="anomalous-workbench-copy-badge is-error">${t('materialCopyFailed')}</span>
`;
}
setTimeout(() => {
card.classList.remove('is-copy-failed');
if (copyIndicator) renderNormalCopyIcon();
}, 2000);
}
};
card.onclick = handleCopy;
card.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCopy(e);
}
};
}
card.appendChild(headerRow);
const valEl = text(card, 'div', String(val), 'anomalous-workbench-spec-val');
if (options.mono) valEl.classList.add('is-mono');
grid.appendChild(card);
};
addTile(t('materialParamSeed') || '种子 (Seed)', params.seed, { copyable: true, mono: true, accent: 'seed' });
addTile(t('recipeCardSpecsSteps') || '采样步数 (Steps)', params.steps != null ? `${params.steps} 步` : null, { });
addTile('CFG Scale', params.cfg, { });
addTile(t('materialDenoise') || '重绘降噪 (Denoise)', params.denoise, { });
addTile(t('recipeCardSpecsSampler') || '采样器 (Sampler)', params.sampler_name, { });
addTile(t('materialScheduler') || '调度器 (Scheduler)', params.scheduler, { });
addTile(t('recipeCardSpecsResolution') || '分辨率 (Resolution)', params.resolution, { wide: true, mono: true, accent: 'res' });
const generationBlocks = [samplerBlock, sizeBlock].filter(Boolean);
if (generationBlocks.length) {
const actions = document.createElement('div');
actions.className = 'anomalous-workbench-generation-save';
const infoBox = document.createElement('div');
infoBox.className = 'anomalous-workbench-generation-info';
const titleEl = document.createElement('div');
titleEl.className = 'anomalous-workbench-generation-title';
titleEl.innerHTML = `
<svg class="anomalous-workbench-generation-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="3"></rect>
<path d="M5 8h6m-3-3v6"></path>
</svg>
<span>${t('materialSaveGeneration')}</span>
`;
infoBox.appendChild(titleEl);
text(infoBox, 'div', t('materialGenerationScope'), 'anomalous-workbench-generation-hint');
const save = document.createElement('button');
save.type = 'button';
save.className = 'anomalous-workbench-generation-btn';
save.textContent = t('materialSaveGenerationAction');
save.dataset.defaultLabel = save.textContent;
save.onclick = () => saveSelectedBlocks(item, suggestedName, generationBlocks, save);
actions.appendChild(infoBox);
actions.appendChild(save);
grid.appendChild(actions);
}
return grid;
}
/**
* Build Prompts Station
*/
function buildPromptsStation(blocks, fallbackPrompt, item, suggestedName) {
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-prompts-wrap';
const promptBlocks = (blocks || []).filter(block =>
/cliptextencode/i.test(block.type || '') &&
(block.widgets_values || []).some(value => typeof value === 'string' && value.trim())
);
if (promptBlocks.length) {
text(wrap, 'p', t('materialPromptRoleHelp'), 'anomalous-workbench-muted');
renderDetailedNodeCards(wrap, promptBlocks, {
onSaveBlock: (block, button) => saveSelectedBlocks(item, suggestedName, [block], button),
onPromptRoleChange: async (block, selectedRole) => {
const key = String(block.node_id);
if (!wb.promptRoleOverrides || typeof wb.promptRoleOverrides !== 'object') wb.promptRoleOverrides = {};
if (selectedRole === 'auto') {
delete wb.promptRoleOverrides[key];
block.promptRole = block.promptRoleAutomatic || 'unknown';
block.promptRoleManual = false;
block.promptRoleSource = 'automatic';
} else {
wb.promptRoleOverrides[key] = { role: selectedRole, nodeType: block.type || null };
block.promptRole = selectedRole;
block.promptRoleManual = true;
block.promptRoleSource = 'manual';
}
},
});
} else if (fallbackPrompt) {
const card = document.createElement('div');
card.className = 'anomalous-workbench-prompt-card';
const topBar = document.createElement('div');
topBar.className = 'anomalous-workbench-prompt-bar';
text(topBar, 'span', t('materialPromptText') || '提示词', 'anomalous-workbench-prompt-title');
const copyBtn = text(topBar, 'button', t('materialCopyPrompt'), 'anomalous-workbench-mini-action-btn');
copyBtn.type = 'button';
copyBtn.onclick = () => copyToClipboard(fallbackPrompt, copyBtn, t('materialCopied'), t('materialCopyPrompt'));
card.appendChild(topBar);
text(card, 'div', fallbackPrompt, 'anomalous-workbench-prompt-content is-expanded');
wrap.appendChild(card);
} else {
text(wrap, 'div', t('materialNoPromptData'), 'anomalous-workbench-muted');
}
return wrap;
}
/**
* Build Models & LoRAs Section
*/
function buildModelsSection(orderedRefs, groups, onOpenModel) {
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-models-wrap';
if (!orderedRefs || !orderedRefs.length) {
const empty = text(wrap, 'div', '(未检测到模型引用)', 'anomalous-workbench-muted');
empty.style.padding = '12px';
return wrap;
}
const renderGroup = (title, items, tagClass) => {
if (!items || !items.length) return;
const groupEl = document.createElement('div');
groupEl.className = 'anomalous-workbench-model-group';
text(groupEl, 'div', title, 'anomalous-workbench-model-group-title');
for (const item of items) {
const row = document.createElement('div');
row.className = 'anomalous-workbench-model-card';
appendLocalPreview(row, item.localModel?.preview_url, 'anomalous-workbench-model-thumb');
const info = document.createElement('div');
info.className = 'anomalous-workbench-model-info';
const rawVal = String(item.saved_value || item.name || 'Unknown');
const fileName = fileBaseName(rawVal);
const nameEl = text(info, 'span', fileName, 'anomalous-workbench-model-name');
nameEl.title = rawVal;
if (item.loraWeights) {
const badge = text(info, 'span', `Model: ${item.loraWeights.strengthModel} · CLIP: ${item.loraWeights.strengthClip}`, 'anomalous-workbench-lora-badge');
badge.title = 'LoRA Weight & CLIP Strength';
}
const notes = modelCustomNotes(item.localModel);
if (notes) text(info, 'span', notes, 'anomalous-workbench-model-notes');
row.appendChild(info);
const actions = document.createElement('div');
actions.className = 'anomalous-workbench-model-actions';
text(actions, 'span', item.category || 'model', `anomalous-workbench-model-tag ${tagClass}`);
if (item.localModel) {
const viewBtn = document.createElement('button');
viewBtn.type = 'button';
viewBtn.className = 'anomalous-workbench-mini-action-btn';
viewBtn.textContent = '🔎 定位模型';
viewBtn.onclick = (e) => {
e.stopPropagation();
onOpenModel(item.localModel);
};
actions.appendChild(viewBtn);
}
row.appendChild(actions);
groupEl.appendChild(row);
}
wrap.appendChild(groupEl);
};
renderGroup(t('materialModelBase') || '主模型 / UNet', groups.base, 'is-base');
renderGroup(t('materialModelLora') || 'LoRA 微调层', groups.lora, 'is-lora');
renderGroup(t('materialModelClip') || '文本编码器 (CLIP)', groups.clip, 'is-clip');
renderGroup(t('materialModelVae') || 'VAE 编码器', groups.vae, 'is-vae');
renderGroup(t('materialModelOther') || '其它模型组件', groups.other, 'is-other');
return wrap;
}
/**
* Build Workflow Nodes Section
*/
function buildWorkflowNodesSection(blocks, clientWorkflow, item, suggestedName) {
const wrap = document.createElement('div');
wrap.className = 'anomalous-workbench-nodes-wrap';
if (!blocks || !blocks.length) {
text(wrap, 'div', '(无底层节点参数数据)', 'anomalous-workbench-muted');
return wrap;
}
// Node count summary chips
const typeCounts = {};
for (const b of blocks) {
const tName = materialNodeHeading(b) || 'Node';
typeCounts[tName] = (typeCounts[tName] || 0) + 1;
}
const chipFlow = document.createElement('div');
chipFlow.className = 'anomalous-material-chip-flow';
for (const [nTitle, cnt] of Object.entries(typeCounts)) {
const chip = document.createElement('span');
chip.className = 'anomalous-material-chip';
text(chip, 'span', nTitle);
if (cnt > 1) text(chip, 'span', `×${cnt}`, 'anomalous-material-chip-count');
chipFlow.appendChild(chip);
}
wrap.appendChild(chipFlow);
const selectionBar = document.createElement('div');
selectionBar.className = 'anomalous-workbench-node-selection-bar';
const selectionText = text(selectionBar, 'span', '', 'anomalous-workbench-node-selection-count');
const selectAllBtn = text(selectionBar, 'button', t('materialSelectAllNodes'), 'anomalous-workbench-mini-action-btn');
selectAllBtn.type = 'button';
const clearBtn = text(selectionBar, 'button', t('materialClearNodeSelection'), 'anomalous-workbench-mini-action-btn');
clearBtn.type = 'button';
const saveSelectedBtn = text(selectionBar, 'button', t('materialSaveSelectedAction', { count: 0 }), 'anomalous-workbench-save-selected-btn');
saveSelectedBtn.type = 'button';
saveSelectedBtn.disabled = true;
wrap.appendChild(selectionBar);
const updateSelection = () => {
const count = wb?.selectedNodeIds?.size || 0;
selectionText.textContent = t('materialSelectedNodeCount', { count });
saveSelectedBtn.textContent = t('materialSaveSelectedAction', { count });
saveSelectedBtn.disabled = count === 0;
};
// Copy full workflow JSON button
const jsonActionRow = document.createElement('div');
jsonActionRow.className = 'anomalous-workbench-action-row';
const copyJsonBtn = document.createElement('button');
copyJsonBtn.type = 'button';
copyJsonBtn.className = 'anomalous-workbench-mini-action-btn';
copyJsonBtn.textContent = `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`;
copyJsonBtn.onclick = () => {
if (clientWorkflow) {
copyToClipboard(JSON.stringify(clientWorkflow, null, 2), copyJsonBtn, '✅ 已复制工作流 JSON', `📋 ${t('workbenchCopyWorkflowJson') || '复制完整工作流 JSON'}`);
}
};
jsonActionRow.appendChild(copyJsonBtn);
wrap.appendChild(jsonActionRow);
// Detailed node cards
const detailedContainer = document.createElement('div');
detailedContainer.className = 'anomalous-workbench-node-list-box';
renderDetailedNodeCards(detailedContainer, blocks, {
selectable: true,
selectedIds: wb?.selectedNodeIds,
onSelectionChange: (block, checked) => {
const key = String(block.node_id);
if (checked) wb?.selectedNodeIds?.add(key);
else wb?.selectedNodeIds?.delete(key);
updateSelection();
},
onSaveBlock: (block, button) => saveSelectedBlocks(item, suggestedName, [block], button),
});
wrap.appendChild(detailedContainer);
const setAllSelections = checked => {
if (!wb?.selectedNodeIds) return;
wb.selectedNodeIds.clear();
if (checked) blocks.forEach(block => wb.selectedNodeIds.add(String(block.node_id)));
detailedContainer.querySelectorAll('.anomalous-material-node-select').forEach(input => {
input.checked = checked;
input.closest('.anomalous-material-node-detail')?.classList.toggle('is-selected', checked);
});
updateSelection();
};
selectAllBtn.onclick = () => setAllSelections(true);
clearBtn.onclick = () => setAllSelections(false);
saveSelectedBtn.onclick = () => {
const selected = blocks.filter(block => wb?.selectedNodeIds?.has(String(block.node_id)));
return saveSelectedBlocks(item, suggestedName, selected, saveSelectedBtn);
};
updateSelection();
return wrap;
}
/**
* 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));
wb.headerEl.replaceWith(newHeader);
wb.headerEl = newHeader;
}
// 2. Update Image & reset pan/zoom
if (wb.stageImg) {
wb.stageImg.src = 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;
// Rebuild filmstrip track
if (wb.filmstripEl && wb.filmstripEl.parentElement) {
const newRail = buildFilmstripRail(wb.items, wb.currentIndex, (idx) => loadWorkbenchImage(idx));
wb.filmstripEl.replaceWith(newRail);
wb.filmstripEl = newRail;
}
}
}).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');
}
}
}
/**
* Render the Inspector Panel Content
*/
async function renderInspectorContent(data, item) {
if (!wb || !wb.sideBodyEl) return;
const { inspectPayload, clientWorkflow, clientDetails, params } = data;
wb.sideBodyEl.replaceChildren();
// Group models
const allModelRefs = Array.isArray(inspectPayload.model_references) ? [...inspectPayload.model_references] : [];
const addIfNotExists = (refItem) => {
const clean = (val) => String(val || '').replace(/\\/g, '/').split('/').pop().toLowerCase();
const target = clean(refItem.saved_value || refItem.name);
if (!target) return;
if (!allModelRefs.some(m => clean(m.saved_value || m.name) === target)) {
allModelRefs.push(refItem);
}
};
if (clientDetails.discoveredModels) {
for (const c of clientDetails.discoveredModels.checkpoints) addIfNotExists({ saved_value: c.name, category: c.category });
for (const l of clientDetails.discoveredModels.loras) addIfNotExists({ saved_value: l.name, category: 'lora' });
for (const tModel of clientDetails.discoveredModels.textEncoders) addIfNotExists({ saved_value: tModel.name, category: 'text_encoder' });
for (const v of clientDetails.discoveredModels.vaes) addIfNotExists({ saved_value: v.name, category: 'vae' });
}
const groups = { base: [], lora: [], clip: [], vae: [], other: [] };
for (const ref of allModelRefs) {
const cat = String(ref.category || '').toLowerCase();
const val = String(ref.saved_value || ref.name || '').toLowerCase();
const rawVal = String(ref.saved_value || ref.name || '');
const fileName = fileBaseName(rawVal);
ref.loraWeights = clientDetails.loraDetailsMap?.get(rawVal) || clientDetails.loraDetailsMap?.get(fileName);
if (cat === 'checkpoint' || cat === 'unet' || val.includes('checkpoint') || val.includes('unet')) {
groups.base.push(ref);
} else if (cat === 'lora' || val.includes('lora')) {
groups.lora.push(ref);
} else if (cat === 'text_encoder' || cat === 'clip' || val.includes('clip') || val.includes('t5')) {
groups.clip.push(ref);
} else if (cat === 'vae' || val.includes('vae')) {
groups.vae.push(ref);
} else {
groups.other.push(ref);
}
}
const orderedRefs = [...groups.base, ...groups.lora, ...groups.clip, ...groups.vae, ...groups.other];
const applyResolvedModels = localModels => {
for (const reference of orderedRefs) {
reference.localModel = lookupLocalModel(localModels, reference.saved_value || reference.name);
}
};
if (data._localModels) applyResolvedModels(data._localModels);
const recipe = wb?.owner?.recipeDetailPayload?.recipe;
const recipeOverrides = recipe?.params?.promptRoleOverrides || recipe?.data?.params?.promptRoleOverrides || {};
const promptSourceKey = `${item.subfolder || ''}/${item.filename || item.url || ''}`;
if (wb.promptRoleSourceKey !== promptSourceKey) {
wb.promptRoleSourceKey = promptSourceKey;
wb.promptRoleOverrides = JSON.parse(JSON.stringify(recipeOverrides));
}
const promptRoles = mergePromptRoleOverrides(
inspectPayload.prompt_roles,
wb.promptRoleOverrides,
);
const blocks = applyPromptRolesToBlocks(
detailedBlocksFromWorkflow(clientWorkflow, inspectPayload.node_blocks),
promptRoles,
);
const fallbackPrompt = (Array.isArray(inspectPayload.prompts) && inspectPayload.prompts.length)
? inspectPayload.prompts.join('\n\n')
: (inspectPayload.prompt_excerpt || '');
// Top Inspector Toolbar: One-click actions
const toolbar = document.createElement('div');
toolbar.className = 'anomalous-workbench-inspector-toolbar';
const loadCanvasBtn = document.createElement('button');
loadCanvasBtn.type = 'button';
loadCanvasBtn.className = 'anomalous-workbench-action-btn is-primary';
loadCanvasBtn.innerHTML = `
<svg class="anomalous-workbench-action-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="4" height="4" rx="1"></rect>
<rect x="10" y="3" width="4" height="4" rx="1"></rect>
<rect x="6" y="9.5" width="4" height="4" rx="1"></rect>
<path d="M4 7v2a1 1 0 0 0 1 1h1m6-3v2a1 1 0 0 1-1 1H8"></path>
</svg>
<span>${t('materialOpenWorkflow')}</span>
`;
loadCanvasBtn.title = window.anomalous_browser_lang === 'zh'
? '将这张图片中包含的完整工作流直接还原到 ComfyUI 画布'
: 'Restore the full workflow from this image directly to ComfyUI canvas';
loadCanvasBtn.onclick = () => loadWorkflowToComfyCanvas(clientWorkflow || inspectPayload.workflow);
toolbar.appendChild(loadCanvasBtn);
const saveMaterialBtn = document.createElement('button');
saveMaterialBtn.type = 'button';
saveMaterialBtn.className = 'anomalous-workbench-action-btn is-save';
const cleanSaveLabel = (t('materialSaveSnapshotShort') || '保存到素材库').replace(/^[^\w\u4e00-\u9fa5]+/, '').trim();
saveMaterialBtn.innerHTML = `
<svg class="anomalous-workbench-action-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3.5 2.5h9a1 1 0 0 1 1 1v10.5l-5.5-3-5.5 3V3.5a1 1 0 0 1 1-1z"></path>
</svg>
<span>${cleanSaveLabel}</span>
`;
saveMaterialBtn.title = t('materialSaveSnapshotFocusHint');
saveMaterialBtn.setAttribute('aria-expanded', 'false');
saveMaterialBtn.onclick = () => {
const footer = wb?.sideFooterEl;
if (!footer) return;
footer.hidden = !footer.hidden;
saveMaterialBtn.classList.toggle('is-active', !footer.hidden);
saveMaterialBtn.setAttribute('aria-expanded', String(!footer.hidden));
if (footer.hidden) return;
const nameInput = wb?.sideFooterEl?.querySelector('input[type="text"]');
wb?.sideFooterEl?.scrollIntoView?.({ behavior: 'smooth', block: 'end' });
nameInput?.focus();
nameInput?.select();
};
toolbar.appendChild(saveMaterialBtn);
wb.sideBodyEl.appendChild(toolbar);
// Segmented Tabs
const tabsBar = document.createElement('div');
tabsBar.className = 'anomalous-workbench-tabs-bar';
const tabs = [
{ id: 'specs', label: t('workbenchTabOverview') || '核心参数', icon: '<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>' },
{ id: 'prompts', label: t('workbenchTabPrompts') || '提示词', icon: '<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' },
{ id: 'models', label: t('workbenchTabModels') || '模型与LoRA', icon: '<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
{ id: 'nodes', label: t('workbenchTabNodes') || '工作流节点', icon: '<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>' },
];
const tabPanels = {};
let ensureModelsResolved = () => {};
tabs.forEach(tDef => {
const tabBtn = document.createElement('button');
tabBtn.type = 'button';
tabBtn.className = (wb.activeTab === tDef.id)
? 'anomalous-workbench-tab-btn is-active'
: 'anomalous-workbench-tab-btn';
tabBtn.innerHTML = `${tDef.icon}<span>${tDef.label}</span>`;
tabBtn.onclick = () => {
wb.activeTab = tDef.id;
tabsBar.querySelectorAll('.anomalous-workbench-tab-btn').forEach(b => b.classList.remove('is-active'));
tabBtn.classList.add('is-active');
Object.values(tabPanels).forEach(p => p.style.display = 'none');
if (tabPanels[tDef.id]) tabPanels[tDef.id].style.display = 'flex';
if (tDef.id === 'models') ensureModelsResolved();
};
tabsBar.appendChild(tabBtn);
});
wb.sideBodyEl.appendChild(tabsBar);
// Panel 1: Specs Grid
const specsPanel = document.createElement('div');
specsPanel.className = 'anomalous-workbench-tab-panel';
specsPanel.style.display = wb.activeTab === 'specs' ? 'flex' : 'none';
specsPanel.appendChild(buildSpecsGrid(params, blocks, item, inspectPayload.suggested_name));
tabPanels.specs = specsPanel;
wb.sideBodyEl.appendChild(specsPanel);
// Panel 2: Prompts Station
const promptsPanel = document.createElement('div');
promptsPanel.className = 'anomalous-workbench-tab-panel';
promptsPanel.style.display = wb.activeTab === 'prompts' ? 'flex' : 'none';
promptsPanel.appendChild(buildPromptsStation(blocks, fallbackPrompt, item, inspectPayload.suggested_name));
tabPanels.prompts = promptsPanel;
wb.sideBodyEl.appendChild(promptsPanel);
// Panel 3: Models & LoRAs
const modelsPanel = document.createElement('div');
modelsPanel.className = 'anomalous-workbench-tab-panel';
modelsPanel.style.display = wb.activeTab === 'models' ? 'flex' : 'none';
modelsPanel.appendChild(buildModelsSection(orderedRefs, groups, (m) => openMaterialLocalModel(m)));
tabPanels.models = modelsPanel;
wb.sideBodyEl.appendChild(modelsPanel);
ensureModelsResolved = () => {
if (data._localModels) return;
if (!data._localModelsPromise) {
data._localModelsPromise = resolveLocalModels(orderedRefs.map(reference => reference.saved_value || reference.name))
.then(localModels => {
data._localModels = localModels;
return localModels;
})
.catch(() => ({}));
}
data._localModelsPromise.then(localModels => {
if (!modelsPanel.isConnected) return;
applyResolvedModels(localModels);
modelsPanel.replaceChildren(buildModelsSection(orderedRefs, groups, model => openMaterialLocalModel(model)));
});
};
if (wb.activeTab === 'models') ensureModelsResolved();
// Panel 4: Nodes Section
const nodesPanel = document.createElement('div');
nodesPanel.className = 'anomalous-workbench-tab-panel';
nodesPanel.style.display = wb.activeTab === 'nodes' ? 'flex' : 'none';
nodesPanel.appendChild(buildWorkflowNodesSection(blocks, clientWorkflow, item, inspectPayload.suggested_name));
tabPanels.nodes = nodesPanel;
wb.sideBodyEl.appendChild(nodesPanel);
// Bottom Snapshot Drawer in Footer
renderSaveSnapshotFooter(inspectPayload, item);
}
/**
* Render Save Snapshot form in the sticky footer
*/
function renderSaveSnapshotFooter(inspectPayload, item) {
if (!wb || !wb.sideFooterEl) return;
wb.sideFooterEl.replaceChildren();
wb.sideFooterEl.hidden = true;
const saveRow = document.createElement('div');
saveRow.className = 'anomalous-workbench-save-row';
const inputWrap = document.createElement('div');
inputWrap.className = 'anomalous-workbench-save-input-wrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.placeholder = t('materialName') || '输入素材快照名称…';
nameInput.value = inspectPayload.suggested_name || fileBaseName(item.filename) || '';
nameInput.setAttribute('aria-label', t('materialName'));
inputWrap.appendChild(nameInput);
const tagsInput = document.createElement('input');
tagsInput.type = 'text';
tagsInput.maxLength = 1200;
tagsInput.placeholder = t('materialTagsHint');
tagsInput.setAttribute('aria-label', t('materialTags'));
inputWrap.appendChild(tagsInput);
text(inputWrap, 'small', t('materialFullSaveHint'), 'anomalous-workbench-save-hint');
saveRow.appendChild(inputWrap);
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'anomalous-workbench-action-btn is-save';
const snapshotLabel = (t('materialSaveSnapshot') || '保存为素材').replace(/^[^\w\u4e00-\u9fa5]+/, '').trim();
const renderSaveBtnNormal = () => {
saveBtn.innerHTML = `
<svg class="anomalous-workbench-action-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3.5 2.5h9a1 1 0 0 1 1 1v10.5l-5.5-3-5.5 3V3.5a1 1 0 0 1 1-1z"></path>
</svg>
<span>${snapshotLabel}</span>
`;
};
renderSaveBtnNormal();
saveBtn.onclick = async () => {
const val = nameInput.value.trim();
if (!val) { nameInput.focus(); return; }
saveBtn.disabled = true;
saveBtn.textContent = t('materialSaving');
try {
const tags = tagsInput.value.split(/[,,]/).map(value => value.trim()).filter(Boolean);
const saved = await saveImageMaterial(item, val, null, tags);
if (!saved) { saveBtn.disabled = false; renderSaveBtnNormal(); return; }
saveBtn.textContent = t('materialSaved');
saveBtn.disabled = false;
} catch (error) {
console.error('Could not save image material:', error);
renderSaveBtnNormal();
saveBtn.disabled = false;
await anomalousAlert(t('materialSaveError') || '素材快照保存失败。');
}
};
saveRow.appendChild(saveBtn);
wb.sideFooterEl.appendChild(saveRow);
}
/**
* 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));
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 = 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.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);
const sideFooter = document.createElement('div');
sideFooter.className = 'anomalous-workbench-inspector-footer';
wb.sideFooterEl = sideFooter;
inspector.appendChild(sideFooter);
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 (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) 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);
}