Files
scofanoandClaude Haiku 4.5 7d5d326c97 feat: Phase 1 - GPU acceleration for image and video nodes
Image Processing (PyTorch GPU tensor operations):
- ZoomSequenceNode: Add device handling for GPU-accelerated F.interpolate
- CameraShakeNode: Ensure tensors stay on GPU device
- CameraMoveNode: Add device handling for GPU acceleration
- Expected gain: 4-10x speedup for large batches

Video Encoding (NVENC support):
- VideoLoopExtenderNode: Add use_gpu parameter for HEVC_NVENC encoding
- Falls back to libx264 if NVIDIA GPU unavailable
- Expected gain: 5-10x speedup for video encoding

Note: ComfySimpleVideoCombiner and ColorAdjustmentVideoNode already had
GPU support implemented, so no changes were needed.

Safe, backward-compatible changes with runtime GPU detection and fallback.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-19 10:21:44 -03:00

267 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# comfy_zoom_sequence.py (modified to be Camera Shake instead of Zoom)
import math
import torch
import torch.nn.functional as F
# ---------- EASING ------------------------------------------------------------
def ease_value(t: float, mode: str) -> float:
key = mode.strip().upper().replace("-", "_")
if key == "LINEAR":
return t
elif key == "EASE_IN":
return t * t * t
elif key == "EASE_OUT":
u = 1.0 - t
return 1.0 - u * u * u
elif key == "EASE_IN_OUT":
return t * t * (3 - 2 * t) # smoothstep
return t # fallback
def normalize_mode(mode: str) -> str:
"""
Interpret UI mode string as one of our internal shake modes.
"""
m = mode.strip().lower()
if "circle" in m:
return "CIRCULAR"
if "random" in m:
return "RANDOM"
# Default to circular if unclear
return "CIRCULAR"
# ---------- NODE --------------------------------------------------------------
class CameraShakeNode:
"""
CAMERA SHAKE across a batched IMAGE (video/sequence). The canvas size stays fixed and
the original aspect ratio is preserved for every frame.
Parameters
----------
mode: "Circular Shake" or "Random Shake"
pixels_per_frame (FLOAT):
Interpreted as the *maximum shake radius* on the SMALLER canvas dimension (per-side).
Higher value => bigger possible X/Y shift.
ease: "Linear", "Ease_In", "Ease_Out", "Ease_In_Out"
Eases the shake radius over time (0..1 envelope across the sequence).
loop: BOOLEAN
If True, forces circular shake mode with constant radius for seamless looping (starts and ends on the circle).
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"mode": (["Circular Shake", "Random Shake"], {"default": "Circular Shake"}),
"pixels_per_frame": ("FLOAT", {"default": 5.0, "min": 0.0, "step": 0.1}),
"ease": (["Linear", "Ease_In", "Ease_Out", "Ease_In_Out"], {"default": "Linear"}),
"loop": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("IMAGE", "STRING",)
RETURN_NAMES = ("images", "info",)
FUNCTION = "run"
CATEGORY = "Simple Video Effects/Image Processing"
# ---- helpers -------------------------------------------------------------
def _resize_to(self, frame_hwc: torch.Tensor, size_hw):
H, W = size_hw
x = frame_hwc.permute(2, 0, 1).unsqueeze(0) # 1,C,H,W
x = F.interpolate(x, size=(H, W), mode="bilinear", align_corners=False)
return x.squeeze(0).permute(1, 2, 0)
def _aspect_corrected_crop_box(self, W: int, H: int, m_small_int: int):
"""
Given an integer scalar margin on the smaller dimension (per-side),
compute integer crop box (x0,y0,x1,y1) that:
- preserves aspect (within ±1 px due to rounding),
- never exceeds bounds.
"""
aspect = W / H if H > 0 else 1.0
# Which side is smaller?
small = min(W, H)
if small <= 2:
return 0, 0, W, H, 0, 0
# Safe guard: ensure at least 1 px interior
max_small_margin = max(0, (small // 2) - 1)
m_small = max(0, min(int(m_small_int), max_small_margin))
# Fractional level based on smaller half-size
half_small = small / 2.0
frac = 0.0 if half_small <= 0 else (m_small / half_small) # 0..~1
# Proportional margins per axis to keep aspect (before integer correction)
m_x = int(round(frac * (W / 2.0)))
m_y = int(round(frac * (H / 2.0)))
# Compute crop size
cw = W - 2 * m_x
ch = H - 2 * m_y
# Enforce minimum interior
cw = max(1, cw)
ch = max(1, ch)
# ---- Aspect correction (integer) ------------------------------------
# target: cw / ch == W / H => cw == round(ch * aspect)
ideal_cw = int(round(ch * aspect))
ideal_cw = max(1, min(ideal_cw, W - 2)) # keep some border to be safe in edge cases
if ideal_cw != cw:
# Recompute m_x from ideal_cw, keep m_y if possible
cw = ideal_cw
m_x = (W - cw) // 2
# ensure symmetric crop; parity fix if needed
if W - 2 * m_x != cw:
m_x2 = max(0, min(m_x + 1, (W - 1) // 2))
if W - 2 * m_x2 == cw:
m_x = m_x2
else:
# else recompute ch from cw to restore symmetry
ch = max(1, int(round(cw / aspect)))
m_y = (H - ch) // 2
# Final clamp to safe range
m_x = max(0, min(m_x, (W - 1) // 2))
m_y = max(0, min(m_y, (H - 1) // 2))
x0, y0 = m_x, m_y
x1, y1 = W - m_x, H - m_y
return x0, y0, x1, y1, m_x, m_y
# ---- main ---------------------------------------------------------------
def run(self, images: torch.Tensor, mode: str, pixels_per_frame: float, ease: str, loop: bool):
if images.ndim != 4:
return (images, "Input is not a batched IMAGE (B,H,W,C).")
B, H, W, C = images.shape
if B <= 0:
return (images, "Empty batch; nothing to do.")
# GPU acceleration: keep tensors on their device (GPU if available)
device = images.device
images = images.to(device)
import comfy.utils
pbar = comfy.utils.ProgressBar(B)
# Normalize
shake_mode = normalize_mode(mode)
if loop:
shake_mode = "CIRCULAR"
i_ease = ease
# Compute scalar radius on the smaller dimension (per-side)
small = min(W, H)
max_safe_small_margin = max(0, (small // 2) - 1)
requested_radius_small_f = max(0.0, float(pixels_per_frame))
radius_small_f = min(requested_radius_small_f, float(max_safe_small_margin))
# Convert to integer margin and compute the "safe inner canvas"
m_small_int = int(round(radius_small_f))
# This gives us a central crop with margins m_x, m_y
x0c, y0c, x1c, y1c, m_x, m_y = self._aspect_corrected_crop_box(W, H, m_small_int)
cw = x1c - x0c
ch = y1c - y0c
if cw <= 0 or ch <= 0:
return (images, "Invalid crop; probably too large shake vs resolution.")
# Eased progress 0..1 across frames
ts = [0.0] if B == 1 else [i / (B - 1) for i in range(B)]
es = [ease_value(t, i_ease) for t in ts]
out_frames = []
# How many circles across the sequence for circular shake
num_cycles = 1.0 if loop else 2.0
for i in range(B):
pbar.update(1)
t = ts[i]
# Envelope (0..1) over time for radius
radius_factor = 1.0 if loop else (es[i] if B > 1 else 1.0)
# Per-axis max amplitude for this frame
ax = m_x * radius_factor
ay = m_y * radius_factor
if shake_mode == "CIRCULAR":
# Smooth sinusoidal circular path (like smooth_camera_shake, no jitter)
# num_cycles = how many full circles over the whole sequence
angle = 2.0 * math.pi * num_cycles * t
dx_f = ax * math.sin(angle)
dy_f = ay * math.sin(angle + math.pi * 0.5)
elif shake_mode == "RANDOM":
# Constant-magnitude random shake.
# If pixels_per_frame = 1 on the small dimension, this results in
# a 1-pixel step (left/right/up/down/diagonal) every frame,
# and it does NOT ramp up over time.
# Base step size from margins (already derived from requested radius)
step_x = m_x if m_x > 0 else 0
step_y = m_y if m_y > 0 else 0
# Ensure we still get motion if margins are tiny but non-zero
if step_x == 0 and m_x > 0:
step_x = 1
if step_y == 0 and m_y > 0:
step_y = 1
# Random direction in {-1, 0, +1} for each axis
dir_x = int(torch.randint(-1, 2, (1,)).item())
dir_y = int(torch.randint(-1, 2, (1,)).item())
# Ignore the easing envelope for magnitude here – keep step size constant
dx_f = dir_x * step_x
dy_f = dir_y * step_y
else:
# Fallback: no movement
dx_f = 0.0
dy_f = 0.0
# Clamp to safe integer offsets (so crop stays inside original)
dx = int(round(max(-m_x, min(m_x, dx_f))))
dy = int(round(max(-m_y, min(m_y, dy_f))))
# Base central crop is at (m_x, m_y); we offset it by dx, dy
x0 = m_x + dx
y0 = m_y + dy
x1 = x0 + cw
y1 = y0 + ch
# Extra safety clamp against rounding drift
if x0 < 0:
x0 = 0
x1 = cw
if y0 < 0:
y0 = 0
y1 = ch
if x1 > W:
x1 = W
x0 = W - cw
if y1 > H:
y1 = H
y0 = H - ch
cropped = images[i, y0:y1, x0:x1, :]
resized = self._resize_to(cropped, (H, W))
out_frames.append(resized)
out = torch.stack(out_frames, dim=0)
info_lines = []
info_lines.append(f"Frames: {B}, Canvas: {W}x{H}")
info_lines.append(f"Mode: {shake_mode}, Ease: {ease}, Loop: {loop}")
info_lines.append(f"Requested small-dim radius: {requested_radius_small_f:.2f} px")
info_lines.append(f"Applied small-dim radius: {radius_small_f:.2f} px (safe limit: {max_safe_small_margin} px)")
info_lines.append(f"Per-axis margins (max shake): m_x={m_x}, m_y={m_y}")
info_lines.append("Note: frames are cropped with a safe border then resized back to avoid black edges during shake.")
info = "\n".join(info_lines)
return (out, info)