diff --git a/README.md b/README.md index 9fca075..9b050e2 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Returns frames, lazy audio, frame count, fps, width, height, duration, and a cor ### Video Crop + Rotate + Pad → Frame 🆎 -Find **one frame** in an uploaded or local video, then apply the image transform controls. A playhead rail on the node scrubs to the frame; the editor adds exact frame stepping and playback. Returns one transformed `image`, its generated-area `mask`, a `stitcher`, the selected `original` frame, and the output `width` and `height`. Use the Clip variant for an entire sequence. +Find **one frame** in an uploaded or local video, then apply the image transform controls and an optional megapixel resize. A playhead rail on the node scrubs to the frame; the editor adds exact frame stepping and playback. Returns one transformed `image`, its generated-area `mask`, a `stitcher`, the selected `original` frame, and the output `width` and `height`. Use the Clip variant for an entire sequence. ### Video Crop + Rotate + Pad → Clip 🆎 diff --git a/nodes/_transform_inputs.py b/nodes/_transform_inputs.py index 788a9dd..89b7e72 100644 --- a/nodes/_transform_inputs.py +++ b/nodes/_transform_inputs.py @@ -166,9 +166,8 @@ def transform_inputs(*, feather: int = 24, fill_color: str = "#808080") -> dict[ } -# The image and clip nodes only (the frame node keeps its source geometry): -# resize the transformed output to a pixel budget, core -# ImageScaleToTotalPixels-style. +# Every transform node, appended after its other widgets: resize the +# transformed output to a pixel budget, core ImageScaleToTotalPixels-style. RESIZE_METHODS = ["lanczos", "area", "bicubic", "bilinear", "nearest-exact"] diff --git a/nodes/node_video_crop_rotate_pad.py b/nodes/node_video_crop_rotate_pad.py index c2fb19d..fa0ab7c 100644 --- a/nodes/node_video_crop_rotate_pad.py +++ b/nodes/node_video_crop_rotate_pad.py @@ -9,8 +9,13 @@ from ._media_helpers import ( resolve_video_path, ) from ._inpaint_crop_helpers import build_transform_stitcher -from ._transform_engine import original_image_batch, stable_file_fingerprint, transform_pil_batch -from ._transform_inputs import spec_from_values, transform_inputs +from ._transform_engine import ( + original_image_batch, + resize_batch_to_megapixels, + stable_file_fingerprint, + transform_pil_batch, +) +from ._transform_inputs import resize_inputs, spec_from_values, transform_inputs class AusBossVideoCropRotatePad: @@ -71,6 +76,9 @@ class AusBossVideoCropRotatePad: ), } required.update(transform_inputs()) + # Appended AFTER the stable widgets, so saved workflows' positional + # widgets_values keep loading; missing values fall back to defaults. + required.update(resize_inputs()) return {"required": required} # Appended outputs only: saved links ride slot indices. @@ -80,9 +88,9 @@ class AusBossVideoCropRotatePad: "The selected and transformed frame as a one-image BHWC batch.", "BHW generated-area mask: rotation corners and padding.", "Full-canvas stitcher: restores kept source pixels over an outpaint result; wire to Stitch Inpaint.", - "The picked source frame before rotation, crop, or padding, as a BHWC RGB batch.", - "Output width after the transform.", - "Output height after the transform.", + "The picked source frame before rotation, crop, padding, or resize, as a BHWC RGB batch.", + "Output width after the transform and any resize.", + "Output height after the transform and any resize.", ) FUNCTION = "load_transform" @@ -94,11 +102,19 @@ class AusBossVideoCropRotatePad: seek_mode: str, frame_index: int, frame_time: float, + resize_to_megapixels=False, + megapixels=1.0, + resize_method="lanczos", + resolution_steps=1, **values, ): path = resolve_video_path(source_mode, video, local_path) frame, _, _ = decode_video_frame(path, seek_mode, frame_index, frame_time) output, mask, geometry = transform_pil_batch([frame], spec_from_values(**values)) + if resize_to_megapixels: + output, mask = resize_batch_to_megapixels( + output, mask, float(megapixels), str(resize_method), int(resolution_steps) + ) stitcher = build_transform_stitcher( output, mask, geometry, 32, source="Video Crop + Rotate + Pad -> Frame" ) @@ -131,6 +147,7 @@ class AusBossVideoCropRotatePad: except Exception: path = local_path if source_mode == "local path" else video spec = spec_from_values(**values) + resize = {name: values.get(name) for name in resize_inputs()} return stable_file_fingerprint( path, { @@ -140,6 +157,7 @@ class AusBossVideoCropRotatePad: "frame_index": int(frame_index), "frame_time": round(float(frame_time), 6), **spec.__dict__, + **resize, }, ) diff --git a/tests/test_transform_outputs.py b/tests/test_transform_outputs.py index ddf58dd..fec5797 100644 --- a/tests/test_transform_outputs.py +++ b/tests/test_transform_outputs.py @@ -55,6 +55,22 @@ class TransformOutputTests(unittest.TestCase): self.assertEqual(tuple(original.shape), (1, 96, 128, 3)) self.assertEqual(tuple(apply_stitch(stitcher, image).shape), tuple(image.shape)) + @unittest.skipUnless(COMFY_ROOT, "Set AUSBOSS_COMFY_ROOT for core resize integration") + def test_video_frame_resizes_to_a_budget_like_the_image_node(self): + with patch('nodes.node_video_crop_rotate_pad.resolve_video_path', return_value=Path('source.mp4')), patch('nodes.node_video_crop_rotate_pad.decode_video_frame', return_value=(self.source, 0, 0)): + image, mask, stitcher, original, width, height = AusBossVideoCropRotatePad().load_transform( + 'source.mp4', 'input folder', '', 'frame index', 0, 0, + resize_to_megapixels=True, megapixels=.05, resolution_steps=16, pad_left=32, feather=0, + ) + self.assertEqual((width, height), (int(image.shape[2]), int(image.shape[1]))) + # 0.05 MP x 1024 x 1024 is larger than the 160 x 96 canvas: the + # budget scales the frame to it either way, aspect kept, to steps of 16. + budget = 0.05 * 1024 * 1024 + self.assertLess(abs(width * height - budget) / budget, 0.1) + self.assertEqual((width % 16, height % 16), (0, 0)) + self.assertEqual(tuple(stitcher['canvas'].shape), tuple(image.shape)) + self.assertEqual(tuple(original.shape), (1, 96, 128, 3)) + if __name__ == '__main__': unittest.main()