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", -}