refactor(materials): upgrade image snapshot inspector to wide studio modal with direct PNG workflow parser and dedicated scroll container

This commit is contained in:
DemonGatanjieu
2026-09-03 22:42:06 +08:00
parent cbb6b9adb5
commit e6088fc2a1
3 changed files with 406 additions and 80 deletions
+10
View File
@@ -90,6 +90,11 @@ export const i18n = {
materialNodeApplied: '素材参数已应用',
materialKeyParams: '核心生成参数',
materialPromptText: '提示词',
materialPositivePrompt: '正向提示词 (Positive)',
materialNegativePrompt: '负向提示词 (Negative)',
materialViewOriginal: '🔍 查看大图',
materialDenoise: '降噪',
materialScheduler: '调度',
materialReferencedModels: '引用的模型清单 ({count})',
materialIncludedNodes: '包含的节点清单 ({count})',
materialExpandAll: '展开全部',
@@ -874,6 +879,11 @@ export const i18n = {
materialNodeApplied: 'Material parameters applied',
materialKeyParams: 'Key Parameters',
materialPromptText: 'Prompt',
materialPositivePrompt: 'Positive Prompt',
materialNegativePrompt: 'Negative Prompt',
materialViewOriginal: '🔍 Full View',
materialDenoise: 'Denoise',
materialScheduler: 'Scheduler',
materialReferencedModels: 'Referenced Models ({count})',
materialIncludedNodes: 'Included Nodes ({count})',
materialExpandAll: 'Expand',
+232 -50
View File
@@ -160,42 +160,221 @@ export async function showMaterials() {
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';
text(side, 'h2', t('materialImageDetail'));
const loading = text(side, 'p', t('materialInspecting'), 'anomalous-material-muted');
// 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 response = await fetch('/anomalous/inspect_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_image: sourceImage }),
});
const payload = await jsonResponse(response, 'material inspection failed');
if (payload.status !== 'success') throw new Error(payload.message || 'material inspection failed');
loading.remove();
text(side, 'p', t('materialSnapshotExplanation'), 'anomalous-material-muted');
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),
]);
// 1. Key generation parameters (Steps, CFG, Sampler, Resolution)
const params = payload.params || {};
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');
@@ -214,28 +393,26 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
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) side.appendChild(metricGrid);
if (metricGrid.childElementCount) sideBody.appendChild(metricGrid);
}
// 2. Prompt Preview Card with copy and expand
const promptFullText = (Array.isArray(payload.prompts) && payload.prompts.length)
? payload.prompts.join('\n\n')
: (payload.prompt_excerpt || '');
if (promptFullText) {
// 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', t('materialPromptText') || '提示词', 'anomalous-material-prompt-bar-label');
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
const btns = document.createElement('div');
btns.className = 'anomalous-material-prompt-bar-btns';
if (promptFullText.length > 80 || promptFullText.includes('\n')) {
if (promptStr.length > 90 || promptStr.includes('\n')) {
const expandBtn = text(btns, 'button', t('materialExpandAll') || '展开', 'anomalous-material-mini-btn');
expandBtn.type = 'button';
expandBtn.onclick = () => {
@@ -247,7 +424,7 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
const copyBtn = text(btns, 'button', t('materialCopyPrompt') || '📋 复制', 'anomalous-material-mini-btn');
copyBtn.type = 'button';
copyBtn.onclick = () => {
navigator.clipboard.writeText(promptFullText).then(() => {
navigator.clipboard.writeText(promptStr).then(() => {
copyBtn.textContent = t('materialCopied') || '✅ 已复制';
setTimeout(() => { copyBtn.textContent = t('materialCopyPrompt') || '📋 复制'; }, 1500);
});
@@ -256,12 +433,25 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
bar.appendChild(btns);
promptCard.appendChild(bar);
const promptContent = text(promptCard, 'div', promptFullText, 'anomalous-material-prompt-content');
side.appendChild(promptCard);
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);
}
// 3. Collapsible: Referenced Models
const models = Array.isArray(payload.model_references) ? payload.model_references : [];
// 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';
@@ -283,11 +473,11 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
modelContent.appendChild(row);
}
modelDetails.appendChild(modelContent);
side.appendChild(modelDetails);
sideBody.appendChild(modelDetails);
}
// 4. Collapsible: Included Nodes
const blocks = Array.isArray(payload.node_blocks) ? payload.node_blocks : [];
// 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';
@@ -316,29 +506,20 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
}
nodeContent.appendChild(chipFlow);
nodeDetails.appendChild(nodeContent);
side.appendChild(nodeDetails);
sideBody.appendChild(nodeDetails);
}
// 5. Name input and save action
const label = text(side, 'label', t('materialName'));
const name = document.createElement('input');
name.type = 'text';
name.maxLength = 120;
name.value = payload.suggested_name || '';
label.appendChild(name);
const hint = text(side, 'small', t('materialSaveHint'), 'anomalous-material-muted');
hint.style.marginTop = 'auto';
const save = text(side, 'button', `💾 ${t('materialSaveSnapshot')}`, 'anomalous-btn-primary');
save.type = 'button';
save.onclick = async () => {
const value = name.value.trim();
if (!value) { name.focus(); return; }
save.disabled = true;
// 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: value }),
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');
@@ -348,11 +529,12 @@ export async function showImageMaterialDetail(owner, sourceImage, imageUrl) {
} catch (error) {
console.error('Could not save image material:', error);
await anomalousAlert(t('materialSaveError'));
save.disabled = false;
saveBtn.disabled = false;
}
};
name.focus();
name.select();
nameInput.focus();
nameInput.select();
} catch (error) {
console.error('Could not inspect image material:', error);
loading.textContent = t('materialInspectError');
+164 -30
View File
@@ -3651,6 +3651,7 @@ dialog.anomalous-gallery-viewer[open] {
.anomalous-material-card-actions { display: flex; gap: 8px; margin-top: auto; padding: 0 14px 14px; }
.anomalous-material-card-actions .anomalous-btn-primary { flex: 1; }
/* Professional Studio Material Detail Modal */
.anomalous-material-detail-overlay {
position: fixed;
inset: 0;
@@ -3659,51 +3660,184 @@ dialog.anomalous-gallery-viewer[open] {
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(3,4,7,.82);
backdrop-filter: blur(8px);
background: rgba(3, 4, 7, 0.88);
backdrop-filter: blur(12px);
}
.anomalous-material-detail-dialog {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr);
width: min(1100px, 94vw);
max-height: 90vh;
overflow: hidden;
border: 1px solid rgba(255,255,255,.12);
grid-template-columns: minmax(0, 1.4fr) minmax(440px, 1fr);
width: min(1380px, 96vw);
height: min(880px, 92vh);
max-height: 92vh;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: #181a20;
box-shadow: 0 30px 90px rgba(0,0,0,.65);
background: #111216;
box-shadow: 0 32px 100px rgba(0, 0, 0, 0.8);
overflow: hidden;
}
.anomalous-material-detail-close {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
width: 36px;
height: 36px;
border: 1px solid rgba(255,255,255,.16);
top: 14px;
right: 14px;
z-index: 10;
width: 34px;
height: 34px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(12,13,16,.75);
color: #fff;
font-size: 24px;
background: rgba(18, 20, 26, 0.7);
color: #cbd5e1;
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(6px);
transition: all 0.15s;
}
.anomalous-material-detail-media { min-height: 420px; overflow: hidden; background: #090a0d; }
.anomalous-material-detail-media img { width: 100%; height: 100%; max-height: 90vh; object-fit: contain; }
.anomalous-material-detail-side { display: flex; min-height: 0; flex-direction: column; gap: 14px; padding: 32px 28px 26px; overflow-y: auto; }
.anomalous-material-detail-side h2 { margin: 0; }
.anomalous-material-detail-side label { display: grid; gap: 7px; color: #cbd0dc; font-size: 12px; font-weight: 700; }
.anomalous-material-detail-side input { padding: 10px 12px; border: 1px solid #494d59; border-radius: 8px; background: #111217; color: #fff; }
.anomalous-material-detail-stats { display: flex; flex-wrap: wrap; gap: 8px; }
.anomalous-material-detail-stats span { padding: 6px 9px; border-radius: 999px; background: rgba(78,201,176,.12); color: #78d7c5; font-size: 11px; }
.anomalous-material-prompt-excerpt { margin: 0; padding: 12px; border-left: 3px solid #5ccab4; background: rgba(255,255,255,.035); color: #c7ccd7; font-size: 12px; }
.anomalous-material-detail-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
transform: scale(1.06);
}
@media (max-width: 780px) {
.anomalous-material-detail-dialog { grid-template-columns: 1fr; overflow-y: auto; }
.anomalous-material-detail-media { min-height: 260px; max-height: 42vh; }
/* Left Media Showcase */
.anomalous-material-detail-media {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #07080b;
overflow: hidden;
height: 100%;
min-height: 0;
}
.anomalous-material-detail-media img {
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
}
.anomalous-material-media-bar {
position: absolute;
bottom: 14px;
left: 14px;
right: 14px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-radius: 9px;
background: rgba(14, 16, 22, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
font-size: 0.76rem;
color: #cbd5e1;
}
/* Right Inspector Panel - 3-Tier Layout (Fixed Header + Scroll Body + Sticky Footer) */
.anomalous-material-detail-side {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #14161c;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.anomalous-material-side-header {
padding: 22px 24px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.anomalous-material-side-header h2 {
margin: 0 0 6px;
font-size: 1.22rem;
font-weight: 600;
color: #f8fafc;
}
.anomalous-material-side-header p {
margin: 0;
font-size: 0.78rem;
color: #94a3b8;
line-height: 1.45;
}
/* Scrollable Body */
.anomalous-material-side-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.anomalous-material-side-body::-webkit-scrollbar {
width: 6px;
}
.anomalous-material-side-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
border-radius: 3px;
}
.anomalous-material-side-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.24);
}
/* Sticky Footer */
.anomalous-material-side-footer {
padding: 14px 24px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #111318;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.anomalous-material-side-footer label {
display: grid;
gap: 6px;
color: #cbd5e1;
font-size: 0.78rem;
font-weight: 600;
}
.anomalous-material-side-footer input {
padding: 9px 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #ffffff;
font-size: 0.82rem;
outline: none;
transition: border-color 0.15s;
}
.anomalous-material-side-footer input:focus {
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.07);
}
@media (max-width: 860px) {
.anomalous-material-detail-dialog {
grid-template-columns: 1fr;
height: 94vh;
}
.anomalous-material-detail-media {
min-height: 280px;
max-height: 40vh;
}
}
/* Ensure wide widgets (like long prompts) span full width in the node cards */