Files

235 lines
7.3 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_TYPE = "QRCodePreview";
const PANEL_HEIGHT = 280;
const PANEL_MARGIN = 8;
let animating = false;
function findPreviewNodes() {
if (!app?.graph?._nodes) return [];
return app.graph._nodes.filter((n) => n.type === NODE_TYPE);
}
function startAnimating() {
if (animating) return;
animating = true;
const tick = () => {
let active = false;
for (const n of findPreviewNodes()) {
if (n.__qrState === "spinning") {
active = true;
n.setDirtyCanvas(true, false);
}
}
if (active) {
requestAnimationFrame(tick);
} else {
animating = false;
}
};
requestAnimationFrame(tick);
}
function imageUrl({ filename, subfolder, type }) {
const params = new URLSearchParams({
filename: filename ?? "",
subfolder: subfolder ?? "",
type: type ?? "temp",
rand: String(Date.now()),
});
return `/view?${params.toString()}`;
}
function loadImage(node, info) {
const img = new Image();
img.onload = () => {
node.__qrImage = img;
node.setDirtyCanvas(true, true);
};
img.onerror = () => {
node.__qrImage = null;
node.__qrState = "error";
node.setDirtyCanvas(true, true);
};
img.src = imageUrl(info);
}
function drawSpinner(ctx, x, y, w, h) {
const cx = x + w / 2;
const cy = y + h / 2;
const r = Math.max(12, Math.min(w, h) * 0.16);
const t = (Date.now() / 1000) * Math.PI * 1.6;
const segs = 12;
for (let i = 0; i < segs; i++) {
const angle = t + (i / segs) * Math.PI * 2;
const alpha = (i + 1) / segs;
ctx.strokeStyle = `rgba(220,220,220,${alpha})`;
ctx.lineWidth = Math.max(2, r * 0.18);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * r * 0.55, cy + Math.sin(angle) * r * 0.55);
ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
ctx.stroke();
}
ctx.fillStyle = "rgba(220,220,220,0.85)";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("Processing…", cx, cy + r + 10);
}
function drawError(ctx, x, y, w, h) {
const cx = x + w / 2;
const cy = y + h / 2;
const r = Math.max(14, Math.min(w, h) * 0.16);
ctx.strokeStyle = "#ff5757";
ctx.fillStyle = "#ff5757";
ctx.lineWidth = Math.max(3, r * 0.18);
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx, cy - r * 0.45);
ctx.lineTo(cx, cy + r * 0.05);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy + r * 0.32, Math.max(2, r * 0.1), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,180,180,0.95)";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("Upload failed", cx, cy + r + 10);
}
function drawImageFitted(ctx, img, x, y, w, h) {
const ar = img.width / img.height;
const boxAr = w / h;
let dw, dh;
if (ar > boxAr) {
dw = w;
dh = w / ar;
} else {
dh = h;
dw = h * ar;
}
const dx = x + (w - dw) / 2;
const dy = y + (h - dh) / 2;
ctx.drawImage(img, dx, dy, dw, dh);
}
app.registerExtension({
name: "comfyui-penplotter.QRCodePreview",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_TYPE) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
this.__qrState = "idle";
this.__qrImage = null;
const widget = {
type: "qr_panel",
name: "qr_panel",
draw(ctx, node, widgetWidth, posY) {
const x = PANEL_MARGIN;
const y = posY;
const w = Math.max(40, widgetWidth - PANEL_MARGIN * 2);
const h = PANEL_HEIGHT;
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#2a2a2a";
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
if (node.__qrState === "spinning") {
drawSpinner(ctx, x, y, w, h);
} else if (node.__qrState === "error") {
drawError(ctx, x, y, w, h);
} else if (node.__qrImage) {
drawImageFitted(ctx, node.__qrImage, x + 4, y + 4, w - 8, h - 8);
}
},
computeSize(width) {
return [width, PANEL_HEIGHT + PANEL_MARGIN];
},
serializeValue() {
return null;
},
};
this.addCustomWidget(widget);
this.setSize(this.computeSize());
return r;
};
},
setup() {
api.addEventListener("execution_start", () => {
for (const n of findPreviewNodes()) {
n.__qrState = "spinning";
n.__qrImage = null;
n.setDirtyCanvas(true, true);
}
startAnimating();
});
// Fired by UploadSubmission as soon as the API call succeeds, before
// any long-running downstream node (e.g. PlotSVG) starts. This is the
// primary path that surfaces the QR quickly.
api.addEventListener("penplotter.qr_ready", ({ detail }) => {
const filename = detail?.filename;
if (!filename) return;
const info = {
filename,
subfolder: detail?.subfolder ?? "",
type: detail?.type ?? "temp",
};
for (const n of findPreviewNodes()) {
n.__qrState = "ready";
loadImage(n, info);
}
});
// Fallback: fires when QRCodePreview itself executes. May arrive
// after a long downstream node finishes, so it's only useful if the
// early push above didn't run (e.g. QRCodePreview without
// UploadSubmission upstream).
api.addEventListener("executed", ({ detail }) => {
const id = detail?.node;
if (id == null) return;
const node = app.graph.getNodeById(id);
if (!node || node.type !== NODE_TYPE) return;
if (node.__qrState === "ready" && node.__qrImage) return;
const images = detail?.output?.qr_images;
if (Array.isArray(images) && images.length > 0) {
node.__qrState = "ready";
loadImage(node, images[0]);
} else {
node.__qrState = "error";
node.setDirtyCanvas(true, true);
}
});
const fail = () => {
for (const n of findPreviewNodes()) {
if (n.__qrState === "spinning") {
n.__qrState = "error";
n.setDirtyCanvas(true, true);
}
}
};
api.addEventListener("execution_error", fail);
api.addEventListener("execution_interrupted", fail);
},
});