修改架构,增加可视化

This commit is contained in:
2026-07-23 13:07:49 +08:00
parent e7152727ce
commit f17aa7db8a
27 changed files with 1725 additions and 2278 deletions
+270
View File
@@ -0,0 +1,270 @@
.ssi-panel {
display: flex;
flex-direction: column;
gap: 9px;
padding: 6px 8px 10px;
box-sizing: border-box;
width: 100%;
min-width: 0;
font-size: 12px;
color: var(--input-text, #e6e6e6);
}
.ssi-row {
display: flex;
align-items: center;
gap: 8px;
}
.ssi-field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.ssi-label {
font-weight: 600;
color: var(--descrip-text, #b8c0cc);
min-width: 60px;
}
.ssi-input,
.ssi-select {
flex: 1;
width: 100%;
min-width: 0;
height: 30px;
box-sizing: border-box;
background: var(--comfy-input-bg, #222);
color: var(--input-text, #e6e6e6);
border: 1px solid var(--border-color, #444);
border-radius: 5px;
padding: 5px 8px;
outline: none;
}
.ssi-input:focus,
.ssi-select:focus {
border-color: #5b9dff;
}
.ssi-mono {
font-family: "Consolas", "Menlo", monospace;
letter-spacing: 0.2px;
}
/* 根目录分段按钮 */
.ssi-location {
align-items: stretch;
}
.ssi-seg {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 3px;
width: 100%;
}
.ssi-seg-btn {
min-width: 0;
height: 30px;
padding: 4px 3px;
border: 1px solid var(--border-color, #444);
border-radius: 5px;
background: var(--comfy-input-bg, #222);
color: var(--descrip-text, #b8c0cc);
cursor: pointer;
transition: all 0.12s ease;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ssi-seg-btn:hover {
border-color: #5b9dff;
}
.ssi-seg-active {
background: #3a6df0;
color: #fff;
border-color: #3a6df0;
}
/* 占位符调色板 */
.ssi-palette {
padding: 7px 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
}
.ssi-palette-summary {
cursor: pointer;
color: var(--descrip-text, #b8c0cc);
font-weight: 600;
user-select: none;
}
.ssi-palette[open] .ssi-palette-summary {
margin-bottom: 7px;
}
.ssi-palette .ssi-hint,
.ssi-palette .ssi-token-group {
margin-top: 6px;
}
.ssi-hint {
color: var(--descrip-text, #8a93a2);
font-size: 11px;
}
.ssi-token-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.ssi-group-name {
color: #8a93a2;
font-size: 11px;
margin-right: 4px;
min-width: 28px;
}
.ssi-chip {
padding: 3px 7px;
border: 1px solid var(--border-color, #444);
border-radius: 5px;
background: var(--comfy-input-bg, #2a2a2a);
color: #cdd6e0;
cursor: pointer;
font-size: 11px;
transition: all 0.12s ease;
}
.ssi-chip:hover {
background: #3a6df0;
color: #fff;
border-color: #3a6df0;
}
/* 选项网格 */
.ssi-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
align-items: end;
}
.ssi-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.ssi-cell-label {
font-size: 11px;
color: var(--descrip-text, #8a93a2);
}
.ssi-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
min-height: 30px;
}
/* 预览区 */
.ssi-preview {
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid var(--border-color, #444);
}
.ssi-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.ssi-preview-title {
font-weight: 600;
color: var(--input-text, #e6e6e6);
}
.ssi-refresh {
padding: 3px 8px;
border: 1px solid var(--border-color, #555);
border-radius: 5px;
background: transparent;
color: var(--descrip-text, #b8c0cc);
cursor: pointer;
}
.ssi-refresh:hover {
background: #3a6df0;
color: #fff;
}
.ssi-path {
font-family: "Consolas", "Menlo", monospace;
overflow-wrap: anywhere;
color: var(--input-text, #e6e6e6);
background: rgba(0, 0, 0, 0.25);
padding: 5px 7px;
border-radius: 4px;
max-height: 38px;
overflow: auto;
font-size: 11px;
}
.ssi-file {
color: #cdd6e0;
overflow-wrap: anywhere;
font-size: 11px;
}
.ssi-context {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ssi-context:empty,
.ssi-file:empty {
display: none;
}
.ssi-ctx-tag {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
color: #dfe6ee;
}
.ssi-ctx-tag b {
color: #9fc3ff;
font-weight: 600;
}
.ssi-status {
font-size: 11px;
color: #8a93a2;
}
.ssi-status-ok {
color: #6bd08a;
}
.ssi-status-warn {
color: #f0a35b;
}
+445
View File
@@ -0,0 +1,445 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "SmartSaveImage";
const CSS_HREF = "extensions/SmartSaveImage/smart_save.css";
// 需要隐藏并由自定义面板托管的原生 widget
const MANAGED_WIDGETS = [
"root_mode",
"custom_root",
"folder_template",
"filename_template",
"file_format",
"quality",
"collision_mode",
"save_mode",
"manual_model",
"embed_workflow",
"counter_digits",
"png_compression",
];
// 占位符说明(分组展示,点击插入到当前聚焦的模板输入框)
const TOKENS = [
{ group: "时间", items: [
{ t: "%date:yyyy-MM-dd%", d: "日期(可自定义格式)" },
{ t: "%year%", d: "年" }, { t: "%month%", d: "月" }, { t: "%day%", d: "日" },
{ t: "%hour%", d: "时" }, { t: "%minute%", d: "分" }, { t: "%second%", d: "秒" },
]},
{ group: "模型", items: [
{ t: "%model%", d: "模型名(去扩展名)" },
{ t: "%model_full%", d: "模型完整名" },
{ t: "%unet%", d: "UNet/扩散模型" },
{ t: "%lora%", d: "首个 LoRA" },
{ t: "%vae%", d: "VAE" },
]},
{ group: "采样", items: [
{ t: "%seed%", d: "种子" }, { t: "%steps%", d: "步数" }, { t: "%cfg%", d: "CFG" },
{ t: "%sampler%", d: "采样器" }, { t: "%scheduler%", d: "调度器" },
]},
{ group: "图片", items: [
{ t: "%width%", d: "宽" }, { t: "%height%", d: "高" },
{ t: "%prompt%", d: "正向提示词" }, { t: "%batch%", d: "批次序号(仅文件名)" },
]},
];
function ensureStyles() {
const id = "smart-save-image-css";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = CSS_HREF;
document.head.append(link);
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function getWidget(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function widgetValue(node, name, fallback) {
const w = getWidget(node, name);
return w ? w.value : fallback;
}
function setWidget(node, name, value) {
const w = getWidget(node, name);
if (!w) return;
w.value = value;
w.callback?.(value);
}
function hideWidget(node, name) {
const w = getWidget(node, name);
if (!w) return;
w.hidden = true;
w.computeSize = () => [0, -4];
if (!w.options) w.options = {};
w.options.serialize = true;
}
function buildPanel(node) {
let debounceTimer = null;
let previewRequest = 0;
node.serialize_widgets = true;
for (const name of MANAGED_WIDGETS) hideWidget(node, name);
const modelWidget = getWidget(node, "manual_model");
const modelOptions = modelWidget?.options?.values || ["auto"];
const root = el("div", "ssi-panel");
// ---- 根目录 ----
const rootRow = el("div", "ssi-field ssi-location");
rootRow.append(el("label", "ssi-label", "保存位置"));
const rootSeg = el("div", "ssi-seg");
const ROOTS = [
{ v: "output", label: "输出目录" },
{ v: "custom", label: "自定义" },
{ v: "input", label: "输入" },
{ v: "temp", label: "临时" },
];
const rootButtons = {};
for (const r of ROOTS) {
const b = el("button", "ssi-seg-btn", r.label);
b.type = "button";
b.onclick = () => { setWidget(node, "root_mode", r.v); syncRoot(); schedulePreview(); };
rootButtons[r.v] = b;
rootSeg.append(b);
}
rootRow.append(rootSeg);
const customRoot = el("input", "ssi-input");
customRoot.placeholder = "例如 D:\\AI\\output(留空使用输出目录)";
customRoot.value = widgetValue(node, "custom_root", "");
customRoot.oninput = () => { setWidget(node, "custom_root", customRoot.value); schedulePreview(); };
const customRootWrap = el("div", "ssi-field ssi-custom-root");
customRootWrap.append(el("label", "ssi-cell-label", "自定义路径"), customRoot);
// ---- 目录模板 ----
const folderRow = el("div", "ssi-field");
folderRow.append(el("label", "ssi-label", "子目录规则"));
const folderInput = el("input", "ssi-input ssi-mono");
folderInput.placeholder = "例如 %date:yyyy-MM-dd%/%model%";
folderInput.value = widgetValue(node, "folder_template", "");
folderInput.oninput = () => { setWidget(node, "folder_template", folderInput.value); schedulePreview(); };
folderInput.onfocus = () => { activeField = folderInput; };
folderRow.append(folderInput);
// ---- 文件名模板 ----
const nameRow = el("div", "ssi-field");
nameRow.append(el("label", "ssi-label", "文件名规则"));
const nameInput = el("input", "ssi-input ssi-mono");
nameInput.placeholder = "例如 %model%_%seed%";
nameInput.value = widgetValue(node, "filename_template", "");
nameInput.oninput = () => { setWidget(node, "filename_template", nameInput.value); schedulePreview(); };
nameInput.onfocus = () => { activeField = nameInput; };
nameRow.append(nameInput);
let activeField = folderInput;
// ---- 占位符调色板 ----
const palette = el("details", "ssi-palette");
palette.append(el("summary", "ssi-palette-summary", "模板变量"));
palette.append(el("div", "ssi-hint", "先选中目录或文件名输入框,再点击变量插入。"));
for (const grp of TOKENS) {
const gwrap = el("div", "ssi-token-group");
gwrap.append(el("span", "ssi-group-name", grp.group));
for (const it of grp.items) {
const chip = el("button", "ssi-chip", it.t.replace(/%/g, ""));
chip.type = "button";
chip.title = `${it.t}${it.d}`;
chip.onclick = () => insertToken(it.t);
gwrap.append(chip);
}
palette.append(gwrap);
}
function insertToken(token) {
const field = activeField || folderInput;
const start = field.selectionStart ?? field.value.length;
const end = field.selectionEnd ?? field.value.length;
field.value = field.value.slice(0, start) + token + field.value.slice(end);
const pos = start + token.length;
field.setSelectionRange(pos, pos);
field.focus();
field.dispatchEvent(new Event("input"));
}
// ---- 模型来源 ----
const modelRow = el("div", "ssi-row");
modelRow.append(el("label", "ssi-label", "模型来源"));
const modelSelect = el("select", "ssi-select");
for (const opt of modelOptions) {
const o = el("option", null, opt === "auto" ? "自动读取工作流" : opt);
o.value = opt;
modelSelect.append(o);
}
modelSelect.value = widgetValue(node, "manual_model", "auto");
modelSelect.onchange = () => { setWidget(node, "manual_model", modelSelect.value); schedulePreview(); };
modelRow.append(modelSelect);
// ---- 格式 / 压缩 / 冲突 / 模式 ----
const optGrid = el("div", "ssi-grid");
const fmtSelect = makeSelect(["png", "jpeg", "webp"], widgetValue(node, "file_format", "png"),
(v) => { setWidget(node, "file_format", v); syncFormat(); schedulePreview(); });
optGrid.append(labeled("格式", fmtSelect));
const compressionInput = el("input", "ssi-input");
compressionInput.type = "number";
compressionInput.min = "0"; compressionInput.max = "9";
compressionInput.value = widgetValue(node, "png_compression", 4);
compressionInput.title = "0 最快且文件最大,9 最慢;所有等级均为无损";
compressionInput.oninput = () => {
const value = Math.max(0, Math.min(parseInt(compressionInput.value || "0", 10), 9));
setWidget(node, "png_compression", value);
};
const compressionWrap = labeled("PNG 压缩", compressionInput);
optGrid.append(compressionWrap);
const collisionSelect = makeSelect(
[["increment", "自动编号"], ["overwrite", "覆盖"]],
widgetValue(node, "collision_mode", "increment"),
(v) => { setWidget(node, "collision_mode", v); schedulePreview(); });
optGrid.append(labeled("同名冲突", collisionSelect));
const modeSelect = makeSelect(
[["save_and_preview", "保存并预览"], ["save_only", "仅保存"], ["preview_only", "仅预览"]],
widgetValue(node, "save_mode", "save_and_preview"),
(v) => setWidget(node, "save_mode", v));
optGrid.append(labeled("保存模式", modeSelect));
const digitsInput = el("input", "ssi-input");
digitsInput.type = "number";
digitsInput.min = "0"; digitsInput.max = "8";
digitsInput.value = widgetValue(node, "counter_digits", 3);
digitsInput.oninput = () => { setWidget(node, "counter_digits", parseInt(digitsInput.value || "0", 10)); schedulePreview(); };
optGrid.append(labeled("序号位数", digitsInput));
const embedLabel = el("label", "ssi-check");
const embedBox = el("input");
embedBox.type = "checkbox";
embedBox.checked = widgetValue(node, "embed_workflow", true) !== false;
embedBox.onchange = () => setWidget(node, "embed_workflow", embedBox.checked);
embedLabel.append(embedBox, document.createTextNode(" 嵌入工作流"));
optGrid.append(embedLabel);
// ---- 预览区 ----
const previewBox = el("div", "ssi-preview");
const previewHead = el("div", "ssi-preview-head");
const previewTitle = el("span", "ssi-preview-title", "保存结果预览");
const refreshBtn = el("button", "ssi-refresh", "刷新");
refreshBtn.type = "button";
refreshBtn.onclick = () => runPreview();
previewHead.append(previewTitle, refreshBtn);
const pathLine = el("div", "ssi-path", "—");
const fileLine = el("div", "ssi-file", "");
const ctxLine = el("div", "ssi-context", "");
const statusLine = el("div", "ssi-status", "");
previewBox.append(previewHead, pathLine, fileLine, ctxLine, statusLine);
root.append(rootRow, customRootWrap, folderRow, nameRow, previewBox, palette, modelRow, optGrid);
function syncFormat() {
compressionWrap.style.display = fmtSelect.value === "png" ? "flex" : "none";
}
function syncRoot() {
const mode = widgetValue(node, "root_mode", "output");
for (const [v, b] of Object.entries(rootButtons)) {
b.classList.toggle("ssi-seg-active", v === mode);
}
customRootWrap.style.display = mode === "custom" ? "flex" : "none";
}
function syncFromWidgets() {
customRoot.value = widgetValue(node, "custom_root", "");
folderInput.value = widgetValue(node, "folder_template", "");
nameInput.value = widgetValue(node, "filename_template", "");
const modelValue = widgetValue(node, "manual_model", "auto");
if (![...modelSelect.options].some((option) => option.value === modelValue)) {
const missingModel = el("option", null, `${modelValue}(当前不可用)`);
missingModel.value = modelValue;
modelSelect.append(missingModel);
}
modelSelect.value = modelValue;
fmtSelect.value = widgetValue(node, "file_format", "png");
compressionInput.value = widgetValue(node, "png_compression", 4);
collisionSelect.value = widgetValue(node, "collision_mode", "increment");
modeSelect.value = widgetValue(node, "save_mode", "save_and_preview");
digitsInput.value = widgetValue(node, "counter_digits", 3);
embedBox.checked = widgetValue(node, "embed_workflow", true) !== false;
syncFormat();
syncRoot();
}
// ---- 调用后端计算预览 ----
async function runPreview() {
const requestId = ++previewRequest;
statusLine.textContent = "正在计算…";
statusLine.className = "ssi-status";
try {
const graph = await app.graphToPrompt();
const payload = {
prompt: graph?.output || {},
root_mode: widgetValue(node, "root_mode", "output"),
custom_root: widgetValue(node, "custom_root", ""),
folder_template: widgetValue(node, "folder_template", ""),
filename_template: widgetValue(node, "filename_template", "image"),
file_format: widgetValue(node, "file_format", "png"),
manual_model: widgetValue(node, "manual_model", "auto"),
counter_digits: widgetValue(node, "counter_digits", 3),
collision_mode: widgetValue(node, "collision_mode", "increment"),
batch_size: 1,
};
const resp = await api.fetchApi("/smartsave/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (requestId !== previewRequest) return;
if (!data.ok) {
statusLine.textContent = "预览失败:" + (data.error || "未知错误");
statusLine.className = "ssi-status ssi-status-warn";
return;
}
pathLine.textContent = data.target;
pathLine.title = data.target;
const examples = (data.example_filenames || []).join(" 、 ");
fileLine.textContent = "示例文件:" + examples;
const c = data.context || {};
ctxLine.innerHTML = "";
const chips = [
["模型", c.model], ["LoRA", c.lora], ["种子", c.seed],
["采样器", c.sampler], ["尺寸", c.width && c.height ? `${c.width}x${c.height}` : ""],
];
for (const [k, v] of chips) {
if (!v) continue;
const tag = el("span", "ssi-ctx-tag");
tag.append(el("b", null, k + ""), document.createTextNode(v));
ctxLine.append(tag);
}
if (data.exists) {
statusLine.textContent = `目录已存在,已有 ${data.existing_count} 张图片`;
statusLine.className = "ssi-status ssi-status-ok";
} else {
statusLine.textContent = "目录尚不存在,保存时将自动创建";
statusLine.className = "ssi-status";
}
} catch (err) {
if (requestId !== previewRequest) return;
statusLine.textContent = "预览异常:" + err;
statusLine.className = "ssi-status ssi-status-warn";
}
}
function schedulePreview() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runPreview, 350);
}
node.__ssi_refresh = () => {
syncFromWidgets();
runPreview();
};
function panelContentHeight() {
const style = getComputedStyle(root);
const visibleChildren = [...root.children].filter((child) => child.offsetHeight > 0);
const gap = parseFloat(style.rowGap || style.gap || "0") || 0;
const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
const childrenHeight = visibleChildren.reduce((sum, child) => sum + child.offsetHeight, 0);
return Math.ceil(padding + childrenHeight + gap * Math.max(visibleChildren.length - 1, 0));
}
const widget = node.addDOMWidget("smart_save_panel", "smart-save", root, {
serialize: false,
getMinHeight() { return Math.max(360, panelContentHeight() + 8); },
getMaxHeight() { return 900; },
});
widget.serialize = false;
function fitNodeToPanel() {
const host = root.parentElement;
if (!host) return;
const desiredHeight = Math.ceil(panelContentHeight() + 50);
const desiredWidth = Math.max(node.size[0], 420);
if (Math.abs(node.size[1] - desiredHeight) > 2 || node.size[0] < 420) {
node.setSize([desiredWidth, desiredHeight]);
app.graph.setDirtyCanvas(true, true);
}
}
const panelObserver = new ResizeObserver(() => requestAnimationFrame(fitNodeToPanel));
panelObserver.observe(root);
palette.addEventListener("toggle", () => requestAnimationFrame(fitNodeToPanel));
const onRemoved = node.onRemoved;
node.onRemoved = function () {
clearTimeout(debounceTimer);
panelObserver.disconnect();
onRemoved?.apply(this, arguments);
};
syncFromWidgets();
requestAnimationFrame(() => {
node.setSize([Math.max(node.size[0], 420), Math.max(node.size[1], 500)]);
requestAnimationFrame(fitNodeToPanel);
runPreview();
});
}
// 辅助:带标签的字段
function labeled(text, control) {
const wrap = el("div", "ssi-cell");
wrap.append(el("label", "ssi-cell-label", text), control);
return wrap;
}
// 辅助:下拉框(支持 [value, label] 或纯字符串)
function makeSelect(options, value, onChange) {
const sel = el("select", "ssi-select");
for (const opt of options) {
const [v, label] = Array.isArray(opt) ? opt : [opt, opt];
const o = el("option", null, label);
o.value = v;
sel.append(o);
}
sel.value = value;
sel.onchange = () => onChange(sel.value);
return sel;
}
app.registerExtension({
name: "Comfy.SmartSaveImage",
init() {
ensureStyles();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
buildPanel(this);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
requestAnimationFrame(() => this.__ssi_refresh?.());
};
},
});