From de8f4f8e16bc321e6d7798446d20eddd67f8d0de Mon Sep 17 00:00:00 2001 From: adenek Date: Mon, 13 Jul 2026 14:13:24 +0200 Subject: [PATCH] Round frame canvas dimensions up when scaling --- README.md | 7 ++- js/frame_canvas_recut/geometry.js | 40 ++++++++------- js/frame_canvas_recut/interactions.js | 12 +++-- nodes.py | 66 +++++++++++++------------ tests/test_frame_canvas_recut_layout.py | 62 +++++++++++++++++++++++ 5 files changed, 132 insertions(+), 55 deletions(-) create mode 100644 tests/test_frame_canvas_recut_layout.py diff --git a/README.md b/README.md index 080c182..84c798d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ Main controls: - `background_r/g/b`: background color visible where the frame does not cover the canvas - `resize_algorithm`: resize method +When canvas or frame sizes are recalculated from scaling, multiples, dragging, or zooming, fractional sizes are rounded up so the result does not undershoot the requested size. Hard `max_width` and `max_height` limits can still force the processing layout smaller. + Frontend preview: - Blue outline: requested content canvas inside the larger processing canvas - White outline: frame/image bounding box @@ -37,8 +39,9 @@ Outputs: - `recut_frames`: full recut frame batch on the processing canvas - `preview_first_frame`: first frame only, useful for connecting to a normal ComfyUI Preview Image node - `extended_area_mask`: mask frame sequence, repeated to match the input batch -- `actual_canvas_width`, `actual_canvas_height`: final processing canvas size -- `content_x`, `content_y`, `content_width`, `content_height`: position and size of the requested content canvas inside the processing canvas. Use this metadata later to crop away padding. +- `final_canvas_width`, `final_canvas_height`: exact requested canvas size +- `processing_canvas_width`, `processing_canvas_height`: final processing canvas size +- `crop_x`, `crop_y`, `crop_width`, `crop_height`: position and size of the requested content canvas inside the processing canvas. Use this metadata later to crop away padding. ## Install diff --git a/js/frame_canvas_recut/geometry.js b/js/frame_canvas_recut/geometry.js index d2a61d1..1aae0bc 100644 --- a/js/frame_canvas_recut/geometry.js +++ b/js/frame_canvas_recut/geometry.js @@ -2,9 +2,13 @@ import { app } from "../../../scripts/app.js"; import { HANDLE_SIZE } from "./constants.js"; import { getValue } from "./utils.js"; +function ceilDimension(value) { + return Math.max(1, Math.ceil(Number(value))); +} + function ceilToMultiple(value, multiple) { - value = Math.max(1, Math.round(Number(value))); - multiple = Math.max(1, Math.round(Number(multiple))); + value = ceilDimension(value); + multiple = ceilDimension(multiple); return Math.ceil(value / multiple) * multiple; } @@ -17,7 +21,7 @@ function floorToMultiple(value, multiple) { export function getTargetSize(node, canvasW, canvasH) { const targetW = Number(getValue(node, "target_width", canvasW)); const targetH = Number(getValue(node, "target_height", canvasH)); - return { w: Math.max(1, Math.round(targetW)), h: Math.max(1, Math.round(targetH)) }; + return { w: ceilDimension(targetW), h: ceilDimension(targetH) }; } export function getAnchorBase(node, canvasW, canvasH, frameW, frameH) { @@ -32,15 +36,15 @@ export function getAnchorBase(node, canvasW, canvasH, frameW, frameH) { export function getProcessingLayout(node, requestedCanvasW, requestedCanvasH) { const anchor = getValue(node, "anchor", "top_left"); - const multiple = Math.max(1, Math.round(Number(getValue(node, "processing_multiple", 32)))); - const minW = Math.max(1, Math.round(Number(getValue(node, "min_width", 512)))); - const minH = Math.max(1, Math.round(Number(getValue(node, "min_height", 512)))); - const maxW = Math.max(1, Math.round(Number(getValue(node, "max_width", 2048)))); - const maxH = Math.max(1, Math.round(Number(getValue(node, "max_height", 2048)))); + const multiple = ceilDimension(getValue(node, "processing_multiple", 32)); + const minW = ceilDimension(getValue(node, "min_width", 512)); + const minH = ceilDimension(getValue(node, "min_height", 512)); + const maxW = ceilDimension(getValue(node, "max_width", 2048)); + const maxH = ceilDimension(getValue(node, "max_height", 2048)); const paddingStrategy = getValue(node, "padding_strategy", "centered"); - let contentW = Math.max(1, Math.round(requestedCanvasW)); - let contentH = Math.max(1, Math.round(requestedCanvasH)); + let contentW = ceilDimension(requestedCanvasW); + let contentH = ceilDimension(requestedCanvasH); let target = getTargetSize(node, contentW, contentH); let xOffset = Math.round(Number(getValue(node, "x_offset", 0))); let yOffset = Math.round(Number(getValue(node, "y_offset", 0))); @@ -49,9 +53,9 @@ export function getProcessingLayout(node, requestedCanvasW, requestedCanvasH) { const maxAlignedH = floorToMultiple(maxH, multiple); let scale = Math.min(1.0, maxAlignedW / contentW, maxAlignedH / contentH); - contentW = Math.max(1, Math.round(contentW * scale)); - contentH = Math.max(1, Math.round(contentH * scale)); - target = { w: Math.max(1, Math.round(target.w * scale)), h: Math.max(1, Math.round(target.h * scale)) }; + contentW = ceilDimension(contentW * scale); + contentH = ceilDimension(contentH * scale); + target = { w: ceilDimension(target.w * scale), h: ceilDimension(target.h * scale) }; xOffset = Math.round(xOffset * scale); yOffset = Math.round(yOffset * scale); @@ -62,9 +66,9 @@ export function getProcessingLayout(node, requestedCanvasW, requestedCanvasH) { if (contentW > processingW || contentH > processingH) { const scale2 = Math.min(processingW / contentW, processingH / contentH); - contentW = Math.max(1, Math.round(contentW * scale2)); - contentH = Math.max(1, Math.round(contentH * scale2)); - target = { w: Math.max(1, Math.round(target.w * scale2)), h: Math.max(1, Math.round(target.h * scale2)) }; + contentW = ceilDimension(contentW * scale2); + contentH = ceilDimension(contentH * scale2); + target = { w: ceilDimension(target.w * scale2), h: ceilDimension(target.h * scale2) }; xOffset = Math.round(xOffset * scale2); yOffset = Math.round(yOffset * scale2); scale *= scale2; @@ -100,8 +104,8 @@ export function getProcessingLayout(node, requestedCanvasW, requestedCanvasH) { } export function getCanvasInfo(node, image) { - const requestedCanvasW = Math.max(1, Math.round(Number(getValue(node, "canvas_width", 1280)))); - const requestedCanvasH = Math.max(1, Math.round(Number(getValue(node, "canvas_height", 720)))); + const requestedCanvasW = ceilDimension(getValue(node, "canvas_width", 1280)); + const requestedCanvasH = ceilDimension(getValue(node, "canvas_height", 720)); const layout = getProcessingLayout(node, requestedCanvasW, requestedCanvasH); const canvasW = layout.processingW; diff --git a/js/frame_canvas_recut/interactions.js b/js/frame_canvas_recut/interactions.js index e4c2a04..6903313 100644 --- a/js/frame_canvas_recut/interactions.js +++ b/js/frame_canvas_recut/interactions.js @@ -2,17 +2,21 @@ import { MIN_FRAME_SIZE } from "./constants.js"; import { clamp, getPoint, getValue, setValue } from "./utils.js"; import { findHandle, getAnchorBase, getCanvasInfo, pointInsideRect, previewPointToCanvas, rectsIntersect } from "./geometry.js"; +function ceilFrameSize(value) { + return Math.max(MIN_FRAME_SIZE, Math.ceil(Number(value))); +} + function applyTargetTransform(node, info, newPasteX, newPasteY, newW, newH) { - newW = Math.max(MIN_FRAME_SIZE, Math.round(newW)); - newH = Math.max(MIN_FRAME_SIZE, Math.round(newH)); + newW = ceilFrameSize(newW); + newH = ceilFrameSize(newH); const safeScale = info.scaleFactor || 1; const localPasteX = newPasteX - (info.contentX || 0); const localPasteY = newPasteY - (info.contentY || 0); const base = getAnchorBase(node, info.contentW, info.contentH, newW, newH); - setValue(node, "target_width", Math.max(MIN_FRAME_SIZE, Math.round(newW / safeScale))); - setValue(node, "target_height", Math.max(MIN_FRAME_SIZE, Math.round(newH / safeScale))); + setValue(node, "target_width", ceilFrameSize(newW / safeScale)); + setValue(node, "target_height", ceilFrameSize(newH / safeScale)); setValue(node, "x_offset", Math.round((localPasteX - base.x) / safeScale)); setValue(node, "y_offset", Math.round((localPasteY - base.y) / safeScale)); } diff --git a/nodes.py b/nodes.py index b28ee8d..2a9317b 100644 --- a/nodes.py +++ b/nodes.py @@ -1,3 +1,4 @@ +import math import os import uuid import numpy as np @@ -196,8 +197,11 @@ class MAIFrameCanvasRecut: CATEGORY = "mAI/video" OUTPUT_NODE = True + def _ceil_dimension(self, value): + return max(1, int(math.ceil(float(value)))) + def _ceil_to_multiple(self, value, multiple): - value = max(1, int(round(value))) + value = self._ceil_dimension(value) multiple = max(1, int(multiple)) return ((value + multiple - 1) // multiple) * multiple @@ -222,15 +226,15 @@ class MAIFrameCanvasRecut: max_height, padding_strategy, ): - canvas_w = max(1, int(canvas_w)) - canvas_h = max(1, int(canvas_h)) - target_w = max(1, int(target_w)) - target_h = max(1, int(target_h)) - processing_multiple = max(1, int(processing_multiple)) - min_width = max(1, int(min_width)) - min_height = max(1, int(min_height)) - max_width = max(1, int(max_width)) - max_height = max(1, int(max_height)) + canvas_w = self._ceil_dimension(canvas_w) + canvas_h = self._ceil_dimension(canvas_h) + target_w = self._ceil_dimension(target_w) + target_h = self._ceil_dimension(target_h) + processing_multiple = self._ceil_dimension(processing_multiple) + min_width = self._ceil_dimension(min_width) + min_height = self._ceil_dimension(min_height) + max_width = self._ceil_dimension(max_width) + max_height = self._ceil_dimension(max_height) max_aligned_w = self._floor_to_multiple(max_width, processing_multiple) max_aligned_h = self._floor_to_multiple(max_height, processing_multiple) @@ -241,10 +245,10 @@ class MAIFrameCanvasRecut: max_aligned_h / max(1, canvas_h), ) - safe_canvas_w = max(1, int(round(canvas_w * scale))) - safe_canvas_h = max(1, int(round(canvas_h * scale))) - safe_target_w = max(1, int(round(target_w * scale))) - safe_target_h = max(1, int(round(target_h * scale))) + safe_canvas_w = self._ceil_dimension(canvas_w * scale) + safe_canvas_h = self._ceil_dimension(canvas_h * scale) + safe_target_w = self._ceil_dimension(target_w * scale) + safe_target_h = self._ceil_dimension(target_h * scale) safe_x_offset = int(round(x_offset * scale)) safe_y_offset = int(round(y_offset * scale)) @@ -256,10 +260,10 @@ class MAIFrameCanvasRecut: # If min/max are contradictory, keep the aligned max and scale the composition down again. if safe_canvas_w > processing_w or safe_canvas_h > processing_h: scale2 = min(processing_w / safe_canvas_w, processing_h / safe_canvas_h) - safe_canvas_w = max(1, int(round(safe_canvas_w * scale2))) - safe_canvas_h = max(1, int(round(safe_canvas_h * scale2))) - safe_target_w = max(1, int(round(safe_target_w * scale2))) - safe_target_h = max(1, int(round(safe_target_h * scale2))) + safe_canvas_w = self._ceil_dimension(safe_canvas_w * scale2) + safe_canvas_h = self._ceil_dimension(safe_canvas_h * scale2) + safe_target_w = self._ceil_dimension(safe_target_w * scale2) + safe_target_h = self._ceil_dimension(safe_target_h * scale2) safe_x_offset = int(round(safe_x_offset * scale2)) safe_y_offset = int(round(safe_y_offset * scale2)) scale *= scale2 @@ -306,7 +310,7 @@ class MAIFrameCanvasRecut: } def _get_resized_size(self, target_w, target_h): - return max(1, int(target_w)), max(1, int(target_h)) + return self._ceil_dimension(target_w), self._ceil_dimension(target_h) def _anchor_position( self, @@ -607,18 +611,18 @@ class MAIFrameCanvasRecut: batch, src_h, src_w, channels = frames.shape layout = self._get_processing_layout( - canvas_w=int(canvas_width), - canvas_h=int(canvas_height), - target_w=int(target_width), - target_h=int(target_height), + canvas_w=canvas_width, + canvas_h=canvas_height, + target_w=target_width, + target_h=target_height, x_offset=int(x_offset), y_offset=int(y_offset), anchor=anchor, - processing_multiple=int(processing_multiple), - min_width=int(min_width), - min_height=int(min_height), - max_width=int(max_width), - max_height=int(max_height), + processing_multiple=processing_multiple, + min_width=min_width, + min_height=min_height, + max_width=max_width, + max_height=max_height, padding_strategy=padding_strategy, ) @@ -732,8 +736,8 @@ class MAIFrameCanvasRecut: canvas, preview, mask, - int(canvas_width), - int(canvas_height), + self._ceil_dimension(canvas_width), + self._ceil_dimension(canvas_height), int(canvas_w), int(canvas_h), int(content_x), @@ -750,4 +754,4 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "MAIFrameCanvasRecut": "mAI Frame Canvas Recut", -} \ No newline at end of file +} diff --git a/tests/test_frame_canvas_recut_layout.py b/tests/test_frame_canvas_recut_layout.py new file mode 100644 index 0000000..4bada9e --- /dev/null +++ b/tests/test_frame_canvas_recut_layout.py @@ -0,0 +1,62 @@ +import unittest + +from nodes import MAIFrameCanvasRecut + + +class FrameCanvasRecutLayoutTests(unittest.TestCase): + def test_scaled_layout_dimensions_round_up(self): + node = MAIFrameCanvasRecut() + + layout = node._get_processing_layout( + canvas_w=999, + canvas_h=1000, + target_w=999, + target_h=1000, + x_offset=0, + y_offset=0, + anchor="top_left", + processing_multiple=1, + min_width=1, + min_height=1, + max_width=512, + max_height=512, + padding_strategy="centered", + ) + + self.assertEqual(layout["content_w"], 512) + self.assertEqual(layout["content_h"], 512) + self.assertEqual(layout["target_w"], 512) + self.assertEqual(layout["target_h"], 512) + + def test_ceil_to_multiple_uses_upper_integer(self): + node = MAIFrameCanvasRecut() + + self.assertEqual(node._ceil_to_multiple(32.1, 32), 64) + + def test_size_inputs_are_not_truncated(self): + node = MAIFrameCanvasRecut() + + layout = node._get_processing_layout( + canvas_w=728.1, + canvas_h=90.1, + target_w=512.1, + target_h=256.1, + x_offset=0, + y_offset=0, + anchor="top_left", + processing_multiple=32, + min_width=1, + min_height=1, + max_width=2048, + max_height=2048, + padding_strategy="pad_right_bottom", + ) + + self.assertEqual(layout["content_w"], 729) + self.assertEqual(layout["content_h"], 91) + self.assertEqual(layout["target_w"], 513) + self.assertEqual(layout["target_h"], 257) + + +if __name__ == "__main__": + unittest.main()