From 24e616b4189f911ffbb72f6e65d25b9fd5cdcaa0 Mon Sep 17 00:00:00 2001 From: Derryyyyyy <98146773+Derryyyyyy@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:58:58 +0800 Subject: [PATCH] Add files via upload --- __init__.py | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 __init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5d91709 --- /dev/null +++ b/__init__.py @@ -0,0 +1,143 @@ +# ============================================================================== +# 本节点由 心寸 开发 +# Author: 心寸 +# Description: 基于 Lissajous 曲线的 ComfyUI 手持镜头运镜模拟节点 +# ============================================================================== + +import torch +import torch.nn.functional as F +import numpy as np +import json + +def ease_out_quadratic(t: float) -> float: + return 1 - (1 - t) ** 2 + +def generate_lissajous_trajectory( + frame_count: int, + fps: float, + width: int, + height: int, + intensity: float = 1.0, + seed: int = 42 +) -> dict: + np_random = np.random.RandomState(seed) + dx_total = np.zeros(frame_count) + dy_total = np.zeros(frame_count) + frequencies =[0.033, 0.1, 0.2, 0.3] + + for freq in frequencies: + t = np.arange(frame_count) / fps + phase = 2 * np.pi * t * freq + amp = np_random.uniform(5.2, 15.6) + + if np_random.random() < 0.64: + angle_deg = np_random.uniform(0, 45) + else: + angle_deg = np_random.uniform(45, 90) + + if np_random.random() < 0.5: + angle_deg = -angle_deg + if np_random.random() < 0.5: + angle_deg = 180 - angle_deg + + angle_rad = np.deg2rad(angle_deg) + phase_normalized = (phase % (2 * np.pi)) / (2 * np.pi) + envelope = np.array([ease_out_quadratic(min(p, 1.0)) for p in phase_normalized]) + + base_motion = np.sin(phase) * envelope * amp + arc_offset = np.sin(phase) * np.deg2rad(np_random.uniform(15, 30)) + + dx_total += base_motion * np.cos(angle_rad + arc_offset) + dy_total += base_motion * np.sin(angle_rad + arc_offset) + + dx_total = dx_total * intensity + dy_total = dy_total * intensity + dtheta = np.zeros(frame_count) + + return { + "dx": dx_total.tolist(), + "dy": dy_total.tolist(), + "dtheta": dtheta.tolist(), + "frame_count": frame_count, + "fps": fps, + "width": width, + "height": height, + "intensity": intensity + } + + +class DNode_CameraShakeLissajous: + DESCRIPTION = """🎥 Lissajous Camera Shake (V1) + +【核心特性】 +基于利萨茹曲线(Lissajous)算法,通过叠加多组低频正弦波,确保镜头始终围绕画面绝对中心点进行平滑的呼吸感往复漂移,从根本上解决传统噪波抖动带来的画面偏离问题。 + +【参数说明】 +- intensity (强度): 控制晃动剧烈程度。0.40为最佳手持平衡点。 +- zoom_factor (缩放): 边缘防穿帮保护。1.05为默认安全区。 +- fps (帧速率): 用于计算正确的物理运动周期。 +- seed (随机种子): 决定频率叠加的初始形态。 + +--------------------------------------------------[Core Features] +Based on the Lissajous curve algorithm, this node superimposes multiple low-frequency sine waves to ensure the camera smoothly and rhythmically drifts around the absolute center of the frame. This fundamentally solves the frame deviation problem caused by traditional noise-based shakes. + +[Parameters] +- intensity: Controls the intensity of the shake. 0.40 is the optimal balance for handheld camera simulation. +- zoom_factor: Edge protection to prevent clipping. 1.05 is the default safe zone. +- fps: Frame rate, used to calculate the correct physical motion cycle. +- seed: Determines the initial state of the frequency superposition. +""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.40, "min": 0.0, "max": 5.0, "step": 0.01}), + "zoom_factor": ("FLOAT", {"default": 1.05, "min": 1.0, "max": 2.0, "step": 0.01}), + "fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}), + "seed": ("INT", {"default": 42, "min": 0, "max": 0xffffffff}), + } + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("IMAGE", "trajectory_json") + FUNCTION = "apply_shake" + CATEGORY = "DNode" + + def apply_shake(self, images, intensity, zoom_factor, fps, seed): + B, H, W, C = images.shape + trajectory = generate_lissajous_trajectory(B, fps, W, H, intensity, seed) + dx_arr = trajectory["dx"] + dy_arr = trajectory["dy"] + + images_pt = images.permute(0, 3, 1, 2) + device = images_pt.device + theta = torch.zeros((B, 2, 3), dtype=torch.float32, device=device) + z = 1.0 / zoom_factor + + for i in range(B): + tx = (dx_arr[i]) / (W / 2) + ty = (dy_arr[i]) / (H / 2) + theta[i, 0, 0] = z + theta[i, 1, 1] = z + theta[i, 0, 2] = tx + theta[i, 1, 2] = ty + + grid = F.affine_grid(theta, images_pt.size(), align_corners=False) + shaken_images = F.grid_sample( + images_pt, grid, mode='bilinear', padding_mode='border', align_corners=False + ) + + shaken_images_out = shaken_images.permute(0, 2, 3, 1) + json_output = json.dumps(trajectory, indent=2, ensure_ascii=False) + + return (shaken_images_out, json_output) + +NODE_CLASS_MAPPINGS = { + "DNode_CameraShakeLissajous": DNode_CameraShakeLissajous +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DNode_CameraShakeLissajous": "🎥 Lissajous Camera Shake (V1)" +} \ No newline at end of file