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

535 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';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { text, sectionLabel, fileBaseName, jsonResponse, renderDetailedNodeCards } from './material_inspector.js';
const t = (key, params) => translate(key, params);
function materialAssetUrl(filename, asset) {
if (!filename || !asset) return '';
return `/anomalous/material_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(asset)}`;
}
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, options = {}) {
const response = await fetch(`/anomalous/material_full?filename=${encodeURIComponent(filename)}`, {
cache: 'no-store',
signal: options.signal,
});
const payload = await jsonResponse(response, 'material load failed');
if (payload.status !== 'success') throw new Error(payload.message || 'material load failed');
return payload;
}
function renderMaterialInspector(content, payload) {
const references = Array.isArray(payload.data?.model_references) ? payload.data.model_references : [];
const blocks = Array.isArray(payload.node_blocks) ? payload.node_blocks : [];
const overview = document.createElement('div');
overview.className = 'anomalous-library-detail-stats';
const nodeStat = document.createElement('div');
nodeStat.className = 'anomalous-library-detail-stat';
text(nodeStat, 'strong', blocks.length);
text(nodeStat, 'span', t('materialParameterNodes'));
const modelStat = document.createElement('div');
modelStat.className = 'anomalous-library-detail-stat';
text(modelStat, 'strong', references.length);
text(modelStat, 'span', t('materialModelReferences'));
overview.append(nodeStat, modelStat);
content.appendChild(overview);
if (payload.data?.selection?.scope === 'nodes') {
text(content, 'p', t('materialUseFromNodeAssistant'), 'anomalous-library-detail-callout');
}
if (references.length) {
sectionLabel(content, t('materialModelReferences'));
const models = document.createElement('div');
models.className = 'anomalous-material-expanded-models';
for (const reference of references) {
const item = document.createElement('div');
item.className = 'anomalous-material-expanded-model';
const rawValue = reference.saved_value || reference.name || t('materialUntitled');
const name = text(item, 'span', fileBaseName(rawValue));
name.title = String(rawValue);
text(item, 'small', reference.category || 'model');
models.appendChild(item);
}
content.appendChild(models);
}
const blocksHeader = document.createElement('div');
blocksHeader.className = 'anomalous-material-blocks-header';
sectionLabel(blocksHeader, t('materialDetailedNodeParameters', { count: blocks.length }));
if (blocks.length > 1) {
let allOpen = true;
const toggleAllBtn = document.createElement('button');
toggleAllBtn.type = 'button';
toggleAllBtn.className = 'anomalous-material-toggle-all-btn';
toggleAllBtn.textContent = t('materialCollapseAll') || '全部收起';
toggleAllBtn.onclick = () => {
allOpen = !allOpen;
content.querySelectorAll('.anomalous-material-node-detail').forEach(d => {
d.open = allOpen;
});
toggleAllBtn.textContent = allOpen
? (t('materialCollapseAll') || '全部收起')
: (t('materialExpandAllNodes') || '全部展开');
};
blocksHeader.appendChild(toggleAllBtn);
}
content.appendChild(blocksHeader);
if (blocks.length) {
renderDetailedNodeCards(content, blocks, { defaultOpen: true });
} else {
text(content, 'p', t('materialNoNodeParameters'), 'anomalous-material-muted');
}
}
function leaveMaterialDetail(owner) {
owner.materialDetailController?.abort();
owner.materialDetailController = null;
owner.materialDetailView?.remove();
owner.materialDetailView = null;
if (owner.materialIntro) owner.materialIntro.style.display = 'flex';
if (owner.materialList) owner.materialList.style.display = 'grid';
if (owner.materialToolbar) owner.materialToolbar.style.display = 'flex';
if (owner.materialPager) owner.materialPager.style.display = 'flex';
}
async function deleteMaterial(owner, material) {
if (!await anomalousConfirm(t('materialDeleteConfirm'))) return false;
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 false;
}
leaveMaterialDetail(owner);
await owner.refreshMaterials?.();
return true;
}
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 buildMaterialDetailHeader(owner, material) {
const header = document.createElement('header');
header.className = 'anomalous-library-detail-header';
const back = text(header, 'button', `← ${t('materialBackToLibrary')}`, 'anomalous-library-detail-back');
back.type = 'button';
back.onclick = () => leaveMaterialDetail(owner);
const heading = document.createElement('div');
heading.className = 'anomalous-library-detail-heading';
const title = text(heading, 'h2', material.name || t('materialUntitled'));
title.title = material.name || t('materialUntitled');
text(
heading,
'span',
material.selection?.scope === 'nodes' ? t('materialSelectedNodeMaterial') : t('materialFullWorkflowMaterial'),
'anomalous-material-scope-badge',
);
header.appendChild(heading);
const headerActions = document.createElement('div');
headerActions.className = 'anomalous-library-detail-actions';
if ((material.capabilities || []).includes('open_workflow')) {
const open = text(headerActions, '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 edit = text(headerActions, 'button', t('materialEditDetails'), 'anomalous-btn-ghost');
edit.type = 'button';
edit.onclick = () => toggleMaterialEditor(owner, material, header);
const remove = text(headerActions, 'button', `🗑️ ${t('materialDelete')}`, 'anomalous-btn-ghost');
remove.type = 'button';
remove.onclick = () => deleteMaterial(owner, material);
header.appendChild(headerActions);
return header;
}
function buildMaterialMediaStage(owner, material, sourceNameElement) {
const media = document.createElement('aside');
media.className = 'anomalous-library-detail-media';
const previewUrl = materialAssetUrl(material.filename, material.image?.preview_asset_id || material.image?.source_asset_id);
const sourceUrl = materialAssetUrl(material.filename, material.image?.source_asset_id || material.image?.preview_asset_id);
const imageStage = document.createElement('button');
imageStage.className = 'anomalous-library-detail-image-stage';
imageStage.type = 'button';
imageStage.title = t('materialOpenSourceImage');
if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = material.name || t('materialUntitled');
image.decoding = 'async';
imageStage.appendChild(image);
imageStage.onclick = () => owner.showGalleryViewer?.(sourceUrl);
} else {
imageStage.textContent = '🖼️';
imageStage.disabled = true;
}
media.appendChild(imageStage);
const sourceMeta = document.createElement('div');
sourceMeta.className = 'anomalous-library-detail-source';
const sourceCopy = document.createElement('div');
sourceCopy.className = 'anomalous-library-detail-source-copy';
text(sourceCopy, 'span', t('materialSourceImage'));
sourceCopy.appendChild(sourceNameElement);
sourceMeta.appendChild(sourceCopy);
const openSource = text(sourceMeta, 'button', t('materialOpenSourceImage'), 'anomalous-library-detail-source-open');
openSource.type = 'button';
openSource.disabled = !sourceUrl;
openSource.onclick = () => owner.showGalleryViewer?.(sourceUrl);
sourceMeta.appendChild(openSource);
media.appendChild(sourceMeta);
return media;
}
async function showMaterialDetail(owner, material) {
owner.materialDetailController?.abort();
owner.materialDetailView?.remove();
owner.materialDetailView = null;
if (owner.materialIntro) owner.materialIntro.style.display = 'none';
if (owner.materialList) owner.materialList.style.display = 'none';
if (owner.materialToolbar) owner.materialToolbar.style.display = 'none';
if (owner.materialPager) owner.materialPager.style.display = 'none';
const detail = document.createElement('section');
detail.className = 'anomalous-library-detail-view';
owner.materialDetailView = detail;
const sourceNameElement = document.createElement('strong');
sourceNameElement.textContent = t('loading');
const header = buildMaterialDetailHeader(owner, material);
detail.appendChild(header);
const layout = document.createElement('div');
layout.className = 'anomalous-library-detail-layout';
const media = buildMaterialMediaStage(owner, material, sourceNameElement);
layout.appendChild(media);
const inspector = document.createElement('main');
inspector.className = 'anomalous-library-detail-inspector';
text(inspector, 'p', t('loading'), 'anomalous-material-muted');
layout.appendChild(inspector);
detail.appendChild(layout);
owner.materialView.appendChild(detail);
const controller = new AbortController();
owner.materialDetailController = controller;
try {
const payload = await fetchMaterial(material.filename, { signal: controller.signal });
if (owner.materialDetailView !== detail) return;
const sourceImage = payload.data?.source?.image || {};
const sourceName = sourceImage.filename || material.name || t('materialUntitled');
sourceNameElement.textContent = sourceName;
sourceNameElement.title = sourceName;
inspector.replaceChildren();
renderMaterialInspector(inspector, payload);
} catch (error) {
if (error?.name === 'AbortError') return;
console.error('Could not load material detail:', error);
inspector.replaceChildren();
text(inspector, 'p', t('materialDetailLoadError'), 'anomalous-material-empty');
} finally {
if (owner.materialDetailController === controller) owner.materialDetailController = null;
}
}
function renderMaterialCard(owner, material) {
const card = document.createElement('article');
card.className = 'anomalous-material-card';
card.title = t('materialViewDetails') || '点击查看详细参数';
card.onclick = () => showMaterialDetail(owner, material);
card.tabIndex = 0;
card.setAttribute('aria-label', `${material.name} — ${t('materialViewDetails')}`);
card.onkeydown = event => {
if (event.target === card && ['Enter', ' '].includes(event.key)) {
event.preventDefault();
showMaterialDetail(owner, material);
}
};
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'anomalous-material-card-delete';
remove.innerHTML = '🗑️';
remove.title = t('materialDelete');
remove.onclick = (e) => {
e.stopPropagation();
deleteMaterial(owner, material);
};
card.appendChild(remove);
if ((material.capabilities || []).includes('open_workflow')) {
const quickOpen = document.createElement('button');
quickOpen.type = 'button';
quickOpen.className = 'anomalous-material-card-quick-load';
quickOpen.innerHTML = '🚀';
quickOpen.title = t('materialOpenWorkflow');
quickOpen.onclick = async (e) => {
e.stopPropagation();
try {
await openMaterialWorkflow(owner, material.filename);
} catch (error) {
await anomalousAlert(t('materialOpenError'));
}
};
card.appendChild(quickOpen);
}
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';
const cardTitle = text(body, 'h3', material.name || t('materialUntitled'));
cardTitle.title = material.name || t('materialUntitled');
const metadata = document.createElement('div');
metadata.className = 'anomalous-material-card-meta';
if (material.selection?.scope === 'nodes') {
const isPromptOnly = Array.isArray(material.node_types) &&
material.node_types.length > 0 &&
material.node_types.every(tp => /cliptextencode/i.test(tp));
const badgeText = isPromptOnly
? `💬 ${t('materialPromptNode') || '提示词'}`
: t('materialSelectedNodeMaterial');
text(metadata, 'span', badgeText, 'anomalous-material-scope-badge is-nodes');
} else {
text(metadata, 'span', t('materialFullWorkflowMaterial'), 'anomalous-material-scope-badge is-workflow');
}
text(metadata, 'span', t('materialNodeSummary', { count: material.node_count || 0 }), 'anomalous-material-meta-pill');
body.appendChild(metadata);
if (Array.isArray(material.node_types) && material.node_types.length) {
text(body, 'small', material.node_types.slice(0, 5).join(' · '), 'anomalous-material-types');
}
if (material.tags?.length) {
const tags = text(body, 'div', '', 'anomalous-material-tags');
material.tags.forEach(tag => text(tags, 'span', tag, 'anomalous-material-tag'));
}
card.appendChild(body);
return card;
}
function toggleMaterialEditor(owner, material, header) {
const existing = header.querySelector('.anomalous-material-editor');
if (existing) { existing.remove(); return; }
const form = text(header, 'form', '', 'anomalous-material-editor');
const nameLabel = text(form, 'label', t('materialName'));
const name = text(nameLabel, 'input', '');
name.value = material.name || '';
name.maxLength = 120;
name.required = true;
const tagsLabel = text(form, 'label', t('materialTags'));
const tags = text(tagsLabel, 'input', '');
tags.value = (material.tags || []).join(', ');
tags.placeholder = t('materialTagsHint');
tags.maxLength = 1200;
const save = text(form, 'button', t('materialSaveDetails'), 'anomalous-btn-primary');
save.type = 'submit';
const status = text(form, 'span', '', 'anomalous-material-muted');
status.setAttribute('role', 'status');
form.onsubmit = async event => {
event.preventDefault();
save.disabled = true;
try {
const response = await fetch('/anomalous/update_material', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: material.filename, name: name.value.trim(),
tags: tags.value.split(/[,,]/).map(value => value.trim()).filter(Boolean) }),
});
const payload = await jsonResponse(response, 'material update failed');
if (payload.status !== 'success') throw new Error('material update failed');
Object.assign(material, payload.material);
header.querySelector('h2').textContent = material.name;
header.querySelector('h2').title = material.name;
const preview = owner.materialDetailView?.querySelector('.anomalous-library-detail-image-stage img');
if (preview) preview.alt = material.name;
await owner.refreshMaterials?.();
form.remove();
} catch (error) {
status.textContent = t('materialUpdateError');
save.disabled = false;
}
};
name.focus();
}
function buildMaterialFilters(owner) {
const toolbar = text(owner.materialView, 'div', '', 'anomalous-material-toolbar');
owner.materialToolbar = toolbar;
const search = text(toolbar, 'input', '');
search.type = 'search';
search.placeholder = t('materialSearchHint');
search.setAttribute('aria-label', t('materialSearchHint'));
search.maxLength = 200;
search.value = owner.materialQuery || '';
search.oninput = () => {
clearTimeout(owner.materialSearchTimer);
owner.materialQuery = search.value;
owner.materialSearchTimer = setTimeout(() => owner.refreshMaterials(1), 250);
};
const kind = text(toolbar, 'select', '');
kind.setAttribute('aria-label', t('materialFilterKind'));
for (const [value, key] of [['', 'materialAllKinds'], ['image_workflow_snapshot', 'materialFullWorkflowMaterial'], ['image_node_selection', 'materialSelectedNodeMaterial']]) {
const option = text(kind, 'option', t(key));
option.value = value;
}
kind.value = owner.materialKind || '';
kind.onchange = () => { owner.materialKind = kind.value; owner.refreshMaterials(1); };
owner.materialTagSelect = text(toolbar, 'select', '');
owner.materialTagSelect.setAttribute('aria-label', t('materialTags'));
owner.materialTagSelect.onchange = () => { owner.materialTag = owner.materialTagSelect.value; owner.refreshMaterials(1); };
}
function renderMaterialPagination(owner, payload) {
owner.materialPager.replaceChildren();
const previous = text(owner.materialPager, 'button', t('materialPrevious'), 'anomalous-btn-ghost');
previous.type = 'button';
previous.disabled = payload.page <= 1;
previous.onclick = () => owner.refreshMaterials(payload.page - 1);
text(owner.materialPager, 'span', t('materialPageSummary', { page: payload.page, pages: payload.pages, count: payload.total }));
const next = text(owner.materialPager, 'button', t('materialNext'), 'anomalous-btn-ghost');
next.type = 'button';
next.disabled = payload.page >= payload.pages;
next.onclick = () => owner.refreshMaterials(payload.page + 1);
const tags = owner.materialTagSelect;
tags.replaceChildren();
text(tags, 'option', t('materialAllTags')).value = '';
for (const tag of payload.tags || []) text(tags, 'option', tag).value = tag;
if (owner.materialTag && !(payload.tags || []).includes(owner.materialTag)) text(tags, 'option', owner.materialTag).value = owner.materialTag;
tags.value = owner.materialTag || '';
}
export async function refreshMaterials(page = this.materialPage || 1) {
if (!this.materialList) return;
this.materialListController?.abort();
const controller = new AbortController();
this.materialListController = controller;
this.materialList.replaceChildren();
text(this.materialList, 'p', t('loading'), 'anomalous-material-empty');
const query = new URLSearchParams({ page, limit: 48, q: this.materialQuery || '',
tag: this.materialTag || '', kind: this.materialKind || '' });
try {
const response = await fetch(`/anomalous/materials?${query}`, { cache: 'no-store', signal: controller.signal });
const payload = await jsonResponse(response, 'material list failed');
if (this.materialListController !== controller) return;
this.materialPage = payload.page;
this.materialList.replaceChildren();
renderMaterialPagination(this, payload);
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
text(this.materialList, 'p', t(this.materialQuery || this.materialTag || this.materialKind ? 'materialNoMatches' : 'materialEmpty'), 'anomalous-material-empty');
return;
}
const fragment = document.createDocumentFragment();
for (const material of materials) fragment.appendChild(renderMaterialCard(this, material));
this.materialList.appendChild(fragment);
} catch (error) {
if (error?.name === 'AbortError' || this.materialListController !== controller) return;
console.error('Could not load materials:', error);
this.materialList.replaceChildren();
text(this.materialList, 'p', t('materialLoadError'), 'anomalous-material-empty');
} finally {
if (this.materialListController === controller) this.materialListController = null;
}
}
export async function showMaterials() {
if (!this.notebookContainer) {
this.nbPanel.style.display = 'flex';
await this.showNotebooks();
}
hideSiblingWorkspaceViews(this);
leaveMaterialDetail(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';
this.materialIntro = 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);
buildMaterialFilters(this);
this.materialList = document.createElement('div');
this.materialList.className = 'anomalous-material-list';
this.materialView.appendChild(this.materialList);
this.materialPager = text(this.materialView, 'nav', '', 'anomalous-material-pager');
this.materialPager.setAttribute('aria-label', t('materialPagination'));
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.
*/
export async function showImageMaterialDetail(owner, sourceImage, imageUrl, options = {}) {
return showImageWorkbench(owner, sourceImage, imageUrl, options);
}