Round frame canvas dimensions up when scaling

This commit is contained in:
adenek
2026-07-13 14:13:24 +02:00
parent 5a3fbdc4d1
commit de8f4f8e16
5 changed files with 132 additions and 55 deletions
+5 -2
View File
@@ -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
+22 -18
View File
@@ -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;
+8 -4
View File
@@ -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));
}
+35 -31
View File
@@ -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",
}
}
+62
View File
@@ -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()