From e6088fc2a182094a48da41dbf252d72db09c67b4 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Thu, 3 Sep 2026 22:42:06 +0800 Subject: [PATCH] refactor(materials): upgrade image snapshot inspector to wide studio modal with direct PNG workflow parser and dedicated scroll container --- web/modules/locales.js | 10 ++ web/modules/ui_materials.js | 282 +++++++++++++++++++++++++++++------- web/styles.css | 194 +++++++++++++++++++++---- 3 files changed, 406 insertions(+), 80 deletions(-) diff --git a/web/modules/locales.js b/web/modules/locales.js index ded402a..26a0910 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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', diff --git a/web/modules/ui_materials.js b/web/modules/ui_materials.js index 2e68056..3f7b76f 100644 --- a/web/modules/ui_materials.js +++ b/web/modules/ui_materials.js @@ -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'); diff --git a/web/styles.css b/web/styles.css index a0cdc69..82edb5c 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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 */