aimusic / ComfyUI /custom_nodes /ComfyUI-KJNodes /web /js /image_transform.js
vidfom's picture
Upload folder using huggingface_hub (part 6)
f2b53cf verified
Raw
History Blame Contribute Delete
96.8 kB
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();
});
},
});