Files
electricty00-ComfyUI-Toggle…/js/pose_reference.js
T
GBrain af57d757eb feat: PoseReference node, sampler presets, model-type rename, auto clip_type rework, output reorder
新增 / New
- PoseReference 节点:姿势素材库(线稿 / 深度两套)+ 节点内缩略图,选中素材以 data URI 内嵌进工作流
- 采样器「⚡预设」下拉:一键填 采样器/调度器/步数/CFG/Sigma曲线;预设表只有一份在 sampler.py,
  前端走 /toggle_pass/sampler_presets 拉取;手改任一参数自动退回「手动(不套用)」
- ToggleEmptyLatent 新增 latent类型(4 档:SD 4ch/8x、SD3-Flux-Krea2 16ch/8x、Qwen2.1 64ch/16x、Flux2 128ch/16x)
- DynamicRefImageEncode 新增专门的 negative 输出(内部编空文本),替代 ConditioningZeroOut

变更 / Changed
- 模型类型:SD / SDXL → SD / SDXL / Z-Image(前后端双层别名迁移,旧工作流自动改写)
- DynamicRefImageEncode 输出口顺序 → conditioning / negative / latent,老工作流按 link 类型自动迁移连线
- clip_type 从 14 项补到 40 项;auto 重做为「精确表 → MRO 继承链 → 前缀规则」三层
- CLIP 校验加固:手工表未登记的模型改问 model_config.clip_target(),覆盖 106/108 个模型类
- 统一加载器支持 FIBO 2.0 / MOSS-Image;采样器预设新增 FIBO 2.0 turbo(4步)

修复 / Fixed
- Krea2 的 reference_latents_method: index_timestep_zero → index(前者会把参考线稿「描」进画面)
- Krea2 参考图 latent fit 到出图网格
- 修正三处 Flux2 空 latent 的通道 / 尺寸(Ref Image Encode / OneImageMultiPrompt / Ref Independent)
- 广播:槽位小点点亮、端点圆点改到节点下层、修「紫线伸向空白处断掉」(零尺寸 boundingRect 误判)
- 输出口顺序迁移只在 onConfigure 跑一次太早(graph.links 要几百毫秒才建好)⇒ 改为阶梯重试,
  否则会出现「槽位改名了、连线没搬」

其他 / Misc
- 新增诊断命令 __tpAudit():打印每条广播线两端坐标 / 归属节点 / 是否悬空
- 画布菜单新增「🔄 刷新姿势素材库」
- 移除示例 Krea2 Pose Editing (Ostris).json(硬依赖外部节点包且会换人)

版本 / Version
- 新增 pyproject.toml(ComfyUI-Manager / Registry 元数据入口),版本号 v1.0.0
- __init__.py 增加 __version__,启动打印 [Toggle-Pass] v1.0.0 loaded — 13 nodes
- README 顶部加版本标识
2026-11-07 21:29:09 +08:00

638 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ComfyUI-Toggle-Pass: 参考姿势(PoseReference)— 三级联动 + 节点内缩略图
*
* Python 端把全部选项平铺给出(大类 4 / 姿势组 26 / 姿势 288 / 视角 5),
* 不联动也能用。这里做三件事:
* 1. 选了大类就把「姿势组」收窄到该类下的组,选了组再把「姿势」收窄到该组下的,
* 切完顺手把画布重绘一次,免得下拉还显示旧选项。
* 2. 任何下拉一变(大类/组/姿势/视角/线稿深度/翻转)就去 /toggle_pass/pose_image
* 取一张缩略图,直接画在节点下半部分 —— 不用执行工作流就能看到选的是哪张。
* 3. 图进来了按比例把节点撑高,保证图不会被挤成一条缝。
*
* 图片走自绘(onDrawForeground)而不是 node.imgs:后者需要 HTMLImage 对象、
* 还依赖前端内部绘制逻辑,跨版本不稳。自绘只依赖 LiteGraph 的公开钩子。
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "PoseReference";
const TREE_API = "/toggle_pass/pose_tree";
const IMAGE_API = "/toggle_pass/pose_image";
const STAMP_API = "/toggle_pass/pose_stamp";
const PAD = 10;
const DEBOUNCE_MS = 120;
// 素材指纹轮询间隔:往本机素材目录里丢新姿势,不用刷新浏览器,最多等这么久自己就出现
const POLL_MS = 15000;
// 会改变「该显示哪张图」的六个下拉
const WATCHED = ["category", "group", "pose", "view", "source", "flip"];
// 1.57 前端给「有 imgs 的节点」自动挂的官方画布预览 widget
const PREVIEW_WIDGET = "$$canvas-image-preview";
let treePromise = null;
function fetchTree(force) {
if (force) treePromise = null; // 强制重扫:走 ?refresh=1 让后端丢掉缓存
if (!treePromise) {
treePromise = api
.fetchApi(TREE_API + (force ? "?refresh=1" : ""))
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
// 失败/空结果不能留在缓存里:否则后面所有节点都拿这个 null,
// 联动永远建不起来,只有刷新页面(清掉模块状态)才恢复。
if (!d || !d.tree) treePromise = null;
return d;
})
.catch(() => {
treePromise = null;
return null;
});
}
return treePromise;
}
function widget(node, name) {
return (node.widgets || []).find((w) => w.name === name);
}
function val(node, name, fallback) {
const w = widget(node, name);
return w && w.value !== undefined && w.value !== null ? w.value : fallback;
}
/**
* 换一套选项。keep=true 时保留当前值(加载工作流用,避免把用户存的姿势改掉)。
* 返回是否真的变了,用于决定要不要重绘。
*/
function setValues(w, values, keep) {
if (!w || !Array.isArray(values) || values.length === 0) return false;
w.options = w.options || {};
const old = w.options.values;
const same =
Array.isArray(old) &&
old.length === values.length &&
old.every((v, i) => v === values[i]);
if (same) return false;
w.options.values = values.slice();
if (!keep && !values.includes(w.value)) w.value = values[0];
return true;
}
function buildSync(node, tree) {
const wCat = widget(node, "category");
const wGroup = widget(node, "group");
const wPose = widget(node, "pose");
if (!wCat || !wGroup || !wPose) return null;
const groupsOf = (cat) => Object.keys((tree && tree[cat]) || {});
const posesOf = (cat, grp) => Object.keys(((tree && tree[cat]) || {})[grp] || {});
return function sync(keep) {
let dirty = false;
dirty = setValues(wGroup, groupsOf(wCat.value), keep) || dirty;
dirty = setValues(wPose, posesOf(wCat.value, wGroup.value), keep) || dirty;
if (dirty) {
node.setDirtyCanvas(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
return dirty;
};
}
// ---------------- 工作流内嵌 ----------------
// 选中的素材原文件(webp 约 5KB)以 data URI 存进 node.properties.tp_pose,
// 跟着工作流一起走:别人下载后既能看到图,素材库不在时后端也能拿它出图。
// 后端从 extra_pnginfo 里按 id 找 properties,所以 key 必须和 Python 的 embed_key 拼法一致。
function embedKey(node) {
return [
val(node, "category", ""),
val(node, "group", ""),
val(node, "pose", ""),
val(node, "view", ""),
val(node, "source", "线稿"),
]
.map((x) => String(x))
.join("|");
}
function flipOn(node) {
return val(node, "flip", "关") === "开";
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.onerror = () => reject(fr.error);
fr.readAsDataURL(blob);
});
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
}
function storeEmbed(node, dataUrl) {
const key = embedKey(node);
node.properties = node.properties || {};
const cur = node.properties.tp_pose;
if (cur && cur.key === key && cur.img === dataUrl) return;
node.properties.tp_pose = { key, img: dataUrl };
}
/** 兜底显示:素材库取不到时,先用工作流里带的那张。 */
function showEmbedded(node) {
const emb = node.properties && node.properties.tp_pose;
if (!emb || !emb.img) return false;
loadImage(emb.img)
.then((img) => {
if (node._tpPoseFromNet) return; // 真图已经到了,不自找麻烦
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = emb.key === embedKey(node) ? null : "素材库缺失,显示内嵌旧图";
fitNode(node, img);
node.setDirtyCanvas(true, true);
})
.catch(() => {});
return true;
}
// ---------------- 缩略图 ----------------
function previewUrl(node) {
// raw=1:直接返回素材原文件(webp 约 5KB),不缩放不翻转 —— 既当缩略图又当内嵌源
const q = new URLSearchParams({
category: String(val(node, "category", "")),
group: String(val(node, "group", "")),
pose: String(val(node, "pose", "")),
view: String(val(node, "view", "")),
source: String(val(node, "source", "线稿")),
raw: "1",
});
return `${IMAGE_API}?${q.toString()}`;
}
/** 所有 widget 画完后的底部 y(节点 body 坐标系,原点在标题栏下沿)。 */
function widgetsBottom(node) {
let bottom = 0;
const ws = node.widgets || [];
for (let i = 0; i < ws.length; i++) {
const w = ws[i];
let y = typeof w.last_y === "number" ? w.last_y : 4 + i * 28;
let h = 20;
if (typeof w.height === "number" && w.height > 0) {
h = w.height;
} else if (typeof w.computeSize === "function") {
try {
const s = w.computeSize(node.size[0]);
if (Array.isArray(s) && s.length > 1) h = s[1];
} catch (e) {
/* 忽略:拿不到就用默认行高 */
}
}
bottom = Math.max(bottom, y + h);
}
return bottom;
}
function fitNode(node, img) {
const top = widgetsBottom(node);
const availW = Math.max(80, node.size[0] - PAD * 2);
// 按图比例算一个「看得清又不占满屏」的高度,限制在 190~300
const wanted = Math.round(Math.min(300, Math.max(190, (availW * img.height) / img.width * 0.72)));
const need = Math.ceil(top + 6 + wanted + PAD);
if (node.size[1] < need) {
node.size[1] = need; // size 是类数组对象,整块赋值会被吞,逐下标写
node.setDirtyCanvas(true, true);
}
}
function refreshPreview(node) {
const url = previewUrl(node);
// flip 不参与 URL(拿的是未翻转的原文件),所以单独同步,不能跟着 URL 早退
const f = flipOn(node);
if (node._tpPoseFlip !== f) {
node._tpPoseFlip = f;
node.setDirtyCanvas(true, true);
}
// 已经有这张图就不重复取;但图对象丢了(出错被清、加载中新节点)还是要重取
if (node._tpPoseUrl === url && node._tpPoseImg) return;
node._tpPoseUrl = url;
const keyAtRequest = embedKey(node);
(async () => {
try {
const res = await api.fetchApi(url);
if (!res.ok) throw new Error("HTTP " + res.status);
const blob = await res.blob();
const dataUrl = await blobToDataUrl(blob);
if (node._tpPoseUrl !== url) return; // 期间又切了别的,丢弃
const img = await loadImage(dataUrl);
if (node._tpPoseUrl !== url) return;
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = null;
node._tpPoseFromNet = true;
// 只有参数没在旁边被改过才内嵌,避免把 A 的图写进 B 的设置
if (embedKey(node) === keyAtRequest) storeEmbed(node, dataUrl);
fitNode(node, img);
node.setDirtyCanvas(true, true);
// litegraph 0.4 里单个节点的 dirty 标记不一定能把画布叫醒,
// 图换了却停在旧画面,看着就是「点了没反应」。图是重点,再踹一脚整图。
app.graph?.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
} catch (e) {
if (node._tpPoseUrl !== url) return;
// 失败时解锁,否则这个参数组合会被永久判成「已请求过」,
// 后端恢复后切回来也不重试。
node._tpPoseUrl = null;
node._tpPoseFromNet = false;
const emb = node.properties && node.properties.tp_pose;
const curKey = embedKey(node);
// 只有内嵌的那张「正好就是当前选的」才能顶上去。
// 以前不管三七二十一都拿内嵌图兜底,结果切到别的姿势时画面还是老图,
// 用户看到的是「点了没反应」,还把真正的取图失败藏得干干净净。
if (emb && emb.img && emb.key === curKey) {
try {
const img = await loadImage(emb.img);
node._tpPoseImg = img;
node._tpPoseFlip = flipOn(node);
node._tpPoseErr = null;
fitNode(node, img);
node.setDirtyCanvas(true, true);
return;
} catch (e2) {
/* 解码也失败,走下面统一报错 */
}
}
node._tpPoseImg = null;
node._tpPoseErr = emb && emb.img ? "这张没取到(素材库缺失)" : "素材库缺失";
node.setDirtyCanvas(true, true);
app.graph?.setDirtyCanvas?.(true, true);
console.warn("[PoseReference] 取图失败:", url, e && e.message);
}
})();
}
function schedulePreview(node) {
if (node._tpPoseTimer) clearTimeout(node._tpPoseTimer);
node._tpPoseTimer = setTimeout(() => {
node._tpPoseTimer = null;
try {
refreshPreview(node);
} catch (e) {
console.warn("[PoseReference] 预览刷新失败", e);
}
}, DEBOUNCE_MS);
}
/**
* 参数自检:当前六个下拉的取值,和「上一次送去取图的那套参数」不一致就重取。
*
* 这个检查放在绘制里每帧跑,是刻意不依赖 widget.callback 的。实测(1.57 /
* litegraph 0.4)下拉选完只有 callback 一条通知路径,而 callback 可能被第三方
* 扩展重写、被旧版本闭包吃掉,或者干脆不触发 —— 一旦漏掉,节点就会永远停在
* 上一张图上,看起来像「切换不了,得重启」。绘制路径只要画布在动就一定跑,
* 拿它当兜底,任何漏通知的情况都能自己补上。
*/
function watchParams(node) {
const key = embedKey(node);
if (node._tpPoseKey === key) return;
node._tpPoseKey = key;
schedulePreview(node);
}
function drawPreview(node, ctx) {
watchParams(node); // 先自检:参数变了但没人通知,这里补一次
const top = widgetsBottom(node);
const x0 = PAD;
const y0 = top + 6;
const w0 = node.size[0] - PAD * 2;
const h0 = node.size[1] - y0 - PAD;
if (w0 < 40 || h0 < 30) return;
ctx.save();
try {
// 底衬:避免透明网格/深色背景上白线稿看不清边界
ctx.fillStyle = "#1b1b1b";
ctx.fillRect(x0, y0, w0, h0);
const img = node._tpPoseImg;
if (!img) {
ctx.fillStyle = "#7a7a7a";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(node._tpPoseErr || "加载中…", x0 + w0 / 2, y0 + h0 / 2);
return;
}
const scale = Math.min(w0 / img.width, h0 / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
const dx = x0 + (w0 - dw) / 2;
const dy = y0 + (h0 - dh) / 2;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
if (node._tpPoseFlip) {
// 缩略图取的是素材原文件(未翻转),翻转在这里镜像
ctx.save();
try {
ctx.translate(dx + dw, dy);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0, dw, dh);
} finally {
ctx.restore();
}
} else {
ctx.drawImage(img, dx, dy, dw, dh);
}
ctx.strokeStyle = "rgba(255,255,255,0.14)";
ctx.lineWidth = 1;
ctx.strokeRect(dx + 0.5, dy + 0.5, dw - 1, dh - 1);
} finally {
ctx.restore();
}
}
function hookCallback(w, node) {
if (!w || w._tpPoseHooked) return;
w._tpPoseHooked = true;
const orig = w.callback;
w.callback = function (...args) {
const ret = orig ? orig.apply(this, args) : undefined;
// 联动和取图分开 try:联动抛异常不能把取图一起带走,
// 否则下拉值变了、图却停在上一张。
try {
// 必须每次现取 node._tpPoseSync:素材库热更新会换上新树的闭包,
// 如果在这里闭包捕获旧 sync,下拉永远按旧树联动 —— 新增的类别/组
// 切不过去,只有重启(重新挂钩)才恢复。
if (typeof node._tpPoseSync === "function") node._tpPoseSync(false);
} catch (e) {
console.warn("[PoseReference] 联动失败", e);
}
try {
schedulePreview(node);
} catch (e) {
console.warn("[PoseReference] 预览排队失败", e);
}
return ret;
};
}
function attach(node, tree) {
const sync = buildSync(node, tree);
if (sync) node._tpPoseSync = sync;
// 六个下拉都要挂:前三个带联动,后三个只触发换图
WATCHED.forEach((name) => {
hookCallback(widget(node, name), node);
});
if (sync) sync(true);
showEmbedded(node);
schedulePreview(node);
}
// ---------------- 素材库热更新 ----------------
// 素材分两层:插件自带 pose_assets/(随插件分发、只放非敏感大类)+ 本机外挂
// (D:\pose 或 TP_POSE_ROOT,敏感/自建素材放这里)。往本机目录里丢新姿势后,
// 后端指纹会变,这里自动把下拉重建一遍 —— 不用刷新浏览器。
function poseNodes() {
return (app.graph?._nodes || []).filter((n) => n.type === NODE_NAME);
}
async function refreshAll(reason) {
const nodes = poseNodes();
if (!nodes.length) return 0;
const data = await fetchTree(true);
if (!data || !data.tree) return 0;
for (const n of nodes) {
try {
attach(n, data.tree); // 换上新的素材树
// 新树里可能没有当前选中的组/姿势了(素材被删或改名),这里校正一次,
// 否则下拉会显示一个不存在的值、点别的又对不上。
if (typeof n._tpPoseSync === "function") n._tpPoseSync(false);
// 素材可能被同名替换了内容,URL 却一模一样,不清掉就永远不会重取。
n._tpPoseUrl = null;
n._tpPoseFromNet = false;
schedulePreview(n);
} catch (e) {
console.warn("[PoseReference] 重建下拉失败", e);
}
}
console.log(`[PoseReference] 素材库已刷新(${reason}):${nodes.length} 个节点`);
return nodes.length;
}
let lastStamp = null;
let pollStarted = false;
function startStampPoll() {
if (pollStarted) return;
pollStarted = true;
const tick = async () => {
if (!poseNodes().length) return; // 画布上没有本节点就不打扰后端
try {
const r = await api.fetchApi(STAMP_API);
if (!r.ok) return;
const d = await r.json();
if (!d || !d.stamp) return;
if (lastStamp === null) {
lastStamp = d.stamp; // 首次只记基准,不触发重建
return;
}
if (d.stamp === lastStamp) return;
lastStamp = d.stamp;
await refreshAll("指纹变化");
} catch (e) {
/* 网络抖动忽略,下一轮再说 */
}
};
// 立刻记一次基准(和画布上有没有节点无关),不能等第一个 interval ——
// 否则「打开页面后 15 秒内放进去的素材」会被当成基准吞掉,指纹再也不变,永远不刷新。
(async () => {
try {
const r = await api.fetchApi(STAMP_API);
if (!r.ok) return;
const d = await r.json();
if (d && d.stamp) lastStamp = d.stamp;
} catch (e) {
/* 拿不到就退化成 tick 里首次记录基准 */
}
})();
setInterval(tick, POLL_MS);
}
/**
* 摘掉 ComfyUI 1.57 自动挂的官方画布预览 widget。
*
* 前端 `useNodeCanvasImagePreview` 的规则很简单:只要节点有 `node.imgs`,
* 就 `addCustomWidget("$$canvas-image-preview")`。这个 widget 会占掉节点底部
* 一行,并把执行输出的那张 PNG 画在节点下部 —— 正好压在我们的预览区上面。
*
* 于是「生图一开始」就等于给节点盖了一张定死的图:下拉照点、我们的图在背面
* 其实也换了,但画面上永远停着那张执行结果,看起来就是再也切不动。
* 本节点的图完全由 onDrawForeground 自己画,不需要官方这一份,直接摘掉;
* 同时清空 `node.imgs`,否则它下次判断又会把 widget 挂回来。
*/
function stripOfficialPreview(node) {
if (!node || !node.widgets) return false;
let hit = false;
for (let i = node.widgets.length - 1; i >= 0; i--) {
const w = node.widgets[i];
if (String((w && w.name) || "") !== PREVIEW_WIDGET) continue;
if (typeof w.onRemove === "function") {
try {
w.onRemove();
} catch (e) {
/* 清理回调失败也要把 widget 摘掉 */
}
}
node.widgets.splice(i, 1);
hit = true;
}
if (hit) {
node.imgs = null;
node.setDirtyCanvas(true, true);
}
return hit;
}
/**
* 参数巡检:300ms 扫一遍画布上的本节点,参数变了就补一次取图。
*
* 绘制里已经有一道同样的检查,但节点被折叠、画布静止不重绘时绘制不一定会跑。
* 这里只读六个 widget 的 value 拼个字符串,画布上没有本节点时直接返回,
* 开销可以忽略,换来的是「不管通知从哪条路径丢了,图都能自己跟上」。
*
* 顺便每轮摘一次官方预览 widget(见 stripOfficialPreview)。
*/
function startParamWatch() {
setInterval(() => {
for (const n of poseNodes()) {
try {
stripOfficialPreview(n);
watchParams(n);
} catch (e) {
/* 单个节点异常不能拖垮整轮巡检 */
}
}
}, 300);
}
// 画布右键菜单「刷新姿势素材库」通过这个全局调用(canvas_menu.js 里引用)
window.__tpRefreshPoses = () => refreshAll("手动");
/**
* 排查用:在浏览器控制台敲 __tpPoseDiag(),打印画布上每个本节点的
* 选项值 / 已加载图 / 上次请求的 URL / 错误信息。出问题时一眼看出卡在哪一步。
*/
window.__tpPoseDiag = () => {
const rows = poseNodes().map((n) => ({
选中: WATCHED.map((k) => val(n, k, "")).join(" / "),
上次请求: decodeURIComponent((n._tpPoseUrl || "").split("?")[1] || "(无)"),
已加载图: n._tpPoseImg ? `${n._tpPoseImg.naturalWidth}x${n._tpPoseImg.naturalHeight}` : "(无)",
来自网络: !!n._tpPoseFromNet,
错误: n._tpPoseErr || "",
}));
console.table(rows);
return rows;
};
app.registerExtension({
name: "TogglePass.PoseReference",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
// ---- 节点内缩略图(自绘,链式调用宿主钩子)----
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
const ret = origDraw ? origDraw.apply(this, arguments) : undefined;
if (ctx) {
try {
drawPreview(this, ctx);
} catch (e) {
console.warn("[PoseReference] 绘制失败", e);
}
}
return ret;
};
// ---- 创建节点:拉目录树、挂联动、取首张预览 ----
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const ret = origCreated ? origCreated.apply(this, arguments) : undefined;
const node = this;
// size 是类数组对象,整块赋值会被忽略,必须逐下标写
if (node.size && node.size[0] < 320) {
node.size[0] = 340;
}
fetchTree().then((data) => {
if (!data || !data.tree) {
schedulePreview(node); // 树拉不到也先把图取上
return;
}
attach(node, data.tree);
});
return ret;
};
// ---- 加载工作流:选项可能和存的不一致,刷新一次但保留原值 ----
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const ret = origConfigure ? origConfigure.apply(this, arguments) : undefined;
const node = this;
// 换了工作流:上一张的 URL / 来源标记作废,先把内嵌图顶上去再拉本机的
node._tpPoseUrl = null;
node._tpPoseFromNet = false;
node._tpPoseKey = null; // 让绘制自检也认为「参数刚变过」
showEmbedded(node);
fetchTree().then((data) => {
if (!data || !data.tree) {
schedulePreview(node);
return;
}
attach(node, data.tree);
});
return ret;
};
// ---- 执行完:立刻摘掉官方预览 widget,别让它盖住预览区 ----
// 这是「生图一开始就再也切不动」的元凶:节点返回 ui.images 后前端会给它
// 挂一个固定显示执行结果的预览 widget,正好压在预览区上。
const origExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function () {
const ret = origExecuted ? origExecuted.apply(this, arguments) : undefined;
try {
this.imgs = null; // 官方预览靠它判断要不要挂
stripOfficialPreview(this);
schedulePreview(this); // 执行结束是「该重画」的信号,顺手对齐一次
} catch (e) {
console.warn("[PoseReference] 清理官方预览失败", e);
}
return ret;
};
},
setup() {
// 前端就绪后开始轮询素材指纹(每 POLL_MS 一次,画布没本节点时直接跳过)
startStampPoll();
// 参数巡检:回调漏通知时靠它把图补上
startParamWatch();
},
});