Improve material library browsing and reuse

This commit is contained in:
DemonGatanjieu
2026-09-07 22:47:27 +08:00
parent a7104fa507
commit f8d159dc11
14 changed files with 947 additions and 577 deletions
+4 -1
View File
@@ -65,7 +65,7 @@ DOM or live LiteGraph state.
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
- `api/notebooks.py` owns Prompt Note persistence and recoverable legacy copying.
- `api/materials.py` owns curated material persistence, private image assets,
and node-type lookup.
summary search/pagination, editable names/tags, and node-type lookup.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner.
@@ -87,6 +87,9 @@ DOM or live LiteGraph state.
default-expanded node parameters with dedicated prompt station views (1-click copy),
and global collapse/expand toggles; delegates image inspection to `ui_gallery_detail.js`;
Node Assistant remains the owner of applying a material node block.
- `material_inspector.js` owns shared image-metadata parsing and node-parameter
rendering used by the library and Image Detail Workbench. The workbench does
not import the library UI.
- `recipe_parser.js`, `recipe_identity.js`, `recipe_diff.js`, and
`recipe_actions.js` own pure or transactional recipe behavior.
- `ui_doctor.js`, `model_picker.js`, and `graph_splice.js` own assistant and
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
### Material Library
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
### Reliability
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
+10
View File
@@ -83,6 +83,11 @@ Open it with **Ctrl + Shift + M**, the floating **📦** button, or **Extensions
* **Location**: Top navigation tabs (**Models 📦**, **Gallery 🖼️**, **Workspace 📑**, **Dock Side ◧**).
* **Usage**: Select a Base Model architecture, attach compatible LoRAs, paste & auto-tag bilingual prompts, and click **Send to Canvas** to deploy directly.
#### 7. Material Library 🖼️
* **Capture**: Open a generated PNG's parameter details to save its image and complete workflow, or save selected node parameters. Repeated saves of the same image and selection ask before creating another copy.
* **Find and reuse**: In Workspace → Material Library, search names, tags, or node types; filter by type/tag; edit names and tags in a material's details. Complete snapshots open as workflows; node parameters are applied through Node Assistant.
* **Storage**: Materials live in the ComfyUI user directory under `workflows/anomalous_materials`. Prompt Notes use `workflows/anomalous_notebooks`; old notes in the extension are copied on first access, retaining the originals.
> [!WARNING]
> **Beta Data Protection:** Workflow Recipes and Parameter Presets are currently in testing. Please back up `workflows/anomalous_recipes` and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
@@ -152,6 +157,11 @@ Open it with **Ctrl + Shift + M**, the floating **📦** button, or **Extensions
* **入口位置**:顶部导航栏 (**模型 📦**、**图库 🖼️**、**工作台 📑**、**侧栏停靠 ◧**)。
* **提示词组装**:选择基础模型架构过滤兼容 LoRA,粘贴提示词并一键双语翻译,点击 **发送到画布** 即可自动连线布署。
#### 7. 素材库 🖼️
* **保存素材**:打开生成 PNG 的参数详情,可以保存图片与完整工作流,也可以只保存选中节点参数。相同图片和节点范围再次保存时,会先提示是否另存一份。
* **查找复用**:在「工作台 → 素材库」按名称、标签或节点类型搜索,按类型和标签筛选;进入详情可修改名称与标签。完整素材可打开工作流,节点参数通过节点助手应用。
* **本地存储**:素材保存在 ComfyUI 用户目录的 `workflows/anomalous_materials`,提示词笔记保存在 `workflows/anomalous_notebooks`。首次访问会复制插件内的旧笔记,并保留原文件。
> [!WARNING]
> **测试功能数据安全提醒:** 工作流配方与参数预设目前属于测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes` 与 `workflows/anomalous_parameters` 文件夹。
+1
View File
@@ -62,6 +62,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/inspect_image_material', api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', api_save_image_material)
app.router.add_post('/anomalous/delete_material', api_delete_material)
app.router.add_post('/anomalous/update_material', api_update_material)
app.router.add_post('/anomalous/translate', api_translate)
app.router.add_get('/anomalous/base_models', api_base_models)
+102 -9
View File
@@ -15,6 +15,7 @@ import shutil
import tempfile
import time
import uuid
import threading
from functools import lru_cache
from aiohttp import web
@@ -36,6 +37,7 @@ from .utils import require_filename, resolve_within, atomic_write_json as _atomi
MATERIAL_SCHEMA_VERSION = 1
MAX_MATERIAL_NAME_LENGTH = 120
MAX_SOURCE_IMAGE_BYTES = 64 * 1024 * 1024
_material_write_lock = threading.RLock()
MODEL_PRIORITY = {"checkpoint": 0, "unet": 1, "lora": 2}
@@ -273,6 +275,8 @@ def _material_summary(filename, material):
"id": material.get("id"),
"name": material.get("name") or "未命名素材",
"kind": material.get("kind"),
"tags": _normalise_material_tags(material.get("tags") or []),
"source_sha256": image.get("source_sha256", ""),
"timestamp": material.get("timestamp", 0),
"node_count": len(blocks),
"node_types": sorted({block["type"] for block in blocks}),
@@ -360,13 +364,39 @@ async def api_inspect_image_material(request):
})
def _persist_material(materials_dir, filename, source_path, material):
def _normalise_material_tags(tags):
if not isinstance(tags, list) or len(tags) > 20:
raise ValueError("Invalid material tags")
result = []
seen = set()
for tag in tags:
if not isinstance(tag, str) or not tag.strip() or len(tag.strip()) > 60:
raise ValueError("Invalid material tag")
tag = tag.strip()
if tag.casefold() not in seen:
result.append(tag)
seen.add(tag.casefold())
return result
def _persist_material(materials_dir, filename, source_path, material, allow_duplicate=False):
with _material_write_lock:
return _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate)
def _persist_material_files(materials_dir, filename, source_path, material, allow_duplicate):
target = resolve_within(materials_dir, filename)
assets_target = _material_assets_dir(materials_dir, filename)
if os.path.exists(target) or os.path.exists(assets_target):
raise ValueError("Material already exists")
with tempfile.TemporaryDirectory(prefix=".save-", dir=materials_dir) as staging:
material["image"] = _store_assets(staging, filename, source_path)
if not allow_duplicate:
selection = sorted(str(value) for value in (material.get("selection") or {}).get("node_ids", []))
for summary in _list_materials(materials_dir):
candidate_selection = sorted(str(value) for value in (summary.get("selection") or {}).get("node_ids", []))
if summary.get("source_sha256") == material["image"]["source_sha256"] and summary.get("kind") == material.get("kind") and candidate_selection == selection:
return summary
staged_json = resolve_within(staging, filename)
_atomic_write_json(staged_json, material)
os.makedirs(os.path.dirname(assets_target), exist_ok=True)
@@ -387,6 +417,10 @@ async def api_save_image_material(request):
name = payload.get("name") or suggested_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
selected_node_ids = _normalise_selected_node_ids(workflow, payload.get("selected_node_ids"))
selected_id_keys = {str(node_id) for node_id in selected_node_ids or []}
if selected_node_ids is not None:
@@ -404,6 +438,7 @@ async def api_save_image_material(request):
"id": material_id,
"kind": "image_node_selection" if selected_node_ids is not None else "image_workflow_snapshot",
"name": name,
"tags": tags,
"timestamp": now,
"source": {"type": "generated_image", "image": source},
"workflow": workflow,
@@ -416,7 +451,9 @@ async def api_save_image_material(request):
}
if selected_node_ids is not None:
material["selection"] = {"scope": "nodes", "node_ids": selected_node_ids}
await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material)
duplicate = await asyncio.to_thread(_persist_material, materials_dir, filename, source_path, material, allow_duplicate)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save image material"}, status=400)
except FileNotFoundError:
@@ -431,9 +468,64 @@ async def api_save_image_material(request):
})
def _query_materials(materials_dir, query):
materials = _list_materials(materials_dir)
all_tags = sorted({tag for material in materials for tag in material.get("tags", [])}, key=str.casefold)
search = query.get("q", "").strip().casefold()
tag = query.get("tag", "").strip().casefold()
kind = query.get("kind", "")
if len(search) > 200 or len(tag) > 60 or kind not in ("", "image_workflow_snapshot", "image_node_selection"):
raise ValueError("Invalid material filter")
materials = [material for material in materials
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
and (not kind or material["kind"] == kind)]
total = len(materials)
limit = min(100, max(1, int(query.get("limit", 48))))
pages = max(1, (total + limit - 1) // limit)
page = min(pages, max(1, int(query.get("page", 1))))
# Existing non-paginated callers retain their response; the library requests pages.
if "page" in query or "limit" in query:
materials = materials[(page - 1) * limit:page * limit]
return {"status": "success", "materials": materials, "total": total,
"page": page, "pages": pages, "tags": all_tags}
async def api_get_materials(request):
materials = await asyncio.to_thread(_list_materials, get_materials_dir())
return web.json_response({"status": "success", "materials": materials})
try:
payload = await asyncio.to_thread(_query_materials, get_materials_dir(), dict(request.query))
return web.json_response(payload)
except (TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material filter"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
def _update_material_details(materials_dir, filename, name, tags):
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material.update(name=name, tags=tags)
_atomic_write_json(path, material)
return _material_summary(filename, material)
async def api_update_material(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
name = payload.get("name", "")
if not filename.endswith(".json") or filename.startswith(".") or not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material details")
tags = _normalise_material_tags(payload.get("tags", []))
material = await asyncio.to_thread(_update_material_details, get_materials_dir(), filename, name.strip(), tags)
return web.json_response({"status": "success", "material": material})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid material details"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
async def api_get_material_full(request):
@@ -525,8 +617,9 @@ async def api_delete_material(request):
def _delete_material(materials_dir, filename, path):
_read_material(path)
os.remove(path)
assets_dir = _material_assets_dir(materials_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
with _material_write_lock:
_read_material(path)
os.remove(path)
assets_dir = _material_assets_dir(materials_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
+7 -1
View File
@@ -21,10 +21,16 @@ The main surfaces are:
- Sidebar and folder manager: navigation, folder visibility, scan controls.
- Grid and model detail: model browsing and metadata/media presentation.
- Gallery: the user's generated outputs and the full-screen Image Detail Studio Workbench (`ui_gallery_detail.js`).
- Workspace: Prompt Notes and Workflow Recipes.
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library.
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
presets, and missing-model recovery.
`material_inspector.js` shares image metadata helpers and node-parameter rendering
between `ui_materials.js` and `ui_gallery_detail.js`. The workbench does not import
the library UI, keeping this dependency chain acyclic. Library discovery uses
server-side filters and pages with cancellable requests; full records load only
on detail entry. See `material-library.md` for the API and persistence contract.
## Entry modes and host integration
One shared `openBrowser()` command backs the floating trigger, ComfyUI action-bar
+28 -2
View File
@@ -38,6 +38,21 @@ inventory detects additions/deletions and re-parses only changed records. Node-t
lookup filters summaries before opening matching workflows; asset authorization
uses the same validated summary cache. Callers receive independent summary copies.
The library requests 48 summaries per page. `materials` accepts `q` (name, tags,
or node type), `tag`, `kind`, `page`, and `limit` (at most 100), and returns
`total`, `page`, `pages`, and the library's available `tags`. Older callers without
page/limit retain their complete summary response. Name/tag edits use
`update_material`, preserve the source workflow, and atomically replace the record.
Tags are trimmed, deduplicated without case sensitivity, and limited to 20 tags
of 60 characters each; older records without tags remain valid.
Save, edit, and delete serialize their writes within the server process. Before
publishing a new record, save compares the source PNG SHA-256, material kind, and
selected node IDs against existing summaries. A duplicate returns HTTP 409 with
`status: duplicate` and the existing name/filename, without leaving new assets.
An explicit retry with `allow_duplicate: true` creates a separate copy. This is
exact-source duplicate detection, not visual similarity or model identity.
The route family is:
- `POST /anomalous/inspect_image_material`
@@ -46,12 +61,17 @@ The route family is:
- `GET /anomalous/material_full`
- `GET /anomalous/material_asset`
- `GET /anomalous/materials/by_node_type`
- `POST /anomalous/update_material`
- `POST /anomalous/delete_material`
## Frontend ownership
`ui_materials.js` owns the Workspace library, image-inspection/save dialog,
full-workflow handoff, and library CRUD presentation. `ui_gallery.js` and
`ui_materials.js` owns the Workspace library, filters, pagination, editable
names/tags, full-workflow handoff, and library CRUD presentation.
`ui_gallery_detail.js` owns image inspection and image/node saving.
Both use `material_inspector.js` for shared metadata helpers and exact node
parameter rendering; the workbench does not import the library UI.
`ui_gallery.js` and
`ui_recipe_detail.js` only supply non-invasive gallery entry points. Main
Gallery retains click-to-view, drag, delete, and cover-selection behavior; the
material action appears only on hover and is hidden during cover selection.
@@ -66,6 +86,12 @@ Only the opened material fetches `material_full`. Returning to the list aborts
an unfinished request and releases the detail payload/DOM so browsing never
accumulates full workflows in browser memory.
Search is debounced and each list request cancels its predecessor. Changing a
filter resets the page; returning from detail preserves the current filters.
Card activation supports Enter/Space as well as mouse clicks. A name/tag edit
refreshes summary cards and filter choices. Shared confirmation dialogs sit above
the image workbench, whose keyboard shortcuts yield while a dialog is open.
A full material workflow is exact and retains its seed. Node-sized reuse is a
preset operation and therefore uses the existing transactional parameter
application path, which skips known volatile seed widgets. The lookup endpoint
+44 -8
View File
@@ -1,5 +1,23 @@
export const i18n = {
zh: {
materialSearchHint: "搜索名称、标签或节点类型",
materialTags: "标签",
materialTagsHint: "标签,用逗号分隔(最多 20 个)",
materialFilterKind: "素材类型",
materialAllKinds: "全部类型",
materialAllTags: "全部标签",
materialNoMatches: "没有匹配的素材,试试其他关键词或筛选条件。",
materialPageSummary: "第 {page} / {pages} 页 · 共 {count} 份素材",
materialPrevious: "上一页",
materialNext: "下一页",
materialPagination: "素材分页",
materialEditDetails: "编辑名称和标签",
materialSaveDetails: "保存修改",
materialUpdateError: "修改未保存,请检查名称和标签后重试。",
materialDuplicateConfirm: "这张图片和所选参数已经保存为“{name}”。仍要另存一份吗?",
materialCopyError: "复制失败,请重试",
materialChars: "{count} 字符",
materialCopyPromptText: "复制提示词",
notebookSaveError: '保存失败,修改仍留在编辑器中,请重试。',
title: '📦 Anomalous 模型浏览器',
scan: '🔄 扫描目录并同步 C 站数据',
@@ -111,7 +129,7 @@ export const i18n = {
materialSnapshotExplanation: '保存后,图片与当前完整工作流会作为一个素材包保留;节点助手会按当前节点类型筛选其中的参数。',
materialName: '素材名称',
materialSaveHint: '完整工作流保留精确种子;作为单节点参数应用时会跳过种子。',
materialSaveSnapshot: '保存为素材快照',
materialSaveSnapshot: '保存图片与完整工作流',
materialSaveSuccess: '素材快照已保存。',
materialSaveError: '素材快照保存失败。',
materialNoNodePresets: '素材库中还没有适用于当前节点的参数。',
@@ -183,9 +201,9 @@ export const i18n = {
materialSelectAllNodes: '全选节点',
materialClearNodeSelection: '清空选择',
materialSelectedNodeCount: '已选择 {count} 个节点',
materialSaveNode: '+ 存入素材库',
materialSaveNode: '+ 保存节点参数',
materialSaveNodeHint: '只保存这个节点的参数供节点助手使用',
materialSaveSelectedAction: '存入素材库 ({count})',
materialSaveSelectedAction: '保存选中节点参数 ({count})',
materialSaving: '正在保存…',
materialSaved: '✓ 已存入',
materialFullSaveHint: '保存图片与完整工作流,可整体打开,也可按节点复用参数。',
@@ -193,7 +211,7 @@ export const i18n = {
materialSelectedNodeMaterial: '节点参数素材',
materialUseFromNodeAssistant: '请在节点助手中使用',
createNotebook: '新建',
saveNotebook: '💾 保存',
saveNotebook: '💾 保存提示词笔记',
deleteNotebook: '🗑️ 删除',
renameNotebook: '✏️ 重命名',
baseModel: 'Base Model (基础模型)',
@@ -880,6 +898,24 @@ export const i18n = {
recipeCardSpecsResolution: '尺寸',
},
en: {
materialSearchHint: "Search names, tags or node types",
materialTags: "Tags",
materialTagsHint: "Tags, separated by commas (up to 20)",
materialFilterKind: "Material type",
materialAllKinds: "All types",
materialAllTags: "All tags",
materialNoMatches: "No matching materials. Try another search or filter.",
materialPageSummary: "Page {page} / {pages} · {count} materials",
materialPrevious: "Previous",
materialNext: "Next",
materialPagination: "Material pages",
materialEditDetails: "Edit name and tags",
materialSaveDetails: "Save changes",
materialUpdateError: "Changes were not saved. Check the name and tags, then retry.",
materialDuplicateConfirm: "This image and selection are already saved as “{name}”. Save another copy?",
materialCopyError: "Copy failed; retry",
materialChars: "{count} characters",
materialCopyPromptText: "Copy prompt",
notebookSaveError: 'Save failed. Your edits remain in the editor; please retry.',
title: '📦 Anomalous Model Browser',
scan: '🔄 Scan folder and sync Civitai data',
@@ -991,7 +1027,7 @@ export const i18n = {
materialSnapshotExplanation: 'The image and exact full workflow are kept as one bundle. Node Assistant filters its parameters by the selected node type.',
materialName: 'Material Name',
materialSaveHint: 'The full workflow keeps the exact seed; single-node application skips seed widgets.',
materialSaveSnapshot: 'Save Material Snapshot',
materialSaveSnapshot: 'Save image and full workflow',
materialSaveSuccess: 'Material snapshot saved.',
materialSaveError: 'Could not save the material snapshot.',
materialNoNodePresets: 'No Material Library parameters match this node type.',
@@ -1063,9 +1099,9 @@ export const i18n = {
materialSelectAllNodes: 'Select All Nodes',
materialClearNodeSelection: 'Clear Selection',
materialSelectedNodeCount: '{count} nodes selected',
materialSaveNode: '+ Save to Library',
materialSaveNode: '+ Save node parameters',
materialSaveNodeHint: 'Save only this node\'s parameters for Node Assistant',
materialSaveSelectedAction: 'Save to Library ({count})',
materialSaveSelectedAction: 'Save selected node parameters ({count})',
materialSaving: 'Saving…',
materialSaved: '✓ Saved',
materialFullSaveHint: 'Save the image and full workflow for full loading or node-sized parameter reuse.',
@@ -1073,7 +1109,7 @@ export const i18n = {
materialSelectedNodeMaterial: 'Node Parameter Material',
materialUseFromNodeAssistant: 'Use from Node Assistant',
createNotebook: 'New',
saveNotebook: '💾 Save',
saveNotebook: '💾 Save prompt note',
deleteNotebook: '🗑️ Delete',
renameNotebook: '✏️ Rename',
baseModel: 'Base Model',
+534
View File
@@ -0,0 +1,534 @@
/** Shared image metadata and node-parameter inspection for the library and workbench. */
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export 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;
}
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
export function fileBaseName(value) {
return String(value || '').replace(/\\/g, '/').split('/').pop();
}
function previewIsVideo(url) {
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
}
export function modelCustomNotes(model) {
return String(model?.metadata?.custom_notes || '').trim();
}
function bindHoverPreviewVideo(video) {
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.onpointerenter = () => video.play().catch(() => {});
video.onpointerleave = () => {
video.pause();
video.currentTime = 0;
};
}
export function appendLocalPreview(parent, url, className) {
if (!url) return null;
const wrap = document.createElement('div');
wrap.className = className;
if (previewIsVideo(url)) {
const video = document.createElement('video');
video.src = url;
bindHoverPreviewVideo(video);
wrap.appendChild(video);
} else {
const image = document.createElement('img');
image.src = url;
image.alt = '';
image.loading = 'lazy';
wrap.appendChild(image);
}
parent.appendChild(wrap);
return wrap;
}
export async function resolveLocalModels(paths) {
const unique = [...new Set((paths || []).filter(Boolean))];
if (!unique.length) return {};
const response = await fetch('/anomalous/resolve_paths_to_previews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: unique, exact_only: false }),
});
const payload = await jsonResponse(response, 'preview resolve failed');
return payload.models || {};
}
export function lookupLocalModel(localModels, path) {
if (!path || !localModels) return null;
return localModels[path] || localModels[fileBaseName(path)] || null;
}
export 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;
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
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) {
if (e?.name === 'AbortError') return null;
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
/**
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
* directly from the workflow.
*/
export function extractWorkflowDetails(workflow) {
const params = {};
const positivePrompts = [];
const negativePrompts = [];
const allPrompts = [];
const loraDetailsMap = new Map();
const discoveredModels = {
checkpoints: [],
loras: [],
textEncoders: [],
vaes: [],
others: [],
};
if (!workflow || typeof workflow !== 'object') {
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
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 : [];
// 1. Sampling parameters
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
const isAdv = ntypeLower === 'ksampleradvanced';
const offset = isAdv ? 1 : 0;
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
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]}`;
}
}
// 2. Full un-truncated prompts
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);
}
}
}
}
// 3. Categorized model references & LoRA weights
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
discoveredModels.checkpoints.push({
name,
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
});
}
}
} else if (ntypeLower.includes('lora')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
loraDetailsMap.set(name, { strengthModel, strengthClip });
const baseName = name.replace(/\\/g, '/').split('/').pop();
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
if (!discoveredModels.loras.some(l => l.name === name)) {
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
}
}
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.vaes.some(v => v.name === name)) {
discoveredModels.vaes.push({ name, category: 'vae' });
}
}
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
for (const w of widgets) {
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
}
}
}
}
}
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
const occurrences = {};
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
const nodeType = String(node.type);
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
const server = serverById.get(String(node.id)) || {};
const lower = nodeType.toLowerCase();
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
return {
node_id: node.id,
type: nodeType,
title: node.title || server.title || nodeType,
occurrence: occurrences[nodeType],
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
? server.volatile_widget_indexes
: volatile,
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
mode: node.mode,
};
});
}
function materialWidgetLabels(nodeType) {
const type = String(nodeType || '').toLowerCase();
if (type === 'ksampler') return [
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
];
if (type === 'ksampleradvanced') return [
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
];
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
if (type.includes('cliptextencode')) return [t('materialPromptText')];
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
return [];
}
const comfyWidgetLabelCache = new Map();
function comfyNodeTitle(nodeType) {
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
return (ctor && ctor.title) || nodeType;
}
function comfyWidgetLabels(nodeType) {
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
let labels = [];
try {
const node = globalThis.LiteGraph?.createNode?.(nodeType);
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
if (!labels.length) labels = materialWidgetLabels(nodeType);
comfyWidgetLabelCache.set(nodeType, labels);
return labels;
}
export function materialNodeHeading(block) {
const type = block.type || '';
const saved = block.title || '';
if (saved && saved !== type) return saved;
return comfyNodeTitle(type) || saved || type;
}
function formatMaterialParameterValue(value) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
}
function isPromptBlock(block) {
const type = String(block?.type || '').toLowerCase();
return (type.includes('cliptextencode') || type.includes('prompt')) &&
Array.isArray(block.widgets_values) &&
block.widgets_values.length > 0 &&
typeof block.widgets_values[0] === 'string';
}
function bindCopyButton(button, value, successKey) {
const label = button.textContent;
button.onclick = async event => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(value());
button.textContent = t(successKey);
} catch (error) {
button.textContent = t('materialCopyError');
}
setTimeout(() => { if (button.isConnected) button.textContent = label; }, 1200);
};
}
function renderPromptBlock(content, block, widgetValues) {
const promptWrap = document.createElement('div');
promptWrap.className = 'anomalous-material-prompt-station';
const promptText = String(widgetValues[0] ?? '');
const charCount = promptText.length;
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
const titleSec = document.createElement('div');
titleSec.className = 'anomalous-material-prompt-title-sec';
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
text(titleSec, 'span', t('materialChars', { count: charCount }), 'anomalous-material-prompt-count');
bar.appendChild(titleSec);
const copyPromptBtn = text(bar, 'button', t('materialCopyPromptText'), 'anomalous-material-copy-prompt-btn');
copyPromptBtn.type = 'button';
bindCopyButton(copyPromptBtn, () => promptText, 'materialPromptCopied');
promptWrap.appendChild(bar);
const body = document.createElement('div');
body.className = 'anomalous-material-prompt-body';
body.textContent = promptText || t('materialNoWidgetParameters');
promptWrap.appendChild(body);
content.appendChild(promptWrap);
}
function renderNodeParameterRows(content, block, widgetValues, offset = 0) {
const labels = comfyWidgetLabels(block.type);
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
if (!widgetValues.length) {
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
return;
}
widgetValues.forEach((value, localIndex) => {
const index = localIndex + offset;
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
text(labelWrap, 'code', `#${index}`);
if (volatileIndexes.has(index)) {
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
}
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
valueText.title = t('materialParameterFullValue');
row.prepend(labelWrap);
content.appendChild(row);
});
}
function renderNodeProperties(content, properties) {
const props = properties && typeof properties === 'object' ? properties : {};
if (!Object.keys(props).length) return;
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', t('materialNodeProperties'));
const propertyValue = document.createElement('pre');
propertyValue.className = 'anomalous-material-parameter-value';
propertyValue.textContent = formatMaterialParameterValue(props);
row.append(labelWrap, propertyValue);
content.appendChild(row);
}
function renderNodeCardContent(node, block, widgetValues) {
const content = document.createElement('div');
content.className = 'anomalous-material-node-parameter-content';
const meta = document.createElement('div');
meta.className = 'anomalous-material-node-meta';
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
copy.type = 'button';
bindCopyButton(copy, () => JSON.stringify({
node_id: block.node_id, type: block.type, title: block.title,
widgets_values: widgetValues, properties: block.properties || {}, mode: block.mode,
}, null, 2), 'materialCopied');
content.appendChild(meta);
if (isPromptBlock(block)) {
renderPromptBlock(content, block, widgetValues);
if (widgetValues.length > 1) {
renderNodeParameterRows(content, block, widgetValues.slice(1), 1);
}
} else {
renderNodeParameterRows(content, block, widgetValues);
}
renderNodeProperties(content, block.properties);
node.appendChild(content);
}
function renderSingleNodeCard(block, options = {}) {
const node = document.createElement('details');
node.className = 'anomalous-material-node-detail';
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
const summary = document.createElement('summary');
if (options.selectable) {
const select = document.createElement('input');
select.type = 'checkbox';
select.className = 'anomalous-material-node-select';
select.dataset.nodeId = String(block.node_id);
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
select.title = t('materialSelectNodeForSaving');
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
select.addEventListener('click', event => event.stopPropagation());
select.addEventListener('change', () => {
options.onSelectionChange?.(block, select.checked);
node.classList.toggle('is-selected', select.checked);
});
node.classList.toggle('is-selected', select.checked);
summary.appendChild(select);
}
const heading = document.createElement('span');
heading.className = 'anomalous-material-node-heading';
text(heading, 'strong', materialNodeHeading(block));
text(heading, 'small', block.type || t('recipeUnknownNode'));
summary.prepend(heading);
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
if (typeof options.onSaveBlock === 'function') {
const save = text(summary, 'button', t('materialSaveNode'), 'anomalous-material-node-save');
save.type = 'button';
save.title = t('materialSaveNodeHint');
save.addEventListener('click', event => event.stopPropagation());
save.addEventListener('click', () => options.onSaveBlock(block, save));
}
node.appendChild(summary);
const shouldOpen = options.defaultOpen === true || (options.selectable ? false : true);
let rendered = false;
const renderContentIfNeeded = () => {
if (rendered) return;
rendered = true;
renderNodeCardContent(node, block, widgetValues);
};
if (shouldOpen) {
node.open = true;
renderContentIfNeeded();
}
node.addEventListener('toggle', () => {
if (node.open) renderContentIfNeeded();
});
return node;
}
export function renderDetailedNodeCards(parent, blocks, options = {}) {
const list = document.createElement('div');
list.className = 'anomalous-material-node-list';
blocks.forEach(block => {
list.appendChild(renderSingleNodeCard(block, options));
});
parent.appendChild(list);
return list;
}
+8 -3
View File
@@ -1,13 +1,16 @@
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
// Confirmations must remain above the image workbench (z-index: 1000000).
const DIALOG_Z_INDEX = '1000001';
export function anomalousAlert(message, title = 'Anomalous') {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.className = 'anomalous-dialog-overlay';
overlay.style.zIndex = DIALOG_Z_INDEX;
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
@@ -73,7 +76,8 @@ export function anomalousConfirm(message, title = 'Anomalous', options = {}) {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.className = 'anomalous-dialog-overlay';
overlay.style.zIndex = DIALOG_Z_INDEX;
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
@@ -156,7 +160,8 @@ export function anomalousPrompt(message, defaultValue = '', title = 'Anomalous',
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '999999';
overlay.className = 'anomalous-dialog-overlay';
overlay.style.zIndex = DIALOG_Z_INDEX;
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
+32 -15
View File
@@ -13,7 +13,7 @@
import { app } from '../../../scripts/app.js';
import { translate } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import {
text,
sectionLabel,
@@ -28,7 +28,7 @@ import {
detailedBlocksFromWorkflow,
materialNodeHeading,
renderDetailedNodeCards
} from './ui_materials.js';
} from './material_inspector.js';
const t = (key, params) => translate(key, params);
@@ -138,17 +138,23 @@ function materialSourceImage(item) {
};
}
async function saveImageMaterial(item, name, selectedNodeIds = null) {
async function saveImageMaterial(item, name, selectedNodeIds = null, tags = []) {
const owner = wb?.owner;
const response = await fetch('/anomalous/save_image_material', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_image: materialSourceImage(item),
name: String(name || '').trim().slice(0, 120),
...(selectedNodeIds?.length ? { selected_node_ids: selectedNodeIds } : {}),
}),
const body = {
source_image: materialSourceImage(item), name: String(name || '').trim().slice(0, 120), tags,
...(selectedNodeIds?.length ? { selected_node_ids: selectedNodeIds } : {}),
};
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');
await owner?.refreshMaterials?.();
@@ -171,11 +177,12 @@ async function saveSelectedBlocks(item, suggestedName, blocks, button) {
button.disabled = true;
button.textContent = t('materialSaving');
try {
await saveImageMaterial(
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;
@@ -1093,7 +1100,14 @@ function renderSaveSnapshotFooter(inspectPayload, item) {
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);
@@ -1107,10 +1121,12 @@ function renderSaveSnapshotFooter(inspectPayload, item) {
const val = nameInput.value.trim();
if (!val) { nameInput.focus(); return; }
saveBtn.disabled = true;
saveBtn.textContent = '💾 正在保存…';
saveBtn.textContent = t('materialSaving');
try {
await saveImageMaterial(item, val);
saveBtn.textContent = '✅ 已保存素材';
const tags = tagsInput.value.split(/[,,]/).map(value => value.trim()).filter(Boolean);
const saved = await saveImageMaterial(item, val, null, tags);
if (!saved) { saveBtn.disabled = false; saveBtn.textContent = t('materialSaveSnapshot'); return; }
saveBtn.textContent = t('materialSaved');
await anomalousAlert(t('materialSaveSuccess') || '素材快照已保存!');
} catch (error) {
console.error('Could not save image material:', error);
@@ -1323,6 +1339,7 @@ export async function showImageWorkbench(owner, sourceImage, imageUrl, options =
// 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') {
+130 -538
View File
@@ -4,94 +4,15 @@ 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);
export 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;
}
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
export function fileBaseName(value) {
return String(value || '').replace(/\\/g, '/').split('/').pop();
}
function previewIsVideo(url) {
return /\.(?:mp4|webm)(?:$|\?|&|#)/i.test(url || '');
}
export function modelCustomNotes(model) {
return String(model?.metadata?.custom_notes || '').trim();
}
function bindHoverPreviewVideo(video) {
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.onpointerenter = () => video.play().catch(() => {});
video.onpointerleave = () => {
video.pause();
video.currentTime = 0;
};
}
export function appendLocalPreview(parent, url, className) {
if (!url) return null;
const wrap = document.createElement('div');
wrap.className = className;
if (previewIsVideo(url)) {
const video = document.createElement('video');
video.src = url;
bindHoverPreviewVideo(video);
wrap.appendChild(video);
} else {
const image = document.createElement('img');
image.src = url;
image.alt = '';
image.loading = 'lazy';
wrap.appendChild(image);
}
parent.appendChild(wrap);
return wrap;
}
export async function resolveLocalModels(paths) {
const unique = [...new Set((paths || []).filter(Boolean))];
if (!unique.length) return {};
const response = await fetch('/anomalous/resolve_paths_to_previews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: unique, exact_only: false }),
});
const payload = await jsonResponse(response, 'preview resolve failed');
return payload.models || {};
}
export function lookupLocalModel(localModels, path) {
if (!path || !localModels) return null;
return localModels[path] || localModels[fileBaseName(path)] || null;
}
function materialAssetUrl(filename, asset) {
if (!filename || !asset) return '';
return `/anomalous/material_asset?filename=${encodeURIComponent(filename)}&asset=${encodeURIComponent(asset)}`;
}
export 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';
@@ -184,6 +105,8 @@ function leaveMaterialDetail(owner) {
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) {
@@ -248,6 +171,9 @@ function buildMaterialDetailHeader(owner, material) {
}
};
}
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);
@@ -304,6 +230,8 @@ async function showMaterialDetail(owner, material) {
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';
@@ -355,6 +283,14 @@ function renderMaterialCard(owner, material) {
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';
@@ -422,29 +358,136 @@ function renderMaterialCard(owner, material) {
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;
}
export async function refreshMaterials() {
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', { cache: 'no-store' });
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('materialEmpty'), 'anomalous-material-empty');
text(this.materialList, 'p', t(this.materialQuery || this.materialTag || this.materialKind ? 'materialNoMatches' : 'materialEmpty'), 'anomalous-material-empty');
return;
}
for (const material of materials) this.materialList.appendChild(renderMaterialCard(this, material));
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;
}
}
@@ -469,9 +512,12 @@ export async function showMaterials() {
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';
@@ -482,460 +528,6 @@ export async function showMaterials() {
* 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 parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
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) {
if (e?.name === 'AbortError') return null;
console.warn('Client-side PNG metadata read skipped:', e);
return null;
}
}
/**
* Extract generation parameters, full prompts, and categorized model references with details (e.g. LoRA strengths)
* directly from the workflow.
*/
export function extractWorkflowDetails(workflow) {
const params = {};
const positivePrompts = [];
const negativePrompts = [];
const allPrompts = [];
const loraDetailsMap = new Map();
const discoveredModels = {
checkpoints: [],
loras: [],
textEncoders: [],
vaes: [],
others: [],
};
if (!workflow || typeof workflow !== 'object') {
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
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 : [];
// 1. Sampling parameters
if (ntypeLower === 'ksampler' || ntypeLower === 'ksampleradvanced') {
const isAdv = ntypeLower === 'ksampleradvanced';
const offset = isAdv ? 1 : 0;
if (widgets[offset] != null && params.seed == null) params.seed = widgets[offset];
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]}`;
}
}
// 2. Full un-truncated prompts
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);
}
}
}
}
// 3. Categorized model references & LoRA weights
if (ntypeLower.includes('checkpoint') || ntypeLower === 'unetloader' || ntypeLower.includes('diffusionmodel')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.checkpoints.some(c => c.name === name)) {
discoveredModels.checkpoints.push({
name,
category: ntypeLower.includes('unet') ? 'unet' : 'checkpoint',
});
}
}
} else if (ntypeLower.includes('lora')) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
const strengthModel = widgets[1] != null ? Number(widgets[1]) : 1.0;
const strengthClip = widgets[2] != null ? Number(widgets[2]) : strengthModel;
loraDetailsMap.set(name, { strengthModel, strengthClip });
const baseName = name.replace(/\\/g, '/').split('/').pop();
loraDetailsMap.set(baseName, { strengthModel, strengthClip });
if (!discoveredModels.loras.some(l => l.name === name)) {
discoveredModels.loras.push({ name, strengthModel, strengthClip, category: 'lora' });
}
}
} else if (ntypeLower.includes('vaeloader') || (ntypeLower.includes('vae') && !ntypeLower.includes('encode') && !ntypeLower.includes('decode'))) {
if (widgets[0] && typeof widgets[0] === 'string') {
const name = widgets[0];
if (!discoveredModels.vaes.some(v => v.name === name)) {
discoveredModels.vaes.push({ name, category: 'vae' });
}
}
} else if (ntypeLower.includes('cliploader') || ntypeLower.includes('dualcliploader') || ntypeLower.includes('textencoder')) {
for (const w of widgets) {
if (typeof w === 'string' && (w.endsWith('.safetensors') || w.endsWith('.bin') || w.endsWith('.pt') || w.endsWith('.sft'))) {
if (!discoveredModels.textEncoders.some(t => t.name === w)) {
discoveredModels.textEncoders.push({ name: w, category: 'text_encoder' });
}
}
}
}
}
return { params, positivePrompts, negativePrompts, allPrompts, loraDetailsMap, discoveredModels };
}
export function detailedBlocksFromWorkflow(workflow, serverBlocks = []) {
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
if (!nodes.length) return Array.isArray(serverBlocks) ? serverBlocks : [];
const serverById = new Map((serverBlocks || []).map(block => [String(block?.node_id), block]));
const occurrences = {};
return nodes.filter(node => node && typeof node === 'object' && node.type).map(node => {
const nodeType = String(node.type);
occurrences[nodeType] = (occurrences[nodeType] || 0) + 1;
const server = serverById.get(String(node.id)) || {};
const lower = nodeType.toLowerCase();
const volatile = lower === 'ksampler' ? [0] : lower === 'ksampleradvanced' ? [1] : [];
return {
node_id: node.id,
type: nodeType,
title: node.title || server.title || nodeType,
occurrence: occurrences[nodeType],
widget_count: Array.isArray(node.widgets_values) ? node.widgets_values.length : 0,
widgets_values: Array.isArray(node.widgets_values) ? node.widgets_values : [],
volatile_widget_indexes: Array.isArray(server.volatile_widget_indexes)
? server.volatile_widget_indexes
: volatile,
properties: node.properties && typeof node.properties === 'object' ? node.properties : {},
mode: node.mode,
};
});
}
function materialWidgetLabels(nodeType) {
const type = String(nodeType || '').toLowerCase();
if (type === 'ksampler') return [
t('materialParamSeed'), t('materialParamSeedControl'), t('recipeCardSpecsSteps'),
'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'), t('materialDenoise'),
];
if (type === 'ksampleradvanced') return [
t('materialParamAddNoise'), t('materialParamSeed'), t('materialParamSeedControl'),
t('recipeCardSpecsSteps'), 'CFG', t('recipeCardSpecsSampler'), t('materialScheduler'),
t('materialParamStartStep'), t('materialParamEndStep'), t('materialParamLeftoverNoise'),
];
if (type === 'emptylatentimage') return [t('materialParamWidth'), t('materialParamHeight'), t('materialParamBatchSize')];
if (/checkpointloader(simple)?$/.test(type)) return [t('materialParamCheckpoint')];
if (type.endsWith('unetloader')) return [t('materialParamUnet'), t('materialParamWeightDtype')];
if (type.includes('loraloader')) return [t('materialParamLora'), t('materialParamModelStrength'), t('materialParamClipStrength')];
if (type.endsWith('vaeloader')) return [t('materialParamVae')];
if (type.includes('cliptextencode')) return [t('materialPromptText')];
if (type.endsWith('clipvisionloader')) return [t('materialParamClipVision')];
if (type.endsWith('controlnetloader')) return [t('materialParamControlNet')];
if (type.endsWith('dualcliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipType')];
if (type.endsWith('triplecliploader')) return [t('materialParamClipOne'), t('materialParamClipTwo'), t('materialParamClipThree')];
if (type.endsWith('cliploader')) return [t('materialParamClipOne'), t('materialParamClipType')];
if (type === 'saveimage') return [t('materialParamFilenamePrefix')];
return [];
}
const comfyWidgetLabelCache = new Map();
function comfyNodeTitle(nodeType) {
const ctor = globalThis.LiteGraph?.registered_node_types?.[nodeType];
return (ctor && ctor.title) || nodeType;
}
function comfyWidgetLabels(nodeType) {
if (comfyWidgetLabelCache.has(nodeType)) return comfyWidgetLabelCache.get(nodeType);
let labels = [];
try {
const node = globalThis.LiteGraph?.createNode?.(nodeType);
if (node?.widgets?.length) labels = node.widgets.map(widget => widget.label || widget.name || '');
} catch (error) { /* some node types cannot be instantiated off-canvas */ }
if (!labels.length) labels = materialWidgetLabels(nodeType);
comfyWidgetLabelCache.set(nodeType, labels);
return labels;
}
export function materialNodeHeading(block) {
const type = block.type || '';
const saved = block.title || '';
if (saved && saved !== type) return saved;
return comfyNodeTitle(type) || saved || type;
}
function formatMaterialParameterValue(value) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
}
function isPromptBlock(block) {
const type = String(block?.type || '').toLowerCase();
return (type.includes('cliptextencode') || type.includes('prompt')) &&
Array.isArray(block.widgets_values) &&
block.widgets_values.length > 0 &&
typeof block.widgets_values[0] === 'string';
}
function renderPromptBlock(content, block, widgetValues) {
const promptWrap = document.createElement('div');
promptWrap.className = 'anomalous-material-prompt-station';
const promptText = String(widgetValues[0] || '').trim();
const charCount = promptText.length;
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
const titleSec = document.createElement('div');
titleSec.className = 'anomalous-material-prompt-title-sec';
text(titleSec, 'span', `💬 ${t('materialPromptNode') || '提示词'}`, 'anomalous-material-prompt-title');
text(titleSec, 'span', `${charCount} chars`, 'anomalous-material-prompt-count');
bar.appendChild(titleSec);
const copyPromptBtn = text(bar, 'button', t('materialCopyNodeParameters') || '复制提示词', 'anomalous-material-copy-prompt-btn');
copyPromptBtn.type = 'button';
copyPromptBtn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(promptText).then(() => {
copyPromptBtn.textContent = t('materialPromptCopied') || '已复制提示词!';
setTimeout(() => {
copyPromptBtn.textContent = t('materialCopyNodeParameters') || '复制提示词';
}, 1200);
});
};
promptWrap.appendChild(bar);
const body = document.createElement('div');
body.className = 'anomalous-material-prompt-body';
body.textContent = promptText || t('materialNoWidgetParameters');
promptWrap.appendChild(body);
content.appendChild(promptWrap);
}
function renderNodeParameterRows(content, block, widgetValues) {
const labels = comfyWidgetLabels(block.type);
const volatileIndexes = new Set(Array.isArray(block.volatile_widget_indexes) ? block.volatile_widget_indexes : []);
if (!widgetValues.length) {
text(content, 'p', t('materialNoWidgetParameters'), 'anomalous-material-muted');
return;
}
widgetValues.forEach((value, index) => {
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', labels[index] || t('materialWidgetIndex', { index: index + 1 }));
text(labelWrap, 'code', `#${index}`);
if (volatileIndexes.has(index)) {
text(labelWrap, 'span', t('materialVolatileParameter'), 'anomalous-material-volatile-badge');
}
const valueText = text(row, 'pre', formatMaterialParameterValue(value), 'anomalous-material-parameter-value');
valueText.title = t('materialParameterFullValue');
row.prepend(labelWrap);
content.appendChild(row);
});
}
function renderNodeProperties(content, properties) {
const props = properties && typeof properties === 'object' ? properties : {};
if (!Object.keys(props).length) return;
const row = document.createElement('div');
row.className = 'anomalous-material-parameter-row';
const labelWrap = document.createElement('div');
labelWrap.className = 'anomalous-material-parameter-label';
text(labelWrap, 'span', t('materialNodeProperties'));
const propertyValue = document.createElement('pre');
propertyValue.className = 'anomalous-material-parameter-value';
propertyValue.textContent = formatMaterialParameterValue(props);
row.append(labelWrap, propertyValue);
content.appendChild(row);
}
function renderNodeCardContent(node, block, widgetValues) {
const content = document.createElement('div');
content.className = 'anomalous-material-node-parameter-content';
const meta = document.createElement('div');
meta.className = 'anomalous-material-node-meta';
text(meta, 'span', `${t('materialNodeId')}: ${block.node_id ?? '—'}`);
if (block.mode != null) text(meta, 'span', `${t('materialNodeMode')}: ${block.mode}`);
const copy = text(meta, 'button', t('materialCopyNodeParameters'), 'anomalous-material-mini-btn');
copy.type = 'button';
copy.onclick = (e) => {
e.stopPropagation();
const snapshot = {
node_id: block.node_id,
type: block.type,
title: block.title,
widgets_values: widgetValues,
properties: block.properties || {},
mode: block.mode,
};
navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2)).then(() => {
copy.textContent = t('materialCopied');
setTimeout(() => { copy.textContent = t('materialCopyNodeParameters'); }, 1200);
});
};
content.appendChild(meta);
if (isPromptBlock(block)) {
renderPromptBlock(content, block, widgetValues);
if (widgetValues.length > 1) {
renderNodeParameterRows(content, block, widgetValues.slice(1));
}
} else {
renderNodeParameterRows(content, block, widgetValues);
}
renderNodeProperties(content, block.properties);
node.appendChild(content);
}
function renderSingleNodeCard(block, options = {}) {
const node = document.createElement('details');
node.className = 'anomalous-material-node-detail';
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
const summary = document.createElement('summary');
if (options.selectable) {
const select = document.createElement('input');
select.type = 'checkbox';
select.className = 'anomalous-material-node-select';
select.dataset.nodeId = String(block.node_id);
select.checked = options.selectedIds?.has(String(block.node_id)) || false;
select.title = t('materialSelectNodeForSaving');
select.setAttribute('aria-label', t('materialSelectNodeForSaving'));
select.addEventListener('click', event => event.stopPropagation());
select.addEventListener('change', () => {
options.onSelectionChange?.(block, select.checked);
node.classList.toggle('is-selected', select.checked);
});
node.classList.toggle('is-selected', select.checked);
summary.appendChild(select);
}
const heading = document.createElement('span');
heading.className = 'anomalous-material-node-heading';
text(heading, 'strong', materialNodeHeading(block));
text(heading, 'small', block.type || t('recipeUnknownNode'));
summary.prepend(heading);
text(summary, 'span', t('materialParameterCount', { count: widgetValues.length }), 'anomalous-material-node-count');
if (typeof options.onSaveBlock === 'function') {
const save = text(summary, 'button', t('materialSaveNode'), 'anomalous-material-node-save');
save.type = 'button';
save.title = t('materialSaveNodeHint');
save.addEventListener('click', event => event.stopPropagation());
save.addEventListener('click', () => options.onSaveBlock(block, save));
}
node.appendChild(summary);
const shouldOpen = options.defaultOpen === true || (options.selectable ? false : true);
let rendered = false;
const renderContentIfNeeded = () => {
if (rendered) return;
rendered = true;
renderNodeCardContent(node, block, widgetValues);
};
if (shouldOpen) {
node.open = true;
renderContentIfNeeded();
}
node.addEventListener('toggle', () => {
if (node.open) renderContentIfNeeded();
});
return node;
}
export function renderDetailedNodeCards(parent, blocks, options = {}) {
const list = document.createElement('div');
list.className = 'anomalous-material-node-list';
blocks.forEach(block => {
list.appendChild(renderSingleNodeCard(block, options));
});
parent.appendChild(list);
return list;
}
export async function showImageMaterialDetail(owner, sourceImage, imageUrl, options = {}) {
return showImageWorkbench(owner, sourceImage, imageUrl, options);
}
+4
View File
@@ -29,6 +29,10 @@ function restoreWorkspaceReturnPanel(owner) {
}
export function closeWorkspace() {
clearTimeout(this.materialSearchTimer);
this.materialListController?.abort();
this.materialListController = null;
this.materialDetailController?.abort();
this.recipeDetailFinish?.('closed');
const abandonedRecipeModel = typeof this.recipeModelReturn === 'function';
this.recipeModelReturn = null;
+38
View File
@@ -3613,14 +3613,51 @@ dialog.anomalous-gallery-viewer[open] {
.anomalous-material-muted { color: #9298a8; }
.anomalous-material-list {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 300px));
grid-auto-rows: max-content;
align-content: start;
gap: 16px;
padding: 20px;
overflow-y: auto;
}
.anomalous-material-toolbar,
.anomalous-material-pager {
display: flex;
flex: 0 0 auto;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.anomalous-material-toolbar input { flex: 1 1 220px; }
.anomalous-material-toolbar select { flex: 0 1 200px; }
.anomalous-material-toolbar input,
.anomalous-material-toolbar select,
.anomalous-material-editor input {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 1px solid #3d4350;
border-radius: 8px;
background: #1d2028;
color: #e5e9f0;
padding: 9px 11px;
font: inherit;
}
.anomalous-material-pager { justify-content: center; border-top: 1px solid rgba(255,255,255,.08); }
.anomalous-material-pager button:disabled { opacity: .4; cursor: default; }
.anomalous-material-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.anomalous-material-tag { max-width: 100%; overflow-wrap: anywhere; border-radius: 5px; background: #292e39; color: #b9c4d6; padding: 3px 7px; font-size: 12px; }
.anomalous-material-editor { display: flex; flex: 1 0 100%; flex-wrap: wrap; gap: 12px; align-items: end; }
.anomalous-material-editor label { display: flex; flex: 1 1 240px; gap: 6px; flex-direction: column; font-size: 13px; }
.anomalous-library-detail-header:has(.anomalous-material-editor) { flex-wrap: wrap; }
.anomalous-material-card:focus-visible { outline: 2px solid #91b3df; outline-offset: 3px; }
.anomalous-material-empty { grid-column: 1 / -1; padding: 36px; text-align: center; color: #818797; }
.anomalous-material-card {
@@ -3709,6 +3746,7 @@ dialog.anomalous-gallery-viewer[open] {
.anomalous-material-card-preview {
display: flex;
flex-shrink: 0;
height: 180px;
align-items: center;
justify-content: center;