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

543 lines
24 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.
/** Curated material snapshots: generated image + exact workflow + node blocks. */
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
const t = (key, params) => translate(key, params);
function text(parent, tag, value, className = '') {
const element = document.createElement(tag);
if (className) element.className = className;
element.textContent = value == null ? '' : String(value);
parent.appendChild(element);
return element;
}
function materialAssetUrl(filename, asset) {
if (!filename || !asset) return '';
return `/anomalous/material_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(asset)}`;
}
async function jsonResponse(response, fallbackMessage) {
let payload = {};
try { payload = await response.json(); } catch (error) { /* non-JSON server failure */ }
if (!response.ok) throw new Error(payload.message || fallbackMessage);
return payload;
}
function hideSiblingWorkspaceViews(owner) {
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
if (owner.recipeView) owner.recipeView.style.display = 'none';
owner.notebookNotesTab?.classList.remove('active');
owner.notebookRecipesTab?.classList.remove('active');
owner.notebookMaterialsTab?.classList.add('active');
}
async function fetchMaterial(filename) {
const response = await fetch(`/anomalous/material_full?filename=${encodeURIComponent(filename)}`, { cache: 'no-store' });
const payload = await jsonResponse(response, 'material load failed');
if (payload.status !== 'success') throw new Error(payload.message || 'material load failed');
return payload;
}
async function openMaterialWorkflow(owner, filename) {
const payload = await fetchMaterial(filename);
if (!payload.data?.workflow || typeof app.loadGraphData !== 'function') throw new Error('material workflow unavailable');
await app.loadGraphData(JSON.parse(JSON.stringify(payload.data.workflow)));
app.canvas?.setDirty?.(true, true);
owner.closeWorkspace?.();
owner.close?.();
window.setTimeout(() => window.anomalous_resolve_all_missing_nodes?.(true, false), 0);
}
function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
const preview = document.createElement('div');
preview.className = 'anomalous-material-card-preview';
const previewUrl = materialAssetUrl(material.filename, material.image?.preview_asset_id || material.image?.source_asset_id);
if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = material.name || t('materialUntitled');
image.loading = 'lazy';
preview.appendChild(image);
} else {
preview.textContent = '🖼️';
}
card.appendChild(preview);
const body = document.createElement('div');
body.className = 'anomalous-material-card-body';
text(body, 'h3', material.name || t('materialUntitled'));
text(body, 'p', t('materialNodeSummary', { count: material.node_count || 0 }), 'anomalous-material-muted');
if (Array.isArray(material.node_types) && material.node_types.length) {
text(body, 'small', material.node_types.slice(0, 5).join(' · '), 'anomalous-material-types');
}
card.appendChild(body);
const actions = document.createElement('div');
actions.className = 'anomalous-material-card-actions';
const open = text(actions, 'button', `🚀 ${t('materialOpenWorkflow')}`, 'anomalous-btn-primary');
open.type = 'button';
open.onclick = async () => {
open.disabled = true;
try {
await openMaterialWorkflow(owner, material.filename);
} catch (error) {
console.error('Could not open material workflow:', error);
await anomalousAlert(t('materialOpenError'));
open.disabled = false;
}
};
const remove = text(actions, 'button', '🗑️', 'anomalous-btn-ghost');
remove.type = 'button';
remove.title = t('materialDelete');
remove.onclick = async () => {
if (!await anomalousConfirm(t('materialDeleteConfirm'))) return;
const response = await fetch('/anomalous/delete_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: material.filename }),
});
if (!response.ok) {
await anomalousAlert(t('materialDeleteError'));
return;
}
await owner.refreshMaterials?.();
};
card.appendChild(actions);
return card;
}
export async function refreshMaterials() {
if (!this.materialList) return;
this.materialList.replaceChildren();
text(this.materialList, 'p', t('loading'), 'anomalous-material-empty');
try {
const response = await fetch('/anomalous/materials', { cache: 'no-store' });
const payload = await jsonResponse(response, 'material list failed');
this.materialList.replaceChildren();
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
text(this.materialList, 'p', t('materialEmpty'), 'anomalous-material-empty');
return;
}
for (const material of materials) this.materialList.appendChild(renderMaterialCard(this, material));
} catch (error) {
console.error('Could not load materials:', error);
this.materialList.replaceChildren();
text(this.materialList, 'p', t('materialLoadError'), 'anomalous-material-empty');
}
}
export async function showMaterials() {
if (!this.notebookContainer) {
this.nbPanel.style.display = 'flex';
await this.showNotebooks();
}
hideSiblingWorkspaceViews(this);
if (!this.materialView) {
this.materialView = document.createElement('div');
this.materialView.className = 'anomalous-material-body';
const intro = document.createElement('div');
intro.className = 'anomalous-material-intro';
const introCopy = document.createElement('div');
text(introCopy, 'h3', t('materialLibrary'));
text(introCopy, 'p', t('materialLibraryHint'), 'anomalous-material-muted');
const refresh = text(intro, 'button', `↻ ${t('refresh')}`, 'anomalous-btn-ghost');
refresh.type = 'button';
refresh.onclick = () => this.refreshMaterials();
intro.prepend(introCopy);
this.materialView.appendChild(intro);
this.materialList = document.createElement('div');
this.materialList.className = 'anomalous-material-list';
this.materialView.appendChild(this.materialList);
this.notebookContainer.appendChild(this.materialView);
}
this.materialView.style.display = 'flex';
await this.refreshMaterials();
}
/**
* Direct client-side PNG chunk parser to safely extract embedded ComfyUI metadata
* without requiring a server reboot or server-side re-encoding.
*/
async function parsePngMetadataFromUrl(url) {
if (!url) return null;
try {
const response = await fetch(url);
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 32) return null;
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) return null;
let offset = 8;
const utf8 = new TextDecoder('utf-8');
const latin1 = new TextDecoder('iso-8859-1');
const result = {};
while (offset + 8 <= buffer.byteLength) {
const length = view.getUint32(offset);
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
);
const dataOffset = offset + 8;
if (type === 'IDAT') break; // Reached image raster data, text metadata is prior
if (dataOffset + length > buffer.byteLength) break;
if (type === 'tEXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
const val = latin1.decode(bytes.subarray(nullIdx + 1));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
} else if (type === 'iTXt') {
const bytes = new Uint8Array(buffer, dataOffset, length);
const nullIdx = bytes.indexOf(0);
if (nullIdx > -1) {
const key = latin1.decode(bytes.subarray(0, nullIdx));
let ptr = nullIdx + 1;
const compFlag = bytes[ptr++];
const compMethod = bytes[ptr++];
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
while (ptr < bytes.length && bytes[ptr] !== 0) ptr++;
ptr++;
if (compFlag === 0 && ptr <= bytes.length) {
const val = utf8.decode(bytes.subarray(ptr));
try { result[key] = JSON.parse(val); } catch { result[key] = val; }
}
}
}
offset += 12 + length;
}
return result.workflow || result.prompt || null;
} catch (e) {
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
/**
* Extract generation parameters and full prompts from a ComfyUI workflow JSON.
*/
function extractWorkflowDetails(workflow) {
const params = {};
const positivePrompts = [];
const negativePrompts = [];
const allPrompts = [];
if (!workflow || typeof workflow !== 'object') {
return { params, positivePrompts, negativePrompts, allPrompts };
}
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const ntype = String(node.type || '').trim();
const ntypeLower = ntype.toLowerCase();
const widgets = Array.isArray(node.widgets_values) ? node.widgets_values : [];
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
const isAdv = ntypeLower === 'ksampleradvanced';
const offset = isAdv ? 1 : 0;
if (widgets[2 + offset] != null && params.steps == null) params.steps = widgets[2 + offset];
if (widgets[3 + offset] != null && params.cfg == null) params.cfg = widgets[3 + offset];
if (widgets[4 + offset] != null && params.sampler_name == null) params.sampler_name = widgets[4 + offset];
if (widgets[5 + offset] != null && params.scheduler == null) params.scheduler = widgets[5 + offset];
if (widgets[6 + offset] != null && params.denoise == null) params.denoise = widgets[6 + offset];
} else if (ntypeLower === 'emptylatentimage') {
if (widgets[0] && widgets[1] && params.resolution == null) {
params.resolution = `${widgets[0]} × ${widgets[1]}`;
}
}
if (ntypeLower.includes('cliptextencode') || ntypeLower.includes('prompt')) {
const nodeTitle = String(node.title || ntype).toLowerCase();
for (const val of widgets) {
if (typeof val === 'string' && val.trim()) {
const textVal = val.trim();
if (!allPrompts.includes(textVal)) allPrompts.push(textVal);
if (nodeTitle.includes('neg') || nodeTitle.includes('负向')) {
if (!negativePrompts.includes(textVal)) negativePrompts.push(textVal);
} else {
if (!positivePrompts.includes(textVal)) positivePrompts.push(textVal);
}
}
}
}
}
return { params, positivePrompts, negativePrompts, allPrompts };
}
export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
const overlay = document.createElement('div');
overlay.className = 'anomalous-material-detail-overlay';
const dialog = document.createElement('div');
dialog.className = 'anomalous-material-detail-dialog';
const close = text(dialog, 'button', '×', 'anomalous-material-detail-close');
close.type = 'button';
close.onclick = () => overlay.remove();
// 1. Left Media Stage
const media = document.createElement('div');
media.className = 'anomalous-material-detail-media';
const image = document.createElement('img');
image.src = imageUrl;
image.alt = t('materialImageDetail');
media.appendChild(image);
const mediaBar = document.createElement('div');
mediaBar.className = 'anomalous-material-media-bar';
const mediaResText = text(mediaBar, 'span', sourceImage?.filename || 'PNG Image');
const fullViewBtn = text(mediaBar, 'button', t('materialViewOriginal') || '🔍 查看大图', 'anomalous-material-mini-btn');
fullViewBtn.type = 'button';
fullViewBtn.onclick = () => owner?.showGalleryViewer?.(imageUrl);
media.appendChild(mediaBar);
// 2. Right Inspector Panel with 3-Tier Layout
const side = document.createElement('div');
side.className = 'anomalous-material-detail-side';
// Tier 1: Fixed Header
const sideHeader = document.createElement('div');
sideHeader.className = 'anomalous-material-side-header';
text(sideHeader, 'h2', t('materialImageDetail'));
text(sideHeader, 'p', t('materialSnapshotExplanation'));
side.appendChild(sideHeader);
// Tier 2: Scrollable Body (Independent scroll container)
const sideBody = document.createElement('div');
sideBody.className = 'anomalous-material-side-body';
const loading = text(sideBody, 'p', t('materialInspecting'), 'anomalous-material-muted');
side.appendChild(sideBody);
// Tier 3: Sticky Footer
const sideFooter = document.createElement('div');
sideFooter.className = 'anomalous-material-side-footer';
const label = text(sideFooter, 'label', t('materialName'));
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.maxLength = 120;
nameInput.placeholder = t('materialName');
label.appendChild(nameInput);
text(sideFooter, 'small', t('materialSaveHint'), 'anomalous-material-muted');
const saveBtn = text(sideFooter, 'button', `💾 ${t('materialSaveSnapshot')}`, 'anomalous-btn-primary');
saveBtn.type = 'button';
saveBtn.disabled = true;
side.appendChild(sideFooter);
dialog.append(media, side);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
overlay.addEventListener('click', event => { if (event.target === overlay) overlay.remove(); });
// Load data: parallel fetch from backend inspection AND direct PNG client-side parse
try {
const [inspectPayload, clientWorkflow] = await Promise.all([
fetch('/anomalous/inspect_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_image: sourceImage }),
}).then(r => jsonResponse(r, 'material inspection failed')),
parsePngMetadataFromUrl(imageUrl).catch(() => null),
]);
loading.remove();
saveBtn.disabled = false;
const clientDetails = extractWorkflowDetails(clientWorkflow);
// Merge generation parameters
const params = {
...(clientDetails.params || {}),
...(inspectPayload.params || {}),
};
if (params.resolution) {
mediaResText.textContent = `${params.resolution} · ${sourceImage?.filename || 'PNG'}`;
}
// Section A: Essential Metric Grid
const hasSamplingParams = params.steps || params.cfg || params.sampler_name || params.resolution;
if (hasSamplingParams) {
const metricGrid = document.createElement('div');
metricGrid.className = 'anomalous-material-metric-grid';
const addMetric = (labelStr, val) => {
if (val == null || val === '') return;
const card = document.createElement('div');
card.className = 'anomalous-material-metric-card';
text(card, 'span', labelStr, 'anomalous-material-metric-label');
text(card, 'span', String(val), 'anomalous-material-metric-val');
metricGrid.appendChild(card);
};
addMetric(t('recipeCardSpecsSteps') || '步数', params.steps);
addMetric('CFG', params.cfg);
const samplerFull = [params.sampler_name, params.scheduler].filter(Boolean).join(' / ');
addMetric(t('recipeCardSpecsSampler') || '采样', samplerFull);
if (params.denoise != null) addMetric(t('materialDenoise') || '降噪', params.denoise);
addMetric(t('recipeCardSpecsResolution') || '尺寸', params.resolution);
if (metricGrid.childElementCount) sideBody.appendChild(metricGrid);
}
// Section B: Prompts (Full, un-truncated prompts)
const renderPromptCard = (titleText, promptStr) => {
if (!promptStr || !promptStr.trim()) return;
const promptCard = document.createElement('div');
promptCard.className = 'anomalous-material-prompt-card';
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
const btns = document.createElement('div');
btns.className = 'anomalous-material-prompt-bar-btns';
if (promptStr.length > 90 || promptStr.includes('\n')) {
const expandBtn = text(btns, 'button', t('materialExpandAll') || '展开', 'anomalous-material-mini-btn');
expandBtn.type = 'button';
expandBtn.onclick = () => {
const isExp = promptContent.classList.toggle('is-expanded');
expandBtn.textContent = isExp ? (t('materialCollapse') || '收起') : (t('materialExpandAll') || '展开');
};
}
const copyBtn = text(btns, 'button', t('materialCopyPrompt') || '📋 复制', 'anomalous-material-mini-btn');
copyBtn.type = 'button';
copyBtn.onclick = () => {
navigator.clipboard.writeText(promptStr).then(() => {
copyBtn.textContent = t('materialCopied') || '✅ 已复制';
setTimeout(() => { copyBtn.textContent = t('materialCopyPrompt') || '📋 复制'; }, 1500);
});
};
bar.appendChild(btns);
promptCard.appendChild(bar);
const promptContent = text(promptCard, 'div', promptStr, 'anomalous-material-prompt-content');
sideBody.appendChild(promptCard);
};
const posText = clientDetails.positivePrompts.join('\n\n');
const negText = clientDetails.negativePrompts.join('\n\n');
if (posText || negText) {
if (posText) renderPromptCard(t('materialPositivePrompt') || '正向提示词', posText);
if (negText) renderPromptCard(t('materialNegativePrompt') || '负向提示词', negText);
} else {
const fallbackText = (Array.isArray(inspectPayload.prompts) && inspectPayload.prompts.length)
? inspectPayload.prompts.join('\n\n')
: (inspectPayload.prompt_excerpt || '');
if (fallbackText) renderPromptCard(t('materialPromptText') || '提示词', fallbackText);
}
// Section C: Referenced Models (Collapsible)
const models = Array.isArray(inspectPayload.model_references) ? inspectPayload.model_references : [];
if (models.length) {
const modelDetails = document.createElement('details');
modelDetails.className = 'anomalous-material-accordion';
const modelSummary = document.createElement('summary');
modelSummary.textContent = `📦 ${t('materialReferencedModels', { count: models.length })} ▾`;
modelDetails.appendChild(modelSummary);
const modelContent = document.createElement('div');
modelContent.className = 'anomalous-material-accordion-content';
for (const ref of models) {
const row = document.createElement('div');
row.className = 'anomalous-material-model-row';
const modelName = String(ref.saved_value || ref.name || 'Unknown').replace(/\\/g, '/').split('/').pop();
const nameEl = text(row, 'span', modelName, 'anomalous-material-model-name');
nameEl.title = ref.saved_value || modelName;
text(row, 'span', ref.category || 'model', 'anomalous-material-model-tag');
modelContent.appendChild(row);
}
modelDetails.appendChild(modelContent);
sideBody.appendChild(modelDetails);
}
// Section D: Included Nodes (Collapsible)
const blocks = Array.isArray(inspectPayload.node_blocks) ? inspectPayload.node_blocks : [];
if (blocks.length) {
const nodeDetails = document.createElement('details');
nodeDetails.className = 'anomalous-material-accordion';
const nodeSummary = document.createElement('summary');
nodeSummary.textContent = `⚙️ ${t('materialIncludedNodes', { count: blocks.length })} ▾`;
nodeDetails.appendChild(nodeSummary);
const nodeContent = document.createElement('div');
nodeContent.className = 'anomalous-material-accordion-content';
const typeCounts = {};
for (const b of blocks) {
const tName = b.title || b.type || '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);
}
nodeContent.appendChild(chipFlow);
nodeDetails.appendChild(nodeContent);
sideBody.appendChild(nodeDetails);
}
// Tier 3 Setup: Name Input & Save
nameInput.value = inspectPayload.suggested_name || '';
saveBtn.onclick = async () => {
const val = nameInput.value.trim();
if (!val) { nameInput.focus(); return; }
saveBtn.disabled = true;
try {
const saveResponse = await fetch('/anomalous/save_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_image: sourceImage, name: val }),
});
const savePayload = await jsonResponse(saveResponse, 'material save failed');
if (savePayload.status !== 'success') throw new Error(savePayload.message || 'material save failed');
owner?.refreshMaterials?.();
overlay.remove();
await anomalousAlert(t('materialSaveSuccess'));
} catch (error) {
console.error('Could not save image material:', error);
await anomalousAlert(t('materialSaveError'));
saveBtn.disabled = false;
}
};
nameInput.focus();
nameInput.select();
} catch (error) {
console.error('Could not inspect image material:', error);
loading.textContent = t('materialInspectError');
}
}