diff --git a/py/cube_rotation.py b/py/cube_rotation.py
deleted file mode 100644
index 99d2892..0000000
--- a/py/cube_rotation.py
+++ /dev/null
@@ -1,411 +0,0 @@
-"""
-立方体旋转效果节点 - Playwright版本
-将6张图片贴在3D立方体上进行旋转展示
-"""
-
-import torch
-import base64
-import io
-from PIL import Image
-import numpy as np
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class CubeRotationNode(ComfyNodeABC):
- """立方体旋转效果节点 - 3D立方体旋转展示"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "images": (IO.IMAGE,),
- "rotation_style": ([
- "x_axis", # 绕X轴旋转
- "y_axis", # 绕Y轴旋转
- "z_axis", # 绕Z轴旋转
- "diagonal", # 对角线旋转
- "random_spin", # 随机旋转
- "cube_unfold", # 立方体展开
- ],),
- "rotation_speed": (IO.FLOAT, {"default": 1.0, "min": 0.1, "max": 5.0, "step": 0.1}),
- "duration": (IO.FLOAT, {"default": 5.0, "min": 1.0, "max": 30.0, "step": 0.1}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "background_color": (IO.STRING, {"default": "#1a1a2e"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "cube_size": (IO.FLOAT, {"default": 0.6, "min": 0.3, "max": 1.0}),
- "perspective": (IO.FLOAT, {"default": 1000.0, "min": 500.0, "max": 3000.0}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_cube_rotation"
-
- async def generate_cube_rotation(
- self,
- images,
- rotation_style,
- rotation_speed,
- duration,
- fps,
- background_color="#1a1a2e",
- width=1920,
- height=1080,
- cube_size=0.6,
- perspective=1000.0
- ):
- """生成立方体旋转效果"""
-
- print(f"🎬 开始生成立方体旋转效果: {rotation_style}")
-
- # 将输入图片转换为base64列表
- image_base64_list = self._images_to_base64_list(images)
-
- # 确保至少有6张图片(立方体6个面)
- while len(image_base64_list) < 6:
- image_base64_list.extend(image_base64_list[:6-len(image_base64_list)])
-
- # 只使用前6张图片
- image_base64_list = image_base64_list[:6]
-
- # 使用Playwright渲染器
- async with PlaywrightRenderer() as renderer:
- # 生成HTML
- html_content = self._generate_html(
- image_base64_list, rotation_style, rotation_speed,
- background_color, cube_size, perspective, width, height
- )
-
- # 渲染视频帧
- video_tensor = await renderer.render_frames(html_content, duration, fps, width, height)
-
- return (video_tensor,)
-
- def _images_to_base64_list(self, images):
- """将图片tensor列表转换为base64列表"""
- base64_list = []
-
- for img_tensor in images:
- # 处理单张图片
- if img_tensor.dim() == 4:
- img_tensor = img_tensor[0]
-
- array = img_tensor.cpu().numpy()
-
- # 转换数据类型
- if array.dtype == np.float32 or array.dtype == np.float64:
- array = (array * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(array, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- base64_list.append(f"data:image/png;base64,{img_str}")
-
- return base64_list
-
- def _generate_html(
- self,
- image_base64_list,
- rotation_style,
- rotation_speed,
- background_color,
- cube_size,
- perspective,
- width,
- height
- ):
- """生成立方体旋转HTML模板"""
-
- # 计算立方体尺寸(使用偶数避免半像素问题)
- cube_width = int(600 * cube_size)
- if cube_width % 2 != 0:
- cube_width += 1
-
- # 精确的半宽度
- half_width = cube_width / 2
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "CubeRotationNode": CubeRotationNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "CubeRotationNode": "Cube Rotation",
-}
-
diff --git a/py/video_blinds_transition.py b/py/video_blinds_transition.py
deleted file mode 100644
index b4cee95..0000000
--- a/py/video_blinds_transition.py
+++ /dev/null
@@ -1,542 +0,0 @@
-"""
-视频百叶窗转场节点 - CSS3版本(批处理优化)
-两个视频片段之间的百叶窗转场效果,支持批处理和GPU加速
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoBlindsTransitionNode(ComfyNodeABC):
- """视频百叶窗转场 - 专业百叶窗翻转效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频
- "video2": (IO.IMAGE,), # 第二个视频
- "blinds_direction": ([
- "horizontal", # 水平百叶窗
- "vertical", # 垂直百叶窗
- "diagonal_right", # 右对角百叶窗
- "diagonal_left", # 左对角百叶窗
- ],),
- "total_frames": (IO.INT, {"default": 24, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "blinds_count": (IO.INT, {"default": 4, "min": 4, "max": 30}),
- "flip_duration": (IO.FLOAT, {"default": 0.6, "min": 0.3, "max": 1.0}),
- "stagger_delay": (IO.FLOAT, {"default": 0.05, "min": 0.0, "max": 0.2}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_blinds_transition"
-
- async def generate_blinds_transition(
- self,
- video1,
- video2,
- blinds_direction,
- total_frames,
- fps,
- blinds_count=12,
- flip_duration=0.6,
- stagger_delay=0.05,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=1920,
- height=1080,
- quality=90
- ):
- """生成视频百叶窗转场效果 - 批处理优化版本"""
-
- start_time = time.time()
- print(f"🎬 开始生成百叶窗转场(批处理优化)...")
- print(f" 百叶窗方向: {blinds_direction}")
- print(f" 百叶窗数量: {blinds_count}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
- print(f" 输出尺寸: {width}x{height}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- blinds_direction, blinds_count, flip_duration, stagger_delay,
- background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待百叶窗控制器初始化
- await page.wait_for_function("window.blindsController && window.blindsController.ready", timeout=10000)
-
- print("✅ 百叶窗场景初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 百叶窗转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新百叶窗动画
- await page.evaluate(f"""
- if (window.blindsController) {{
- window.blindsController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- # 等待动画更新
- await page.wait_for_timeout(25)
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待渲染完成
- await page.wait_for_timeout(15)
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, blinds_direction, blinds_count, flip_duration, stagger_delay, background_color, width, height):
- """生成HTML模板 - CSS3百叶窗效果"""
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 1080, 1920, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoBlindsTransitionNode": VideoBlindsTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoBlindsTransitionNode": "Video Blinds Transition",
-}
diff --git a/py/video_blink_transition.py b/py/video_blink_transition.py
deleted file mode 100644
index 4a57112..0000000
--- a/py/video_blink_transition.py
+++ /dev/null
@@ -1,289 +0,0 @@
-"""
-视频眨眼转场节点 - 纯Python实现
-
-效果描述:
-- 上下黑色遮罩模拟眼睑闭合
-- 中间露出正常图片(带模糊)
-- 眨眼瞬间完成视频切换
-
-技术实现:
-- 使用 NumPy 数组操作实现遮罩
-- 使用 cv2.GaussianBlur 实现模糊效果
-- 纯Python处理,无需Playwright,性能提升50%+
-"""
-
-import time
-import numpy as np
-import torch
-import cv2
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-
-
-class VideoBlinkTransitionNode(ComfyNodeABC):
- """视频眨眼转场节点 - 纯Python实现"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,),
- "video2": (IO.IMAGE,),
- "total_frames": (IO.INT, {"default": 60, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "blink_speed": (IO.FLOAT, {"default": 1.0, "min": 0.3, "max": 3.0, "step": 0.1}), # 眨眼速度
- "blur_intensity": (IO.FLOAT, {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.1}), # 模糊强度
- "eyelid_curve": (IO.FLOAT, {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05}), # 眼睑弧度
- "edge_feather": (IO.FLOAT, {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.05}), # 边缘羽化
- "mask_color": ([
- "black", # 黑色(推荐)
- "white", # 白色
- "gray", # 灰色
- ],),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_blink_transition"
-
- def generate_blink_transition(
- self,
- video1,
- video2,
- total_frames,
- fps,
- blink_speed=1.0,
- blur_intensity=0.8,
- eyelid_curve=0.3,
- edge_feather=0.2,
- mask_color="black"
- ):
- """生成眨眼转场效果 - 纯Python实现"""
-
- start_time = time.time()
-
- print(f"👁️ 开始生成眨眼转场效果(纯Python版)...")
- print(f" 帧率: {fps} FPS")
- print(f" 眨眼速度: {blink_speed}")
- print(f" 模糊强度: {blur_intensity}")
- print(f" 眼睑弧度: {eyelid_curve}")
- print(f" 边缘羽化: {edge_feather}")
- print(f" 遮罩颜色: {mask_color}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- # 获取视频尺寸
- height, width = frames1[0].shape[:2]
- print(f" 视频尺寸: {width}x{height}")
-
- # 遮罩颜色映射
- mask_colors = {
- "black": (0, 0, 0),
- "white": (1, 1, 1),
- "gray": (0.5, 0.5, 0.5),
- }
- mask_rgb = mask_colors.get(mask_color, (0, 0, 0))
-
- # 生成所有帧
- output_frames = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取当前帧
- frame1 = frames1[i % len(frames1)]
- frame2 = frames2[i % len(frames2)]
-
- # 生成眨眼帧
- blink_frame = self._generate_blink_frame(
- frame1, frame2, progress,
- blink_speed, blur_intensity, eyelid_curve, edge_feather,
- mask_rgb, height, width
- )
-
- output_frames.append(blink_frame)
-
- # 打印进度
- if (i + 1) % 10 == 0 or i == total_frames - 1:
- progress_percent = (i + 1) / total_frames * 100
- print(f" 进度: {progress_percent:.1f}% ({i+1}/{total_frames})")
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 眨眼转场生成完成!")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- def _generate_blink_frame(self, frame1, frame2, progress,
- blink_speed, blur_intensity, eyelid_curve, edge_feather,
- mask_rgb, height, width):
- """生成单帧眨眼效果"""
-
- # 计算眨眼遮罩高度(正弦曲线:0→0.5→0)
- blink_curve = abs(np.sin(progress * np.pi * blink_speed))
- mask_height_ratio = blink_curve * 0.5 # 最大50%高度
-
- # 计算模糊强度(中间时刻最模糊)
- blur_curve = np.sin(progress * np.pi) # 0→1→0
- blur_kernel_size = int(blur_curve * blur_intensity * 20) * 2 + 1 # 确保为奇数
- blur_kernel_size = max(1, blur_kernel_size) # 至少为1
-
- # 选择当前显示的视频(眨眼瞬间切换)
- if progress < 0.5:
- current_frame = frame1.copy()
- else:
- current_frame = frame2.copy()
-
- # 应用模糊效果
- if blur_kernel_size > 1:
- current_frame = cv2.GaussianBlur(current_frame, (blur_kernel_size, blur_kernel_size), 0)
-
- # 计算遮罩高度(像素)
- mask_height = int(mask_height_ratio * height)
-
- # 应用弧形眼睑遮罩
- if mask_height > 0 and eyelid_curve > 0:
- # 创建弧形遮罩
- self._apply_curved_eyelid_mask(
- current_frame, mask_height, eyelid_curve, edge_feather,
- mask_rgb, height, width
- )
- elif mask_height > 0 and edge_feather > 0:
- # 平行遮罩(eyelid_curve=0时)但带羽化
- self._apply_feathered_straight_mask(
- current_frame, mask_height, edge_feather,
- mask_rgb, height, width
- )
- elif mask_height > 0:
- # 平行遮罩无羽化
- current_frame[:mask_height, :] = mask_rgb
- current_frame[-mask_height:, :] = mask_rgb
-
- return current_frame
-
- def _apply_curved_eyelid_mask(self, frame, mask_height, curve_intensity, edge_feather,
- mask_rgb, height, width):
- """应用弧形眼睑遮罩(带羽化)- 修正版"""
-
- # 计算弧度参数
- curve_depth = int(mask_height * curve_intensity * 0.5) # 弧形深度
- feather_pixels = int(mask_height * edge_feather) # 羽化像素数
-
- # 创建x坐标的归一化数组
- normalized_x = np.linspace(-1, 1, width)
-
- # 上眼睑遮罩(从上向下的弧形,中间深入,两边浅)
- # 抛物线:y = curve_depth * x^2,中间是0,两边是curve_depth
- parabola_top = curve_depth * (normalized_x ** 2)
-
- # 遍历可能被遮罩影响的区域
- max_y = int(mask_height + curve_depth + feather_pixels)
- for y in range(max_y):
- # 计算这一行的弧形边界(中间深,两边浅)
- boundary = mask_height + parabola_top # 两边高,中间低
-
- # 计算距离(负数=在遮罩内,正数=在遮罩外)
- distance = boundary - y
-
- if feather_pixels > 0:
- # 羽化:distance > 0 表示在遮罩内
- # alpha = 1 (完全遮罩) 到 0 (完全透明)
- alpha = np.clip(distance / feather_pixels, 0, 1)
- else:
- # 无羽化:硬边缘
- alpha = (distance > 0).astype(float)
-
- # 应用遮罩(alpha=1时完全是mask_rgb,alpha=0时完全是原图)
- frame[y, :] = frame[y, :] * (1 - alpha[:, np.newaxis]) + np.array(mask_rgb) * alpha[:, np.newaxis]
-
- # 下眼睑遮罩(从下向上的弧形,中间深入,两边浅)
- parabola_bottom = curve_depth * (normalized_x ** 2)
-
- # 遍历可能被遮罩影响的区域
- min_y = int(height - mask_height - curve_depth - feather_pixels)
- for y in range(min_y, height):
- # 计算这一行的弧形边界(中间深,两边浅)
- boundary = height - mask_height - parabola_bottom # 两边高,中间低
-
- # 计算距离(负数=在遮罩外,正数=在遮罩内)
- distance = y - boundary
-
- if feather_pixels > 0:
- # 羽化:distance > 0 表示在遮罩内
- alpha = np.clip(distance / feather_pixels, 0, 1)
- else:
- # 无羽化:硬边缘
- alpha = (distance > 0).astype(float)
-
- # 应用遮罩
- frame[y, :] = frame[y, :] * (1 - alpha[:, np.newaxis]) + np.array(mask_rgb) * alpha[:, np.newaxis]
-
- def _apply_feathered_straight_mask(self, frame, mask_height, edge_feather,
- mask_rgb, height, width):
- """应用平行遮罩(带羽化)"""
-
- feather_pixels = int(mask_height * edge_feather)
-
- # 上遮罩带羽化
- for y in range(mask_height + feather_pixels):
- if y < mask_height:
- # 完全遮罩区域
- frame[y, :] = mask_rgb
- else:
- # 羽化区域
- distance = y - mask_height
- alpha = 1 - (distance / feather_pixels)
- frame[y, :] = frame[y, :] * (1 - alpha) + np.array(mask_rgb) * alpha
-
- # 下遮罩带羽化
- for y in range(height - mask_height - feather_pixels, height):
- if y >= height - mask_height:
- # 完全遮罩区域
- frame[y, :] = mask_rgb
- else:
- # 羽化区域
- distance = (height - mask_height) - y
- alpha = 1 - (distance / feather_pixels)
- frame[y, :] = frame[y, :] * (1 - alpha) + np.array(mask_rgb) * alpha
-
- def _extract_video_frames(self, video_tensor):
- """提取视频帧"""
- # 转换为numpy数组
- if isinstance(video_tensor, torch.Tensor):
- video_array = video_tensor.cpu().numpy()
- else:
- video_array = video_tensor
-
- # 确保数据在[0,1]范围内
- video_array = np.clip(video_array, 0, 1)
-
- if len(video_array.shape) == 4: # [frames, height, width, channels]
- return [video_array[i] for i in range(video_array.shape[0])]
- else: # 单帧
- return [video_array]
-
- def _frames_to_tensor(self, frames):
- """帧列表转tensor"""
- frames_array = np.array(frames)
- return torch.from_numpy(frames_array).float()
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoBlinkTransitionNode": VideoBlinkTransitionNode
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoBlinkTransitionNode": "Video Blink Transition"
-}
-
diff --git a/py/video_checkerboard_transition.py b/py/video_checkerboard_transition.py
deleted file mode 100644
index 81a47d8..0000000
--- a/py/video_checkerboard_transition.py
+++ /dev/null
@@ -1,671 +0,0 @@
-"""
-视频棋盘格转场节点 - CSS3版本(真实切割效果)
-两个视频片段之间的棋盘格转场效果,每个方格显示对应的图片区域
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoCheckerboardTransitionNode(ComfyNodeABC):
- """视频棋盘格转场 - 真实图片切割效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频
- "video2": (IO.IMAGE,), # 第二个视频
- "transition_style": ([
- "flip_squares", # 方格翻转
- "scale_squares", # 方格缩放
- "rotate_squares", # 方格旋转
- "slide_squares", # 方格滑动
- "wave_squares", # 波浪式方格
- ],),
- "total_frames": (IO.INT, {"default": 24, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "grid_size": (IO.INT, {"default": 8, "min": 4, "max": 20}),
- "animation_duration": (IO.FLOAT, {"default": 0.8, "min": 0.3, "max": 1.5}),
- "stagger_delay": (IO.FLOAT, {"default": 0.02, "min": 0.0, "max": 0.1}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_checkerboard_transition"
-
- async def generate_checkerboard_transition(
- self,
- video1,
- video2,
- transition_style,
- total_frames,
- fps,
- grid_size=8,
- animation_duration=0.8,
- stagger_delay=0.02,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=640,
- height=640,
- quality=90
- ):
- """生成视频棋盘格转场效果 - 真实切割版本"""
-
- start_time = time.time()
- print(f"🎬 开始生成棋盘格转场(真实切割效果)...")
- print(f" 转场样式: {transition_style}")
- print(f" 网格大小: {grid_size}x{grid_size}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
- print(f" 输出尺寸: {width}x{height}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- transition_style, grid_size, animation_duration, stagger_delay,
- background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待棋盘格控制器初始化
- await page.wait_for_function("window.checkerboardController && window.checkerboardController.ready", timeout=10000)
-
- print("✅ 棋盘格场景初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 棋盘格转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新棋盘格动画
- await page.evaluate(f"""
- if (window.checkerboardController) {{
- window.checkerboardController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- # 等待动画更新
- await page.wait_for_timeout(25)
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待渲染完成
- await page.wait_for_timeout(15)
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, transition_style, grid_size, animation_duration, stagger_delay, background_color, width, height):
- """生成HTML模板 - CSS3棋盘格效果(真实切割)"""
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoCheckerboardTransitionNode": VideoCheckerboardTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoCheckerboardTransitionNode": "Video Checkerboard Transition",
-}
\ No newline at end of file
diff --git a/py/video_crossfade.py b/py/video_crossfade.py
deleted file mode 100644
index 76fa8fc..0000000
--- a/py/video_crossfade.py
+++ /dev/null
@@ -1,428 +0,0 @@
-"""
-视频透明叠化转场节点
-两个视频片段之间的简单透明叠化转场效果
-"""
-
-import torch
-import numpy as np
-import cv2
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-
-
-class VideoCrossfadeNode(ComfyNodeABC):
- """视频透明叠化转场 - 两个视频之间的多种转场模式"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频
- "video2": (IO.IMAGE,), # 第二个视频
- "transition_mode": ([
- "crossfade", # 直接叠化
- "fade_to_black", # 闪黑
- "fade_to_white", # 闪白
- "fade_to_custom", # 闪自定义色
- "additive_dissolve", # 叠加
- "chromatic_dissolve", # 色散叠化
- ],),
- "total_frames": (IO.INT, {"default": 30, "min": 1, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "background_color": (IO.STRING, {"default": "#FF0000"}), # 用于 fade_to_custom 模式,默认红色
- "width": (IO.INT, {"default": 640, "min": 64, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 64, "max": 2160}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_crossfade"
-
- async def generate_crossfade(
- self,
- video1,
- video2,
- transition_mode,
- total_frames,
- fps,
- background_color="#000000",
- width=640,
- height=640
- ):
- """生成视频转场效果"""
-
- mode_names = {
- "crossfade": "直接叠化",
- "fade_to_black": "闪黑",
- "fade_to_white": "闪白",
- "fade_to_custom": f"闪自定义色 ({background_color})",
- "additive_dissolve": "叠加",
- "chromatic_dissolve": "色散叠化",
- }
-
- print(f"🎬 开始生成视频转场...")
- print(f" 转场模式: {mode_names.get(transition_mode, transition_mode)}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 解析背景颜色
- bg_color_bgr = self._parse_background_color(background_color)
- print(f"🎨 背景颜色: {background_color} -> BGR{bg_color_bgr}")
-
- # 使用OpenCV直接合成(简单高效)
- output_frames = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧 - 根据进度获取
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
-
- frame1 = frames1[frame1_idx]
- frame2 = frames2[frame2_idx]
-
- # 根据转场模式进行不同的混合
- if transition_mode == "crossfade":
- # 直接叠化
- blended_frame = self._blend_frames_with_background(
- frame1, frame2, progress, bg_color_bgr, width, height
- )
- elif transition_mode == "fade_to_black":
- # 闪黑:video1 → 黑色 → video2
- blended_frame = self._fade_through_color(
- frame1, frame2, progress, (0, 0, 0), width, height
- )
- elif transition_mode == "fade_to_white":
- # 闪白:video1 → 白色 → video2
- blended_frame = self._fade_through_color(
- frame1, frame2, progress, (255, 255, 255), width, height
- )
- elif transition_mode == "fade_to_custom":
- # 闪自定义色:video1 → 自定义颜色 → video2
- blended_frame = self._fade_through_color(
- frame1, frame2, progress, bg_color_bgr, width, height
- )
- elif transition_mode == "additive_dissolve":
- # 叠加:使用加法混合
- blended_frame = self._additive_blend(
- frame1, frame2, progress, width, height
- )
- elif transition_mode == "chromatic_dissolve":
- # 色散叠化:RGB通道错位
- blended_frame = self._chromatic_blend(
- frame1, frame2, progress, width, height
- )
- else:
- # 默认使用直接叠化
- blended_frame = self._blend_frames_with_background(
- frame1, frame2, progress, bg_color_bgr, width, height
- )
-
- output_frames.append(blended_frame)
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- print(f"✅ 叠化转场完成,输出: {video_tensor.shape}")
- return (video_tensor,)
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- # 视频tensor格式: [B, H, W, C]
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i] # [H, W, C]
- frames.append(frame)
- else:
- # 单帧情况
- frames.append(video_tensor)
-
- return frames
-
- def _parse_background_color(self, color_str):
- """解析背景颜色字符串为BGR元组"""
- if color_str.startswith('#'):
- # 移除#号
- hex_color = color_str[1:]
- # 转换为RGB
- r = int(hex_color[0:2], 16)
- g = int(hex_color[2:4], 16)
- b = int(hex_color[4:6], 16)
- # 返回BGR格式(OpenCV使用BGR)
- return (b, g, r)
- else:
- # 默认黑色
- return (0, 0, 0)
-
- def _fade_through_color(self, frame1, frame2, progress, color_bgr, width, height):
- """闪色转场:frame1 → 纯色 → frame2"""
- # 转换为numpy数组
- if isinstance(frame1, torch.Tensor):
- frame1 = frame1.cpu().numpy()
- if isinstance(frame2, torch.Tensor):
- frame2 = frame2.cpu().numpy()
-
- # 确保数据类型正确
- if frame1.dtype == np.float32 or frame1.dtype == np.float64:
- frame1 = (frame1 * 255).clip(0, 255).astype(np.uint8)
- if frame2.dtype == np.float32 or frame2.dtype == np.float64:
- frame2 = (frame2 * 255).clip(0, 255).astype(np.uint8)
-
- # 调整到目标尺寸
- frame1_resized = cv2.resize(frame1, (width, height), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2, (width, height), interpolation=cv2.INTER_AREA)
-
- # 创建纯色画布
- color_canvas = np.full((height, width, 3), color_bgr, dtype=np.uint8)
-
- # 分两个阶段:
- # 阶段1(0 -> 0.5):frame1 淡出到纯色
- # 阶段2(0.5 -> 1.0):纯色淡入到 frame2
- if progress < 0.5:
- # 阶段1:frame1 → 纯色
- fade_progress = progress * 2 # 映射到 0-1
- alpha1 = 1.0 - fade_progress
- alpha2 = fade_progress
- blended = cv2.addWeighted(frame1_resized, alpha1, color_canvas, alpha2, 0)
- else:
- # 阶段2:纯色 → frame2
- fade_progress = (progress - 0.5) * 2 # 映射到 0-1
- alpha1 = 1.0 - fade_progress
- alpha2 = fade_progress
- blended = cv2.addWeighted(color_canvas, alpha1, frame2_resized, alpha2, 0)
-
- return blended
-
- def _blend_frames_with_background(self, frame1, frame2, progress, bg_color_bgr, width, height):
- """使用OpenCV合成两帧,包含背景色处理"""
- # 转换为numpy数组
- if isinstance(frame1, torch.Tensor):
- frame1 = frame1.cpu().numpy()
- if isinstance(frame2, torch.Tensor):
- frame2 = frame2.cpu().numpy()
-
- # 确保数据类型正确
- if frame1.dtype == np.float32 or frame1.dtype == np.float64:
- frame1 = (frame1 * 255).clip(0, 255).astype(np.uint8)
- if frame2.dtype == np.float32 or frame2.dtype == np.float64:
- frame2 = (frame2 * 255).clip(0, 255).astype(np.uint8)
-
- # 调整到目标尺寸
- frame1_resized = cv2.resize(frame1, (width, height), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2, (width, height), interpolation=cv2.INTER_AREA)
-
- # 透明叠化: frame1逐渐淡出,frame2逐渐淡入
- alpha1 = 1.0 - progress # frame1的透明度
- alpha2 = progress # frame2的透明度
-
- # 使用OpenCV的addWeighted进行透明叠化
- blended = cv2.addWeighted(frame1_resized, alpha1, frame2_resized, alpha2, 0)
-
- # 创建背景色画布
- background_canvas = np.full((height, width, 3), bg_color_bgr, dtype=np.uint8)
-
- # 如果输入帧有Alpha通道,进行Alpha合成
- if len(blended.shape) == 3 and blended.shape[2] == 4: # RGBA
- alpha_channel = blended[:, :, 3] / 255.0
- rgb_channels = blended[:, :, :3]
-
- # Alpha合成到背景
- for c in range(3):
- background_canvas[:, :, c] = (
- background_canvas[:, :, c] * (1 - alpha_channel) +
- rgb_channels[:, :, c] * alpha_channel
- ).astype(np.uint8)
- else: # RGB,直接使用混合结果
- background_canvas = blended
-
- return background_canvas
-
- def _additive_blend(self, frame1, frame2, progress, width, height):
- """叠加混合:两个视频直接相加,产生更亮的叠加效果"""
- # 转换为numpy数组
- if isinstance(frame1, torch.Tensor):
- frame1 = frame1.cpu().numpy()
- if isinstance(frame2, torch.Tensor):
- frame2 = frame2.cpu().numpy()
-
- # 确保数据类型正确
- if frame1.dtype == np.float32 or frame1.dtype == np.float64:
- frame1 = (frame1 * 255).clip(0, 255).astype(np.uint8)
- if frame2.dtype == np.float32 or frame2.dtype == np.float64:
- frame2 = (frame2 * 255).clip(0, 255).astype(np.uint8)
-
- # 调整到目标尺寸
- frame1_resized = cv2.resize(frame1, (width, height), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2, (width, height), interpolation=cv2.INTER_AREA)
-
- # 🎯 真正的叠加:使用Screen混合模式或增强的加法混合
- # Screen模式公式: 1 - (1-A) * (1-B)
- # 效果:两个视频相加但不会过度曝光,产生发光叠加效果
-
- # 方案:在转场中间时,两个视频都保持较高的可见度
- # 使用曲线让中间更亮
- if progress < 0.5:
- # 前半段:frame1保持满亮度,frame2逐渐增强
- alpha1 = 1.0
- alpha2 = progress * 2 # 0 -> 1
- else:
- # 后半段:frame1逐渐减弱,frame2保持满亮度
- alpha1 = (1.0 - progress) * 2 # 1 -> 0
- alpha2 = 1.0
-
- # 先转换为float并归一化到0-1
- frame1_norm = frame1_resized.astype(np.float32) / 255.0
- frame2_norm = frame2_resized.astype(np.float32) / 255.0
-
- # 🔥 使用Screen混合模式:1 - (1-A) * (1-B)
- # 这样两个亮的区域叠加会更亮,但不会完全过曝
- screen_blend = 1.0 - (1.0 - frame1_norm * alpha1) * (1.0 - frame2_norm * alpha2)
-
- # 转换回0-255
- blended = (screen_blend * 255.0).clip(0, 255).astype(np.uint8)
-
- return blended
-
- def _chromatic_blend(self, frame1, frame2, progress, width, height):
- """色散叠化:RGB通道错位混合,产生色散/色差效果"""
- # 转换为numpy数组
- if isinstance(frame1, torch.Tensor):
- frame1 = frame1.cpu().numpy()
- if isinstance(frame2, torch.Tensor):
- frame2 = frame2.cpu().numpy()
-
- # 确保数据类型正确
- if frame1.dtype == np.float32 or frame1.dtype == np.float64:
- frame1 = (frame1 * 255).clip(0, 255).astype(np.uint8)
- if frame2.dtype == np.float32 or frame2.dtype == np.float64:
- frame2 = (frame2 * 255).clip(0, 255).astype(np.uint8)
-
- # 调整到目标尺寸
- frame1_resized = cv2.resize(frame1, (width, height), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2, (width, height), interpolation=cv2.INTER_AREA)
-
- # 计算通道偏移量(在转场中间最大)
- # 使用sin曲线使偏移平滑
- max_offset = 10 # 最大偏移像素
- offset_intensity = np.sin(progress * np.pi) # 0 -> 1 -> 0
- offset = int(max_offset * offset_intensity)
-
- # 创建输出图像
- blended = np.zeros_like(frame1_resized)
-
- # 计算混合权重
- alpha1 = 1.0 - progress
- alpha2 = progress
-
- # 分别处理RGB三个通道,每个通道施加不同的偏移
- # R通道:向右偏移
- if offset > 0:
- # frame1的R通道向右偏移
- blended[:, offset:, 2] = (
- frame1_resized[:, :-offset, 2].astype(np.float32) * alpha1 +
- frame2_resized[:, offset:, 2].astype(np.float32) * alpha2
- ).astype(np.uint8)
- blended[:, :offset, 2] = (
- frame1_resized[:, :offset, 2].astype(np.float32) * alpha1 +
- frame2_resized[:, :offset, 2].astype(np.float32) * alpha2
- ).astype(np.uint8)
- else:
- blended[:, :, 2] = (
- frame1_resized[:, :, 2].astype(np.float32) * alpha1 +
- frame2_resized[:, :, 2].astype(np.float32) * alpha2
- ).astype(np.uint8)
-
- # G通道:不偏移(中心)
- blended[:, :, 1] = (
- frame1_resized[:, :, 1].astype(np.float32) * alpha1 +
- frame2_resized[:, :, 1].astype(np.float32) * alpha2
- ).astype(np.uint8)
-
- # B通道:向左偏移
- if offset > 0:
- # frame1的B通道向左偏移
- blended[:, :-offset, 0] = (
- frame1_resized[:, offset:, 0].astype(np.float32) * alpha1 +
- frame2_resized[:, :-offset, 0].astype(np.float32) * alpha2
- ).astype(np.uint8)
- blended[:, -offset:, 0] = (
- frame1_resized[:, -offset:, 0].astype(np.float32) * alpha1 +
- frame2_resized[:, -offset:, 0].astype(np.float32) * alpha2
- ).astype(np.uint8)
- else:
- blended[:, :, 0] = (
- frame1_resized[:, :, 0].astype(np.float32) * alpha1 +
- frame2_resized[:, :, 0].astype(np.float32) * alpha2
- ).astype(np.uint8)
-
- return blended
-
- def _blend_frames(self, frame1, frame2, progress):
- """使用OpenCV合成两帧"""
- # 转换为numpy数组
- if isinstance(frame1, torch.Tensor):
- frame1 = frame1.cpu().numpy()
- if isinstance(frame2, torch.Tensor):
- frame2 = frame2.cpu().numpy()
-
- # 确保数据类型正确
- if frame1.dtype == np.float32 or frame1.dtype == np.float64:
- frame1 = (frame1 * 255).clip(0, 255).astype(np.uint8)
- if frame2.dtype == np.float32 or frame2.dtype == np.float64:
- frame2 = (frame2 * 255).clip(0, 255).astype(np.uint8)
-
- # 确保尺寸一致
- if frame1.shape != frame2.shape:
- frame2 = cv2.resize(frame2, (frame1.shape[1], frame1.shape[0]))
-
- # 透明叠化: frame1逐渐淡出,frame2逐渐淡入
- alpha1 = 1.0 - progress # frame1的透明度
- alpha2 = progress # frame2的透明度
-
- # 使用OpenCV的addWeighted进行透明叠化
- blended = cv2.addWeighted(frame1, alpha1, frame2, alpha2, 0)
-
- return blended
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 1080, 1920, 3), dtype=torch.float32)
-
- # 转换为tensor
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- # 转换为float32并归一化
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- # 堆叠成视频tensor [B, H, W, C]
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoCrossfadeNode": VideoCrossfadeNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoCrossfadeNode": "Video overlay",
-}
diff --git a/py/video_cube_rotation_advanced.py b/py/video_cube_rotation_advanced.py
deleted file mode 100644
index 9bebc6a..0000000
--- a/py/video_cube_rotation_advanced.py
+++ /dev/null
@@ -1,381 +0,0 @@
-"""
-多视频立方体旋转效果节点 - 高级混合架构版本
-真正的3D投影计算 + OpenCV合成
-"""
-
-import torch
-import cv2
-import numpy as np
-from PIL import Image
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoCubeRotationAdvancedNode(ComfyNodeABC):
- """多视频立方体旋转效果节点 - 高级混合架构版本"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "rotation_style": ([
- "x_axis", # 绕X轴旋转
- "y_axis", # 绕Y轴旋转
- "z_axis", # 绕Z轴旋转
- "diagonal", # 对角线旋转
- "random_spin", # 随机旋转
- "cube_unfold", # 立方体展开
- ],),
- "rotation_speed": (IO.FLOAT, {"default": 1.0, "min": 0.1, "max": 5.0, "step": 0.1}),
- "duration": (IO.FLOAT, {"default": 5.0, "min": 1.0, "max": 30.0, "step": 0.1}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "video_front": (IO.IMAGE,), # 前面视频
- "video_back": (IO.IMAGE,), # 后面视频
- "video_left": (IO.IMAGE,), # 左面视频
- "video_right": (IO.IMAGE,), # 右面视频
- "video_top": (IO.IMAGE,), # 上面视频
- "video_bottom": (IO.IMAGE,), # 下面视频
- "background_color": (IO.STRING, {"default": "#1a1a2e"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "cube_size": (IO.FLOAT, {"default": 0.6, "min": 0.3, "max": 1.0}),
- "perspective": (IO.FLOAT, {"default": 1000.0, "min": 500.0, "max": 3000.0}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_video_cube_rotation"
-
- def __init__(self):
- # 立方体的8个顶点(3D坐标)
- self.cube_vertices = np.array([
- [-1, -1, -1], # 0: 左后下
- [ 1, -1, -1], # 1: 右后下
- [ 1, 1, -1], # 2: 右前下
- [-1, 1, -1], # 3: 左前下
- [-1, -1, 1], # 4: 左后上
- [ 1, -1, 1], # 5: 右后上
- [ 1, 1, 1], # 6: 右前上
- [-1, 1, 1], # 7: 左前上
- ], dtype=np.float32)
-
- # 立方体的6个面(每个面4个顶点索引)
- self.cube_faces = [
- [0, 1, 2, 3], # 前面 (front)
- [4, 7, 6, 5], # 后面 (back)
- [1, 5, 6, 2], # 右面 (right)
- [4, 0, 3, 7], # 左面 (left)
- [3, 2, 6, 7], # 上面 (top)
- [4, 5, 1, 0], # 下面 (bottom)
- ]
-
- # 面名称映射
- self.face_names = ['front', 'back', 'right', 'left', 'top', 'bottom']
-
- async def generate_video_cube_rotation(
- self,
- rotation_style,
- rotation_speed,
- duration,
- fps,
- video_front=None,
- video_back=None,
- video_left=None,
- video_right=None,
- video_top=None,
- video_bottom=None,
- background_color="#1a1a2e",
- width=1920,
- height=1080,
- cube_size=0.6,
- perspective=1000.0
- ):
- """生成多视频立方体旋转效果 - 高级混合架构"""
-
- print(f"🎬 开始生成多视频立方体旋转效果 (高级混合架构): {rotation_style}")
-
- # 处理6个面的视频数据
- video_frames_data = self._process_cube_videos(
- video_front, video_back, video_left, video_right, video_top, video_bottom
- )
-
- print(f"📹 视频立方体信息:")
- for i, frames in enumerate(video_frames_data):
- print(f" - {self.face_names[i]}: {len(frames)}帧")
-
- # 计算总帧数
- total_frames = int(duration * fps)
-
- # 纯Python渲染(不使用Playwright)
- video_tensor = self._render_with_opencv(
- video_frames_data, rotation_style, rotation_speed,
- total_frames, width, height, cube_size, perspective, background_color
- )
-
- return (video_tensor,)
-
- def _process_cube_videos(self, video_front, video_back, video_left, video_right, video_top, video_bottom):
- """处理6个面的视频数据,转换为numpy数组"""
- video_frames_data = []
- videos = [video_front, video_back, video_left, video_right, video_top, video_bottom]
-
- for i, video_tensor in enumerate(videos):
- if video_tensor is not None:
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- print(f" 处理 {self.face_names[i]} tensor: {video_tensor.shape}")
-
- frames = []
- for j in range(batch_size):
- frame_tensor = video_tensor[j] # [H, W, C]
- frame_np = self._tensor_to_numpy(frame_tensor)
- frames.append(frame_np)
-
- video_frames_data.append(frames)
- else:
- raise ValueError(f"{self.face_names[i]}视频tensor必须是4D格式 [B, H, W, C],当前: {video_tensor.shape}")
- else:
- print(f" {self.face_names[i]}: 未提供,使用默认黑色帧")
- default_frame = np.zeros((512, 512, 3), dtype=np.uint8)
- video_frames_data.append([default_frame])
-
- return video_frames_data
-
- def _tensor_to_numpy(self, tensor):
- """将tensor转换为numpy数组"""
- if tensor.dim() == 4:
- tensor = tensor[0]
-
- array = tensor.cpu().numpy()
-
- if array.dtype in [np.float32, np.float64]:
- array = (array * 255).clip(0, 255).astype(np.uint8)
- elif array.dtype != np.uint8:
- array = array.astype(np.uint8)
-
- return array
-
- def _render_with_opencv(
- self,
- video_frames_data,
- rotation_style,
- rotation_speed,
- total_frames,
- width,
- height,
- cube_size,
- perspective,
- background_color
- ):
- """使用OpenCV进行纯Python渲染"""
-
- print(f"🔄 开始OpenCV渲染 {total_frames} 帧...")
-
- # 解析背景颜色
- bg_color = self._parse_background_color(background_color)
-
- frames = []
-
- for i in range(total_frames):
- progress = i / max(total_frames - 1, 1)
-
- # 创建背景
- frame = np.full((height, width, 3), bg_color, dtype=np.uint8)
-
- # 计算旋转矩阵
- rotation_matrix = self._calculate_rotation_matrix(rotation_style, progress, rotation_speed)
-
- # 渲染立方体
- self._render_cube(frame, video_frames_data, rotation_matrix, progress, cube_size, perspective, width, height)
-
- # 转换为tensor
- frame_tensor = torch.from_numpy(frame.astype(np.float32) / 255.0)
- frames.append(frame_tensor)
-
- # 进度提示
- if (i + 1) % 10 == 0 or i == total_frames - 1:
- print(f" 渲染进度: {i + 1}/{total_frames} ({(i + 1) / total_frames * 100:.1f}%)")
-
- # 堆叠为视频tensor
- video_tensor = torch.stack(frames, dim=0)
- print(f"✅ OpenCV渲染完成!输出形状: {video_tensor.shape}")
-
- return video_tensor
-
- def _parse_background_color(self, color_str):
- """解析背景颜色字符串"""
- if color_str.startswith('#'):
- # 十六进制颜色
- hex_color = color_str[1:]
- r = int(hex_color[0:2], 16)
- g = int(hex_color[2:4], 16)
- b = int(hex_color[4:6], 16)
- return [b, g, r] # OpenCV使用BGR格式
- else:
- # 默认黑色
- return [0, 0, 0]
-
- def _calculate_rotation_matrix(self, rotation_style, progress, rotation_speed):
- """计算旋转矩阵"""
- angle = progress * 360 * rotation_speed
-
- if rotation_style == 'x_axis':
- return self._rotation_matrix_x(np.radians(angle))
- elif rotation_style == 'y_axis':
- return self._rotation_matrix_y(np.radians(angle))
- elif rotation_style == 'z_axis':
- return self._rotation_matrix_z(np.radians(angle))
- elif rotation_style == 'diagonal':
- rx = self._rotation_matrix_x(np.radians(angle * 0.7))
- ry = self._rotation_matrix_y(np.radians(angle))
- return np.dot(ry, rx)
- elif rotation_style == 'random_spin':
- rx = self._rotation_matrix_x(np.radians(angle * 0.6))
- ry = self._rotation_matrix_y(np.radians(angle * 1.0))
- rz = self._rotation_matrix_z(np.radians(angle * 0.3))
- return np.dot(rz, np.dot(ry, rx))
- elif rotation_style == 'cube_unfold':
- if progress < 0.5:
- return self._rotation_matrix_y(np.radians(progress * 2 * 180))
- else:
- unfold_progress = (progress - 0.5) * 2
- return self._rotation_matrix_y(np.radians(180 + unfold_progress * 180))
- else:
- return self._rotation_matrix_y(np.radians(angle))
-
- def _rotation_matrix_x(self, angle):
- """绕X轴旋转矩阵"""
- cos_a, sin_a = np.cos(angle), np.sin(angle)
- return np.array([
- [1, 0, 0],
- [0, cos_a, -sin_a],
- [0, sin_a, cos_a]
- ], dtype=np.float32)
-
- def _rotation_matrix_y(self, angle):
- """绕Y轴旋转矩阵"""
- cos_a, sin_a = np.cos(angle), np.sin(angle)
- return np.array([
- [cos_a, 0, sin_a],
- [0, 1, 0],
- [-sin_a, 0, cos_a]
- ], dtype=np.float32)
-
- def _rotation_matrix_z(self, angle):
- """绕Z轴旋转矩阵"""
- cos_a, sin_a = np.cos(angle), np.sin(angle)
- return np.array([
- [cos_a, -sin_a, 0],
- [sin_a, cos_a, 0],
- [0, 0, 1]
- ], dtype=np.float32)
-
- def _render_cube(self, frame, video_frames_data, rotation_matrix, progress, cube_size, perspective, width, height):
- """渲染立方体"""
-
- # 缩放立方体
- scale = cube_size * min(width, height) / 4
- vertices = self.cube_vertices * scale
-
- # 应用旋转
- rotated_vertices = np.dot(vertices, rotation_matrix.T)
-
- # 透视投影
- projected_vertices = self._perspective_projection(rotated_vertices, perspective, width, height)
-
- # 渲染每个面
- for face_idx, face_vertex_indices in enumerate(self.cube_faces):
- if face_idx < len(video_frames_data) and video_frames_data[face_idx]:
- self._render_face(frame, face_vertex_indices, projected_vertices,
- video_frames_data[face_idx], progress, face_idx)
-
- def _perspective_projection(self, vertices, perspective, width, height):
- """透视投影"""
- # 将3D点投影到2D屏幕
- projected = np.zeros((len(vertices), 2), dtype=np.float32)
-
- for i, vertex in enumerate(vertices):
- if vertex[2] + perspective > 0: # 避免除零
- # 透视投影
- x = (vertex[0] * perspective) / (vertex[2] + perspective)
- y = (vertex[1] * perspective) / (vertex[2] + perspective)
-
- # 转换到屏幕坐标
- projected[i][0] = x + width / 2
- projected[i][1] = y + height / 2
-
- return projected
-
- def _render_face(self, frame, face_vertex_indices, projected_vertices, face_frames, progress, face_idx):
- """渲染立方体的一个面"""
-
- # 获取面的4个顶点
- face_vertices = projected_vertices[face_vertex_indices]
-
- # 检查面是否可见(简单的背面剔除)
- if not self._is_face_visible(face_vertices):
- return
-
- # 计算当前帧索引
- total_frames = len(face_frames)
- frame_index = int(progress * (total_frames - 1))
- frame_index = max(0, min(frame_index, total_frames - 1))
-
- # 获取当前帧
- current_frame = face_frames[frame_index]
-
- # 创建变换矩阵
- src_points = np.array([
- [0, 0],
- [current_frame.shape[1], 0],
- [current_frame.shape[1], current_frame.shape[0]],
- [0, current_frame.shape[0]]
- ], dtype=np.float32)
-
- dst_points = face_vertices.astype(np.float32)
-
- # 计算透视变换矩阵
- try:
- transform_matrix = cv2.getPerspectiveTransform(src_points, dst_points)
-
- # 应用透视变换
- warped = cv2.warpPerspective(current_frame, transform_matrix, (frame.shape[1], frame.shape[0]))
-
- # 创建掩码
- mask = np.zeros((frame.shape[0], frame.shape[1]), dtype=np.uint8)
- cv2.fillPoly(mask, [dst_points.astype(np.int32)], 255)
-
- # 合成到帧上
- frame[mask > 0] = warped[mask > 0]
-
- except cv2.error:
- # 如果变换失败,跳过这个面
- pass
-
- def _is_face_visible(self, face_vertices):
- """简单的背面剔除"""
- # 计算面的法向量(简化版本)
- v1 = face_vertices[1] - face_vertices[0]
- v2 = face_vertices[2] - face_vertices[0]
-
- # 叉积计算法向量
- normal_z = v1[0] * v2[1] - v1[1] * v2[0]
-
- # 如果法向量指向屏幕外,则面不可见
- return normal_z > 0
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoCubeRotationAdvancedNode": VideoCubeRotationAdvancedNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoCubeRotationAdvancedNode": "Multi-video cube rotation",
-}
-
diff --git a/py/video_cube_transition.py b/py/video_cube_transition.py
deleted file mode 100644
index c6d8d71..0000000
--- a/py/video_cube_transition.py
+++ /dev/null
@@ -1,694 +0,0 @@
-"""
-视频立方体转场节点 - Playwright版本(批处理优化)
-两个视频片段之间的3D立方体旋转转场效果,支持批处理和内存优化
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoCubeTransitionNode(ComfyNodeABC):
- """视频立方体转场 - 两个视频之间的3D立方体旋转转场(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(立方体正面)
- "video2": (IO.IMAGE,), # 第二个视频(立方体另一面)
- "direction": ([
- "horizontal", # 水平立方体转场(左右转)
- "vertical", # 垂直立方体转场(上下转)
- ],),
- "total_frames": (IO.INT, {"default": 60, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "rotation_angle": (IO.FLOAT, {"default": 90.0, "min": 45.0, "max": 180.0}),
- "cube_size": (IO.FLOAT, {"default": 300.0, "min": 200.0, "max": 600.0}),
- "perspective": (IO.FLOAT, {"default": 800.0, "min": 500.0, "max": 2000.0}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 320, "max": 1920}),
- "height": (IO.INT, {"default": 640, "min": 240, "max": 1080}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_cube_transition"
-
- async def generate_cube_transition(
- self,
- video1,
- video2,
- direction,
- total_frames,
- fps,
- rotation_angle=90.0,
- cube_size=300.0,
- perspective=800.0,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=640,
- height=640
- ):
- """生成视频立方体转场效果 - 批处理优化版本"""
-
- start_time = time.time()
- print(f"🎬 开始生成视频立方体转场(批处理优化)...")
- print(f" 转场方向: {'水平(左右转)' if direction == 'horizontal' else '垂直(上下转)'}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 🎯 立方体面尺寸直接使用画布尺寸(矩形面)
- cube_width = width
- cube_height = height
- print(f"📐 立方体面尺寸: {cube_width}x{cube_height}px (匹配画布)")
-
- # 解析背景颜色
- bg_color_bgr = self._parse_background_color(background_color)
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu', # 启用GPU
- '--use-gl=angle', # 使用ANGLE(支持GPU)
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox', # 避免权限问题
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader', # 强制使用CPU软件渲染
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板(传递cube_width、cube_height和direction)
- html_content = self._generate_html_template(
- direction, rotation_angle, cube_width, cube_height, perspective, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待页面初始化
- await page.wait_for_function("window.cubeController && window.cubeController.ready", timeout=5000)
-
- print("✅ Playwright初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- # 使用生成器进行内存优化
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, rotation_angle
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 立方体转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, rotation_angle):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新立方体面的背景图片和旋转
- await page.evaluate(f"""
- const frontFace = document.getElementById('frontFace');
- const secondFace = document.getElementById('secondFace');
-
- if (frontFace) {{
- frontFace.style.backgroundImage = 'url({frame1_base64})';
- }}
-
- if (secondFace) {{
- secondFace.style.backgroundImage = 'url({frame2_base64})';
- }}
-
- // 更新旋转
- if (window.cubeController) {{
- window.cubeController.updateRotation({progress});
- }}
- """)
-
- # 优化等待时间
- await page.wait_for_timeout(20) # 从200ms减少到20ms
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待CSS动画完成
- await page.wait_for_timeout(10) # 从50ms减少到10ms
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=85)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, direction, rotation_angle, cube_width, cube_height, perspective, background_color, width, height):
- """生成HTML模板 - 矩形立方体面(支持水平和垂直方向)"""
-
- # 根据方向设置立方体面的变换
- if direction == "horizontal":
- # 水平立方体(左右转):正面 + 右侧面
- # 深度使用宽度的一半
- cube_depth = cube_width / 2
- front_transform = f"translateZ({cube_depth}px)"
- second_face_transform = f"rotateY(90deg) translateZ({cube_depth}px)"
- second_face_class = "right"
- else:
- # 垂直立方体(上下转):正面 + 底面
- # 深度使用高度的一半(重要!)
- cube_depth = cube_height / 2
- front_transform = f"translateZ({cube_depth}px)"
- second_face_transform = f"rotateX(90deg) translateZ({cube_depth}px)"
- second_face_class = "bottom"
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _compose_frame_with_opencv(self, frame1_data, frame2_data, cube_transforms, bg_color_bgr, width, height, cube_size):
- """使用OpenCV合成最终帧 - 使用透视变换"""
-
- # 创建背景画布
- canvas = np.full((height, width, 3), bg_color_bgr, dtype=np.uint8)
-
- # 调整视频帧大小到立方体尺寸
- frame1_resized = cv2.resize(frame1_data, (cube_size, cube_size), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2_data, (cube_size, cube_size), interpolation=cv2.INTER_AREA)
-
- # 绘制立方体的两个面
- faces_to_draw = []
-
- # 正面(video1)
- front_transform = cube_transforms.get('front', {})
- if front_transform.get('visible', True):
- faces_to_draw.append({
- 'frame': frame1_resized,
- 'transform': front_transform,
- 'name': 'front'
- })
-
- # 右侧面(video2)
- right_transform = cube_transforms.get('right', {})
- if right_transform.get('visible', True):
- faces_to_draw.append({
- 'frame': frame2_resized,
- 'transform': right_transform,
- 'name': 'right'
- })
-
- # 按Z值排序(先绘制远的面)
- faces_to_draw.sort(key=lambda x: x['transform'].get('zIndex', 0))
-
- # 绘制所有面
- for face_data in faces_to_draw:
- frame = face_data['frame']
- transform = face_data['transform']
- rect = transform.get('rect', {})
-
- x = rect.get('x', 0)
- y = rect.get('y', 0)
- w = rect.get('width', cube_size)
- h = rect.get('height', cube_size)
-
- # 简单的边界检查
- if w <= 0 or h <= 0:
- continue
-
- # 直接使用rect的尺寸进行缩放(Playwright已经计算好透视)
- try:
- # 缩放到目标尺寸
- frame_scaled = cv2.resize(frame, (int(w), int(h)), interpolation=cv2.INTER_LINEAR)
-
- # 计算绘制位置
- draw_x = int(x)
- draw_y = int(y)
- draw_w = int(w)
- draw_h = int(h)
-
- # 裁剪到画布范围内
- src_x1 = 0
- src_y1 = 0
- src_x2 = draw_w
- src_y2 = draw_h
-
- dst_x1 = draw_x
- dst_y1 = draw_y
- dst_x2 = draw_x + draw_w
- dst_y2 = draw_y + draw_h
-
- # 处理负坐标
- if dst_x1 < 0:
- src_x1 = -dst_x1
- dst_x1 = 0
- if dst_y1 < 0:
- src_y1 = -dst_y1
- dst_y1 = 0
-
- # 处理超出边界
- if dst_x2 > width:
- src_x2 -= (dst_x2 - width)
- dst_x2 = width
- if dst_y2 > height:
- src_y2 -= (dst_y2 - height)
- dst_y2 = height
-
- # 确保有有效的绘制区域
- if dst_x2 > dst_x1 and dst_y2 > dst_y1 and src_x2 > src_x1 and src_y2 > src_y1:
- canvas[dst_y1:dst_y2, dst_x1:dst_x2] = frame_scaled[src_y1:src_y2, src_x1:src_x2]
- except Exception as e:
- print(f"⚠️ 绘制面 {face_data['name']} 时出错: {e}")
- pass
-
- return canvas
-
- def _calculate_z_index(self, face_transform):
- """计算面的Z索引(用于深度排序)"""
- transform_str = face_transform.get('transform', 'none')
-
- if transform_str == 'none':
- return 0
-
- # 简单提取translateZ值
- if 'translateZ' in transform_str:
- try:
- z_value = float(transform_str.split('translateZ(')[1].split('px)')[0])
- return z_value
- except:
- return 0
-
- return 0
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _parse_background_color(self, color_str):
- """解析背景颜色字符串为BGR元组"""
- if color_str.startswith('#'):
- hex_color = color_str[1:]
- r = int(hex_color[0:2], 16)
- g = int(hex_color[2:4], 16)
- b = int(hex_color[4:6], 16)
- return (b, g, r)
- else:
- return (0, 0, 0)
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoCubeTransitionNode": VideoCubeTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoCubeTransitionNode": "Video Cube Transition",
-}
-
diff --git a/py/video_explosion_transition.py b/py/video_explosion_transition.py
deleted file mode 100644
index 586ac32..0000000
--- a/py/video_explosion_transition.py
+++ /dev/null
@@ -1,615 +0,0 @@
-"""
-视频爆炸转场节点 - CSS3版本(真实切割效果)
-第一个视频片段分割成碎块向四周飞散,露出第二个视频片段
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoExplosionTransitionNode(ComfyNodeABC):
- """视频爆炸转场 - 碎片飞散效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(爆炸的)
- "video2": (IO.IMAGE,), # 第二个视频(背景)
- "explosion_style": ([
- "random_scatter", # 随机散射
- "radial_burst", # 径向爆炸
- "spiral_explosion", # 螺旋爆炸
- "gravity_fall", # 重力下落
- "wind_blow", # 风吹效果
- "center_burst", # 中心爆炸
- ],),
- "total_frames": (IO.INT, {"default": 24, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "fragment_size": (IO.INT, {"default": 64, "min": 16, "max": 128}),
- "explosion_force": (IO.FLOAT, {"default": 1.0, "min": 0.3, "max": 3.0}),
- "rotation_speed": (IO.FLOAT, {"default": 1.0, "min": 0.0, "max": 3.0}),
- "gravity_strength": (IO.FLOAT, {"default": 0.5, "min": 0.0, "max": 2.0}),
- "fade_out": (IO.BOOLEAN, {"default": True}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_explosion_transition"
-
- async def generate_explosion_transition(
- self,
- video1,
- video2,
- explosion_style,
- total_frames,
- fps,
- fragment_size=32,
- explosion_force=1.0,
- rotation_speed=1.0,
- gravity_strength=0.5,
- fade_out=True,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=640,
- height=640,
- quality=90
- ):
- """生成视频爆炸转场效果 - 碎片飞散版本"""
-
- start_time = time.time()
- print(f"💥 开始生成爆炸转场(碎片飞散效果)...")
- print(f" 爆炸样式: {explosion_style}")
- print(f" 碎片大小: {fragment_size}px")
- print(f" 爆炸力度: {explosion_force}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
- print(f" 输出尺寸: {width}x{height}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- explosion_style, fragment_size, explosion_force, rotation_speed,
- gravity_strength, fade_out, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待爆炸控制器初始化
- await page.wait_for_function("window.explosionController && window.explosionController.ready", timeout=10000)
-
- print("✅ 爆炸场景初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 爆炸转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新爆炸动画
- await page.evaluate(f"""
- if (window.explosionController) {{
- window.explosionController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- # 等待动画更新
- await page.wait_for_timeout(25)
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待渲染完成
- await page.wait_for_timeout(15)
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, explosion_style, fragment_size, explosion_force, rotation_speed, gravity_strength, fade_out, background_color, width, height):
- """生成HTML模板 - CSS3爆炸效果(真实切割)"""
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoExplosionTransitionNode": VideoExplosionTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoExplosionTransitionNode": "Video Explosion Transition",
-}
diff --git a/py/video_flip3d_transition.py b/py/video_flip3d_transition.py
deleted file mode 100644
index f7b0cdf..0000000
--- a/py/video_flip3d_transition.py
+++ /dev/null
@@ -1,453 +0,0 @@
-"""
-视频3D翻转转场节点 - Playwright版本(批处理优化)
-两个视频片段之间的3D翻转转场效果,支持批处理和内存优化
-"""
-
-import torch
-import json
-import numpy as np
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-
-
-class VideoFlip3DTransitionNode(ComfyNodeABC):
- """视频3D翻转转场 - 两个视频之间的3D翻转转场(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(正面)
- "video2": (IO.IMAGE,), # 第二个视频(背面)
- "transition_type": ([
- "horizontal_flip", # 水平翻转
- "vertical_flip", # 垂直翻转
- "oblique_flip", # 斜向翻转
- ],),
- "total_frames": (IO.INT, {"default": 60, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "perspective": (IO.FLOAT, {"default": 1000.0, "min": 500.0, "max": 2000.0}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 320, "max": 1920}),
- "height": (IO.INT, {"default": 640, "min": 240, "max": 1080}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_flip_transition"
-
- async def generate_flip_transition(
- self,
- video1,
- video2,
- transition_type,
- total_frames,
- fps,
- perspective=1000.0,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=640,
- height=640
- ):
- """生成视频3D翻转转场效果 - 批处理优化版本"""
-
- start_time = time.time()
- print(f"🎬 开始生成视频3D翻转转场(批处理优化)...")
- print(f" 转场类型: {transition_type}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu', # 启用GPU
- '--use-gl=angle', # 使用ANGLE(支持GPU)
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox', # 避免权限问题
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader', # 强制使用CPU软件渲染
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- transition_type, perspective, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待页面初始化
- await page.wait_for_function("window.flipController && window.flipController.ready", timeout=5000)
-
- print("✅ Playwright初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- # 使用生成器进行内存优化
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 3D翻转转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新页面内容和翻转动画
- await page.evaluate(f"""
- const frontCard = document.getElementById('frontCard');
- const backCard = document.getElementById('backCard');
-
- if (frontCard) {{
- frontCard.style.backgroundImage = 'url({frame1_base64})';
- }}
-
- if (backCard) {{
- backCard.style.backgroundImage = 'url({frame2_base64})';
- }}
-
- // 更新翻转
- if (window.flipController) {{
- window.flipController.updateRotation({progress});
- }}
- """)
-
- # 优化等待时间
- await page.wait_for_timeout(20) # 从100ms减少到20ms
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待CSS动画完成
- await page.wait_for_timeout(10) # 从50ms减少到10ms
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=85)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, transition_type, perspective, background_color, width, height):
- """生成HTML模板 - 3D翻转卡片"""
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoFlip3DTransitionNode": VideoFlip3DTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoFlip3DTransitionNode": "Video Flip 3D Transition",
-}
diff --git a/py/video_glitch_art_enhanced.py b/py/video_glitch_art_enhanced.py
deleted file mode 100644
index 1592aa6..0000000
--- a/py/video_glitch_art_enhanced.py
+++ /dev/null
@@ -1,1405 +0,0 @@
-"""
-视频故障艺术转场节点 - 增强版(超强视觉效果)
-数字故障、像素错位、RGB分离、信号干扰等效果 - 加强版
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoGlitchArtEnhancedNode(ComfyNodeABC):
- """视频故障艺术转场 - 增强版(超强视觉冲击)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(故障源)
- "video2": (IO.IMAGE,), # 第二个视频(目标)
- "glitch_style": ([
- "compression_nightmare", # 压缩噩梦
- "digital_meltdown", # 数字崩溃
- "quantum_decay", # 量子衰变
- "neural_collapse", # 神经网络崩溃
- ],),
- "total_frames": (IO.INT, {"default": 36, "min": 4, "max": 120}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "chaos_level": (IO.FLOAT, {"default": 2.0, "min": 0.5, "max": 5.0}),
- "corruption_rate": (IO.FLOAT, {"default": 0.6, "min": 0.2, "max": 1.0}),
- "visual_intensity": (IO.FLOAT, {"default": 3.0, "min": 1.0, "max": 5.0}),
- "color_madness": (IO.FLOAT, {"default": 2.5, "min": 1.0, "max": 4.0}),
- "screen_tear": (IO.BOOLEAN, {"default": True}),
- "flash_effects": (IO.BOOLEAN, {"default": True}),
- "use_gpu": (IO.BOOLEAN, {"default": True}),
- "batch_size": (IO.INT, {"default": 6, "min": 1, "max": 15}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_enhanced_glitch_transition"
-
- async def generate_enhanced_glitch_transition(
- self,
- video1,
- video2,
- glitch_style,
- total_frames,
- fps,
- chaos_level=2.0,
- corruption_rate=0.6,
- visual_intensity=3.0,
- color_madness=2.5,
- screen_tear=True,
- flash_effects=True,
- use_gpu=True,
- batch_size=6,
- background_color="#000000",
- width=640,
- height=640,
- quality=90
- ):
- """生成增强版故障艺术转场效果"""
-
- start_time = time.time()
- print(f"🔥💥 开始生成增强版故障艺术转场(超强视觉冲击)...")
- print(f" 故障样式: {glitch_style}")
- print(f" 混乱等级: {chaos_level}")
- print(f" 损坏率: {corruption_rate}")
- print(f" 视觉强度: {visual_intensity}")
- print(f" 颜色疯狂度: {color_madness}")
- print(f" 屏幕撕裂: {'启用' if screen_tear else '禁用'}")
- print(f" 闪光效果: {'启用' if flash_effects else '禁用'}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用'}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- # 预计算帧数据
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- # 使用Playwright渲染
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- if use_gpu:
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-webgl2',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--max_old_space_size=4096',
- ]
- )
- else:
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
-
- # 生成增强版HTML模板
- html_content = self._generate_enhanced_html_template(
- glitch_style, chaos_level, corruption_rate, visual_intensity,
- color_madness, screen_tear, flash_effects, background_color, width, height
- )
-
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
- await page.wait_for_function("window.enhancedGlitchController && window.enhancedGlitchController.ready", timeout=15000)
-
- print("✅ 增强版故障系统初始化完成")
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- batch_frames = await self._process_enhanced_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- output_frames.extend(batch_frames)
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 增强版故障艺术转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
-
- return (video_tensor,)
-
- async def _process_enhanced_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """处理增强版批次"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- await page.evaluate(f"""
- if (window.enhancedGlitchController) {{
- window.enhancedGlitchController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- await page.wait_for_timeout(40)
- await page.evaluate("document.body.offsetHeight")
- await page.wait_for_timeout(20)
-
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_enhanced_html_template(self, glitch_style, chaos_level, corruption_rate, visual_intensity, color_madness, screen_tear, flash_effects, background_color, width, height):
- """生成增强版HTML模板"""
-
- return f"""
-
-
-
-
-
-
-
-
- 初始化增强故障系统...
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoGlitchArtEnhancedNode": VideoGlitchArtEnhancedNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoGlitchArtEnhancedNode": "Video Glitch Art Enhanced",
-}
diff --git a/py/video_glitch_art_transition.py b/py/video_glitch_art_transition.py
deleted file mode 100644
index 4ff94d8..0000000
--- a/py/video_glitch_art_transition.py
+++ /dev/null
@@ -1,989 +0,0 @@
-"""
-视频故障艺术转场节点 - WebGL版本(赛博朋克故障效果)
-数字故障、像素错位、RGB分离、信号干扰等效果
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoGlitchArtTransitionNode(ComfyNodeABC):
- """视频故障艺术转场 - 赛博朋克数字故障效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(故障源)
- "video2": (IO.IMAGE,), # 第二个视频(目标)
- "glitch_style": ([
- "signal_interference", # 信号干扰
- "data_moshing", # 数据混合
- "pixel_sorting", # 像素排序
- "matrix_rain", # 矩阵雨
- ],),
- "total_frames": (IO.INT, {"default": 36, "min": 4, "max": 120}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "glitch_intensity": (IO.FLOAT, {"default": 1.0, "min": 0.1, "max": 3.0}),
- "corruption_rate": (IO.FLOAT, {"default": 0.3, "min": 0.1, "max": 0.8}),
- "scanline_frequency": (IO.FLOAT, {"default": 5.0, "min": 1.0, "max": 20.0}),
- "noise_amount": (IO.FLOAT, {"default": 0.2, "min": 0.0, "max": 0.5}),
- "digital_artifacts": (IO.BOOLEAN, {"default": True}),
- "color_bleeding": (IO.BOOLEAN, {"default": True}),
- "screen_tear": (IO.BOOLEAN, {"default": True}),
- "use_gpu": (IO.BOOLEAN, {"default": True}),
- "batch_size": (IO.INT, {"default": 6, "min": 1, "max": 15}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_glitch_art_transition"
-
- async def generate_glitch_art_transition(
- self,
- video1,
- video2,
- glitch_style,
- total_frames,
- fps,
- glitch_intensity=1.0,
- corruption_rate=0.3,
- scanline_frequency=5.0,
- noise_amount=0.2,
- digital_artifacts=True,
- color_bleeding=True,
- screen_tear=True,
- use_gpu=True,
- batch_size=6,
- background_color="#000000",
- width=640,
- height=640,
- quality=90
- ):
- """生成视频故障艺术转场效果 - 赛博朋克数字故障"""
-
- start_time = time.time()
- print(f"🔥 开始生成故障艺术转场(赛博朋克风格)...")
- print(f" 故障样式: {glitch_style}")
- print(f" 故障强度: {glitch_intensity}")
- print(f" 损坏率: {corruption_rate}")
- print(f" 扫描线频率: {scanline_frequency}")
- print(f" 噪声量: {noise_amount}")
- print(f" 数字伪影: {'启用' if digital_artifacts else '禁用'}")
- print(f" 颜色溢出: {'启用' if color_bleeding else '禁用'}")
- print(f" 屏幕撕裂: {'启用' if screen_tear else '禁用'}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
- print(f" 输出尺寸: {width}x{height}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染故障效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速(故障效果需要GPU)
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-webgl2',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--max_old_space_size=4096',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速 + WebGL2)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- glitch_style, glitch_intensity, corruption_rate,
- scanline_frequency, noise_amount, digital_artifacts, color_bleeding,
- screen_tear, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待故障系统初始化
- await page.wait_for_function("window.glitchController && window.glitchController.ready", timeout=15000)
-
- print("✅ 故障艺术系统初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 故障艺术转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新故障动画
- await page.evaluate(f"""
- if (window.glitchController) {{
- window.glitchController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- # 等待故障系统更新(故障效果需要更多时间)
- await page.wait_for_timeout(40)
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待渲染完成
- await page.wait_for_timeout(20)
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, glitch_style, glitch_intensity, corruption_rate, scanline_frequency, noise_amount, digital_artifacts, color_bleeding, screen_tear, background_color, width, height):
- """生成HTML模板 - WebGL故障艺术系统效果(赛博朋克风格)"""
-
- return f"""
-
-
-
-
-
-
-
-
- 初始化故障艺术系统...
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoGlitchArtTransitionNode": VideoGlitchArtTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoGlitchArtTransitionNode": "Video Glitch Art Transition",
-}
diff --git a/py/video_page_turn.py b/py/video_page_turn.py
deleted file mode 100644
index 3f8360b..0000000
--- a/py/video_page_turn.py
+++ /dev/null
@@ -1,608 +0,0 @@
-"""
-视频翻页转场节点 - Playwright版本(批处理优化)
-模拟真实书页翻动的物理效果,支持批处理和内存优化
-支持左右翻页和上下翻页模式
-"""
-
-import torch
-import json
-import numpy as np
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-
-
-class VideoPageTurnNode(ComfyNodeABC):
- """视频翻页转场 - 真实书页翻动效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频(当前页)
- "video2": (IO.IMAGE,), # 第二个视频(下一页)
- "page_direction": ([
- "right_to_left", # 从左到右翻页
- "left_to right", # 从右到左翻页
- "top_to_bottom", # 从上到下翻页
- "bottom_to_top", # 从下到上翻页
- ],),
- "total_frames": (IO.INT, {"default": 60, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "page_curl_size": (IO.FLOAT, {"default": 0.3, "min": 0.1, "max": 0.5}),
- "perspective": (IO.FLOAT, {"default": 1200.0, "min": 800.0, "max": 2000.0}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#1a1a1a"}),
- "width": (IO.INT, {"default": 640, "min": 320, "max": 1920}),
- "height": (IO.INT, {"default": 640, "min": 240, "max": 1080}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_page_turn"
-
- async def generate_page_turn(
- self,
- video1,
- video2,
- page_direction,
- total_frames,
- fps,
- page_curl_size=0.3,
- perspective=1200.0,
- use_gpu=False,
- batch_size=5,
- background_color="#1a1a1a",
- width=640,
- height=640
- ):
- """生成视频翻页转场效果 - 批处理优化版本"""
-
- start_time = time.time()
- print(f"🎬 开始生成视频翻页转场(批处理优化)...")
- print(f" 翻页方向: {page_direction}")
- print(f" 翻页卷曲度: {page_curl_size}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染3D效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu', # 启用GPU
- '--use-gl=angle', # 使用ANGLE(支持GPU)
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox', # 避免权限问题
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader', # 强制使用CPU软件渲染
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- page_direction, page_curl_size, perspective, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待页面初始化
- await page.wait_for_function("window.pageController && window.pageController.ready", timeout=5000)
-
- print("✅ Playwright初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- # 使用生成器进行内存优化
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 翻页转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新页面内容和翻页动画
- await page.evaluate(f"""
- const currentPage = document.getElementById('currentPage');
- const nextPage = document.getElementById('nextPage');
- const turningPage = document.getElementById('turningPage');
- const turningPageBack = document.getElementById('turningPageBack');
-
- if (currentPage) {{
- currentPage.style.backgroundImage = 'url({frame1_base64})';
- }}
-
- if (nextPage) {{
- nextPage.style.backgroundImage = 'url({frame2_base64})';
- }}
-
- if (turningPage) {{
- turningPage.style.backgroundImage = 'url({frame1_base64})';
- }}
-
- if (turningPageBack) {{
- turningPageBack.style.backgroundImage = 'url({frame2_base64})';
- }}
-
- // 更新翻页动画
- if (window.pageController) {{
- window.pageController.updateTurn({progress});
- }}
- """)
-
- # 优化等待时间
- await page.wait_for_timeout(20) # 从100ms减少到20ms
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待CSS动画完成
- await page.wait_for_timeout(10) # 从50ms减少到10ms
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=85)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, page_direction, page_curl_size, perspective, background_color, width, height):
- """生成HTML模板 - 真实翻页效果(支持多方向)"""
-
- # 根据翻页方向设置CSS
- if page_direction == "right_to_left":
- # 从左到右翻页:以左边为轴心
- transform_origin = "left center"
- curl_position = "top: 0; right: 0;"
- curl_border = "border-bottom-color"
- elif page_direction == "left_to right":
- # 从右到左翻页:以右边为轴心
- transform_origin = "right center"
- curl_position = "top: 0; left: 0;"
- curl_border = "border-bottom-color"
- elif page_direction == "bottom_to_top":
- # 从下往上翻页:以顶部为轴心
- transform_origin = "top center"
- curl_position = "top: 0; left: 0;"
- curl_border = "border-right-color"
- else:
- # 从上往下翻页:以底部为轴心
- transform_origin = "bottom center"
- curl_position = "bottom: 0; left: 0;"
- curl_border = "border-right-color"
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoPageTurnNode": VideoPageTurnNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoPageTurnNode": "Video Page Turn",
-}
-
diff --git a/py/video_shake_transition.py b/py/video_shake_transition.py
deleted file mode 100644
index 3b55b5e..0000000
--- a/py/video_shake_transition.py
+++ /dev/null
@@ -1,532 +0,0 @@
-"""
-视频抖动转场节点 - CSS3版本(剪映风格)
-两个视频片段之间的抖动转场效果,模拟剪映热门转场
-"""
-
-import torch
-import json
-import cv2
-import numpy as np
-import math
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-from .playwright_renderer import PlaywrightRenderer
-
-
-class VideoShakeTransitionNode(ComfyNodeABC):
- """视频抖动转场 - 剪映风格抖动效果(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频
- "video2": (IO.IMAGE,), # 第二个视频
- "shake_style": ([
- "random_shake", # 随机抖动
- "horizontal_shake", # 水平抖动
- "vertical_shake", # 垂直抖动
- "rotation_shake", # 旋转抖动
- "scale_shake", # 缩放抖动
- "combo_shake", # 组合抖动
- ],),
- "total_frames": (IO.INT, {"default": 12, "min": 4, "max": 60}),
- "fps": (IO.INT, {"default": 24, "min": 15, "max": 60}),
- },
- "optional": {
- "shake_intensity": (IO.FLOAT, {"default": 20.0, "min": 5.0, "max": 50.0}),
- "shake_frequency": (IO.FLOAT, {"default": 8.0, "min": 2.0, "max": 20.0}),
- "transition_point": (IO.FLOAT, {"default": 0.5, "min": 0.2, "max": 0.8}),
- "fade_mix": (IO.BOOLEAN, {"default": True}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 8, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 640, "max": 3840}),
- "height": (IO.INT, {"default": 640, "min": 360, "max": 2160}),
- "quality": (IO.INT, {"default": 90, "min": 60, "max": 100}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_shake_transition"
-
- async def generate_shake_transition(
- self,
- video1,
- video2,
- shake_style,
- total_frames,
- fps,
- shake_intensity=20.0,
- shake_frequency=8.0,
- transition_point=0.5,
- fade_mix=True,
- use_gpu=False,
- batch_size=8,
- background_color="#000000",
- width=640,
- height=640,
- quality=90
- ):
- """生成视频抖动转场效果 - 剪映风格"""
-
- start_time = time.time()
- print(f"📳 开始生成抖动转场(剪映风格)...")
- print(f" 抖动样式: {shake_style}")
- print(f" 抖动强度: {shake_intensity}")
- print(f" 抖动频率: {shake_frequency}")
- print(f" 转场点: {transition_point}")
- print(f" GPU加速: {'启用' if use_gpu else '禁用(SwiftShader)'}")
- print(f" 批处理大小: {batch_size}")
- print(f" 输出尺寸: {width}x{height}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 预计算优化:提前计算所有帧的base64数据
- print("🔄 预计算帧数据...")
- precompute_start = time.time()
-
- frame1_base64_list = []
- frame2_base64_list = []
-
- for i in range(total_frames):
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 预计算base64数据
- frame1_base64_list.append(self._numpy_to_base64(frame1_data))
- frame2_base64_list.append(self._numpy_to_base64(frame2_data))
-
- precompute_time = time.time() - precompute_start
- print(f"✅ 预计算完成,耗时: {precompute_time:.2f}秒")
-
- # 使用Playwright直接渲染抖动效果
- from playwright.async_api import async_playwright
-
- playwright = await async_playwright().start()
-
- try:
- # 根据GPU设置选择渲染方式
- if use_gpu:
- # GPU硬件加速
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--enable-gpu',
- '--use-gl=angle',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- '--no-sandbox',
- '--disable-setuid-sandbox',
- ]
- )
- print("✅ Playwright浏览器已启动(GPU硬件加速)")
- else:
- # SwiftShader软件渲染
- browser = await playwright.chromium.launch(
- headless=True,
- args=[
- '--use-angle=swiftshader',
- '--enable-webgl',
- '--enable-accelerated-2d-canvas',
- '--disable-dev-shm-usage',
- '--hide-scrollbars',
- '--mute-audio',
- ]
- )
- print("✅ Playwright浏览器已启动(SwiftShader软件渲染)")
-
- # 生成HTML模板
- html_content = self._generate_html_template(
- shake_style, shake_intensity, shake_frequency, transition_point,
- fade_mix, background_color, width, height
- )
-
- # 创建页面
- page = await browser.new_page(viewport={'width': width, 'height': height})
-
- try:
- # 加载HTML页面
- await page.set_content(html_content, wait_until='domcontentloaded', timeout=30000)
-
- # 等待抖动控制器初始化
- await page.wait_for_function("window.shakeController && window.shakeController.ready", timeout=10000)
-
- print("✅ 抖动场景初始化完成")
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = await self._process_batch(
- page, batch_indices, frame1_base64_list, frame2_base64_list,
- total_frames, quality
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- finally:
- await page.close()
-
- await browser.close()
- finally:
- await playwright.stop()
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 抖动转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- async def _process_batch(self, page, batch_indices, frame1_base64_list, frame2_base64_list, total_frames, quality):
- """批处理渲染多个帧"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 使用预计算的base64数据
- frame1_base64 = frame1_base64_list[i]
- frame2_base64 = frame2_base64_list[i]
-
- # 更新抖动动画
- await page.evaluate(f"""
- if (window.shakeController) {{
- window.shakeController.updateFrame({progress}, '{frame1_base64}', '{frame2_base64}');
- }}
- """)
-
- # 等待动画更新
- await page.wait_for_timeout(16) # 抖动需要更快的更新
-
- # 强制触发重绘
- await page.evaluate("document.body.offsetHeight")
-
- # 等待渲染完成
- await page.wait_for_timeout(8)
-
- # 优化截图:使用JPEG格式
- screenshot_bytes = await page.screenshot(type='jpeg', quality=quality)
-
- # 转换为numpy数组
- from PIL import Image
- import io
- image = Image.open(io.BytesIO(screenshot_bytes)).convert('RGB')
- final_frame = np.array(image).astype(np.uint8)
-
- batch_frames.append(final_frame)
-
- return batch_frames
-
- def _generate_html_template(self, shake_style, shake_intensity, shake_frequency, transition_point, fade_mix, background_color, width, height):
- """生成HTML模板 - CSS3抖动效果(剪映风格)"""
-
- return f"""
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
- def _numpy_to_base64(self, frame_np):
- """将numpy数组转换为base64字符串"""
- import io
- import base64
- from PIL import Image
-
- # 确保是uint8类型
- if frame_np.dtype != np.uint8:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- # 转换为PIL图片
- image = Image.fromarray(frame_np, mode='RGB')
-
- # 转换为base64
- buffered = io.BytesIO()
- image.save(buffered, format="PNG")
- img_str = base64.b64encode(buffered.getvalue()).decode()
-
- return f"data:image/png;base64,{img_str}"
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoShakeTransitionNode": VideoShakeTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoShakeTransitionNode": "Video Shake Transition",
-}
diff --git a/py/video_warp_transition.py b/py/video_warp_transition.py
deleted file mode 100644
index cd87d68..0000000
--- a/py/video_warp_transition.py
+++ /dev/null
@@ -1,494 +0,0 @@
-"""
-视频扭曲转场节点 - OpenCV像素位移版本(批处理优化)
-两个视频片段之间的扭曲转场效果,支持多种扭曲模式
-基于OpenCV remap函数实现真正的像素位移扭曲
-"""
-
-import torch
-import numpy as np
-import cv2
-import time
-from comfy.comfy_types.node_typing import ComfyNodeABC, InputTypeDict, IO
-
-
-class VideoWarpTransitionNode(ComfyNodeABC):
- """视频扭曲转场 - 基于OpenCV像素位移的扭曲转场(批处理优化版)"""
-
- CATEGORY = "VideoTransition"
-
- @classmethod
- def INPUT_TYPES(cls) -> InputTypeDict:
- return {
- "required": {
- "video1": (IO.IMAGE,), # 第一个视频
- "video2": (IO.IMAGE,), # 第二个视频
- "warp_type": ([
- "swirl", # 旋涡扭曲
- "squeeze_h", # 水平挤压
- "squeeze_v", # 垂直挤压
- "liquid", # 液体扭曲
- "wave", # 波浪扭曲
- ],),
- "total_frames": (IO.INT, {"default": 60, "min": 4, "max": 300}),
- "fps": (IO.INT, {"default": 30, "min": 15, "max": 60}),
- },
- "optional": {
- "warp_intensity": (IO.FLOAT, {"default": 0.5, "min": 0.1, "max": 2.0}),
- "warp_speed": (IO.FLOAT, {"default": 1.0, "min": 0.1, "max": 3.0}),
- "max_scale": (IO.FLOAT, {"default": 1.3, "min": 1.0, "max": 3.0, "step": 0.1}),
- "scale_recovery": (IO.BOOLEAN, {"default": True}),
- "use_gpu": (IO.BOOLEAN, {"default": False}),
- "batch_size": (IO.INT, {"default": 5, "min": 1, "max": 20}),
- "background_color": (IO.STRING, {"default": "#000000"}),
- "width": (IO.INT, {"default": 640, "min": 320, "max": 1920}),
- "height": (IO.INT, {"default": 640, "min": 240, "max": 1080}),
- }
- }
-
- RETURN_TYPES = (IO.IMAGE,)
- RETURN_NAMES = ("video",)
- FUNCTION = "generate_warp_transition"
-
- async def generate_warp_transition(
- self,
- video1,
- video2,
- warp_type,
- total_frames,
- fps,
- warp_intensity=0.5,
- warp_speed=1.0,
- max_scale=1.3,
- scale_recovery=True,
- use_gpu=False,
- batch_size=5,
- background_color="#000000",
- width=640,
- height=640
- ):
- """生成视频扭曲转场效果 - 基于OpenCV像素位移的批处理优化版本"""
-
- start_time = time.time()
-
- warp_names = {
- "swirl": "旋涡扭曲",
- "squeeze_h": "水平挤压",
- "squeeze_v": "垂直挤压",
- "liquid": "液体扭曲",
- "wave": "波浪扭曲",
- }
-
- print(f"🎬 开始生成视频扭曲转场(OpenCV像素位移)...")
- print(f" 扭曲类型: {warp_names.get(warp_type, warp_type)}")
- print(f" 扭曲强度: {warp_intensity}")
- print(f" 扭曲速度: {warp_speed}")
- print(f" 最大缩放: {max_scale}x")
- print(f" 缩放恢复: {'启用' if scale_recovery else '禁用'}")
- print(f" 批处理大小: {batch_size}")
-
- # 提取视频帧
- frames1 = self._extract_video_frames(video1)
- frames2 = self._extract_video_frames(video2)
-
- print(f"📹 视频1: {len(frames1)}帧, 视频2: {len(frames2)}帧")
- print(f"🎞️ 转场帧数: {total_frames}")
-
- # 解析背景颜色
- bg_color_bgr = self._parse_background_color(background_color)
-
- # 批处理渲染
- print("🎬 开始批处理渲染...")
- render_start = time.time()
-
- output_frames = []
-
- for batch_start in range(0, total_frames, batch_size):
- batch_end = min(batch_start + batch_size, total_frames)
- batch_indices = list(range(batch_start, batch_end))
-
- print(f"📦 处理批次 {batch_start//batch_size + 1}/{(total_frames-1)//batch_size + 1}: 帧 {batch_start+1}-{batch_end}")
-
- # 批处理:一次处理多个帧
- batch_frames = self._process_batch_opencv(
- frames1, frames2, batch_indices, total_frames, warp_type,
- warp_intensity, warp_speed, max_scale, scale_recovery, bg_color_bgr, width, height
- )
-
- # 立即添加到结果中,避免内存积累
- output_frames.extend(batch_frames)
-
- # 打印批次进度
- batch_progress = (batch_end / total_frames) * 100
- print(f"✅ 批次完成,总进度: {batch_progress:.1f}%")
-
- render_time = time.time() - render_start
- print(f"✅ 批处理渲染完成,耗时: {render_time:.2f}秒")
-
- # 转换为tensor
- video_tensor = self._frames_to_tensor(output_frames)
-
- total_time = time.time() - start_time
- print(f"✅ 扭曲转场完成,输出: {video_tensor.shape}")
- print(f"⏱️ 总耗时: {total_time:.2f}秒")
- print(f"🚀 平均每帧: {total_time/total_frames*1000:.1f}ms")
-
- return (video_tensor,)
-
- def _process_batch_opencv(self, frames1, frames2, batch_indices, total_frames, warp_type,
- warp_intensity, warp_speed, max_scale, scale_recovery, bg_color_bgr, width, height):
- """使用OpenCV进行批处理扭曲渲染"""
- batch_frames = []
-
- for i in batch_indices:
- progress = i / (total_frames - 1) if total_frames > 1 else 0
-
- # 获取对应的帧
- if len(frames1) == 1:
- frame1_data = self._tensor_to_numpy(frames1[0])
- else:
- frame1_idx = min(int(progress * (len(frames1) - 1)), len(frames1) - 1)
- frame1_data = self._tensor_to_numpy(frames1[frame1_idx])
-
- if len(frames2) == 1:
- frame2_data = self._tensor_to_numpy(frames2[0])
- else:
- frame2_idx = min(int(progress * (len(frames2) - 1)), len(frames2) - 1)
- frame2_data = self._tensor_to_numpy(frames2[frame2_idx])
-
- # 调整到目标尺寸
- frame1_resized = cv2.resize(frame1_data, (width, height), interpolation=cv2.INTER_AREA)
- frame2_resized = cv2.resize(frame2_data, (width, height), interpolation=cv2.INTER_AREA)
-
- # 应用扭曲效果 - 剪映风格
- # 第一个视频:扭曲但不缩放
- frame1_warped = self._apply_warp_effect(frame1_resized, warp_type, warp_intensity, warp_speed, progress, 1.0 - progress, is_first_video=True, max_scale=max_scale, scale_recovery=scale_recovery)
-
- # 第二个视频:反向扭曲 + 缩放恢复
- frame2_warped = self._apply_warp_effect(frame2_resized, warp_type, warp_intensity, warp_speed, progress, progress, is_first_video=False, max_scale=max_scale, scale_recovery=scale_recovery)
-
- # 确保两个帧的尺寸一致
- if frame1_warped.shape != frame2_warped.shape:
- frame2_warped = cv2.resize(frame2_warped, (frame1_warped.shape[1], frame1_warped.shape[0]), interpolation=cv2.INTER_AREA)
-
- # 混合两个扭曲后的帧 - 确保无黑色背景的平滑过渡
- alpha1, alpha2 = self._calculate_blend_weights(progress)
-
- # 使用addWeighted进行混合,确保无黑色背景
- # alpha1和alpha2已经在_calculate_blend_weights中确保总和为1
- blended = cv2.addWeighted(frame1_warped, alpha1, frame2_warped, alpha2, 0)
-
- batch_frames.append(blended)
-
- return batch_frames
-
- def _apply_warp_effect(self, image, warp_type, intensity, speed, progress, alpha, is_first_video=True, max_scale=1.3, scale_recovery=True):
- """应用扭曲效果到单帧图像 - 剪映风格"""
- height, width = image.shape[:2]
-
- # 创建网格
- y, x = np.indices((height, width), dtype=np.float32)
-
- # 计算时间因子
- time_factor = progress * speed * np.pi * 2
-
- # 根据扭曲类型和视频类型计算位移
- if warp_type == "swirl":
- x_offset, y_offset = self._calculate_swirl_displacement(x, y, width, height, intensity, time_factor, progress, is_first_video)
- elif warp_type == "squeeze_h":
- x_offset, y_offset = self._calculate_squeeze_displacement(x, y, width, height, intensity, time_factor, progress, "horizontal", is_first_video)
- elif warp_type == "squeeze_v":
- x_offset, y_offset = self._calculate_squeeze_displacement(x, y, width, height, intensity, time_factor, progress, "vertical", is_first_video)
- elif warp_type == "liquid":
- x_offset, y_offset = self._calculate_liquid_displacement(x, y, width, height, intensity, time_factor, progress, is_first_video)
- elif warp_type == "wave":
- x_offset, y_offset = self._calculate_wave_displacement(x, y, width, height, intensity, time_factor, progress, is_first_video)
- else:
- x_offset, y_offset = np.zeros_like(x), np.zeros_like(y)
-
- # 计算新的坐标
- x_new = x + x_offset
- y_new = y + y_offset
-
- # 确保坐标在有效范围内
- x_new = np.clip(x_new, 0, width-1)
- y_new = np.clip(y_new, 0, height-1)
-
- # 应用扭曲
- warped = cv2.remap(image, x_new, y_new, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT)
-
- # 第二个视频:应用缩放恢复效果
- if not is_first_video and scale_recovery:
- warped = self._apply_scale_recovery(warped, progress, width, height, max_scale)
-
- # 应用透明度
- if alpha < 1.0:
- warped = (warped * alpha).astype(np.uint8)
-
- return warped
-
- def _apply_scale_recovery(self, image, progress, width, height, max_scale=1.3):
- """应用缩放恢复效果 - 使用裁切方法实现第二个视频的缩放效果"""
- h, w = image.shape[:2]
-
- # 计算缩放比例:progress=0时最大(max_scale),progress=1时正常(1.0x)
- scale = max_scale - progress * (max_scale - 1.0)
-
- # 确保缩放值在合理范围内
- scale = max(0.8, min(scale, max_scale))
-
- # 如果不需要缩放,直接返回原图
- if abs(scale - 1.0) < 0.01:
- return image
-
- # 使用裁切方法:从中心裁切出缩放后的区域
- # 当scale > 1时,相当于放大(裁切更小的区域)
- # 当scale < 1时,相当于缩小(裁切更大的区域)
-
- # 计算裁切区域的大小(与缩放效果相反)
- crop_scale = 1.0 / scale
- crop_w = int(w * crop_scale)
- crop_h = int(h * crop_scale)
-
- # 确保裁切尺寸不超过原图
- crop_w = min(crop_w, w)
- crop_h = min(crop_h, h)
-
- # 计算裁切起始位置(居中裁切)
- start_x = (w - crop_w) // 2
- start_y = (h - crop_h) // 2
-
- # 确保裁切位置在有效范围内
- start_x = max(0, min(start_x, w - crop_w))
- start_y = max(0, min(start_y, h - crop_h))
-
- # 裁切图像
- cropped = image[start_y:start_y + crop_h, start_x:start_x + crop_w]
-
- # 将裁切后的图像缩放回原始尺寸
- result = cv2.resize(cropped, (w, h), interpolation=cv2.INTER_AREA)
-
- return result
-
- def _calculate_blend_weights(self, progress):
- """计算叠化权重 - 确保无黑色背景的平滑过渡"""
- # 使用平滑的S型曲线,确保alpha总和始终为1
- # 避免出现黑色背景,保持视频内容的连续性
-
- # 确保progress在有效范围内
- progress = max(0.0, min(progress, 1.0))
-
- # 使用更平滑的过渡曲线,避免突然的切换
- # 第一个视频:从1.0平滑过渡到0.0
- alpha1 = 1.0 - progress
-
- # 第二个视频:从0.0平滑过渡到1.0
- alpha2 = progress
-
- # 应用S型曲线让过渡更自然
- # 使用smoothstep函数:3t² - 2t³
- def smoothstep(t):
- return 3 * t * t - 2 * t * t * t
-
- # 对alpha2应用smoothstep,让过渡更自然
- alpha2 = smoothstep(alpha2)
- alpha1 = 1.0 - alpha2
-
- # 确保权重在合理范围内,并且总和为1
- alpha1 = max(0.0, min(alpha1, 1.0))
- alpha2 = max(0.0, min(alpha2, 1.0))
-
- # 最终确保alpha总和为1,避免黑色背景
- total_alpha = alpha1 + alpha2
- if total_alpha > 0:
- alpha1 = alpha1 / total_alpha
- alpha2 = alpha2 / total_alpha
-
- return alpha1, alpha2
-
- def _calculate_swirl_displacement(self, x, y, width, height, intensity, time_factor, progress, is_first_video=True):
- """计算旋涡扭曲的位移 - 剪映风格"""
- center_x = width / 2
- center_y = height / 2
- max_radius = np.sqrt(center_x**2 + center_y**2)
- swirl_intensity = intensity * 2
-
- # 计算到中心的距离和角度
- dx = x - center_x
- dy = y - center_y
- distance = np.sqrt(dx**2 + dy**2)
- angle = np.arctan2(dy, dx)
-
- # 计算影响因子
- influence = np.where(distance > 0,
- np.clip(1.0 - distance / max_radius, 0, 1), 0)
- influence = influence * influence * (3 - 2 * influence)
-
- if is_first_video:
- # 第一个视频:逐渐增强的扭曲
- rotation_factor = progress
- rotation_direction = 1
- else:
- # 第二个视频:反向扭曲恢复
- rotation_factor = 1.0 - progress
- rotation_direction = -1
-
- # 计算扭转角度
- twist_angle = (distance / max_radius) * swirl_intensity * np.pi * influence * rotation_factor * rotation_direction
-
- # 计算新坐标
- cos_theta = np.cos(twist_angle)
- sin_theta = np.sin(twist_angle)
- x_new = center_x + dx * cos_theta - dy * sin_theta
- y_new = center_y + dx * sin_theta + dy * cos_theta
-
- # 计算位移
- x_offset = x_new - x
- y_offset = y_new - y
-
- return x_offset, y_offset
-
- def _calculate_squeeze_displacement(self, x, y, width, height, intensity, time_factor, progress, direction, is_first_video=True):
- """计算挤压扭曲的位移 - 剪映风格"""
- if is_first_video:
- # 第一个视频:逐渐增强的挤压
- squeeze_intensity = intensity * progress * 58
- else:
- # 第二个视频:反向挤压恢复
- squeeze_intensity = intensity * (1.0 - progress) * 58
-
- if direction == "horizontal":
- center_x = width / 2
- squeeze = np.sin((x - center_x) / width * np.pi * 3) * squeeze_intensity
- x_offset = squeeze
- y_offset = np.zeros_like(x)
- else: # vertical
- center_y = height / 2
- squeeze = np.sin((y - center_y) / height * np.pi * 3) * squeeze_intensity
- x_offset = np.zeros_like(x)
- y_offset = squeeze
-
- return x_offset, y_offset
-
- def _calculate_liquid_displacement(self, x, y, width, height, intensity, time_factor, progress, is_first_video=True):
- """计算液体扭曲的位移 - 剪映风格"""
- if is_first_video:
- # 第一个视频:逐渐增强的液体效果
- liquid_intensity = intensity * progress * 30
- else:
- # 第二个视频:反向液体恢复
- liquid_intensity = intensity * (1.0 - progress) * 30
-
- # 多方向波动
- wave1 = np.sin(x * 0.02 + time_factor) * liquid_intensity
- wave2 = np.cos(y * 0.02 + time_factor * 0.7) * liquid_intensity * 0.8
-
- # 添加额外的液体波动层
- wave3 = np.sin(x * 0.03 + time_factor * 1.3) * liquid_intensity * 0.4
- wave4 = np.cos(y * 0.03 + time_factor * 0.5) * liquid_intensity * 0.3
-
- x_offset = wave1 + wave3
- y_offset = wave2 + wave4
-
- return x_offset, y_offset
-
- def _calculate_wave_displacement(self, x, y, width, height, intensity, time_factor, progress, is_first_video=True):
- """计算波浪扭曲的位移 - 剪映风格"""
- if is_first_video:
- # 第一个视频:逐渐增强的波浪
- wave_intensity = intensity * progress * 40
- else:
- # 第二个视频:反向波浪恢复
- wave_intensity = intensity * (1.0 - progress) * 40
-
- # 波浪形扭曲
- wave1 = np.sin(x * 0.03 + time_factor) * wave_intensity
- wave2 = np.sin(x * 0.05 + time_factor * 1.5) * wave_intensity * 0.6
-
- # 添加垂直方向的波浪
- wave3 = np.sin(y * 0.02 + time_factor * 0.8) * wave_intensity * 0.4
-
- x_offset = wave3 # 垂直波浪影响x方向
- y_offset = wave1 + wave2 # 水平波浪影响y方向
-
- return x_offset, y_offset
-
- def _parse_background_color(self, color_str):
- """解析背景颜色字符串为BGR格式"""
- try:
- # 移除#号
- if color_str.startswith('#'):
- color_str = color_str[1:]
-
- # 解析RGB
- if len(color_str) == 6:
- r = int(color_str[0:2], 16)
- g = int(color_str[2:4], 16)
- b = int(color_str[4:6], 16)
- elif len(color_str) == 3:
- r = int(color_str[0] * 2, 16)
- g = int(color_str[1] * 2, 16)
- b = int(color_str[2] * 2, 16)
- else:
- # 默认黑色
- r, g, b = 0, 0, 0
-
- # 返回BGR格式(OpenCV使用)
- return (b, g, r)
- except:
- # 解析失败时返回黑色
- return (0, 0, 0)
-
- def _extract_video_frames(self, video_tensor):
- """从视频tensor中提取帧"""
- frames = []
-
- if video_tensor.dim() == 4:
- batch_size = video_tensor.shape[0]
- for i in range(batch_size):
- frame = video_tensor[i]
- frames.append(frame)
- else:
- frames.append(video_tensor)
-
- return frames
-
- def _tensor_to_numpy(self, frame_tensor):
- """将tensor转换为numpy数组"""
- if isinstance(frame_tensor, torch.Tensor):
- frame_np = frame_tensor.cpu().numpy()
- else:
- frame_np = frame_tensor
-
- # 转换数据类型
- if frame_np.dtype == np.float32 or frame_np.dtype == np.float64:
- frame_np = (frame_np * 255).clip(0, 255).astype(np.uint8)
-
- return frame_np
-
-
- def _frames_to_tensor(self, frames):
- """将帧列表转换为视频tensor"""
- if not frames:
- return torch.zeros((1, 640, 640, 3), dtype=torch.float32)
-
- frame_tensors = []
- for frame in frames:
- if isinstance(frame, np.ndarray):
- frame_tensor = torch.from_numpy(frame).float() / 255.0
- frame_tensors.append(frame_tensor)
- else:
- frame_tensors.append(frame)
-
- video_tensor = torch.stack(frame_tensors, dim=0)
-
- return video_tensor
-
-
-# 注册节点
-NODE_CLASS_MAPPINGS = {
- "VideoWarpTransitionNode": VideoWarpTransitionNode,
-}
-
-NODE_DISPLAY_NAME_MAPPINGS = {
- "VideoWarpTransitionNode": "Video Warp Transition",
-}