修改架构,增加可视化
This commit is contained in:
@@ -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?.());
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user