Instructions to use qwecja/ltx-ugc-bundle with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LTX.io
How to use qwecja/ltx-ugc-bundle with LTX.io:
# Install the LTX-2 pipelines git clone https://github.com/Lightricks/LTX-2.git cd LTX-2 uv sync --frozen
# Download the weights from this repo, plus the Gemma text encoder hf download qwecja/ltx-ugc-bundle --local-dir models/ltx-ugc-bundle hf download google/gemma-3-12b-it-qat-q4_0-unquantized --local-dir models/gemma-3-12b
# Fast pipeline (distilled model, no distilled LoRA needed) uv run python -m ltx_pipelines.distilled \ --distilled-checkpoint-path models/ltx-ugc-bundle/<distilled-checkpoint>.safetensors \ --spatial-upsampler-path models/ltx-ugc-bundle/<spatial-upsampler>.safetensors \ --gemma-root models/gemma-3-12b \ --prompt "A beautiful sunset over the ocean" \ --output-path output.mp4 # For image-to-video, add: --image path/to/image.jpg 0 0.8# HQ pipeline (two-stage, higher quality) uv run python -m ltx_pipelines.ti2vid_two_stages_hq \ --checkpoint-path models/ltx-ugc-bundle/<checkpoint>.safetensors \ --distilled-lora models/ltx-ugc-bundle/<distilled-lora>.safetensors 0.8 \ --spatial-upsampler-path models/ltx-ugc-bundle/<spatial-upsampler>.safetensors \ --gemma-root models/gemma-3-12b \ --prompt "A beautiful sunset over the ocean" \ --output-path output.mp4 # For image-to-video, add: --image path/to/image.jpg 0 0.8 - Notebooks
- Google Colab
- Kaggle
| 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(); | |
| 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 = 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 = 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) { | |
| 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; | |
| } | |
| function setCanvasSize(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) { | |
| 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(); | |
| }); | |
| }, | |
| }); | |