Files

359 lines
15 KiB
JavaScript
Raw Permalink 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.
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
class Layer {
constructor(img, index) {
this.img = img;
this.index = index;
this.x = 0;
this.y = 0;
this.scaleX = 1;
this.scaleY = 1;
this.rotation = 0;
this.locked = index === 0;
}
}
/* backend notify */
// 修复:使用官方 api 监听器和更稳健的 getNodeById 方法
api.addEventListener("image_layer_editor:images_ready", e => {
const node = app.graph.getNodeById(e.detail.node_id);
if (node && node.loadImages) {
node.loadImages();
}
});
app.registerExtension({
name: "comfy.image_layer_editor.final.resize_safe",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "LP-ImageLayerEditor") return;
nodeType.prototype.onNodeCreated = function () {
this.layers = [];
this.activeLayer = -1;
this.worldScale = 1;
this.logicalW = 0;
this.logicalH = 0;
this.asp = 1;
this.dpr = window.devicePixelRatio || 1;
this.MARGIN = 24;
this.originX = 0;
this.originY = 0;
this.outW = 1;
this.outH = 1;
this.drawX = 0;
this.drawY = 0;
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
const widget = this.addDOMWidget("editor", "canvas", this.canvas);
widget.computeSize = () => {
const w = Math.max(200, this.size[0] - 20);
// 固定正方形视口,整个输出画布等比例缩放容纳其中,避免被节点面板高度裁切
return [w, Math.max(200, Math.round(w * 0.9))];
};
this.addWidget("button", "Reset", null, () => this.loadImages());
this.addWidget("button", "Continue", null, () => this.send());
this.bindEvents();
};
/* resize-safe, HiDPI-aware canvas;固定方形视口,整个输出画布缩放居中,绝不裁剪 */
nodeType.prototype.updateCanvasSize = function () {
const s = Math.max(200, this.size[0] - 20);
this.logicalW = s;
this.logicalH = Math.max(200, Math.round(s * 0.9));
this.canvas.style.width = this.logicalW + "px";
this.canvas.style.height = this.logicalH + "px";
// 只有物理尺寸变化时才赋值 canvas.width/height——赋值会清空位图
const pw = Math.max(1, Math.floor(this.logicalW * this.dpr));
const ph = Math.max(1, Math.floor(this.logicalH * this.dpr));
if (this.canvas.width !== pw) this.canvas.width = pw;
if (this.canvas.height !== ph) this.canvas.height = ph;
this.worldScale = Math.min(this.logicalW / this.outW, this.logicalH / this.outH);
this.drawX = (this.logicalW - this.outW * this.worldScale) / 2;
this.drawY = (this.logicalH - this.outH * this.worldScale) / 2;
};
/* 节点尺寸变化后重绘,避免画布内容停留在旧布局。
注意:新版前端在构造阶段(onNodeCreated 之前)就会触发 setSize→onResize,
此时 this.layers 尚未初始化,必须先判空,否则构造函数抛异常导致节点无法添加到画布 */
nodeType.prototype.onResize = function () {
if (!this.layers) return;
this.draw();
};
/* 固定画布:尺寸=第0层图像(背景/透明画布)的尺寸,画布中心为世界原点。
图层可自由移动/缩放/旋转,超出画布的部分输出时裁切(与后端一致) */
nodeType.prototype.computeBounds = function () {
const base = this.layers[0];
this.outW = base ? base.img.width : 1;
this.outH = base ? base.img.height : 1;
this.originX = -this.outW / 2;
this.originY = -this.outH / 2;
};
nodeType.prototype.bindEvents = function () {
let dragging = false;
let rotating = false;
let sx = 0, sy = 0, ox = 0, oy = 0, or = 0;
this.canvas.onmousedown = e => {
const hit = this.pickLayer(e.offsetX, e.offsetY);
if (hit < 1) {
// 未命中可编辑图层:取消选中并重绘(绝不能直接 return——
// 任何触碰过画布尺寸的路径之后都必须重绘,否则画布停留清空状态)
this.activeLayer = -1;
this.draw();
return;
}
this.activeLayer = hit;
if (e.button === 0) { // 左键拖动
dragging = true;
sx = e.offsetX;
sy = e.offsetY;
ox = this.layers[hit].x;
oy = this.layers[hit].y;
} else if (e.button === 2) { // 右键旋转
rotating = true;
sx = e.offsetX;
sy = e.offsetY;
or = this.layers[hit].rotation;
}
this.draw();
};
this.canvas.onmousemove = e => {
if (dragging && this.activeLayer >= 1) {
const l = this.layers[this.activeLayer];
l.x = ox + (e.offsetX - sx) / this.worldScale;
l.y = oy + (e.offsetY - sy) / this.worldScale;
this.draw();
} else if (rotating && this.activeLayer >= 1) {
const l = this.layers[this.activeLayer];
const cx = this.drawX + (this.outW * this.worldScale) / 2;
const cy = this.drawY + (this.outH * this.worldScale) / 2;
const dx = sx - cx;
const dy = sy - cy;
const newDx = e.offsetX - cx;
const newDy = e.offsetY - cy;
const angle = Math.atan2(newDy, newDx) - Math.atan2(dy, dx);
l.rotation = or + angle * 180 / Math.PI;
this.draw();
} else {
// 悬停反馈:可编辑图层上显示移动光标,让"能点什么"一目了然
const hit = this.pickLayer(e.offsetX, e.offsetY);
this.canvas.style.cursor = hit >= 1 ? "move" : "default";
}
};
window.addEventListener("mouseup", () => {
dragging = false;
rotating = false;
});
this.canvas.onwheel = e => {
if (this.activeLayer < 1) return;
e.preventDefault();
const l = this.layers[this.activeLayer];
const s = e.deltaY < 0 ? 1.1 : 0.9;
// 交互缩放硬范围:5%–1000%,防止无限放大
const MIN_S = 0.05, MAX_S = 10;
l.scaleX = Math.min(MAX_S, Math.max(MIN_S, l.scaleX * s));
l.scaleY = Math.min(MAX_S, Math.max(MIN_S, l.scaleY * s));
this.draw();
};
// 禁用右键菜单
this.canvas.oncontextmenu = e => e.preventDefault();
};
nodeType.prototype.pickLayer = function (mx, my) {
// 纯只读命中检测:只用 draw() 缓存的布局值。
// 绝不在此重设 canvas 尺寸——给 canvas.width 赋值会清空画布位图,
// 若随后不重绘就会出现"点击后图像消失"的假死现象
if (!this.layers || !this.layers.length) return -1;
const ws = this.worldScale;
if (!(ws > 0)) return -1;
const ox = this.drawX, oy = this.drawY;
for (let i = this.layers.length - 1; i >= 1; i--) {
const l = this.layers[i];
const w = l.img.width * l.scaleX * ws;
const h = l.img.height * l.scaleY * ws;
const x = ox + (l.x - this.originX) * ws - w / 2;
const y = oy + (l.y - this.originY) * ws - h / 2;
if (mx >= x && mx <= x + w && my >= y && my <= y + h)
return i;
}
return -1;
};
nodeType.prototype.draw = function () {
if (!this.layers || !this.layers.length) return;
this.computeBounds();
this.updateCanvasSize();
const w = this.logicalW;
const h = this.logicalH;
const ctx = this.ctx;
// HiDPI:统一在 CSS 逻辑像素坐标下绘制,物理分辨率由 dpr 决定,避免高DPI屏幕发虚
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const ox = this.drawX, oy = this.drawY;
const ow = this.outW * this.worldScale, oh = this.outH * this.worldScale;
// 固定画布:内容裁切到画布矩形内显示——所见即所得,超出部分输出时被裁掉
ctx.save();
ctx.beginPath();
ctx.rect(ox, oy, ow, oh);
ctx.clip();
const cell = Math.max(8, Math.floor(Math.min(ow, oh) / 20));
for (let yy = oy; yy < oy + oh; yy += cell) {
for (let xx = ox; xx < ox + ow; xx += cell) {
ctx.fillStyle = (((xx - ox) / cell + (yy - oy) / cell) % 2 === 0) ? "#e0e0e0" : "#ffffff";
ctx.fillRect(xx, yy, cell, cell);
}
}
for (const l of this.layers) {
ctx.save();
ctx.translate(
ox + (l.x - this.originX) * this.worldScale,
oy + (l.y - this.originY) * this.worldScale
);
ctx.rotate(l.rotation * Math.PI / 180);
ctx.scale(l.scaleX * this.worldScale, l.scaleY * this.worldScale);
ctx.drawImage(l.img, -l.img.width / 2, -l.img.height / 2);
ctx.restore();
}
ctx.restore(); // 结束裁切
// 画布边框
ctx.strokeStyle = "rgba(0,0,0,0.35)";
ctx.lineWidth = 1;
ctx.strokeRect(ox + 0.5, oy + 0.5, ow - 1, oh - 1);
// 选中图层描边不裁切:完整显示其范围,画布边框外的部分即输出时会被裁掉的内容
if (this.activeLayer >= 1 && this.activeLayer < this.layers.length) {
const l = this.layers[this.activeLayer];
ctx.save();
ctx.translate(
ox + (l.x - this.originX) * this.worldScale,
oy + (l.y - this.originY) * this.worldScale
);
ctx.rotate(l.rotation * Math.PI / 180);
ctx.scale(l.scaleX * this.worldScale, l.scaleY * this.worldScale);
const sc = Math.max(1e-6, Math.max(l.scaleX, l.scaleY) * this.worldScale);
ctx.strokeStyle = "#4caf50";
ctx.lineWidth = 2 / sc;
ctx.strokeRect(
-l.img.width / 2,
-l.img.height / 2,
l.img.width,
l.img.height
);
// 旋转控制点
ctx.fillStyle = "#4caf50";
ctx.beginPath();
ctx.arc(l.img.width / 2, 0, 5 / sc, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// 画布尺寸读数(含图层数,便于确认前端实际加载了多少层)
ctx.font = "12px sans-serif";
ctx.textBaseline = "top";
const label = `画布 ${this.outW}x${this.outH}px · 图层 ${this.layers.length} · 超出裁切`;
const tw = ctx.measureText(label).width;
ctx.fillStyle = "rgba(255,255,255,0.82)";
ctx.fillRect(6, 6, tw + 10, 18);
ctx.fillStyle = "rgba(0,0,0,0.65)";
ctx.fillText(label, 11, 9);
// 只有背景层时给出操作指引:此时没有任何可编辑对象
if (this.layers.length <= 1) {
const hint = "无可编辑图层:再连接一路图片,或开启「透明画布」";
const hw = ctx.measureText(hint).width;
const hx = Math.max(4, (this.logicalW - hw) / 2);
const hy = this.logicalH - 26;
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.fillRect(hx - 6, hy - 4, hw + 12, 20);
ctx.fillStyle = "#d32f2f";
ctx.fillText(hint, hx, hy);
}
};
nodeType.prototype.loadImages = async function () {
this.layers = [];
const load = name => new Promise((res, rej) => {
const img = new Image();
// 添加随机参数以避免缓存
img.src = api.apiURL(`/view?filename=${name}&type=temp&t=${Date.now()}`);
img.onload = () => res(img);
img.onerror = rej;
});
// 先尝试加载第0张图片(背景/画布),它的尺寸即固定画布尺寸
try {
const img = await load(`input_layer_${this.id}_0.png`);
this.layers.push(new Layer(img, 0));
} catch {
// 如果背景图加载失败,直接返回
return;
}
// 尝试加载其他图层,但最多尝试5次
for (let i = 1; i < 20; i++) {
try {
const img = await load(`input_layer_${this.id}_${i}.png`);
this.layers.push(new Layer(img, i));
} catch {
// 如果加载失败,停止尝试
break;
}
}
this.activeLayer = this.layers.length > 1 ? 1 : -1;
this.draw();
};
nodeType.prototype.send = async function () {
await api.fetchApi(`/image_layer_editor/set_transforms/${this.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
transforms: JSON.stringify(
this.layers.map(l => ({
x: l.x,
y: l.y,
scaleX: l.scaleX,
scaleY: l.scaleY,
rotation: l.rotation
}))
)
})
});
// 更新随机数(基于当前时间戳生成)
const seedWidget = this.widgets.find(w => w.name === "random_seed");
if (seedWidget) {
// 获取当前时间的毫秒级时间戳(13位整数,如1740000000000)
const timestamp = new Date().getTime();
// 直接将时间戳赋值给种子(也可对时间戳做简单运算增加随机性)
seedWidget.value = timestamp;
this.onChange?.();
}
};
}
});