Files
2026-06-16 12:20:16 +03:00

2059 lines
94 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 { chainCallback, addMiddleClickPan, addWheelPassthrough, captureVideoFrame, watchImageInputs, rectHitTest, cursorForBboxMode } from './utility.js';
const { app } = window.comfyAPI.app;
const BBOX_PALETTE = ["#46b4e6", "#e68246", "#82e646", "#e646b4", "#e6e646", "#46e6c8"];
const MAX_CANVAS_W = 1024, MAX_CANVAS_H = 768;
const BBOX_COLORS = BBOX_PALETTE.map((hex) => {
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
return { rgb: `rgb(${r}, ${g}, ${b})`, gridActive: `rgba(${r}, ${g}, ${b}, 0.35)`,
gridInactive: `rgba(${r}, ${g}, ${b}, 0.3)`, tint: `rgba(${r}, ${g}, ${b}, 0.2)` };
});
function getBboxColor(index, active) {
const c = BBOX_COLORS[index % BBOX_COLORS.length];
return { border: c.rgb, fill: c.rgb, handle: c.rgb, gridColor: active ? c.gridActive : c.gridInactive, tint: c.tint };
}
function clampToMaxCanvas(w, h) {
if (w > MAX_CANVAS_W || h > MAX_CANVAS_H) {
const s = Math.min(MAX_CANVAS_W / w, MAX_CANVAS_H / h);
return [Math.round(w * s), Math.round(h * s)];
}
return [w, h];
}
function roundDown(val, divBy) {
return divBy > 1 ? val - (val % divBy) : val;
}
app.registerExtension({
name: "KJNodes.ImageTransformKJ",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "ImageTransformKJ") return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
const node = this;
const findW = (n) => this.widgets.find((w) => w.name === n);
const bboxWidget = findW("bboxes"), twWidget = findW("target_width"), thWidget = findW("target_height");
const kpWidget = findW("keep_proportion"), divWidget = findW("divisible_by");
const epWidget = findW("extra_padding"), icWidget = findW("invert_crop");
bboxWidget.hidden = true;
const _subWidgetCache = {};
function _refreshSubWidgetCache() {
if (!node.widgets) return;
for (const w of node.widgets) {
if (w.name?.includes(".")) _subWidgetCache[w.name] = w;
}
}
function _getSubWidget(name) {
let w = _subWidgetCache[name];
if (w && node.widgets?.includes(w)) return w;
// Cache miss — rescan
w = node.widgets?.find(ww => ww.name === name);
if (w) _subWidgetCache[name] = w;
return w || null;
}
const _tmpCanvas = document.createElement("canvas"), _tmpCtx = _tmpCanvas.getContext("2d");
const _mirrorCanvas = document.createElement("canvas"), _mirrorCtx = _mirrorCanvas.getContext("2d");
const _rotCanvas = document.createElement("canvas"), _rotCtx = _rotCanvas.getContext("2d");
let _rotCacheKey = "";
function getRotatedPreview() {
if (!node._previewImg) return null;
const nw = node._previewImg.naturalWidth;
const nh = node._previewImg.naturalHeight;
const { w: effW, h: effH } = getEffectiveImageDims(true);
// Scale to reasonable preview size
let [rw, rh] = clampToMaxCanvas(effW, effH);
const key = `${node._previewImg.src}|${node._rotation}|${rw}|${rh}`;
if (_rotCacheKey === key) return _rotCanvas;
_rotCanvas.width = rw;
_rotCanvas.height = rh;
_rotCtx.clearRect(0, 0, rw, rh);
if (node._rotation === 0) {
_rotCtx.drawImage(node._previewImg, 0, 0, rw, rh);
} else {
const rad = Math.abs(rotRad());
const rotW = Math.abs(nw * Math.cos(rad)) + Math.abs(nh * Math.sin(rad));
const rotH = Math.abs(nw * Math.sin(rad)) + Math.abs(nh * Math.cos(rad));
const fitScale = Math.min(rw / rotW, rh / rotH);
_rotCtx.translate(rw / 2, rh / 2);
_rotCtx.rotate(rotRad());
_rotCtx.drawImage(node._previewImg, -(nw * fitScale) / 2, -(nh * fitScale) / 2, nw * fitScale, nh * fitScale);
_rotCtx.setTransform(1, 0, 0, 1, 0, 0);
}
_rotCacheKey = key;
return _rotCanvas;
}
let _frameEp = { top: 0, bottom: 0, left: 0, right: 0, mode: "disabled", color: null, edgeMode: "clamp" };
let _framePadXY = { x: 0.5, y: 0.5 };
const wrapper = document.createElement("div");
wrapper.style.cssText = "display:flex;flex-direction:column;overflow:hidden;position:relative;pointer-events:auto;";
const canvasEl = document.createElement("canvas");
const canvasCtx = canvasEl.getContext("2d");
canvasEl.style.cssText = "cursor:crosshair;display:block;width:100%;height:auto;";
addWheelPassthrough(wrapper);
addMiddleClickPan(canvasEl);
// Inject slider thumb styles once
if (!document.getElementById("kjcrop-slider-style")) {
const style = document.createElement("style");
style.id = "kjcrop-slider-style";
style.textContent = `
.kjcrop-slider { -webkit-appearance: none; appearance: none; background: #444; border-radius: 4px; outline: none; }
.kjcrop-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 8px; height: 18px; background: #46b4e6; border-radius: 4px; cursor: pointer; border: none; }
.kjcrop-slider::-moz-range-thumb { width: 8px; height: 18px; background: #46b4e6; border-radius: 4px; cursor: pointer; border: none; }
.kjcrop-btn { background: #333; border: 1px solid #555; border-radius: 4px; color: #bbb; font: 11px sans-serif; cursor: pointer; padding: 2px 8px; line-height: 16px; white-space: nowrap; flex-shrink: 0; transition: border-color 0.15s, color 0.15s, background 0.15s; text-align: center; }
.kjcrop-btn:hover { border-color: #46b4e6; color: #fff; background: #3a3a3a; }
.kjcrop-btn.active { border-color: #46b4e6; color: #46b4e6; background: #2a3a42; }
`;
document.head.appendChild(style);
}
// Grid size slider overlay (purely visual aid, not serialized)
const gridBar = document.createElement("div");
gridBar.style.cssText = "display:flex;align-items:center;gap:6px;padding:3px 6px;margin-bottom:4px;font:11px sans-serif;color:#aaa;user-select:none;box-sizing:border-box;width:100%;flex:0 0 auto;";
const gridLabel = document.createElement("span");
gridLabel.textContent = "Grid: off";
gridLabel.style.cssText = "min-width:62px;text-align:right;";
const gridSlider = document.createElement("input");
gridSlider.type = "range";
gridSlider.min = "0";
gridSlider.max = "256";
gridSlider.step = "1";
gridSlider.value = node.properties.gridSize ?? "0";
gridSlider.className = "kjcrop-slider";
gridSlider.style.cssText = "flex:1;height:8px;cursor:pointer;";
function updateGridLabel() {
const v = parseInt(gridSlider.value);
gridLabel.textContent = v > 0 ? `Grid: ${v}px` : "Grid: off";
}
updateGridLabel();
let _gridStops = null;
function getGridStops() {
if (!node._previewImg) return null;
const tw = node._previewImg.naturalWidth;
const th = node._previewImg.naturalHeight;
const max = parseInt(gridSlider.max);
const set = new Set([0]);
for (let n = 1; n <= Math.max(tw, th); n++) {
const gw = tw / n, gh = th / n;
if (gw >= 2 && gw <= max) set.add(Math.round(gw * 10) / 10);
if (gh >= 2 && gh <= max) set.add(Math.round(gh * 10) / 10);
if (gw < 2 && gh < 2) break;
}
return [...set].sort((a, b) => a - b);
}
function invalidateGridStops() { _gridStops = null; }
gridSlider.addEventListener("input", () => {
let v = parseInt(gridSlider.value);
// Snap to divisors of the longer dimension for whole-cell grid
if (v > 0 && node._previewImg) {
if (!_gridStops) _gridStops = getGridStops();
if (_gridStops) {
let best = 0, bestDist = Infinity;
for (const s of _gridStops) {
const d = Math.abs(s - v);
if (d < bestDist) { bestDist = d; best = s; }
}
v = best;
}
}
gridSlider.value = Math.round(v);
node.properties.gridSize = Math.round(v);
updateGridLabel();
drawCanvas();
});
// Prevent drag events from propagating to the LiteGraph canvas,
// but only when not actively dragging a bbox handle
for (const evt of ["mousedown", "mousemove", "mouseup"]) {
gridSlider.addEventListener(evt, (e) => {
if (!node._drawing) e.stopPropagation();
});
}
// Toggle button to disable image preview (draw bboxes on blank canvas)
const previewBtn = document.createElement("button");
previewBtn.title = "Auto: canvas sized to input image. Manual: canvas sized to target dimensions.";
previewBtn.className = "kjcrop-btn";
previewBtn.style.width = "56px";
if (node.properties.previewEnabled === undefined) node.properties.previewEnabled = true;
node._previewEnabled = node.properties.previewEnabled;
function updatePreviewBtn() {
previewBtn.textContent = node._previewEnabled ? "Auto" : "Manual";
previewBtn.classList.toggle("active", !node._previewEnabled);
}
updatePreviewBtn();
previewBtn.addEventListener("click", () => {
node._previewEnabled = !node._previewEnabled;
node.properties.previewEnabled = node._previewEnabled;
updatePreviewBtn();
if (!node._previewEnabled) {
updateCanvasFromTargetDims();
} else if (node._previewImg) {
// Restore canvas to image dimensions
const [cw, ch] = clampToMaxCanvas(node._previewImg.naturalWidth, node._previewImg.naturalHeight);
setCanvasSize(cw, ch);
}
drawCanvas();
});
previewBtn.addEventListener("mousedown", (e) => e.stopPropagation());
const colorSwatch = document.createElement("div");
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = node.properties.fillColor || "#000000";
colorInput.style.cssText = "position:absolute;opacity:0;width:0;height:0;pointer-events:none;";
colorSwatch.style.cssText = "width:18px;height:18px;border:1px solid #666;border-radius:3px;cursor:pointer;flex-shrink:0;background:" + (node.properties.fillColor || "#000000") + ";";
colorSwatch.title = "Fill color (used by pad, invert crop, rotation)";
colorSwatch.appendChild(colorInput);
colorSwatch.addEventListener("click", () => colorInput.click());
colorSwatch.addEventListener("mousedown", (e) => e.stopPropagation());
colorInput.addEventListener("input", () => {
node.properties.fillColor = colorInput.value; colorSwatch.style.background = colorInput.value;
updateBboxWidgets(); drawCanvas();
});
function getEffectiveImageDims(expand = true) {
const w = node._previewImg.naturalWidth, h = node._previewImg.naturalHeight;
if (node._rotation === 0 || !expand) return { w, h };
const rad = Math.abs(rotRad());
const cosR = Math.cos(rad), sinR = Math.sin(rad);
return { w: Math.round(Math.abs(w * cosR) + Math.abs(h * sinR)),
h: Math.round(Math.abs(w * sinR) + Math.abs(h * cosR)) };
}
function renderReplicateRotated(destW, destH) {
const img = node._previewImg, nw = img.naturalWidth, nh = img.naturalHeight;
const pad = Math.max(nw, nh), pw = nw + pad * 2, ph = nh + pad * 2;
// Build replicate-padded image on _mirrorCanvas
_mirrorCanvas.width = pw; _mirrorCanvas.height = ph;
_mirrorCtx.drawImage(img, pad, pad, nw, nh);
// Edges (top, bottom, left, right)
_mirrorCtx.drawImage(img, 0, 0, nw, 1, pad, 0, nw, pad);
_mirrorCtx.drawImage(img, 0, nh - 1, nw, 1, pad, pad + nh, nw, pad);
_mirrorCtx.drawImage(img, 0, 0, 1, nh, 0, pad, pad, nh);
_mirrorCtx.drawImage(img, nw - 1, 0, 1, nh, pad + nw, pad, pad, nh);
// Corners (TL, TR, BL, BR)
_mirrorCtx.drawImage(img, 0, 0, 1, 1, 0, 0, pad, pad);
_mirrorCtx.drawImage(img, nw - 1, 0, 1, 1, pad + nw, 0, pad, pad);
_mirrorCtx.drawImage(img, 0, nh - 1, 1, 1, 0, pad + nh, pad, pad);
_mirrorCtx.drawImage(img, nw - 1, nh - 1, 1, 1, pad + nw, pad + nh, pad, pad);
// Rotate padded image, crop to original size, scale to dest
_tmpCanvas.width = destW; _tmpCanvas.height = destH;
_tmpCtx.save();
_tmpCtx.scale(destW / nw, destH / nh);
_tmpCtx.translate(nw / 2, nh / 2);
_tmpCtx.rotate(rotRad());
_tmpCtx.drawImage(_mirrorCanvas, -(pw / 2), -(ph / 2));
_tmpCtx.restore();
}
// Resize node to fit the current canvas/image. If onlyGrow is true,
// the node will only get larger, never shrink (preserves user resize).
// If false, the node is set to the exact size needed.
function fitNodeToImage(onlyGrow) {
if (!node._previewEnabled) {
// Manual mode: only grow to fit current canvas, never change width
if (onlyGrow && canvasEl.width > 0 && canvasEl.height > 0) {
_resizing = true;
const displayedH = Math.round(canvasEl.height * ((node.size[0] - 30) / canvasEl.width));
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
const aboveH = node.cropEditor?.last_y || 350;
const neededH = aboveH + node._widgetHeight + 20;
if (node.size[1] < neededH) node.setSize([node.size[0], neededH]);
_resizing = false;
}
return;
}
if (!node._previewImg) return;
_resizing = true;
const { w: effW, h: effH } = getEffectiveImageDims(true);
const ar = effH / effW;
const nodeW = onlyGrow ? node.size[0] : canvasEl.width + 30;
const canvasDisplayH = Math.round((nodeW - 30) * ar);
node._widgetHeight = canvasDisplayH + GRID_BAR_HEIGHT;
const aboveH = node.cropEditor?.last_y || 350;
// +20 accounts for DOM widget margin (10px top + 10px bottom)
const neededH = aboveH + node._widgetHeight + 20;
const newW = onlyGrow ? Math.max(node.size[0], nodeW) : nodeW;
const newH = onlyGrow ? Math.max(node.size[1], neededH) : neededH;
if (newW !== node.size[0] || newH !== node.size[1]) {
node.setSize([newW, newH]);
}
_resizing = false;
// Force reflow and redraw so the layout updates immediately
void canvasEl.offsetHeight;
if (node.graph) {
node.graph.setDirtyCanvas(true, true);
requestAnimationFrame(() => {
drawCanvas();
if (node.graph) node.graph.setDirtyCanvas(true, true);
});
}
}
function resetRotation() {
if (node._rotation === 0) return;
node._rotation = 0;
node.properties.rotation = 0;
restoreCanvasToImage();
fitNodeToImage(false);
updateBboxWidgets();
drawCanvas();
}
const rotateBtn = document.createElement("button");
rotateBtn.title = "Toggle rotation cross (drag to rotate image)";
rotateBtn.className = "kjcrop-btn";
if (node.properties.showRotationCross === undefined) node.properties.showRotationCross = false;
node._showRotationCross = node.properties.showRotationCross;
function updateRotateBtn() {
rotateBtn.textContent = "Rotate";
rotateBtn.classList.toggle("active", node._showRotationCross);
}
updateRotateBtn();
rotateBtn.addEventListener("click", () => {
node._showRotationCross = !node._showRotationCross;
node.properties.showRotationCross = node._showRotationCross;
updateRotateBtn();
drawCanvas();
});
rotateBtn.addEventListener("mousedown", (e) => e.stopPropagation());
rotateBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
resetRotation();
});
const gridColorInput = document.createElement("input");
gridColorInput.type = "color";
gridColorInput.value = node.properties.gridColor || "#ffffff";
gridColorInput.style.cssText = "position:absolute;opacity:0;width:0;height:0;pointer-events:none;";
const gridColorSwatch = document.createElement("div");
gridColorSwatch.style.cssText = "width:18px;height:18px;border:1px solid #666;border-radius:3px;cursor:pointer;flex-shrink:0;background:" + (node.properties.gridColor || "#ffffff") + ";";
gridColorSwatch.title = "Grid color";
gridColorSwatch.appendChild(gridColorInput);
gridColorSwatch.addEventListener("click", () => gridColorInput.click());
gridColorSwatch.addEventListener("mousedown", (e) => e.stopPropagation());
let _gridColorRGB = null;
function getGridColorRGB() {
const gc = node.properties.gridColor || "#ffffff";
if (!_gridColorRGB || _gridColorRGB.hex !== gc) {
_gridColorRGB = { hex: gc, r: parseInt(gc.slice(1, 3), 16), g: parseInt(gc.slice(3, 5), 16), b: parseInt(gc.slice(5, 7), 16) };
}
return _gridColorRGB;
}
gridColorInput.addEventListener("input", () => {
node.properties.gridColor = gridColorInput.value; gridColorSwatch.style.background = gridColorInput.value;
_gridColorRGB = null;
drawCanvas();
});
for (const el of [previewBtn, rotateBtn, colorSwatch, gridLabel, gridColorSwatch, gridSlider]) gridBar.appendChild(el);
wrapper.appendChild(gridBar); wrapper.appendChild(canvasEl);
const GRID_BAR_HEIGHT = 46;
node._widgetHeight = 300 + GRID_BAR_HEIGHT;
node.cropEditor = this.addDOMWidget("crop_preview", "CropPreviewWidget", wrapper, {
serialize: false, hideOnZoom: false,
getMinHeight: () => GRID_BAR_HEIGHT + 50,
});
this.resizable = true;
// Set default width wider than LiteGraph's default — deferred so onConfigure can set the flag first
setTimeout(() => {
if (!node._cropConfigured) node.setSize([450, node.size[1]]);
}, 0);
Object.assign(node, { _previewImg: null, _bboxes: [], _activeIdx: -1,
_drawing: false, _dragMode: null, _dragStart: null, _bboxAtDragStart: null,
_rotation: node.properties.rotation || 0 });
for (const w of [twWidget, thWidget, kpWidget, divWidget, epWidget, icWidget]) {
if (!w) continue;
const origCb = w.callback;
w.callback = function (...args) {
if (origCb) origCb.apply(this, args);
updateCanvasFromTargetDims();
drawCanvas();
fitNodeToCanvas();
fitNodeToImage(true);
if (w === kpWidget || w === icWidget || w === epWidget) {
// Restore values with increasing delays to catch ComfyUI's own default-setting
const restoreSaved = () => {
hookSubWidgets();
_refreshSubWidgetCache();
if (w === epWidget && node._savedEpValues && epWidget.value !== "disabled") {
for (const [n, v] of Object.entries(node._savedEpValues)) {
const sw = _getSubWidget(`extra_padding.${n}`);
if (sw) sw.value = v;
}
} else if (w === kpWidget && node._savedKpValues) {
for (const [n, v] of Object.entries(node._savedKpValues)) {
const sw = _getSubWidget(`keep_proportion.${n}`);
if (sw) sw.value = v;
}
}
drawCanvas();
fitNodeToCanvas();
fitNodeToImage(true);
};
for (const delay of [50, 100, 200, 300, 500, 1000]) {
setTimeout(restoreSaved, delay);
}
}
};
}
const hookSubWidgets = () => {
_refreshSubWidgetCache();
for (const subName of ["keep_proportion.edge_mode", "keep_proportion.pad_x", "keep_proportion.pad_y", "keep_proportion.width_mult", "keep_proportion.height_mult", "extra_padding.pad_top", "extra_padding.pad_bottom", "extra_padding.pad_left", "extra_padding.pad_right", "extra_padding.edge_mode", ]) {
const sw = _getSubWidget(subName);
if (sw && !sw._cropPreviewHooked) {
sw._cropPreviewHooked = true;
// Restore saved values from mode switch
const shortName = subName.split(".")[1];
if (subName.startsWith("extra_padding.") && node._savedEpValues && shortName in node._savedEpValues) {
sw.value = node._savedEpValues[shortName];
} else if (subName.startsWith("keep_proportion.") && node._savedKpValues && shortName in node._savedKpValues) {
sw.value = node._savedKpValues[shortName];
}
// Hook callback — save values on change so they persist across mode switches
const swOrig = sw.callback;
sw.callback = function (...a) {
if (_suppressSubCallbacks) return;
if (subName.startsWith("extra_padding.")) node._savedEpValues[shortName] = sw.value;
else if (subName.startsWith("keep_proportion.")) node._savedKpValues[shortName] = sw.value;
if (swOrig) swOrig.apply(this, a);
drawCanvas();
};
}
}
};
for (const delay of [50, 150, 300, 500]) {
setTimeout(hookSubWidgets, delay);
}
let _lastWidgetSnapshot = "";
const _snapshotNames = [
"extra_padding", "extra_padding.pad_top", "extra_padding.pad_bottom",
"extra_padding.pad_left", "extra_padding.pad_right",
"extra_padding.edge_mode",
"keep_proportion.pad_x", "keep_proportion.pad_y", "keep_proportion.color",
"keep_proportion.edge_mode", "keep_proportion.width_mult", "keep_proportion.height_mult",
];
node._savedEpValues = node._savedEpValues || {};
node._savedKpValues = node._savedKpValues || {};
const origDrawFg = node.onDrawForeground;
node.onDrawForeground = function (...args) {
if (origDrawFg) origDrawFg.apply(this, args);
const snap = _snapshotNames.map(n => _getSubWidget(n)?.value ?? (n === "extra_padding" ? epWidget?.value : "") ?? "").join("|");
if (snap !== _lastWidgetSnapshot) {
_lastWidgetSnapshot = snap;
drawCanvas();
}
};
// Restore bboxes from widget
restoreBboxesFromWidget();
// Boxes are serialized in the canvas frame they were authored in (previewWidth/Height).
// Bring them into the current canvas frame so the in-memory coords stay canvas-relative.
function normalizeLoadedBboxes(boxes) {
const cw = canvasEl.width, ch = canvasEl.height;
if (!cw || !ch) return boxes;
return boxes.map((b) => {
const pw = b.previewWidth, ph = b.previewHeight;
if (!pw || !ph || (pw === cw && ph === ch)) {
const { previewWidth, previewHeight, ...rest } = b;
return rest;
}
const sx = cw / pw, sy = ch / ph;
return { startX: b.startX * sx, startY: b.startY * sy, endX: b.endX * sx, endY: b.endY * sy };
});
}
function restoreBboxesFromWidget() {
if (bboxWidget.value) {
try {
const parsed = JSON.parse(bboxWidget.value);
// New format: { bboxes: [...], rotation: N }
if (parsed && !Array.isArray(parsed) && typeof parsed === "object") {
if (parsed.bboxes && Array.isArray(parsed.bboxes)) {
node._bboxes = normalizeLoadedBboxes(parsed.bboxes.filter((b) => b?.startX != null));
node._activeIdx = node._bboxes.length > 0 ? 0 : -1;
}
if (parsed.rotation !== undefined) {
node._rotation = parsed.rotation;
node.properties.rotation = parsed.rotation;
}
if (parsed.fillColor) {
node.properties.fillColor = parsed.fillColor;
colorInput.value = parsed.fillColor;
colorSwatch.style.background = parsed.fillColor;
}
}
// Legacy format: [bbox, bbox, ...]
else if (Array.isArray(parsed) && parsed.length > 0) {
node._bboxes = normalizeLoadedBboxes(parsed.filter((b) => b?.startX != null));
node._activeIdx = node._bboxes.length > 0 ? 0 : -1;
}
} catch (e) {}
}
}
function normBbox(bbox) {
return { x1: Math.min(bbox.startX, bbox.endX), y1: Math.min(bbox.startY, bbox.endY),
x2: Math.max(bbox.startX, bbox.endX), y2: Math.max(bbox.startY, bbox.endY) };
}
function getCanvasMouse(e) {
const rect = canvasEl.getBoundingClientRect();
return { x: (e.clientX - rect.left) * (canvasEl.width / rect.width),
y: (e.clientY - rect.top) * (canvasEl.height / rect.height) };
}
function rotRad() { return node._rotation * Math.PI / 180; }
function getDivBy() { return divWidget ? divWidget.value : 0; }
function getGridSize() { return parseInt(gridSlider.value) || 0; }
// Subtract extra padding from target dims for non-pad keep_proportion modes
function adjustTargetForPadding(rw, rh, tgtW, tgtH) {
const ep = getExtraPadding();
if (hasExtraPad(ep) && !ep.isFirst) {
if (tgtW > 0) rw = Math.max(1, rw - ep.left - ep.right);
if (tgtH > 0) rh = Math.max(1, rh - ep.top - ep.bottom);
}
return { rw, rh };
}
function getEffectiveTargetDims(fallbackW, fallbackH) {
const divBy = getDivBy();
const { w: tgtW, h: tgtH } = getTgt();
let { rw, rh } = adjustTargetForPadding(tgtW > 0 ? tgtW : fallbackW, tgtH > 0 ? tgtH : fallbackH, tgtW, tgtH);
return { rw: roundDown(rw, divBy), rh: roundDown(rh, divBy) };
}
function getFillColor() { return node.properties.fillColor || "#000000"; }
function getTgt() { return { w: twWidget ? twWidget.value : 0, h: thWidget ? thWidget.value : 0 }; }
function getKp() { return kpWidget ? kpWidget.value : "stretch"; }
function isPadMode(kp) { return kp === "pad_color" || kp === "pad_edge"; }
function hasExtraPad(ep) { return ep.top + ep.bottom + ep.left + ep.right > 0; }
function hitTestRotationCross(mx, my) {
if (!node._showRotationCross) return false;
const cx = canvasEl.width / 2;
const cy = canvasEl.height / 2;
const dx = mx - cx;
const dy = my - cy;
const rad = rotRad();
const cosR = Math.cos(rad);
const sinR = Math.sin(rad);
const perpH = -dx * sinR + dy * cosR;
const perpV = dx * cosR + dy * sinR;
const dist = Math.sqrt(dx * dx + dy * dy);
return dist < 15 || Math.abs(perpH) < 8 || Math.abs(perpV) < 8;
}
function restoreCanvasToImage() {
if (!node._previewImg || !node._previewEnabled) return;
setCanvasSize(...clampToMaxCanvas(...Object.values(getEffectiveImageDims(true))));
}
function drawPreviewRegion(ctx, rx, ry, rw, rh, dx, dy, dw, dh) {
const rp = getRotatedPreview();
const sx = _rotCanvas.width / canvasEl.width, sy = _rotCanvas.height / canvasEl.height;
ctx.drawImage(rp, rx * sx, ry * sy, rw * sx, rh * sy, dx, dy, dw, dh);
}
function drawStretchOverlay(ctx, ar, rx, ry, rw, rh) {
const { pW, pH, pX, pY } = fitAspect(ar, rw, rh, rx, ry);
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(rx, ry, rw, rh);
if (node._previewEnabled && node._previewImg) drawPreviewRegion(ctx, rx, ry, rw, rh, pX, pY, pW, pH);
drawDashedBorder(ctx, pX, pY, pW, pH);
}
// Compute content dimensions after keep_proportion resize, before extra padding
function computeContentDims(cropW, cropH) {
if (cropW <= 0 || cropH <= 0) return null;
const { w: tgtW, h: tgtH } = getTgt();
let outW = cropW, outH = cropH;
const kp = getKp();
// For pad-first non-pad modes, the source is padded before keep_proportion
const ep = getExtraPadding();
if (ep.isFirst && hasExtraPad(ep) && !isPadMode(kp)) {
cropW += ep.left + ep.right;
cropH += ep.top + ep.bottom;
}
if (tgtW > 0 || tgtH > 0) {
let { rw, rh } = adjustTargetForPadding(tgtW > 0 ? tgtW : cropW, tgtH > 0 ? tgtH : cropH, tgtW, tgtH);
if (kp === "keep_long_edge") {
const ratio = Math.min(rw / cropW, rh / cropH);
rw = Math.round(cropW * ratio);
rh = Math.round(cropH * ratio);
} else if (kp === "keep_short_edge") {
const ratio = Math.max(rw / cropW, rh / cropH);
rw = Math.round(cropW * ratio);
rh = Math.round(cropH * ratio);
} else if (kp === "total_pixels") {
const totalPx = rw * rh;
const ar = cropW / cropH;
rh = Math.round(Math.sqrt(totalPx / ar));
rw = Math.round(Math.sqrt(totalPx * ar));
}
outW = rw;
outH = rh;
}
if (kp === "multiplier") {
const wm = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
const hm = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
outW = Math.round(cropW * wm);
outH = Math.round(cropH * hm);
}
return { w: outW, h: outH };
}
function computeOutputDims(cropW, cropH) {
const content = computeContentDims(cropW, cropH);
if (!content) return null;
let outW = content.w, outH = content.h;
// Add extra padding (skip for pad-first — handled on the source image)
const ep = getExtraPadding();
if (!ep.isFirst) {
outW += ep.left + ep.right;
outH += ep.top + ep.bottom;
}
const divBy = getDivBy();
outW = roundDown(outW, divBy);
outH = roundDown(outH, divBy);
return { w: outW, h: outH };
}
function getPadXY() { return _framePadXY; }
function getExtraPadding() { return _frameEp; }
function _readPadXY() {
const px = _getSubWidget("keep_proportion.pad_x"), py = _getSubWidget("keep_proportion.pad_y");
return { x: px ? px.value : 0.5, y: py ? py.value : 0.5 };
}
let _suppressSubCallbacks = false;
function setPadXY(x, y, defer) {
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
_framePadXY = { x, y };
if (!defer) {
const px = _getSubWidget("keep_proportion.pad_x"), py = _getSubWidget("keep_proportion.pad_y");
_suppressSubCallbacks = true;
if (px) px.value = x;
if (py) py.value = y;
_suppressSubCallbacks = false;
}
}
function _readExtraPadding() {
const get = (name) => _getSubWidget(`extra_padding.${name}`)?.value || 0;
const mode = epWidget?.value || "disabled";
// pad_color/pad_edge = pad first (before crop), pad_crop_color/pad_crop_edge = pad after crop
const isCrop = mode.startsWith("pad_crop_");
const baseMode = isCrop ? mode.replace("pad_crop_", "pad_") : mode;
return {
top: get("pad_top"), bottom: get("pad_bottom"),
left: get("pad_left"), right: get("pad_right"),
mode: baseMode,
isFirst: !isCrop && mode !== "disabled",
edgeMode: _getSubWidget("extra_padding.edge_mode")?.value || "clamp",
};
}
// Draw extra padding fill around content area (cX,cY,cW,cH) within pad area (pX,pY,pW,pH)
// srcImg is the source image, srcX/Y/W/H define the source region
// Draw edge/color fill around content area. srcImg must be pre-rendered at its full dimensions.
function drawExtraPadFill(ctx, ep, pX, pY, pW, pH, cX, cY, cW, cH, srcImg) {
if (ep.mode === "pad_color") {
ctx.fillStyle = getFillColor();
ctx.fillRect(pX, pY, pW, pH);
} else if (ep.mode === "pad_edge") {
const em = ep.edgeMode;
const sw = srcImg.width, sh = srcImg.height;
if (em === "clamp") {
const l = cX - pX, r = (pX + pW) - (cX + cW), t = cY - pY, b = (pY + pH) - (cY + cH);
if (t > 0) ctx.drawImage(srcImg, 0, 0, sw, 1, cX, pY, cW, t);
if (b > 0) ctx.drawImage(srcImg, 0, sh - 1, sw, 1, cX, cY + cH, cW, b);
if (l > 0) ctx.drawImage(srcImg, 0, 0, 1, sh, pX, cY, l, cH);
if (r > 0) ctx.drawImage(srcImg, sw - 1, 0, 1, sh, cX + cW, cY, r, cH);
if (l > 0 && t > 0) ctx.drawImage(srcImg, 0, 0, 1, 1, pX, pY, l, t);
if (r > 0 && t > 0) ctx.drawImage(srcImg, sw - 1, 0, 1, 1, cX + cW, pY, r, t);
if (l > 0 && b > 0) ctx.drawImage(srcImg, 0, sh - 1, 1, 1, pX, cY + cH, l, b);
if (r > 0 && b > 0) ctx.drawImage(srcImg, sw - 1, sh - 1, 1, 1, cX + cW, cY + cH, r, b);
} else if (em === "repeat" || em === "mirror") {
// Copy srcImg to _mirrorCanvas first — srcImg may be _tmpCanvas
const tileW = Math.max(1, Math.round(cW));
const tileH = Math.max(1, Math.round(cH));
_mirrorCanvas.width = tileW;
_mirrorCanvas.height = tileH;
_mirrorCtx.drawImage(srcImg, 0, 0, tileW, tileH);
let tileCanvas;
if (em === "mirror") {
_tmpCanvas.width = tileW * 2; _tmpCanvas.height = tileH * 2;
_tmpCtx.drawImage(_mirrorCanvas, 0, 0);
_tmpCtx.save(); _tmpCtx.translate(_tmpCanvas.width, 0); _tmpCtx.scale(-1, 1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
_tmpCtx.save(); _tmpCtx.translate(0, _tmpCanvas.height); _tmpCtx.scale(1, -1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
_tmpCtx.save(); _tmpCtx.translate(_tmpCanvas.width, _tmpCanvas.height); _tmpCtx.scale(-1, -1); _tmpCtx.drawImage(_mirrorCanvas, 0, 0); _tmpCtx.restore();
tileCanvas = _tmpCanvas;
} else {
tileCanvas = _mirrorCanvas;
}
const pattern = ctx.createPattern(tileCanvas, "repeat");
ctx.save();
ctx.beginPath(); ctx.rect(pX, pY, pW, pH); ctx.clip();
ctx.translate(cX, cY);
ctx.fillStyle = pattern;
ctx.fillRect(pX - cX, pY - cY, pW, pH);
ctx.restore();
}
}
}
function fitAspect(targetAR, cw, ch, cx = 0, cy = 0) {
if (targetAR > cw / ch) { const pH = cw / targetAR; return { pW: cw, pH, pX: cx, pY: cy + (ch - pH) / 2 }; }
const pW = ch * targetAR; return { pW, pH: ch, pX: cx + (cw - pW) / 2, pY: cy };
}
function drawDashedBorder(ctx, x, y, w, h, color = "rgba(255, 180, 60, 0.8)") {
ctx.setLineDash([4, 4]); ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
}
// Dim four sides around an inner rect within an outer rect
function dimSides(ctx, ox, oy, ow, oh, ix, iy, iw, ih, color = "rgba(255, 80, 80, 0.3)") {
ctx.fillStyle = color;
ctx.fillRect(ox, oy, ow, iy - oy); // top
ctx.fillRect(ox, iy + ih, ow, (oy + oh) - (iy + ih)); // bottom
ctx.fillRect(ox, iy, ix - ox, ih); // left
ctx.fillRect(ix + iw, iy, (ox + ow) - (ix + iw), ih); // right
}
// Compute pad mode layout: fit target AR, compute content rect inside
function computePadLayout(rw, rh, cropW, cropH, fitW, fitH, fitX, fitY) {
const { pW, pH, pX, pY } = fitAspect(rw / rh, fitW, fitH, fitX, fitY);
const imgRatio = Math.min(rw / cropW, rh / cropH);
const cW = pW * (cropW * imgRatio / rw), cH = pH * (cropH * imgRatio / rh);
const { cX, cY } = computePadContentPos(pX, pY, pW, pH, cW, cH);
return { pW, pH, pX, pY, cW, cH, cX, cY };
}
// Render source to _tmpCanvas at given size. If srcImg + source rect provided, draws that region.
// Otherwise draws the rotated preview (with replicate rotation for pad_edge mode).
// replicate: force replicate-pad rotation (for keep_proportion pad_edge mode)
function renderToTmp(w, h, srcImg, sx, sy, sw, sh, replicate) {
const ep = getExtraPadding();
if (!srcImg && node._rotation !== 0 && (replicate || (ep.mode === "pad_edge" && ep.isFirst))) {
renderReplicateRotated(w, h); return;
}
_tmpCanvas.width = w; _tmpCanvas.height = h;
if (srcImg) _tmpCtx.drawImage(srcImg, sx, sy, sw, sh, 0, 0, w, h);
else _tmpCtx.drawImage(getRotatedPreview(), 0, 0, _rotCanvas.width, _rotCanvas.height, 0, 0, w, h);
}
function getEdgeEp() {
return { mode: "pad_edge", edgeMode: _getSubWidget("keep_proportion.edge_mode")?.value || "clamp" };
}
function computePadContentPos(pX, pY, pW, pH, cW, cH) {
const { x, y } = getPadXY();
return { cX: pX + (pW - cW) * x, cY: pY + (pH - cH) * y };
}
function getOtherBboxEdges(axis) {
const edges = [];
for (let i = 0; i < node._bboxes.length; i++) {
if (i === node._activeIdx) continue;
const { x1, y1, x2, y2 } = normBbox(node._bboxes[i]);
edges.push(axis === "x" ? x1 : y1, axis === "x" ? x2 : y2);
}
return edges;
}
function getEffectiveSnap() {
const g = getGridParams();
if (g) return { x: g.effGridW, y: g.effGridH };
const d = getDivBy();
return d > 1 ? { x: d, y: d } : { x: 0, y: 0 };
}
function snapCoord(val, snapSize, scale, canvasDim, otherEdges) {
let best = val, bestDist = Infinity;
const consider = (c) => { const d = Math.abs(val - c); if (d < bestDist) { bestDist = d; best = c; } };
consider(0);
consider(canvasDim);
if (snapSize > 1) consider(Math.round(val * scale / snapSize) * snapSize / scale);
if (otherEdges) for (const edge of otherEdges) consider(edge);
return best;
}
// Snap the moving corner(s) of a bbox to grid / edges / other bboxes.
function snapBbox(bbox) {
if (!bbox) return bbox;
const snap = getEffectiveSnap(), scale = getCanvasScale();
const xEdges = getOtherBboxEdges("x"), yEdges = getOtherBboxEdges("y");
return { startX: snapCoord(bbox.startX, snap.x, scale.x, canvasEl.width, xEdges),
startY: snapCoord(bbox.startY, snap.y, scale.y, canvasEl.height, yEdges),
endX: snapCoord(bbox.endX, snap.x, scale.x, canvasEl.width, xEdges),
endY: snapCoord(bbox.endY, snap.y, scale.y, canvasEl.height, yEdges) };
}
function snapBboxPosition(bbox) {
if (!bbox) return bbox;
const snap = getEffectiveSnap();
const scale = getCanvasScale();
const xEdges = getOtherBboxEdges("x");
const yEdges = getOtherBboxEdges("y");
const { x1, y1 } = normBbox(bbox);
const w = Math.abs(bbox.endX - bbox.startX);
const h = Math.abs(bbox.endY - bbox.startY);
// Snap both the left/top and right/bottom edges, pick whichever is closer
const snappedX1 = snapCoord(x1, snap.x, scale.x, canvasEl.width, xEdges);
const snappedX2 = snapCoord(x1 + w, snap.x, scale.x, canvasEl.width, xEdges) - w;
const snappedX = Math.abs(snappedX1 - x1) <= Math.abs(snappedX2 - x1) ? snappedX1 : snappedX2;
const snappedY1 = snapCoord(y1, snap.y, scale.y, canvasEl.height, yEdges);
const snappedY2 = snapCoord(y1 + h, snap.y, scale.y, canvasEl.height, yEdges) - h;
const snappedY = Math.abs(snappedY1 - y1) <= Math.abs(snappedY2 - y1) ? snappedY1 : snappedY2;
return { startX: snappedX, startY: snappedY, endX: snappedX + w, endY: snappedY + h };
}
function constrainAspect(bbox, dragMode) {
if (!bbox) return bbox;
const { w: tw, h: th } = getTgt();
// Target aspect in source-image pixels
const srcAR = (tw > 0 && th > 0) ? tw / th : 1;
// Convert to preview-pixel aspect by accounting for non-uniform scaling
const scale = getCanvasScale();
const ar = srcAR * scale.y / scale.x;
const { x1, y1, x2, y2 } = normBbox(bbox);
let bw = x2 - x1;
let bh = y2 - y1;
if (bw <= 0 || bh <= 0) return bbox;
// Adjust height to match aspect, keep width
const hFromW = bw / ar;
const wFromH = bh * ar;
// Pick the smaller fit so we don't exceed the drawn extent
if (hFromW <= bh) {
bh = hFromW;
} else {
bw = wFromH;
}
// Anchor based on drag mode
if (dragMode === "resize-tl") {
return { startX: x2 - bw, startY: y2 - bh, endX: x2, endY: y2 };
} else if (dragMode === "resize-tr") {
return { startX: x1, startY: y2 - bh, endX: x1 + bw, endY: y2 };
} else if (dragMode === "resize-bl") {
return { startX: x2 - bw, startY: y1, endX: x2, endY: y1 + bh };
} else {
// resize-br or draw (anchor top-left)
return { startX: x1, startY: y1, endX: x1 + bw, endY: y1 + bh };
}
}
function removeBbox(index) {
node._bboxes.splice(index, 1);
if (node._bboxes.length === 0) node._activeIdx = -1;
else if (index <= node._activeIdx) node._activeIdx = Math.min(node._activeIdx - (index < node._activeIdx ? 1 : 0), node._bboxes.length - 1);
}
function hitTestBboxes(mx, my) {
const order = node._activeIdx >= 0 ? [node._activeIdx] : [];
for (let i = 0; i < node._bboxes.length; i++) { if (i !== node._activeIdx) order.push(i); }
for (const idx of order) {
const { x1, y1, x2, y2 } = normBbox(node._bboxes[idx]);
const mode = rectHitTest(mx, my, x1, y1, x2, y2, 10);
if (mode) return { index: idx, mode };
}
return null;
}
canvasEl.addEventListener("mousedown", (e) => {
canvasEl.focus();
if (e.button === 2) {
e.preventDefault();
const m = getCanvasMouse(e);
// Right-click on rotation cross: reset rotation
if (node._rotation !== 0 && hitTestRotationCross(m.x, m.y)) {
resetRotation();
return;
}
// Right-click: delete the bbox under cursor, or active bbox
const hit = hitTestBboxes(m.x, m.y);
if (hit) {
removeBbox(hit.index);
} else if (node._activeIdx >= 0) {
removeBbox(node._activeIdx);
}
updateBboxWidgets();
drawCanvas();
return;
}
if (e.button !== 0) return;
const m = getCanvasMouse(e);
// Check for rotation cross drag (click near the center or any arm)
if (hitTestRotationCross(m.x, m.y)) {
node._dragMode = "rotate";
node._rotateStart = node._rotation;
const rect0 = canvasEl.getBoundingClientRect();
// Capture screen center once — immune to canvas resize
node._rotateCenterX = rect0.left + rect0.width / 2;
node._rotateCenterY = rect0.top + rect0.height / 2;
node._rotateScreenStartAngle = Math.atan2(
e.clientY - node._rotateCenterY,
e.clientX - node._rotateCenterX
) * 180 / Math.PI;
node._drawing = true;
node._dragStart = m;
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+drag: drag content position within padded area
if (e.shiftKey) {
const kpVal = getKp();
const { w: tW2, h: tH2 } = getTgt();
const ep = getExtraPadding();
const startDrag = (mode, extra) => {
node._dragMode = mode; Object.assign(node, extra);
node._drawing = true; node._dragStart = m;
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
e.preventDefault(); e.stopPropagation();
};
if ((tW2 > 0 || tH2 > 0) && isPadMode(kpVal)) {
startDrag("pad_drag", { _padDragStart: getPadXY(), _bboxAtDragStart: null }); return;
} else if (hasExtraPad(ep)) {
startDrag("extra_pad_drag", { _extraPadStart: { ...ep }, _bboxAtDragStart: null }); return;
}
}
const hit = hitTestBboxes(m.x, m.y);
if (hit) {
// Select and interact with existing bbox
node._activeIdx = hit.index;
node._dragMode = hit.mode;
const bbox = node._bboxes[hit.index];
const { x1, y1, x2, y2 } = normBbox(bbox);
node._bboxAtDragStart = { startX: x1, startY: y1, endX: x2, endY: y2 };
} else {
// Draw new bbox
node._dragMode = "draw";
const newBbox = { startX: m.x, startY: m.y, endX: m.x, endY: m.y };
node._bboxes.push(newBbox);
node._activeIdx = node._bboxes.length - 1;
node._bboxAtDragStart = null;
}
node._drawing = true;
node._dragStart = m;
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
e.preventDefault();
e.stopPropagation();
});
// Hover cursor updates
canvasEl.addEventListener("mousemove", (e) => {
if (node._drawing) return;
const m = getCanvasMouse(e);
const hit = hitTestBboxes(m.x, m.y);
if (!hit) {
if (!node._showRotationCross) {
canvasEl.style.cursor = e.shiftKey ? "grab" : "crosshair";
} else if (hitTestRotationCross(m.x, m.y)) {
canvasEl.style.cursor = "alias";
} else if (e.shiftKey) {
const kpVal = getKp();
const { w: tW, h: tH } = getTgt();
const epHover = getExtraPadding();
if (((tW > 0 || tH > 0) && isPadMode(kpVal)) || hasExtraPad(epHover)) {
canvasEl.style.cursor = "grab";
} else {
canvasEl.style.cursor = "crosshair";
}
} else {
canvasEl.style.cursor = "crosshair";
}
} else {
canvasEl.style.cursor = cursorForBboxMode(hit.mode) || "crosshair";
}
});
function onDragMove(e) {
if (!node._drawing) return;
if (node._dragMode === "rotate") {
// Screen-space atan2 with fixed center (captured at drag start)
const sdx = e.clientX - node._rotateCenterX;
const sdy = e.clientY - node._rotateCenterY;
const screenDist = Math.sqrt(sdx * sdx + sdy * sdy);
if (screenDist < 15) {
node._rotateStart = node._rotation;
node._rotateScreenStartAngle = Math.atan2(sdy, sdx) * 180 / Math.PI;
return;
}
const angleCur = Math.atan2(sdy, sdx) * 180 / Math.PI;
let angleDelta = angleCur - node._rotateScreenStartAngle;
// Normalize delta to -180..180 to handle atan2 wrap-around
while (angleDelta > 180) angleDelta -= 360;
while (angleDelta < -180) angleDelta += 360;
let newRot = node._rotateStart + angleDelta;
if (e.ctrlKey) {
newRot = Math.round(newRot / 15) * 15;
} else {
for (const snap of [0, 90, 180, 270, -90, -180, -270, 360]) {
if (Math.abs(newRot - snap) < 3) { newRot = snap; break; }
}
}
while (newRot > 180) newRot -= 360;
while (newRot < -180) newRot += 360;
node._rotation = newRot;
node.properties.rotation = newRot;
// Resize canvas to match rotated image dimensions
if (node._previewImg && node._previewEnabled) {
const { w: ew, h: eh } = getEffectiveImageDims(true);
const [rw, rh] = clampToMaxCanvas(ew, eh);
if (Math.abs(rw - canvasEl.width) > 3 || Math.abs(rh - canvasEl.height) > 3) {
setCanvasSize(rw, rh);
}
}
drawCanvas();
return;
}
if (node._dragMode === "pad_drag") {
const m = getCanvasMouse(e);
const dx = m.x - node._dragStart.x;
const dy = m.y - node._dragStart.y;
// Compute the pad preview layout to find how many canvas pixels = full 0-1 range
const ep = getExtraPadding();
const { w: imgW, h: imgH } = node._previewImg
? getEffectiveImageDims(getKp() !== "pad_edge")
: { w: canvasEl.width, h: canvasEl.height };
const { rw: baseTw, rh: baseTh } = getEffectiveTargetDims(imgW, imgH);
const totalW = baseTw + ep.left + ep.right;
const totalH = baseTh + ep.top + ep.bottom;
const { pW, pH } = fitAspect(totalW / totalH, canvasEl.width, canvasEl.height);
const imgRatio = Math.min(baseTw / imgW, baseTh / imgH);
const cW = pW * (imgW * imgRatio / totalW), cH = pH * (imgH * imgRatio / totalH);
const rangeX = pW - cW, rangeY = pH - cH;
const newX = rangeX > 0 ? node._padDragStart.x + dx / rangeX : 0.5;
const newY = rangeY > 0 ? node._padDragStart.y + dy / rangeY : 0.5;
setPadXY(newX, newY, true);
drawCanvas();
return;
}
if (node._dragMode === "extra_pad_drag") {
const m = getCanvasMouse(e);
const dx = m.x - node._dragStart.x;
const dy = m.y - node._dragStart.y;
const s = node._extraPadStart;
const totalH = s.top + s.bottom, totalW = s.left + s.right;
const fullW = (node._previewImg ? node._previewImg.naturalWidth : canvasEl.width) + totalW;
const fullH = (node._previewImg ? node._previewImg.naturalHeight : canvasEl.height) + totalH;
const scale = (fullW / fullH > canvasEl.width / canvasEl.height)
? fullW / canvasEl.width : fullH / canvasEl.height;
const pixDx = Math.round(dx * scale);
const pixDy = Math.round(dy * scale);
const newLeft = Math.max(0, Math.min(totalW, s.left + pixDx));
const newTop = Math.max(0, Math.min(totalH, s.top + pixDy));
for (const [name, val] of [["pad_left", newLeft], ["pad_right", totalW - newLeft],
["pad_top", newTop], ["pad_bottom", totalH - newTop]]) {
const w = _getSubWidget(`extra_padding.${name}`);
if (w) w.value = val;
}
drawCanvas();
return;
}
if (node._activeIdx < 0) return;
const m = getCanvasMouse(e);
const cw = canvasEl.width;
const ch = canvasEl.height;
let bbox = node._bboxes[node._activeIdx];
if (node._dragMode === "draw") {
bbox.endX = Math.max(0, Math.min(cw, m.x));
bbox.endY = Math.max(0, Math.min(ch, m.y));
} else if (node._dragMode === "move" && node._bboxAtDragStart) {
const dx = m.x - node._dragStart.x;
const dy = m.y - node._dragStart.y;
const bs = node._bboxAtDragStart;
const w = bs.endX - bs.startX;
const h = bs.endY - bs.startY;
const nx = Math.max(0, Math.min(cw - w, bs.startX + dx));
const ny = Math.max(0, Math.min(ch - h, bs.startY + dy));
bbox = { startX: nx, startY: ny, endX: nx + w, endY: ny + h };
} else if (node._dragMode?.startsWith("resize") && node._bboxAtDragStart) {
const bs = node._bboxAtDragStart;
const dm = node._dragMode;
// Edge resize: only one axis moves
if (dm === "resize-t" || dm === "resize-b" || dm === "resize-l" || dm === "resize-r") {
const edgeCoord = dm === "resize-t" ? bs.startY : dm === "resize-b" ? bs.endY : dm === "resize-l" ? bs.startX : bs.endX;
const isVert = dm === "resize-t" || dm === "resize-b";
const off = (isVert ? node._dragStart.y : node._dragStart.x) - edgeCoord;
const val = Math.max(0, Math.min(isVert ? ch : cw, (isVert ? m.y : m.x) - off));
if (dm === "resize-t") bbox = { startX: bs.startX, startY: val, endX: bs.endX, endY: bs.endY };
else if (dm === "resize-b") bbox = { startX: bs.startX, startY: bs.startY, endX: bs.endX, endY: val };
else if (dm === "resize-l") bbox = { startX: val, startY: bs.startY, endX: bs.endX, endY: bs.endY };
else bbox = { startX: bs.startX, startY: bs.startY, endX: val, endY: bs.endY };
} else {
// Corner resize
let cornerX, cornerY;
if (dm === "resize-tl") { cornerX = bs.startX; cornerY = bs.startY; }
else if (dm === "resize-tr") { cornerX = bs.endX; cornerY = bs.startY; }
else if (dm === "resize-bl") { cornerX = bs.startX; cornerY = bs.endY; }
else { cornerX = bs.endX; cornerY = bs.endY; }
const offX = node._dragStart.x - cornerX;
const offY = node._dragStart.y - cornerY;
const cx = Math.max(0, Math.min(cw, m.x - offX));
const cy = Math.max(0, Math.min(ch, m.y - offY));
if (dm === "resize-tl") bbox = { startX: bs.endX, startY: bs.endY, endX: cx, endY: cy };
else if (dm === "resize-tr") bbox = { startX: bs.startX, startY: bs.endY, endX: cx, endY: cy };
else if (dm === "resize-bl") bbox = { startX: bs.endX, startY: bs.startY, endX: cx, endY: cy };
else if (dm === "resize-br") bbox = { startX: bs.startX, startY: bs.startY, endX: cx, endY: cy };
}
}
// Alt: resize symmetrically — for draw, the click point is center;
// for resize, the center of the original bbox is the pivot
if (e.altKey && node._dragMode !== "move") {
let cx, cy;
if (node._dragMode === "draw") {
cx = node._dragStart.x;
cy = node._dragStart.y;
} else if (node._bboxAtDragStart) {
const bs = node._bboxAtDragStart;
cx = (bs.startX + bs.endX) / 2;
cy = (bs.startY + bs.endY) / 2;
} else {
cx = (bbox.startX + bbox.endX) / 2;
cy = (bbox.startY + bbox.endY) / 2;
}
const dx = Math.abs(bbox.endX - cx);
const dy = Math.abs(bbox.endY - cy);
bbox = {
startX: Math.max(0, cx - dx),
startY: Math.max(0, cy - dy),
endX: Math.min(cw, cx + dx),
endY: Math.min(ch, cy + dy),
};
}
// Shift: constrain to target aspect ratio (not applicable to edge resize)
const isEdgeResize = /^resize-[tblr]$/.test(node._dragMode);
if (e.shiftKey && node._dragMode !== "move" && !isEdgeResize) {
bbox = constrainAspect(bbox, node._dragMode);
}
// Ctrl: snap to grid in source image space
if (e.ctrlKey) {
if (node._dragMode === "move") {
bbox = snapBboxPosition(bbox);
} else if (e.altKey) {
// Snap both edges, keep centered
const snap = getEffectiveSnap();
const scale = getCanvasScale();
const xEdges = getOtherBboxEdges("x");
const yEdges = getOtherBboxEdges("y");
const sx1 = snapCoord(bbox.startX, snap.x, scale.x, cw, xEdges);
const sx2 = snapCoord(bbox.endX, snap.x, scale.x, cw, xEdges);
const sy1 = snapCoord(bbox.startY, snap.y, scale.y, ch, yEdges);
const sy2 = snapCoord(bbox.endY, snap.y, scale.y, ch, yEdges);
bbox = { startX: sx1, startY: sy1, endX: sx2, endY: sy2 };
} else if (isEdgeResize) {
// Only snap the edge being dragged
const snap = getEffectiveSnap();
const scale = getCanvasScale();
const xEdges = getOtherBboxEdges("x"), yEdges = getOtherBboxEdges("y");
const dm = node._dragMode;
bbox = { ...bbox,
...(dm === "resize-t" && { startY: snapCoord(bbox.startY, snap.y, scale.y, ch, yEdges) }),
...(dm === "resize-b" && { endY: snapCoord(bbox.endY, snap.y, scale.y, ch, yEdges) }),
...(dm === "resize-l" && { startX: snapCoord(bbox.startX, snap.x, scale.x, cw, xEdges) }),
...(dm === "resize-r" && { endX: snapCoord(bbox.endX, snap.x, scale.x, cw, xEdges) }),
};
} else {
bbox = snapBbox(bbox);
}
}
node._bboxes[node._activeIdx] = bbox;
drawCanvas();
e.preventDefault();
}
function onDragEnd(e) {
if (!node._drawing) return;
const wasRotate = node._dragMode === "rotate";
const wasPadDrag = node._dragMode === "pad_drag";
Object.assign(node, { _drawing: false, _dragMode: null, _dragStart: null,
_bboxAtDragStart: null, _padDragStart: null, _extraPadStart: null });
// Flush deferred pad_x/pad_y widget values
if (wasPadDrag) setPadXY(_framePadXY.x, _framePadXY.y);
document.removeEventListener("mousemove", onDragMove);
document.removeEventListener("mouseup", onDragEnd);
// Snap canvas to exact rotated dimensions on release (during drag we use a threshold to avoid jitter)
if (wasRotate) {
restoreCanvasToImage();
fitNodeToImage(false);
}
if (node._activeIdx >= 0) {
const bbox = node._bboxes[node._activeIdx];
const { x1, y1, x2, y2 } = normBbox(bbox);
if (x2 - x1 < 3 && y2 - y1 < 3) {
// Too small — remove it
removeBbox(node._activeIdx);
} else {
node._bboxes[node._activeIdx] = { startX: x1, startY: y1, endX: x2, endY: y2 };
}
}
updateBboxWidgets();
drawCanvas();
}
canvasEl.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); });
canvasEl.tabIndex = 0;
canvasEl.style.outline = "none";
canvasEl.addEventListener("keydown", (e) => {
if (node._activeIdx < 0) return;
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const dir = arrows[e.key];
if (!dir) {
// Delete key removes active bbox
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
e.stopPropagation();
removeBbox(node._activeIdx);
updateBboxWidgets();
drawCanvas();
}
// Tab cycles through bboxes
if (e.key === "Tab" && node._bboxes.length > 1) {
e.preventDefault();
e.stopPropagation();
node._activeIdx = (node._activeIdx + (e.shiftKey ? -1 : 1) + node._bboxes.length) % node._bboxes.length;
drawCanvas();
}
return;
}
e.preventDefault();
e.stopPropagation();
const snap = getEffectiveSnap();
const step = e.ctrlKey ? (snap.x > 1 ? snap.x / getCanvasScale().x : 10) : 1;
const stepY = e.ctrlKey ? (snap.y > 1 ? snap.y / getCanvasScale().y : 10) : 1;
const dx = dir[0] * step;
const dy = dir[1] * stepY;
const bbox = node._bboxes[node._activeIdx];
const { x1, y1, x2, y2 } = normBbox(bbox);
const w = x2 - x1, h = y2 - y1;
const nx = Math.max(0, Math.min(canvasEl.width - w, x1 + dx));
const ny = Math.max(0, Math.min(canvasEl.height - h, y1 + dy));
node._bboxes[node._activeIdx] = { startX: nx, startY: ny, endX: nx + w, endY: ny + h };
updateBboxWidgets();
drawCanvas();
});
function updateBboxWidgets() {
const data = {};
if (node._rotation !== 0) data.rotation = node._rotation;
data.fillColor = getFillColor();
if (node._bboxes.length > 0) {
data.bboxes = node._bboxes.map((bbox) => {
const b = Object.assign({}, bbox);
if (node._previewImg) {
b.previewWidth = canvasEl.width;
b.previewHeight = canvasEl.height;
}
return b;
});
}
bboxWidget.value = Object.keys(data).length > 0 ? JSON.stringify(data) : "";
}
let _resizing = false;
function fitNodeToCanvas() {
if (canvasEl.width <= 0 || canvasEl.height <= 0) return;
// If in a no-bbox mode with target dims, resize canvas to output AR
const kp = getKp();
const { w: tgtW, h: tgtH } = getTgt();
const hasBboxes = node._bboxes.length > 0;
if (!hasBboxes && node._previewImg && node._previewEnabled && node._rotation === 0 && (tgtW > 0 || tgtH > 0) &&
(kp === "stretch" || isPadMode(kp))) {
const { w: imgW, h: imgH } = getEffectiveImageDims(kp !== "pad_edge");
let rw = tgtW > 0 ? tgtW : imgW;
let rh = tgtH > 0 ? tgtH : imgH;
const outAR = rw / rh;
const newH = Math.round(canvasEl.width / outAR);
if (Math.abs(newH - canvasEl.height) > 2) {
canvasEl.height = newH;
canvasEl.style.aspectRatio = `${canvasEl.width} / ${newH}`;
_rotCacheKey = "";
}
} else if (node._previewImg && node._previewEnabled) {
// Restore to image AR (including pad-first extra padding if active)
let { w: iw, h: ih } = getEffectiveImageDims(true);
const ep = getExtraPadding();
if (ep.isFirst && (ep.top + ep.bottom + ep.left + ep.right) > 0) {
iw += ep.left + ep.right;
ih += ep.top + ep.bottom;
}
const imgAR = iw / ih;
const newH = Math.round(canvasEl.width / imgAR);
if (Math.abs(newH - canvasEl.height) > 2) {
rescaleBboxesTo(canvasEl.width, newH);
canvasEl.height = newH;
canvasEl.style.aspectRatio = `${canvasEl.width} / ${newH}`;
_rotCacheKey = "";
}
}
// Update widget height but don't force node size — only grow if needed
const nodeW = node.size[0];
const displayedW = nodeW - 30;
const displayedH = Math.round(canvasEl.height * (displayedW / canvasEl.width));
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
}
// Bbox coords are stored in current canvas-pixel space. Whenever the canvas
// buffer is resized (preview load, node resize, AR change) the boxes must be
// rescaled by the same ratio so they stay locked to the image content.
function rescaleBboxesTo(newW, newH) {
const oldW = canvasEl.width, oldH = canvasEl.height;
if (!oldW || !oldH || !newW || !newH || !node._bboxes.length) return;
if (oldW === newW && oldH === newH) return;
const sx = newW / oldW, sy = newH / oldH;
node._bboxes = node._bboxes.map((b) => ({
startX: b.startX * sx, startY: b.startY * sy,
endX: b.endX * sx, endY: b.endY * sy,
}));
}
function setCanvasSize(cw, ch) {
rescaleBboxesTo(cw, ch);
canvasEl.width = cw;
canvasEl.height = ch;
canvasEl.style.aspectRatio = `${cw} / ${ch}`;
node.properties.canvasSize = [cw, ch];
node._widgetHeight = ch + GRID_BAR_HEIGHT;
if (node.graph) node.graph.setDirtyCanvas(true, true);
fitNodeToCanvas();
}
function updateCanvasFromTargetDims() {
if (node._previewImg && node._previewEnabled) return;
const divBy = getDivBy();
const { w: twV, h: thV } = getTgt();
let tw = roundDown(twV, divBy);
let th = roundDown(thV, divBy);
const [cw, ch] = clampToMaxCanvas(tw > 0 ? Math.max(tw, 200) : 400, th > 0 ? Math.max(th, 150) : 300);
if (cw !== canvasEl.width || ch !== canvasEl.height) {
setCanvasSize(cw, ch);
}
}
function getCanvasScale() {
if (node._previewImg && node._previewEnabled) {
if (node._rotation !== 0) {
// When rotated, canvas is sized to rotated bounds — uniform scale
const { w: rotW } = getEffectiveImageDims(true);
const s = rotW / canvasEl.width; // same as rotH / canvasEl.height
return { x: s, y: s };
}
let nw = node._previewImg.naturalWidth;
let nh = node._previewImg.naturalHeight;
// For "pad first" modes, bbox coords map to padded image dimensions
const ep = getExtraPadding();
if (ep.isFirst && hasExtraPad(ep)) {
nw += ep.left + ep.right;
nh += ep.top + ep.bottom;
}
return { x: nw / canvasEl.width, y: nh / canvasEl.height };
}
const { w: tw, h: th } = getTgt();
return { x: tw > 0 ? tw / canvasEl.width : 1, y: th > 0 ? th / canvasEl.height : 1 };
}
function getOutputDims(bbox) {
if (!bbox) return null;
const { x: sX, y: sY } = getCanvasScale();
const { x1, y1, x2, y2 } = normBbox(bbox);
const cropW = Math.round((x2 - x1) * sX);
const cropH = Math.round((y2 - y1) * sY);
const dims = computeOutputDims(cropW, cropH);
return dims ? { w: Math.max(1, dims.w), h: Math.max(1, dims.h) } : null;
}
function getGridParams() {
const gridSize = getGridSize();
if (gridSize <= 1) return null;
let tw, th;
if (node._previewImg && node._previewEnabled) {
const dims = getEffectiveImageDims(true);
tw = dims.w;
th = dims.h;
} else {
tw = twWidget ? twWidget.value : canvasEl.width;
th = thWidget ? thWidget.value : canvasEl.height;
}
const scaleX = canvasEl.width / tw;
const scaleY = canvasEl.height / th;
if (gridSize * scaleX < 4 && gridSize * scaleY < 4) return null;
// Per-dimension effective grid: no partial cells, cells are near-square
const cellsX = Math.max(1, Math.round(tw / gridSize));
const cellsY = Math.max(1, Math.round(th / gridSize));
const effGridW = tw / cellsX;
const effGridH = th / cellsY;
return { gridSize, tw, th, scaleX, scaleY, effGridW, effGridH };
}
function drawGrid(ctx, alpha, clipBbox, color) {
const g = getGridParams();
if (!g) return;
const { tw, th, scaleX, scaleY, effGridW, effGridH } = g;
ctx.save();
if (clipBbox) {
const { x1, y1, x2, y2 } = normBbox(clipBbox);
ctx.beginPath();
ctx.rect(x1, y1, x2 - x1, y2 - y1);
ctx.clip();
}
const showPreview = node._previewImg && node._previewEnabled;
const defaultAlpha = showPreview ? 0.25 : 0.1;
if (color) {
ctx.strokeStyle = color;
} else {
const { r, g, b } = getGridColorRGB();
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha ?? defaultAlpha})`;
}
ctx.lineWidth = 1;
ctx.beginPath();
for (let rx = effGridW; rx < tw - 0.5; rx += effGridW) {
const px = Math.round(rx * scaleX) + 0.5;
ctx.moveTo(px, 0);
ctx.lineTo(px, canvasEl.height);
}
for (let ry = effGridH; ry < th - 0.5; ry += effGridH) {
const py = Math.round(ry * scaleY) + 0.5;
ctx.moveTo(0, py);
ctx.lineTo(canvasEl.width, py);
}
ctx.stroke();
ctx.restore();
}
function drawRotationCross(ctx) {
if (!node._showRotationCross) return;
const { width: cw, height: ch } = canvasEl;
const armLen = Math.sqrt(cw * cw + ch * ch) / 2;
const rad = rotRad();
const isActive = node._rotation !== 0;
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.rotate(rad);
ctx.beginPath();
ctx.moveTo(-armLen, 0); ctx.lineTo(armLen, 0);
ctx.moveTo(0, -armLen); ctx.lineTo(0, armLen);
// Black outline
Object.assign(ctx, { strokeStyle: "rgba(0, 0, 0, 0.6)", lineWidth: isActive ? 5 : 4 });
ctx.stroke();
// Orange fill
Object.assign(ctx, { strokeStyle: "rgba(255, 160, 30, 0.8)", lineWidth: isActive ? 3 : 2 });
ctx.stroke();
if (isActive) {
ctx.rotate(-rad);
const label = `${node._rotation.toFixed(1)}°`;
Object.assign(ctx, { font: "12px sans-serif", textAlign: "center" });
const lw = ctx.measureText(label).width + 8;
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
ctx.beginPath(); ctx.roundRect(-lw / 2, -34, lw, 16, 3); ctx.fill();
ctx.fillStyle = "rgba(255, 160, 30, 1)";
ctx.fillText(label, 0, -20);
}
ctx.restore();
}
function drawBbox(ctx, bbox, index, isActive) {
const { x1, y1, x2, y2 } = normBbox(bbox);
const w = x2 - x1, h = y2 - y1;
const color = getBboxColor(index, isActive);
const invertEnabled = icWidget ? icWidget.value === "enabled" : false;
if (isActive) {
if (invertEnabled) {
ctx.fillStyle = getFillColor(); ctx.fillRect(x1, y1, w, h);
} else {
ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; ctx.beginPath();
ctx.rect(0, 0, canvasEl.width, canvasEl.height); ctx.rect(x1, y1, w, h); ctx.fill("evenodd");
}
}
drawGrid(ctx, null, bbox, color.gridColor);
if (!isActive) { ctx.fillStyle = color.tint; ctx.fillRect(x1, y1, w, h); }
if (isActive && !invertEnabled) {
const { x: sX, y: sY } = getCanvasScale();
const cropW = Math.round(w * sX);
const cropH = Math.round(h * sY);
const { w: tgtW, h: tgtH } = getTgt();
const kp = getKp();
if (kp === "multiplier") {
const wmv = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
const hmv = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
if (wmv !== 1.0 || hmv !== 1.0) {
drawStretchOverlay(ctx, (cropW * wmv) / (cropH * hmv), x1, y1, w, h);
}
}
if ((tgtW > 0 || tgtH > 0) && kp === "stretch") {
const { rw, rh } = getEffectiveTargetDims(cropW, cropH);
drawStretchOverlay(ctx, rw / rh, x1, y1, w, h);
}
if ((tgtW > 0 || tgtH > 0) && (kp === "crop" || isPadMode(kp))) {
const { rw, rh } = getEffectiveTargetDims(cropW, cropH);
if (kp === "crop") {
const ratio = Math.max(rw / cropW, rh / cropH);
const visW = rw / ratio, visH = rh / ratio;
const offX = (cropW - visW) / 2, offY = (cropH - visH) / 2;
const vx1 = x1 + offX / sX, vy1 = y1 + offY / sY;
const vx2 = x1 + (offX + visW) / sX, vy2 = y1 + (offY + visH) / sY;
dimSides(ctx, x1, y1, w, h, vx1, vy1, vx2 - vx1, vy2 - vy1);
drawDashedBorder(ctx, vx1, vy1, vx2 - vx1, vy2 - vy1, "rgba(255, 255, 100, 0.8)");
} else if (isPadMode(kp)) {
const { pW, pH, pX, pY, cW, cH, cX, cY } = computePadLayout(rw, rh, cropW, cropH, w, h, x1, y1);
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(x1, y1, w, h);
if (kp === "pad_edge" && node._previewEnabled && node._previewImg) {
renderToTmp(Math.max(1, Math.round(cW)), Math.max(1, Math.round(cH)),
node._previewImg, x1 * sX, y1 * sY, w * sX, h * sY);
drawExtraPadFill(ctx, getEdgeEp(), pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
} else if (kp === "pad_color") {
ctx.fillStyle = getFillColor();
ctx.fillRect(pX, pY, pW, pH);
}
if (node._previewEnabled) {
drawPreviewRegion(ctx, x1, y1, w, h, cX, cY, cW, cH);
}
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
}
}
const epBbox = getExtraPadding();
const padModeHandledBbox = isPadMode(kp) && (tgtW > 0 || tgtH > 0);
if (hasExtraPad(epBbox) && !padModeHandledBbox && !invertEnabled && !epBbox.isFirst && node._previewImg) {
const epContent = computeContentDims(cropW, cropH);
const epCW = epContent ? epContent.w : cropW, epCH = epContent ? epContent.h : cropH;
const totalW2 = epCW + epBbox.left + epBbox.right, totalH2 = epCH + epBbox.top + epBbox.bottom;
const { pW: pW2, pH: pH2, pX: pX2, pY: pY2 } = fitAspect(totalW2 / totalH2, w, h, x1, y1);
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(x1, y1, w, h);
const cW2 = pW2 * (epCW / totalW2), cH2 = pH2 * (epCH / totalH2);
const cX2 = pX2 + pW2 * (epBbox.left / totalW2), cY2 = pY2 + pH2 * (epBbox.top / totalH2);
// Draw crop fitted within content area (letterbox, don't stretch)
const cropAR = cropW / cropH;
const { pW: iW2, pH: iH2, pX: iX2, pY: iY2 } = fitAspect(cropAR, cW2, cH2, cX2, cY2);
const riW2 = Math.max(1, Math.round(iW2)), riH2 = Math.max(1, Math.round(iH2));
renderToTmp(riW2, riH2, node._previewImg, x1 * sX, y1 * sY, w * sX, h * sY);
drawExtraPadFill(ctx, epBbox, pX2, pY2, pW2, pH2, cX2, cY2, cW2, cH2, _tmpCanvas);
drawPreviewRegion(ctx, x1, y1, w, h, iX2, iY2, iW2, iH2);
drawDashedBorder(ctx, cX2, cY2, cW2, cH2, "rgba(100, 200, 255, 0.8)");
}
const { w: outW, h: outH } = getOutputDims(bbox) || { w: cropW, h: cropH };
const boxLabel = node._bboxes.length > 1 ? `[${index + 1}] ` : "";
const label = (tgtW > 0 || tgtH > 0)
? `${boxLabel}${cropW}×${cropH} → ${outW}×${outH}`
: `${boxLabel}${cropW} × ${cropH}`;
ctx.font = "12px sans-serif";
const lw = ctx.measureText(label).width + 12;
const lh = 20;
const ly = y1 > lh + 4 ? y1 - lh - 2 : y1 + 4;
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(x1, ly, lw, lh);
ctx.fillStyle = "#fff";
ctx.textAlign = "left";
ctx.fillText(label, x1 + 6, ly + 14);
} else if (!isActive) {
// Inactive: show index label centered in bbox
const boxLabel = `${index + 1}`;
ctx.font = "bold 20px sans-serif";
ctx.fillStyle = color.border;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(boxLabel, x1 + w / 2, y1 + h / 2);
ctx.textBaseline = "alphabetic";
}
if (isActive) {
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2;
Object.assign(ctx, { strokeStyle: color.border, lineWidth: 1 });
ctx.beginPath();
ctx.moveTo(cx - 5, cy); ctx.lineTo(cx + 5, cy);
ctx.moveTo(cx, cy - 5); ctx.lineTo(cx, cy + 5);
ctx.stroke();
}
if (!isActive) { ctx.strokeStyle = "rgba(0, 0, 0, 0.6)"; ctx.lineWidth = 4; ctx.strokeRect(x1, y1, w, h); }
ctx.strokeStyle = color.border; ctx.lineWidth = 2; ctx.strokeRect(x1, y1, w, h);
ctx.fillStyle = color.handle;
const hs = isActive ? 5 : 4, s2 = hs * 2;
const cx1 = Math.max(hs, Math.min(canvasEl.width - hs, x1));
const cx2 = Math.max(hs, Math.min(canvasEl.width - hs, x2));
const cy1 = Math.max(hs, Math.min(canvasEl.height - hs, y1));
const cy2 = Math.max(hs, Math.min(canvasEl.height - hs, y2));
for (const [hx, hy] of [[cx1, cy1], [cx2, cy1], [cx1, cy2], [cx2, cy2]])
ctx.fillRect(hx - hs, hy - hs, s2, s2);
}
function drawCanvas() {
const ctx = canvasCtx;
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
_frameEp = _readExtraPadding();
if (node._dragMode !== "pad_drag") _framePadXY = _readPadXY();
const _kpVal = getKp(), { w: _tgtW, h: _tgtH } = getTgt();
const hasBboxes = node._bboxes.length > 0;
const showPreview = node._previewImg && node._previewEnabled;
const _skipFullImage = !hasBboxes && showPreview && isPadMode(_kpVal) && (_tgtW > 0 || _tgtH > 0);
const _epBase = getExtraPadding();
const _isFirst = _epBase.isFirst, _hasFirstPad = _isFirst && hasExtraPad(_epBase);
const maybeResize = (cw, ch) => {
if (cw !== canvasEl.width || ch !== canvasEl.height) { setCanvasSize(cw, ch); ctx.clearRect(0, 0, cw, ch); }
};
if (showPreview) {
if (_hasFirstPad) {
const { w: eiw, h: eih } = getEffectiveImageDims(_epBase.mode !== "pad_edge");
maybeResize(...clampToMaxCanvas(eiw + _epBase.left + _epBase.right, eih + _epBase.top + _epBase.bottom));
} else if (node._previewImg) {
maybeResize(...clampToMaxCanvas(...Object.values(getEffectiveImageDims(true))));
}
}
if (showPreview && !_skipFullImage) {
if (_hasFirstPad) {
const { w: epImgW, h: epImgH } = getEffectiveImageDims(_epBase.mode !== "pad_edge");
const totalW = epImgW + _epBase.left + _epBase.right, totalH = epImgH + _epBase.top + _epBase.bottom;
const cX = canvasEl.width * (_epBase.left / totalW), cY = canvasEl.height * (_epBase.top / totalH);
const cW = canvasEl.width * (epImgW / totalW), cH = canvasEl.height * (epImgH / totalH);
const rcW = Math.max(1, Math.round(cW)), rcH = Math.max(1, Math.round(cH));
// Render content (with replicate-rotate for pad_edge + rotation)
renderToTmp(rcW, rcH);
drawExtraPadFill(ctx, _epBase, 0, 0, canvasEl.width, canvasEl.height, cX, cY, cW, cH, _tmpCanvas);
drawPreviewRegion(ctx, 0, 0, canvasEl.width, canvasEl.height, cX, cY, cW, cH);
} else {
if (node._rotation !== 0) { ctx.fillStyle = getFillColor(); ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); }
const { pW, pH, pX, pY } = fitAspect(_rotCanvas.width / _rotCanvas.height, canvasEl.width, canvasEl.height);
ctx.drawImage(getRotatedPreview(), 0, 0, _rotCanvas.width, _rotCanvas.height, pX, pY, pW, pH);
}
} else if (!showPreview) {
ctx.fillStyle = "#383838"; ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
const divBy = getDivBy(), { w: lw0, h: lh0 } = getTgt();
Object.assign(ctx, { fillStyle: "#888", font: "14px sans-serif", textAlign: "center" });
ctx.fillText(`${roundDown(lw0, divBy) || canvasEl.width} × ${roundDown(lh0, divBy) || canvasEl.height}`,
canvasEl.width / 2, canvasEl.height / 2);
}
// Manual mode: visualize extra padding on the placeholder
if (!hasBboxes && !showPreview) {
const _ep = getExtraPadding();
if (hasExtraPad(_ep)) {
const cW0 = canvasEl.width, cH0 = canvasEl.height;
const { w: tw0, h: th0 } = getTgt();
const rawW = tw0 > 0 ? tw0 : cW0;
const rawH = th0 > 0 ? th0 : cH0;
const content = computeContentDims(rawW, rawH);
const contentW = content ? content.w : rawW;
const contentH = content ? content.h : rawH;
const totalW = contentW + _ep.left + _ep.right;
const totalH = contentH + _ep.top + _ep.bottom;
const { pW, pH, pX, pY } = fitAspect(totalW / totalH, cW0, cH0);
const cW = pW * (contentW / totalW), cH = pH * (contentH / totalH);
const cX = pX + pW * (_ep.left / totalW), cY = pY + pH * (_ep.top / totalH);
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, cW0, cH0);
if (_ep.mode === "pad_color" || _ep.mode === "pad_crop_color") {
ctx.fillStyle = getFillColor();
} else {
ctx.fillStyle = "#555";
}
ctx.fillRect(pX, pY, pW, pH);
ctx.fillStyle = "#383838";
ctx.fillRect(cX, cY, cW, cH);
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
// Redraw dimension text on top
const divBy = getDivBy(), { w: lw0, h: lh0 } = getTgt();
Object.assign(ctx, { fillStyle: "#888", font: "14px sans-serif", textAlign: "center" });
ctx.fillText(`${roundDown(lw0, divBy) || cW0} × ${roundDown(lh0, divBy) || cH0}`, cX + cW / 2, cY + cH / 2);
}
}
// When no bboxes are drawn, show pad/crop preview for the full image (auto mode only)
if (!hasBboxes && node._previewEnabled) {
const cW0 = canvasEl.width, cH0 = canvasEl.height;
if (node._previewImg) {
const { w: tgtW, h: tgtH } = getTgt();
const kp = getKp();
// Multiplier mode: compute effective target from crop dims × multiplier
if (kp === "multiplier") {
const wmv = _getSubWidget("keep_proportion.width_mult")?.value || 1.0;
const hmv = _getSubWidget("keep_proportion.height_mult")?.value || 1.0;
if (wmv !== 1.0 || hmv !== 1.0) {
const nw = node._previewImg.naturalWidth;
const nh = node._previewImg.naturalHeight;
drawStretchOverlay(ctx, (nw * wmv) / (nh * hmv), 0, 0, cW0, cH0);
}
}
if ((tgtW > 0 || tgtH > 0) && (kp === "stretch" || isPadMode(kp) || kp === "crop")) {
const { w: imgW, h: imgH } = getEffectiveImageDims(kp !== "pad_edge");
const { rw, rh } = getEffectiveTargetDims(imgW, imgH);
if (kp === "stretch") {
drawStretchOverlay(ctx, rw / rh, 0, 0, cW0, cH0);
} else if (isPadMode(kp)) {
const epPad = getExtraPadding();
const hasEp = hasExtraPad(epPad);
// Full output = target. Content = target - extra padding.
const fullTgtW = tgtW > 0 ? tgtW : imgW;
const fullTgtH = tgtH > 0 ? tgtH : imgH;
const contentTgtW = hasEp ? Math.max(1, fullTgtW - epPad.left - epPad.right) : fullTgtW;
const contentTgtH = hasEp ? Math.max(1, fullTgtH - epPad.top - epPad.bottom) : fullTgtH;
// Fit the full output to the canvas
const { pW: outerW, pH: outerH, pX: outerX, pY: outerY } = fitAspect(fullTgtW / fullTgtH, cW0, cH0);
// Content area within the output (offset by padding)
const contentW = outerW * (contentTgtW / fullTgtW);
const contentH = outerH * (contentTgtH / fullTgtH);
const contentX = outerX + outerW * (epPad.left / fullTgtW);
const contentY = outerY + outerH * (epPad.top / fullTgtH);
// Image is sized to fit the content area, but positioned across the full output via pad_x/pad_y
const imgRatio = Math.min(contentTgtW / imgW, contentTgtH / imgH);
const scaledW = imgW * imgRatio, scaledH = imgH * imgRatio;
const pW = outerW, pH = outerH, pX = outerX, pY = outerY;
const cW = outerW * (scaledW / fullTgtW), cH = outerH * (scaledH / fullTgtH);
const { cX, cY } = computePadContentPos(outerX, outerY, outerW, outerH, cW, cH);
ctx.clearRect(0, 0, cW0, cH0);
// Fill outer area (extra padding region)
if (hasEp) {
ctx.fillStyle = getFillColor();
ctx.fillRect(outerX, outerY, outerW, outerH);
}
if (kp === "pad_edge") {
renderToTmp(Math.max(1, Math.round(cW)), Math.max(1, Math.round(cH)),
null, 0, 0, 0, 0, true);
ctx.drawImage(_tmpCanvas, 0, 0, _tmpCanvas.width, _tmpCanvas.height, cX, cY, cW, cH);
drawExtraPadFill(ctx, getEdgeEp(), pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
} else {
ctx.fillStyle = getFillColor();
ctx.fillRect(pX, pY, pW, pH);
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
}
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
if (hasEp) drawDashedBorder(ctx, contentX, contentY, contentW, contentH, "rgba(255, 200, 100, 0.6)");
} else if (kp === "crop") {
const ratio = Math.max(rw / imgW, rh / imgH);
const visW = rw / ratio, visH = rh / ratio;
const offX = (imgW - visW) / 2, offY = (imgH - visH) / 2;
const sc = Math.min(cW0 / imgW, cH0 / imgH);
const ox = (cW0 - imgW * sc) / 2, oy = (cH0 - imgH * sc) / 2;
const vx1 = ox + offX * sc, vy1 = oy + offY * sc;
const vw = visW * sc, vh = visH * sc;
dimSides(ctx, 0, 0, cW0, cH0, vx1, vy1, vw, vh);
drawDashedBorder(ctx, vx1, vy1, vw, vh, "rgba(255, 255, 100, 0.8)");
}
}
}
const _ep = getExtraPadding();
if (hasExtraPad(_ep) && node._previewImg && !_isFirst && !(isPadMode(_kpVal) && (_tgtW > 0 || _tgtH > 0))) {
const { w: rawImgW, h: rawImgH } = getEffectiveImageDims(_ep.mode !== "pad_edge");
const content = computeContentDims(rawImgW, rawImgH);
const epImgW = content ? content.w : rawImgW;
const epImgH = content ? content.h : rawImgH;
const totalW = epImgW + _ep.left + _ep.right, totalH = epImgH + _ep.top + _ep.bottom;
const { pW, pH, pX, pY } = fitAspect(totalW / totalH, cW0, cH0);
ctx.clearRect(0, 0, cW0, cH0);
const cW = pW * (epImgW / totalW), cH = pH * (epImgH / totalH);
const cX = pX + pW * (_ep.left / totalW), cY = pY + pH * (_ep.top / totalH);
// Draw padding fill, then image in content area (same approach as keep_proportion pad mode)
if (_ep.mode === "pad_color") {
ctx.fillStyle = getFillColor();
ctx.fillRect(pX, pY, pW, pH);
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
} else {
// pad_edge: render content for edge fill source
const rcW2 = Math.max(1, Math.round(cW)), rcH2 = Math.max(1, Math.round(cH));
renderToTmp(rcW2, rcH2, null, 0, 0, 0, 0, _ep.isFirst);
drawExtraPadFill(ctx, _ep, pX, pY, pW, pH, cX, cY, cW, cH, _tmpCanvas);
drawPreviewRegion(ctx, 0, 0, cW0, cH0, cX, cY, cW, cH);
}
drawDashedBorder(ctx, cX, cY, cW, cH, "rgba(100, 200, 255, 0.8)");
}
drawGrid(ctx);
drawRotationCross(ctx);
if (!node._drawing) updateOutputSlotLabels();
return;
}
if (node._activeIdx >= 0 && node._activeIdx < node._bboxes.length) {
drawBbox(ctx, node._bboxes[node._activeIdx], node._activeIdx, true);
}
for (let i = 0; i < node._bboxes.length; i++) {
if (i === node._activeIdx) continue;
drawBbox(ctx, node._bboxes[i], i, false);
}
drawGrid(ctx);
drawRotationCross(ctx);
if (!node._drawing) updateOutputSlotLabels();
}
let _lastSlotLabelKey = "";
function updateOutputSlotLabels() {
const activeBbox = (node._activeIdx >= 0 && node._activeIdx < node._bboxes.length)
? node._bboxes[node._activeIdx] : null;
let dims = activeBbox ? getOutputDims(activeBbox) : null;
if (!dims) {
const { w: tW, h: tH } = getTgt();
const imgW = node._previewImg ? node._previewImg.naturalWidth
: (tW > 0 ? tW : canvasEl.width);
const imgH = node._previewImg ? node._previewImg.naturalHeight
: (tH > 0 ? tH : canvasEl.height);
const computed = computeOutputDims(imgW, imgH);
if (computed && computed.w > 0 && computed.h > 0) {
dims = computed;
}
}
// Skip expensive DOM/canvas updates if nothing changed
const key = `${dims?.w || 0},${dims?.h || 0},${node._bboxes.length}`;
if (key === _lastSlotLabelKey) return;
_lastSlotLabelKey = key;
if (node.outputs) {
const hasBboxes = node._bboxes.length > 0;
for (let i = 0; i < node.outputs.length; i++) {
const out = node.outputs[i];
if (!out._origName) {
if (out.name?.includes("width") || out.label?.includes("width")) out._origName = "width";
else if (out.name?.includes("height") || out.label?.includes("height")) out._origName = "height";
else if (out.name === "bbox") out._origName = "bbox";
else if (out.name === "bbox_mask") out._origName = "bbox_mask";
}
if (out._origName === "width") {
out.label = dims ? `${dims.w} width` : "width";
} else if (out._origName === "height") {
out.label = dims ? `${dims.h} height` : "height";
}
// Identify output/output_mask slots
if (!out._origName) {
if (out.name === "cropped" || out.name === "output") out._origName = "output";
else if (out.name === "cropped_mask" || out.name === "output_mask") out._origName = "output_mask";
}
// Toggle list icon on output/output_mask
if (out._origName === "output" || out._origName === "output_mask") {
out.shape = hasBboxes && node._bboxes.length > 1 ? 6 : undefined;
}
// Visually hide bbox/bbox_mask slots when no bboxes, show with list icon when multiple
if (out._origName === "bbox" || out._origName === "bbox_mask") {
if (!hasBboxes) {
out.label = " ";
out.color_off = "transparent";
out.color_on = "transparent";
out.shape = 6;
out.dir = undefined;
} else {
out.label = out._origName;
out.color_off = undefined;
out.color_on = undefined;
out.shape = node._bboxes.length > 1 ? 6 : undefined;
}
}
}
node.setDirtyCanvas(true, true);
// Vue nodes mode: hide/show bbox slots via DOM since canvas properties don't apply
if (LiteGraph.vueNodesMode && node.id != null) {
const nodeEl = document.querySelector(`[data-node-id="${node.id}"]`);
if (nodeEl) {
const outputSlots = nodeEl.querySelectorAll(".lg-slot--output");
for (let i = 0; i < node.outputs.length; i++) {
const out = node.outputs[i];
if ((out._origName === "bbox" || out._origName === "bbox_mask") && outputSlots[i]) {
outputSlots[i].style.visibility = hasBboxes ? "" : "hidden";
// Update label text directly since Vue may not observe raw property changes
if (hasBboxes) {
const labelEl = outputSlots[i].querySelector("span");
if (labelEl) labelEl.textContent = out._origName;
}
}
}
}
}
}
}
function loadPreviewImage(src, preserveBbox) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
node._previewImg = img;
_rotCacheKey = "";
invalidateGridStops();
restoreCanvasToImage();
if (node._previewEnabled && !preserveBbox) {
node._bboxes = [];
node._activeIdx = -1;
}
updateBboxWidgets();
drawCanvas();
fitNodeToImage(preserveBbox);
};
img.onerror = () => {
console.warn("ImageTransformKJ: failed to load preview:", src);
};
img.src = src;
}
function loadPreviewFromTempFile(filename) {
if (!filename) return;
loadPreviewImage(`/view?filename=${encodeURIComponent(filename)}&type=temp`, true);
}
function captureVideoFrameToPreview(videoEl, preserveBbox) {
captureVideoFrame(videoEl, (canvas) => {
loadPreviewImage(canvas.toDataURL("image/webp", 0.9), preserveBbox);
});
}
function handleSourceChange(sources, preserveBbox) {
if (sources.length === 0) {
// Disconnected
node._previewImg = null;
delete node.properties.previewFile;
if (node._previewEnabled) {
node._bboxes = [];
node._activeIdx = -1;
updateBboxWidgets();
}
updateCanvasFromTargetDims();
drawCanvas();
return;
}
const src = sources[0];
if (src.isVideo && src.videoEl) {
captureVideoFrameToPreview(src.videoEl, preserveBbox);
} else if (src.url) {
loadPreviewImage(src.url, preserveBbox);
}
}
let _configuring = false, _sourceResolved = false;
watchImageInputs(node, "image", (sources) => {
_sourceResolved = true;
handleSourceChange(sources, _configuring);
});
chainCallback(this, "onResize", function () {
if (_resizing) return;
const availW = Math.max(100, node.size[0] - 30);
_resizing = true;
if (node._previewEnabled) {
// Auto mode: resize canvas pixel buffer to match node width
const ar = canvasEl.height / canvasEl.width;
const newW = Math.round(availW);
const newH = Math.round(availW * ar);
if (newW !== canvasEl.width || newH !== canvasEl.height) {
rescaleBboxesTo(newW, newH);
canvasEl.width = newW;
canvasEl.height = newH;
canvasEl.style.aspectRatio = `${newW} / ${newH}`;
node.properties.canvasSize = [newW, newH];
}
node._widgetHeight = newH + GRID_BAR_HEIGHT;
} else {
// Manual mode: keep pixel buffer fixed, CSS handles display scaling
const displayedH = Math.round(canvasEl.height * (availW / canvasEl.width));
node._widgetHeight = displayedH + GRID_BAR_HEIGHT;
}
drawCanvas();
_resizing = false;
});
chainCallback(this, "onConfigure", function () {
node._cropConfigured = true;
if (node.properties.gridSize !== undefined) {
gridSlider.value = node.properties.gridSize;
updateGridLabel();
}
if (node.properties.previewEnabled !== undefined) {
node._previewEnabled = node.properties.previewEnabled;
updatePreviewBtn();
}
restoreBboxesFromWidget();
if (node.properties.rotation !== undefined) node._rotation = node.properties.rotation;
if (node.properties.fillColor) {
colorInput.value = node.properties.fillColor;
colorSwatch.style.background = node.properties.fillColor;
}
if (node.properties.gridColor) {
gridColorInput.value = node.properties.gridColor;
gridColorSwatch.style.background = node.properties.gridColor;
}
if (node.properties.canvasSize) {
setCanvasSize(node.properties.canvasSize[0], node.properties.canvasSize[1]);
}
_configuring = true;
_sourceResolved = false;
setTimeout(() => {
if (node.properties.gridSize !== undefined) {
gridSlider.value = node.properties.gridSize;
updateGridLabel();
}
_configuring = false;
if (!_sourceResolved) {
if (node.properties.previewFile) {
loadPreviewFromTempFile(node.properties.previewFile);
} else {
updateCanvasFromTargetDims();
drawCanvas();
}
}
}, 200);
});
chainCallback(this, "onExecuted", function (message) {
const filename = message?.preview_filename?.[0];
if (filename) {
node.properties.previewFile = filename;
loadPreviewFromTempFile(filename);
}
});
// Initial draw
setCanvasSize(400, 300);
drawCanvas();
});
},
});