Delete py directory
This commit is contained in:
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
perspective: {perspective}px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}}
|
||||
|
||||
.scene {{
|
||||
width: {cube_width}px;
|
||||
height: {cube_width}px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}}
|
||||
|
||||
.cube {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform: translateZ(-{half_width}px);
|
||||
will-change: transform;
|
||||
}}
|
||||
|
||||
.cube-face {{
|
||||
position: absolute;
|
||||
width: {cube_width}px;
|
||||
height: {cube_width}px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
backface-visibility: hidden;
|
||||
background-size: 101%;
|
||||
box-shadow: inset 0 0 30px rgba(0,0,0,0.2);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.1);
|
||||
outline-offset: -1px;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
transform-style: preserve-3d;
|
||||
}}
|
||||
|
||||
.cube-face::before {{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
width: calc(100% + 2px);
|
||||
height: calc(100% + 2px);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255,255,255,0.08) 0%,
|
||||
transparent 50%,
|
||||
rgba(0,0,0,0.15) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
.front {{
|
||||
background-image: url('{image_base64_list[0]}');
|
||||
transform: rotateY(0deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.back {{
|
||||
background-image: url('{image_base64_list[1]}');
|
||||
transform: rotateY(180deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.right {{
|
||||
background-image: url('{image_base64_list[2]}');
|
||||
transform: rotateY(90deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.left {{
|
||||
background-image: url('{image_base64_list[3]}');
|
||||
transform: rotateY(-90deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.top {{
|
||||
background-image: url('{image_base64_list[4]}');
|
||||
transform: rotateX(90deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.bottom {{
|
||||
background-image: url('{image_base64_list[5]}');
|
||||
transform: rotateX(-90deg) translateZ({half_width}px);
|
||||
}}
|
||||
|
||||
.light {{
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.3), transparent);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(40px);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="light" id="light"></div>
|
||||
<div class="scene" id="scene">
|
||||
<div class="cube" id="cube">
|
||||
<div class="cube-face front"></div>
|
||||
<div class="cube-face back"></div>
|
||||
<div class="cube-face right"></div>
|
||||
<div class="cube-face left"></div>
|
||||
<div class="cube-face top"></div>
|
||||
<div class="cube-face bottom"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 配置
|
||||
window.cubeConfig = {{
|
||||
rotationStyle: "{rotation_style}",
|
||||
rotationSpeed: {rotation_speed},
|
||||
cubeSize: {cube_size}
|
||||
}};
|
||||
|
||||
// 立方体旋转控制器
|
||||
class CubeController {{
|
||||
constructor() {{
|
||||
this.cube = document.getElementById('cube');
|
||||
this.light = document.getElementById('light');
|
||||
this.config = window.cubeConfig;
|
||||
this.ready = false;
|
||||
}}
|
||||
|
||||
init() {{
|
||||
if (this.cube) {{
|
||||
this.ready = true;
|
||||
console.log('CubeController初始化完成:', this.config.rotationStyle);
|
||||
}}
|
||||
}}
|
||||
|
||||
update(progress) {{
|
||||
if (!this.cube || !this.ready) return;
|
||||
|
||||
const speed = this.config.rotationSpeed;
|
||||
const angle = progress * 360 * speed;
|
||||
|
||||
let transform = '';
|
||||
|
||||
switch(this.config.rotationStyle) {{
|
||||
case 'x_axis':
|
||||
transform = `translateZ(-{half_width}px) rotateX(${{angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'y_axis':
|
||||
transform = `translateZ(-{half_width}px) rotateY(${{angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'z_axis':
|
||||
transform = `translateZ(-{half_width}px) rotateZ(${{angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'diagonal':
|
||||
transform = `translateZ(-{half_width}px) rotateX(${{angle}}deg) rotateY(${{angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'random_spin':
|
||||
const angleX = angle * 0.7;
|
||||
const angleY = angle * 1.3;
|
||||
const angleZ = angle * 0.5;
|
||||
transform = `translateZ(-{half_width}px) rotateX(${{angleX}}deg) rotateY(${{angleY}}deg) rotateZ(${{angleZ}}deg)`;
|
||||
break;
|
||||
|
||||
case 'cube_unfold':
|
||||
const unfoldProgress = Math.sin(progress * Math.PI);
|
||||
const rotateY = unfoldProgress * 180;
|
||||
const scale = 1 + unfoldProgress * 0.5;
|
||||
transform = `translateZ(-{half_width}px) rotateY(${{rotateY}}deg) scale(${{scale}})`;
|
||||
break;
|
||||
|
||||
default:
|
||||
transform = `translateZ(-{half_width}px) rotateY(${{angle}}deg)`;
|
||||
}}
|
||||
|
||||
this.cube.style.transform = transform;
|
||||
|
||||
// 更新光照位置
|
||||
const lightX = Math.sin(progress * Math.PI * 2) * 300 + 150;
|
||||
const lightY = Math.cos(progress * Math.PI * 2) * 300 + 150;
|
||||
this.light.style.left = lightX + 'px';
|
||||
this.light.style.top = lightY + 'px';
|
||||
|
||||
if (progress < 0.01 || progress > 0.99) {{
|
||||
console.log(`Cube: progress=${{progress.toFixed(3)}}, angle=${{angle.toFixed(1)}}`);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
// 创建全局实例
|
||||
const cubeController = new CubeController();
|
||||
|
||||
// 等待图片加载完成
|
||||
let loadedImages = 0;
|
||||
const totalImages = 6;
|
||||
|
||||
document.querySelectorAll('.cube-face').forEach((face) => {{
|
||||
const bgImage = window.getComputedStyle(face).backgroundImage;
|
||||
if (bgImage && bgImage !== 'none') {{
|
||||
const img = new Image();
|
||||
img.onload = () => {{
|
||||
loadedImages++;
|
||||
if (loadedImages === totalImages) {{
|
||||
cubeController.init();
|
||||
window.effectReady = true;
|
||||
console.log('✅ 所有立方体面加载完成,效果准备就绪');
|
||||
}}
|
||||
}};
|
||||
img.onerror = () => {{
|
||||
loadedImages++;
|
||||
if (loadedImages === totalImages) {{
|
||||
cubeController.init();
|
||||
window.effectReady = true;
|
||||
console.log('✅ 立方体面加载完成(部分失败),效果准备就绪');
|
||||
}}
|
||||
}};
|
||||
// 从backgroundImage中提取URL
|
||||
const match = bgImage.match(/url\\(["']?([^"']*)["']?\\)/);
|
||||
if (match) {{
|
||||
img.src = match[1];
|
||||
}}
|
||||
}} else {{
|
||||
loadedImages++;
|
||||
}}
|
||||
}});
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {{
|
||||
if (!window.effectReady) {{
|
||||
cubeController.init();
|
||||
window.effectReady = true;
|
||||
console.log('⚠️ 超时保护:强制标记效果就绪');
|
||||
}}
|
||||
}}, 3000);
|
||||
|
||||
// 供渲染器调用的更新函数
|
||||
window.updateEffect = function(progress) {{
|
||||
cubeController.update(progress);
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# 注册节点
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CubeRotationNode": CubeRotationNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CubeRotationNode": "Cube Rotation",
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.blinds-container {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
perspective: 1000px;
|
||||
}}
|
||||
|
||||
.blind-strip {{
|
||||
position: absolute;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center;
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}}
|
||||
|
||||
.blind-strip.horizontal {{
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}}
|
||||
|
||||
.blind-strip.vertical {{
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}}
|
||||
|
||||
.blind-strip.diagonal-right {{
|
||||
transform-origin: top left;
|
||||
}}
|
||||
|
||||
.blind-strip.diagonal-left {{
|
||||
transform-origin: top right;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="blinds-container" id="blindsContainer">
|
||||
<!-- 百叶窗条带将由JavaScript动态生成 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class BlindsController {{
|
||||
constructor() {{
|
||||
this.ready = false;
|
||||
this.direction = '{blinds_direction}';
|
||||
this.blindsCount = {blinds_count};
|
||||
this.flipDuration = {flip_duration};
|
||||
this.staggerDelay = {stagger_delay};
|
||||
this.width = {width};
|
||||
this.height = {height};
|
||||
this.blinds = [];
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.container = document.getElementById('blindsContainer');
|
||||
this.createBlinds();
|
||||
this.ready = true;
|
||||
console.log('🎬 BlindsController初始化完成, 方向=' + this.direction + ', 数量=' + this.blindsCount);
|
||||
}}
|
||||
|
||||
createBlinds() {{
|
||||
// 清空容器
|
||||
this.container.innerHTML = '';
|
||||
this.blinds = [];
|
||||
|
||||
for (let i = 0; i < this.blindsCount; i++) {{
|
||||
const blind = document.createElement('div');
|
||||
blind.className = `blind-strip ${{this.direction}}`;
|
||||
|
||||
// 根据方向设置百叶窗条带的位置和大小
|
||||
this.setupBlindStrip(blind, i);
|
||||
|
||||
this.container.appendChild(blind);
|
||||
this.blinds.push(blind);
|
||||
}}
|
||||
}}
|
||||
|
||||
setupBlindStrip(blind, index) {{
|
||||
const stripSize = this.getStripSize();
|
||||
const position = this.getStripPosition(index, stripSize);
|
||||
|
||||
switch(this.direction) {{
|
||||
case 'horizontal':
|
||||
blind.style.height = stripSize + 'px';
|
||||
blind.style.top = position + 'px';
|
||||
break;
|
||||
case 'vertical':
|
||||
blind.style.width = stripSize + 'px';
|
||||
blind.style.left = position + 'px';
|
||||
break;
|
||||
case 'diagonal_right':
|
||||
case 'diagonal_left':
|
||||
blind.style.width = stripSize + 'px';
|
||||
blind.style.height = this.height + 'px';
|
||||
blind.style.left = position + 'px';
|
||||
blind.style.top = '0px';
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
|
||||
getStripSize() {{
|
||||
switch(this.direction) {{
|
||||
case 'horizontal':
|
||||
return Math.floor(this.height / this.blindsCount);
|
||||
case 'vertical':
|
||||
case 'diagonal_right':
|
||||
case 'diagonal_left':
|
||||
return Math.floor(this.width / this.blindsCount);
|
||||
default:
|
||||
return 50;
|
||||
}}
|
||||
}}
|
||||
|
||||
getStripPosition(index, stripSize) {{
|
||||
return index * stripSize;
|
||||
}}
|
||||
|
||||
updateFrame(progress, texture1Base64, texture2Base64) {{
|
||||
// 更新所有百叶窗条带
|
||||
this.blinds.forEach((blind, index) => {{
|
||||
this.updateBlindStrip(blind, index, progress, texture1Base64, texture2Base64);
|
||||
}});
|
||||
}}
|
||||
|
||||
updateBlindStrip(blind, index, progress, texture1Base64, texture2Base64) {{
|
||||
// 计算每个条带的翻转时机(错开动画)
|
||||
const stripDelay = index * this.staggerDelay;
|
||||
const adjustedProgress = Math.max(0, Math.min(1, (progress - stripDelay) / this.flipDuration));
|
||||
|
||||
// 使用缓动函数让翻转更自然
|
||||
const easeProgress = this.easeInOutCubic(adjustedProgress);
|
||||
|
||||
if (adjustedProgress <= 0) {{
|
||||
// 还没开始翻转,显示第一个视频
|
||||
blind.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
blind.style.transform = this.getTransform(0);
|
||||
}} else if (adjustedProgress >= 1) {{
|
||||
// 翻转完成,显示第二个视频
|
||||
blind.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
blind.style.transform = this.getTransform(0);
|
||||
}} else {{
|
||||
// 翻转过程中
|
||||
if (easeProgress < 0.5) {{
|
||||
// 前半段:显示第一个视频,开始翻转
|
||||
blind.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
blind.style.transform = this.getTransform(easeProgress * 2);
|
||||
}} else {{
|
||||
// 后半段:显示第二个视频,完成翻转
|
||||
blind.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
blind.style.transform = this.getTransform(2 - easeProgress * 2);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
getTransform(flipProgress) {{
|
||||
// flipProgress: 0 = 正面, 1 = 完全翻转
|
||||
const angle = flipProgress * 180;
|
||||
|
||||
switch(this.direction) {{
|
||||
case 'horizontal':
|
||||
return `rotateX(${{angle}}deg)`;
|
||||
case 'vertical':
|
||||
return `rotateY(${{angle}}deg)`;
|
||||
case 'diagonal_right':
|
||||
return `rotateY(${{angle}}deg) rotateX(${{angle * 0.3}}deg)`;
|
||||
case 'diagonal_left':
|
||||
return `rotateY(${{-angle}}deg) rotateX(${{angle * 0.3}}deg)`;
|
||||
default:
|
||||
return `rotateY(${{angle}}deg)`;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 缓动函数
|
||||
easeInOutCubic(t) {{
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 初始化控制器
|
||||
window.blindsController = new BlindsController();
|
||||
console.log('✅ BlindsController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.checkerboard-container {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
perspective: 1000px;
|
||||
}}
|
||||
|
||||
.checker-square {{
|
||||
position: absolute;
|
||||
background-size: {width}px {height}px;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.checker-square.flip {{
|
||||
backface-visibility: hidden;
|
||||
}}
|
||||
|
||||
.checker-square.scale {{
|
||||
transform: scale(1);
|
||||
}}
|
||||
|
||||
.checker-square.rotate {{
|
||||
transform: rotate(0deg);
|
||||
}}
|
||||
|
||||
.checker-square.slide {{
|
||||
transform: translateX(0) translateY(0);
|
||||
}}
|
||||
|
||||
.checker-square.wave {{
|
||||
transform: translateZ(0) rotateX(0deg) rotateY(0deg);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="checkerboard-container" id="checkerboardContainer">
|
||||
<!-- 棋盘格方块将由JavaScript动态生成 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class CheckerboardController {{
|
||||
constructor() {{
|
||||
this.ready = false;
|
||||
this.transitionStyle = '{transition_style}';
|
||||
this.gridSize = {grid_size};
|
||||
this.animationDuration = {animation_duration};
|
||||
this.staggerDelay = {stagger_delay};
|
||||
this.width = {width};
|
||||
this.height = {height};
|
||||
this.squares = [];
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.container = document.getElementById('checkerboardContainer');
|
||||
this.createCheckerboard();
|
||||
this.ready = true;
|
||||
console.log('🎬 CheckerboardController初始化完成, 样式=' + this.transitionStyle + ', 网格=' + this.gridSize + 'x' + this.gridSize);
|
||||
}}
|
||||
|
||||
createCheckerboard() {{
|
||||
// 清空容器
|
||||
this.container.innerHTML = '';
|
||||
this.squares = [];
|
||||
|
||||
const squareWidth = Math.floor(this.width / this.gridSize);
|
||||
const squareHeight = Math.floor(this.height / this.gridSize);
|
||||
|
||||
for (let row = 0; row < this.gridSize; row++) {{
|
||||
for (let col = 0; col < this.gridSize; col++) {{
|
||||
const square = document.createElement('div');
|
||||
square.className = `checker-square ${{this.transitionStyle.split('_')[1]}}`;
|
||||
|
||||
// 设置方块位置和大小
|
||||
square.style.left = (col * squareWidth) + 'px';
|
||||
square.style.top = (row * squareHeight) + 'px';
|
||||
square.style.width = squareWidth + 'px';
|
||||
square.style.height = squareHeight + 'px';
|
||||
|
||||
// 关键:设置背景位置以显示对应的图片区域(真实切割效果)
|
||||
const bgPosX = -(col * squareWidth);
|
||||
const bgPosY = -(row * squareHeight);
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
|
||||
// 存储方块信息
|
||||
square.dataset.row = row;
|
||||
square.dataset.col = col;
|
||||
square.dataset.index = row * this.gridSize + col;
|
||||
square.dataset.bgPosX = bgPosX;
|
||||
square.dataset.bgPosY = bgPosY;
|
||||
|
||||
this.container.appendChild(square);
|
||||
this.squares.push(square);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
updateFrame(progress, texture1Base64, texture2Base64) {{
|
||||
// 更新所有棋盘格方块
|
||||
this.squares.forEach((square, index) => {{
|
||||
this.updateSquare(square, index, progress, texture1Base64, texture2Base64);
|
||||
}});
|
||||
}}
|
||||
|
||||
updateSquare(square, index, progress, texture1Base64, texture2Base64) {{
|
||||
const row = parseInt(square.dataset.row);
|
||||
const col = parseInt(square.dataset.col);
|
||||
const bgPosX = parseInt(square.dataset.bgPosX);
|
||||
const bgPosY = parseInt(square.dataset.bgPosY);
|
||||
|
||||
// 计算每个方块的动画延迟(基于不同的模式)
|
||||
const delay = this.calculateDelay(row, col, index);
|
||||
const adjustedProgress = Math.max(0, Math.min(1, (progress - delay) / this.animationDuration));
|
||||
|
||||
// 使用缓动函数让动画更自然
|
||||
const easeProgress = this.easeInOutCubic(adjustedProgress);
|
||||
|
||||
// 根据转场样式应用不同的动画
|
||||
switch(this.transitionStyle) {{
|
||||
case 'flip_squares':
|
||||
this.animateFlip(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY);
|
||||
break;
|
||||
case 'scale_squares':
|
||||
this.animateScale(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY);
|
||||
break;
|
||||
case 'rotate_squares':
|
||||
this.animateRotate(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY);
|
||||
break;
|
||||
case 'slide_squares':
|
||||
this.animateSlide(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY, row, col);
|
||||
break;
|
||||
case 'wave_squares':
|
||||
this.animateWave(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY, row, col);
|
||||
break;
|
||||
default:
|
||||
this.animateFlip(square, easeProgress, texture1Base64, texture2Base64, bgPosX, bgPosY);
|
||||
}}
|
||||
}}
|
||||
|
||||
calculateDelay(row, col, index) {{
|
||||
switch(this.transitionStyle) {{
|
||||
case 'flip_squares':
|
||||
// 对角线延迟
|
||||
return (row + col) * this.staggerDelay;
|
||||
case 'scale_squares':
|
||||
// 从中心向外
|
||||
const centerRow = this.gridSize / 2;
|
||||
const centerCol = this.gridSize / 2;
|
||||
const distance = Math.sqrt(Math.pow(row - centerRow, 2) + Math.pow(col - centerCol, 2));
|
||||
return distance * this.staggerDelay;
|
||||
case 'rotate_squares':
|
||||
// 螺旋延迟
|
||||
return this.getSpiralDelay(row, col) * this.staggerDelay;
|
||||
case 'slide_squares':
|
||||
// 从左到右
|
||||
return col * this.staggerDelay;
|
||||
case 'wave_squares':
|
||||
// 波浪延迟
|
||||
return Math.sin(col * Math.PI / this.gridSize) * this.staggerDelay;
|
||||
default:
|
||||
return index * this.staggerDelay;
|
||||
}}
|
||||
}}
|
||||
|
||||
getSpiralDelay(row, col) {{
|
||||
// 简化的螺旋计算
|
||||
const centerRow = Math.floor(this.gridSize / 2);
|
||||
const centerCol = Math.floor(this.gridSize / 2);
|
||||
const angle = Math.atan2(row - centerRow, col - centerCol);
|
||||
const distance = Math.sqrt(Math.pow(row - centerRow, 2) + Math.pow(col - centerCol, 2));
|
||||
return angle + distance;
|
||||
}}
|
||||
|
||||
animateFlip(square, progress, texture1Base64, texture2Base64, bgPosX, bgPosY) {{
|
||||
const angle = progress * 180;
|
||||
|
||||
if (progress <= 0) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'rotateY(0deg)';
|
||||
}} else if (progress >= 1) {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'rotateY(0deg)';
|
||||
}} else {{
|
||||
if (progress < 0.5) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = `rotateY(${{angle}}deg)`;
|
||||
}} else {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = `rotateY(${{180 - angle}}deg)`;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
animateScale(square, progress, texture1Base64, texture2Base64, bgPosX, bgPosY) {{
|
||||
if (progress <= 0) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'scale(1)';
|
||||
}} else if (progress >= 1) {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'scale(1)';
|
||||
}} else {{
|
||||
if (progress < 0.5) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
const scale = 1 - progress;
|
||||
square.style.transform = `scale(${{scale}})`;
|
||||
}} else {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
const scale = progress;
|
||||
square.style.transform = `scale(${{scale}})`;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
animateRotate(square, progress, texture1Base64, texture2Base64, bgPosX, bgPosY) {{
|
||||
const angle = progress * 360;
|
||||
|
||||
if (progress <= 0) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'rotate(0deg)';
|
||||
}} else if (progress >= 1) {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'rotate(0deg)';
|
||||
}} else {{
|
||||
if (progress < 0.5) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
}} else {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
}}
|
||||
square.style.transform = `rotate(${{angle}}deg)`;
|
||||
}}
|
||||
}}
|
||||
|
||||
animateSlide(square, progress, texture1Base64, texture2Base64, bgPosX, bgPosY, row, col) {{
|
||||
const slideDistance = 100; // 滑动距离(像素)
|
||||
|
||||
if (progress <= 0) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'translateX(0)';
|
||||
}} else if (progress >= 1) {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'translateX(0)';
|
||||
}} else {{
|
||||
if (progress < 0.5) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
const offset = -progress * slideDistance * 2;
|
||||
square.style.transform = `translateX(${{offset}}px)`;
|
||||
}} else {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
const offset = (1 - progress) * slideDistance * 2;
|
||||
square.style.transform = `translateX(${{offset}}px)`;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
animateWave(square, progress, texture1Base64, texture2Base64, bgPosX, bgPosY, row, col) {{
|
||||
const waveHeight = 50; // 波浪高度
|
||||
const waveFreq = 2; // 波浪频率
|
||||
|
||||
if (progress <= 0) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'translateZ(0) rotateX(0deg)';
|
||||
}} else if (progress >= 1) {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
square.style.transform = 'translateZ(0) rotateX(0deg)';
|
||||
}} else {{
|
||||
if (progress < 0.5) {{
|
||||
square.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
}} else {{
|
||||
square.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
square.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
}}
|
||||
|
||||
const waveOffset = Math.sin(progress * Math.PI * waveFreq + col * 0.5) * waveHeight;
|
||||
const rotateX = Math.sin(progress * Math.PI + col * 0.3) * 15;
|
||||
square.style.transform = `translateZ(${{waveOffset}}px) rotateX(${{rotateX}}deg)`;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 缓动函数
|
||||
easeInOutCubic(t) {{
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 初始化控制器
|
||||
window.checkerboardController = new CheckerboardController();
|
||||
console.log('✅ CheckerboardController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
perspective: {perspective}px;
|
||||
perspective-origin: 50% 50%;
|
||||
}}
|
||||
|
||||
.cube-container {{
|
||||
width: {cube_width}px;
|
||||
height: {cube_height}px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(0deg);
|
||||
}}
|
||||
|
||||
.cube-face {{
|
||||
position: absolute;
|
||||
width: {cube_width + 1}px;
|
||||
height: {cube_height}px;
|
||||
backface-visibility: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
left: -0.5px;
|
||||
}}
|
||||
|
||||
.front {{
|
||||
transform: {front_transform};
|
||||
}}
|
||||
|
||||
.{second_face_class} {{
|
||||
transform: {second_face_transform};
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cube-container" id="cubeContainer">
|
||||
<div class="cube-face front" id="frontFace"></div>
|
||||
<div class="cube-face {second_face_class}" id="secondFace"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class CubeController {{
|
||||
constructor() {{
|
||||
this.container = document.getElementById('cubeContainer');
|
||||
this.frontFace = document.getElementById('frontFace');
|
||||
this.secondFace = document.getElementById('secondFace');
|
||||
this.cubeWidth = {cube_width};
|
||||
this.cubeHeight = {cube_height};
|
||||
this.cubeDepth = {cube_depth};
|
||||
this.rotationAngle = {rotation_angle};
|
||||
this.direction = '{direction}';
|
||||
this.currentAngle = 0;
|
||||
this.ready = true;
|
||||
|
||||
console.log('🎬 CubeController初始化完成, 方向=' + this.direction + ', 面尺寸=' + this.cubeWidth + 'x' + this.cubeHeight);
|
||||
}}
|
||||
|
||||
updateRotation(progress) {{
|
||||
// progress: 0 -> 1
|
||||
const angle = progress * this.rotationAngle;
|
||||
this.currentAngle = angle;
|
||||
|
||||
// 🎯 优化缩放曲线:开始和结束填满画布,中间缩小显示立方体3D效果
|
||||
// 使用平滑的sin曲线:1.0 -> 0.65 -> 1.0
|
||||
let scale;
|
||||
if (progress < 0.05) {{
|
||||
// 阶段1(0 -> 0.05):保持完整显示
|
||||
scale = 1.0;
|
||||
}} else if (progress > 0.95) {{
|
||||
// 阶段4(0.95 -> 1.0):恢复完整显示
|
||||
scale = 1.0;
|
||||
}} else {{
|
||||
// 阶段2-3(0.05 -> 0.95):使用sin曲线平滑缩放
|
||||
// 将progress映射到0-π范围,sin(x)从0到1再到0
|
||||
const t = (progress - 0.05) / 0.9; // 归一化到0-1
|
||||
const sinValue = Math.sin(t * Math.PI); // 0 -> 1 -> 0
|
||||
scale = 1.0 - (sinValue * 0.35); // 1.0 -> 0.65 -> 1.0
|
||||
}}
|
||||
|
||||
// 根据方向选择旋转轴
|
||||
let rotateTransform;
|
||||
if (this.direction === 'horizontal') {{
|
||||
// 水平立方体:绕Y轴旋转(左右转)
|
||||
rotateTransform = `rotateY(${{-angle}}deg)`;
|
||||
}} else {{
|
||||
// 垂直立方体:绕X轴旋转(上下转)
|
||||
rotateTransform = `rotateX(${{-angle}}deg)`;
|
||||
}}
|
||||
|
||||
// 同时应用旋转和缩放
|
||||
const transform = `${{rotateTransform}} scale(${{scale}})`;
|
||||
this.container.style.transform = transform;
|
||||
|
||||
// 强制重绘
|
||||
void this.container.offsetHeight;
|
||||
|
||||
console.log(`[JS] 更新: direction=${{this.direction}}, angle=${{angle.toFixed(1)}}°, scale=${{scale.toFixed(2)}}, transform=${{transform}}`);
|
||||
}}
|
||||
|
||||
getFaceTransforms() {{
|
||||
const frontRect = this.frontFace.getBoundingClientRect();
|
||||
const secondRect = this.secondFace.getBoundingClientRect();
|
||||
|
||||
// 获取计算后的变换矩阵
|
||||
const frontStyle = window.getComputedStyle(this.frontFace);
|
||||
const secondStyle = window.getComputedStyle(this.secondFace);
|
||||
|
||||
return {{
|
||||
front: {{
|
||||
rect: {{
|
||||
x: frontRect.x,
|
||||
y: frontRect.y,
|
||||
width: frontRect.width,
|
||||
height: frontRect.height
|
||||
}},
|
||||
transform: frontStyle.transform,
|
||||
visible: this._isFaceVisible(this.frontFace),
|
||||
zIndex: this._calculateZIndex(frontStyle.transform)
|
||||
}},
|
||||
second: {{
|
||||
rect: {{
|
||||
x: secondRect.x,
|
||||
y: secondRect.y,
|
||||
width: secondRect.width,
|
||||
height: secondRect.height
|
||||
}},
|
||||
transform: secondStyle.transform,
|
||||
visible: this._isFaceVisible(this.secondFace),
|
||||
zIndex: this._calculateZIndex(secondStyle.transform)
|
||||
}},
|
||||
currentAngle: this.currentAngle
|
||||
}};
|
||||
}}
|
||||
|
||||
_isFaceVisible(element) {{
|
||||
const style = window.getComputedStyle(element);
|
||||
const transform = style.transform;
|
||||
|
||||
if (transform === 'none') return true;
|
||||
|
||||
const matrix = this._parseMatrix(transform);
|
||||
if (!matrix) return true;
|
||||
|
||||
// 3D矩阵的背面剔除
|
||||
if (matrix.length === 16) {{
|
||||
// matrix3d: 检查Z轴方向
|
||||
const m11 = matrix[0], m12 = matrix[1], m13 = matrix[2];
|
||||
const m21 = matrix[4], m22 = matrix[5], m23 = matrix[6];
|
||||
const m31 = matrix[8], m32 = matrix[9], m33 = matrix[10];
|
||||
|
||||
// 计算法向量的Z分量
|
||||
const normalZ = m31;
|
||||
return normalZ >= -0.1; // 面向观察者
|
||||
}}
|
||||
|
||||
return true;
|
||||
}}
|
||||
|
||||
_calculateZIndex(transformStr) {{
|
||||
if (transformStr === 'none') return 0;
|
||||
|
||||
const matrix = this._parseMatrix(transformStr);
|
||||
if (!matrix) return 0;
|
||||
|
||||
// 返回Z轴的translateZ值
|
||||
if (matrix.length === 16) {{
|
||||
return matrix[14]; // matrix3d的Z平移
|
||||
}}
|
||||
|
||||
return 0;
|
||||
}}
|
||||
|
||||
_parseMatrix(matrixString) {{
|
||||
if (matrixString === 'none') return null;
|
||||
const values = matrixString.match(/matrix.*?\\((.+)\\)/);
|
||||
if (!values) return null;
|
||||
return values[1].split(/,\\s*/).map(parseFloat);
|
||||
}}
|
||||
}}
|
||||
|
||||
window.cubeController = new CubeController();
|
||||
console.log('✅ CubeController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.explosion-container {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
perspective: 1000px;
|
||||
}}
|
||||
|
||||
.background-layer {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
}}
|
||||
|
||||
.fragment {{
|
||||
position: absolute;
|
||||
background-size: {width}px {height}px;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="explosion-container" id="explosionContainer">
|
||||
<div class="background-layer" id="backgroundLayer"></div>
|
||||
<!-- 碎片将由JavaScript动态生成 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class ExplosionController {{
|
||||
constructor() {{
|
||||
this.ready = false;
|
||||
this.explosionStyle = '{explosion_style}';
|
||||
this.fragmentSize = {fragment_size};
|
||||
this.explosionForce = {explosion_force};
|
||||
this.rotationSpeed = {rotation_speed};
|
||||
this.gravityStrength = {gravity_strength};
|
||||
this.fadeOut = {str(fade_out).lower()};
|
||||
this.width = {width};
|
||||
this.height = {height};
|
||||
this.fragments = [];
|
||||
this.fragmentData = [];
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.container = document.getElementById('explosionContainer');
|
||||
this.backgroundLayer = document.getElementById('backgroundLayer');
|
||||
this.createFragments();
|
||||
this.ready = true;
|
||||
console.log('💥 ExplosionController初始化完成, 样式=' + this.explosionStyle + ', 碎片数量=' + this.fragments.length);
|
||||
}}
|
||||
|
||||
createFragments() {{
|
||||
// 清空容器中的碎片
|
||||
const existingFragments = this.container.querySelectorAll('.fragment');
|
||||
existingFragments.forEach(fragment => fragment.remove());
|
||||
|
||||
this.fragments = [];
|
||||
this.fragmentData = [];
|
||||
|
||||
const cols = Math.floor(this.width / this.fragmentSize);
|
||||
const rows = Math.floor(this.height / this.fragmentSize);
|
||||
const actualFragmentWidth = this.width / cols;
|
||||
const actualFragmentHeight = this.height / rows;
|
||||
|
||||
for (let row = 0; row < rows; row++) {{
|
||||
for (let col = 0; col < cols; col++) {{
|
||||
const fragment = document.createElement('div');
|
||||
fragment.className = 'fragment';
|
||||
|
||||
// 设置碎片位置和大小
|
||||
const x = col * actualFragmentWidth;
|
||||
const y = row * actualFragmentHeight;
|
||||
|
||||
fragment.style.left = x + 'px';
|
||||
fragment.style.top = y + 'px';
|
||||
fragment.style.width = actualFragmentWidth + 'px';
|
||||
fragment.style.height = actualFragmentHeight + 'px';
|
||||
|
||||
// 设置背景位置以显示对应的图片区域
|
||||
const bgPosX = -x;
|
||||
const bgPosY = -y;
|
||||
fragment.style.backgroundPosition = bgPosX + 'px ' + bgPosY + 'px';
|
||||
|
||||
this.container.appendChild(fragment);
|
||||
this.fragments.push(fragment);
|
||||
|
||||
// 计算碎片的运动参数
|
||||
const fragmentData = this.calculateFragmentMotion(x, y, col, row, cols, rows);
|
||||
this.fragmentData.push(fragmentData);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
calculateFragmentMotion(x, y, col, row, cols, rows) {{
|
||||
const centerX = this.width / 2;
|
||||
const centerY = this.height / 2;
|
||||
const fragmentCenterX = x + this.fragmentSize / 2;
|
||||
const fragmentCenterY = y + this.fragmentSize / 2;
|
||||
|
||||
let velocityX = 0;
|
||||
let velocityY = 0;
|
||||
let rotationX = 0;
|
||||
let rotationY = 0;
|
||||
let rotationZ = 0;
|
||||
|
||||
switch(this.explosionStyle) {{
|
||||
case 'random_scatter':
|
||||
velocityX = (Math.random() - 0.5) * 800 * this.explosionForce;
|
||||
velocityY = (Math.random() - 0.5) * 800 * this.explosionForce;
|
||||
rotationX = (Math.random() - 0.5) * 720 * this.rotationSpeed;
|
||||
rotationY = (Math.random() - 0.5) * 720 * this.rotationSpeed;
|
||||
rotationZ = (Math.random() - 0.5) * 720 * this.rotationSpeed;
|
||||
break;
|
||||
|
||||
case 'radial_burst':
|
||||
const angle = Math.atan2(fragmentCenterY - centerY, fragmentCenterX - centerX);
|
||||
const distance = Math.sqrt(Math.pow(fragmentCenterX - centerX, 2) + Math.pow(fragmentCenterY - centerY, 2));
|
||||
const force = (distance / Math.max(this.width, this.height)) * 600 * this.explosionForce;
|
||||
velocityX = Math.cos(angle) * force;
|
||||
velocityY = Math.sin(angle) * force;
|
||||
rotationZ = angle * 180 / Math.PI * this.rotationSpeed;
|
||||
break;
|
||||
|
||||
case 'spiral_explosion':
|
||||
const spiralAngle = Math.atan2(fragmentCenterY - centerY, fragmentCenterX - centerX);
|
||||
const spiralDistance = Math.sqrt(Math.pow(fragmentCenterX - centerX, 2) + Math.pow(fragmentCenterY - centerY, 2));
|
||||
const spiralForce = 400 * this.explosionForce;
|
||||
const spiralOffset = (col + row) * 0.5;
|
||||
velocityX = Math.cos(spiralAngle + spiralOffset) * spiralForce;
|
||||
velocityY = Math.sin(spiralAngle + spiralOffset) * spiralForce;
|
||||
rotationZ = (spiralAngle + spiralOffset) * 180 / Math.PI * this.rotationSpeed;
|
||||
break;
|
||||
|
||||
case 'gravity_fall':
|
||||
velocityX = (Math.random() - 0.5) * 200 * this.explosionForce;
|
||||
velocityY = -100 * this.explosionForce; // 初始向上
|
||||
rotationX = (Math.random() - 0.5) * 360 * this.rotationSpeed;
|
||||
break;
|
||||
|
||||
case 'wind_blow':
|
||||
velocityX = (200 + Math.random() * 300) * this.explosionForce; // 向右吹
|
||||
velocityY = (Math.random() - 0.5) * 100 * this.explosionForce;
|
||||
rotationZ = (Math.random() - 0.5) * 180 * this.rotationSpeed;
|
||||
break;
|
||||
|
||||
case 'center_burst':
|
||||
const burstAngle = Math.atan2(fragmentCenterY - centerY, fragmentCenterX - centerX);
|
||||
const burstForce = 500 * this.explosionForce;
|
||||
velocityX = Math.cos(burstAngle) * burstForce;
|
||||
velocityY = Math.sin(burstAngle) * burstForce;
|
||||
rotationX = (Math.random() - 0.5) * 360 * this.rotationSpeed;
|
||||
rotationY = (Math.random() - 0.5) * 360 * this.rotationSpeed;
|
||||
break;
|
||||
}}
|
||||
|
||||
return {{
|
||||
initialX: x,
|
||||
initialY: y,
|
||||
velocityX: velocityX,
|
||||
velocityY: velocityY,
|
||||
rotationX: rotationX,
|
||||
rotationY: rotationY,
|
||||
rotationZ: rotationZ,
|
||||
delay: Math.random() * 0.3 // 随机延迟0-0.3秒
|
||||
}};
|
||||
}}
|
||||
|
||||
updateFrame(progress, texture1Base64, texture2Base64) {{
|
||||
// 更新背景层(第二个视频)
|
||||
this.backgroundLayer.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
|
||||
// 更新所有碎片
|
||||
this.fragments.forEach((fragment, index) => {{
|
||||
this.updateFragment(fragment, index, progress, texture1Base64);
|
||||
}});
|
||||
}}
|
||||
|
||||
updateFragment(fragment, index, progress, texture1Base64) {{
|
||||
const data = this.fragmentData[index];
|
||||
|
||||
// 考虑延迟的进度
|
||||
const adjustedProgress = Math.max(0, Math.min(1, (progress - data.delay) / (1 - data.delay)));
|
||||
|
||||
// 使用缓动函数
|
||||
const easeProgress = this.easeOutCubic(adjustedProgress);
|
||||
|
||||
if (adjustedProgress <= 0) {{
|
||||
// 还没开始爆炸,显示原始位置
|
||||
fragment.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
fragment.style.transform = 'translate(0, 0) rotateX(0deg) rotateY(0deg) rotateZ(0deg)';
|
||||
fragment.style.opacity = '1';
|
||||
fragment.style.display = 'block';
|
||||
}} else if (adjustedProgress >= 1) {{
|
||||
// 爆炸完成,隐藏碎片
|
||||
fragment.style.display = 'none';
|
||||
}} else {{
|
||||
// 爆炸过程中
|
||||
fragment.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
fragment.style.display = 'block';
|
||||
|
||||
// 计算位置
|
||||
let currentX = data.initialX + data.velocityX * easeProgress;
|
||||
let currentY = data.initialY + data.velocityY * easeProgress;
|
||||
|
||||
// 重力效果(仅对gravity_fall样式)
|
||||
if (this.explosionStyle === 'gravity_fall') {{
|
||||
currentY += 0.5 * this.gravityStrength * 500 * easeProgress * easeProgress;
|
||||
}}
|
||||
|
||||
// 计算旋转
|
||||
const rotX = data.rotationX * easeProgress;
|
||||
const rotY = data.rotationY * easeProgress;
|
||||
const rotZ = data.rotationZ * easeProgress;
|
||||
|
||||
// 应用变换
|
||||
const translateX = currentX - data.initialX;
|
||||
const translateY = currentY - data.initialY;
|
||||
fragment.style.transform = `translate(${{translateX}}px, ${{translateY}}px) rotateX(${{rotX}}deg) rotateY(${{rotY}}deg) rotateZ(${{rotZ}}deg)`;
|
||||
|
||||
// 淡出效果
|
||||
if (this.fadeOut) {{
|
||||
const opacity = 1 - easeProgress * 0.8; // 保留一些透明度直到最后
|
||||
fragment.style.opacity = Math.max(0, opacity).toString();
|
||||
}} else {{
|
||||
fragment.style.opacity = '1';
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
// 缓动函数
|
||||
easeOutCubic(t) {{
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}}
|
||||
}}
|
||||
|
||||
// 初始化控制器
|
||||
window.explosionController = new ExplosionController();
|
||||
console.log('✅ ExplosionController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
perspective: {perspective}px;
|
||||
perspective-origin: 50% 50%;
|
||||
}}
|
||||
|
||||
.flip-container {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}}
|
||||
|
||||
.flip-card {{
|
||||
position: absolute;
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
backface-visibility: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
.front {{
|
||||
z-index: 2;
|
||||
}}
|
||||
|
||||
.back {{
|
||||
transform: rotateY(180deg);
|
||||
z-index: 1;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flip-container" id="flipContainer">
|
||||
<div class="flip-card front" id="frontCard"></div>
|
||||
<div class="flip-card back" id="backCard"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class FlipController {{
|
||||
constructor() {{
|
||||
this.container = document.getElementById('flipContainer');
|
||||
this.frontCard = document.getElementById('frontCard');
|
||||
this.backCard = document.getElementById('backCard');
|
||||
this.transitionType = '{transition_type}';
|
||||
this.ready = true;
|
||||
|
||||
console.log('🎬 FlipController初始化完成, 转场类型=' + this.transitionType);
|
||||
}}
|
||||
|
||||
updateRotation(progress) {{
|
||||
// progress: 0 -> 1
|
||||
const angle = progress * 180; // 0度 -> 180度
|
||||
|
||||
let frontTransform, backTransform;
|
||||
|
||||
switch(this.transitionType) {{
|
||||
case 'horizontal_flip':
|
||||
// 水平翻转(绕Y轴)
|
||||
frontTransform = `rotateY(${{angle}}deg)`;
|
||||
backTransform = `rotateY(${{180 + angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'vertical_flip':
|
||||
// 垂直翻转(绕X轴)
|
||||
frontTransform = `rotateX(${{-angle}}deg)`;
|
||||
backTransform = `rotateX(${{180 - angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'oblique_flip':
|
||||
// 斜向翻转 - 像书页一样从右边翻过来,带轻微抬起
|
||||
const obliqueAngle = angle;
|
||||
// 添加X轴偏移,模拟翻转时的轻微抬起
|
||||
const liftAngle = Math.sin(progress * Math.PI) * 10; // 最大抬起10度
|
||||
frontTransform = `rotateY(${{obliqueAngle}}deg) rotateX(${{-liftAngle}}deg)`;
|
||||
backTransform = `rotateY(${{180 + obliqueAngle}}deg) rotateX(${{-liftAngle}}deg)`;
|
||||
break;
|
||||
|
||||
default:
|
||||
frontTransform = `rotateY(${{angle}}deg)`;
|
||||
backTransform = `rotateY(${{180 + angle}}deg)`;
|
||||
}}
|
||||
|
||||
this.frontCard.style.transform = frontTransform;
|
||||
this.backCard.style.transform = backTransform;
|
||||
|
||||
// 强制重绘
|
||||
void this.container.offsetHeight;
|
||||
|
||||
console.log(`[JS] 更新: angle=${{angle.toFixed(1)}}°, type=${{this.transitionType}}`);
|
||||
}}
|
||||
}}
|
||||
|
||||
window.flipController = new FlipController();
|
||||
console.log('✅ FlipController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
|
||||
.glitch-container {{
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}}
|
||||
|
||||
.video-layer {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}}
|
||||
|
||||
.glitch-layer {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: screen;
|
||||
}}
|
||||
|
||||
.scanlines {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 255, 0, 0.03) 2px,
|
||||
rgba(0, 255, 0, 0.03) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
.noise-overlay {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><defs><filter id="noise"><feTurbulence baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/></filter></defs><rect width="100%" height="100%" filter="url(%23noise)"/></svg>');
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
.loading {{
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #00ff00;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
text-shadow: 0 0 10px #00ff00;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glitch-container" id="glitchContainer">
|
||||
<div class="video-layer" id="video1Layer"></div>
|
||||
<div class="video-layer" id="video2Layer"></div>
|
||||
<div class="glitch-layer" id="glitchLayer"></div>
|
||||
<div class="scanlines" id="scanlines"></div>
|
||||
<div class="noise-overlay" id="noiseOverlay"></div>
|
||||
</div>
|
||||
<div class="loading" id="loading">初始化故障艺术系统...</div>
|
||||
|
||||
<script>
|
||||
class GlitchController {{
|
||||
constructor() {{
|
||||
this.ready = false;
|
||||
this.glitchStyle = '{glitch_style}';
|
||||
this.glitchIntensity = {glitch_intensity};
|
||||
this.corruptionRate = {corruption_rate};
|
||||
this.scanlineFrequency = {scanline_frequency};
|
||||
this.noiseAmount = {noise_amount};
|
||||
this.digitalArtifacts = {str(digital_artifacts).lower()};
|
||||
this.colorBleeding = {str(color_bleeding).lower()};
|
||||
this.screenTear = {str(screen_tear).lower()};
|
||||
this.width = {width};
|
||||
this.height = {height};
|
||||
|
||||
this.video1Layer = null;
|
||||
this.video2Layer = null;
|
||||
this.glitchLayer = null;
|
||||
this.scanlines = null;
|
||||
this.noiseOverlay = null;
|
||||
|
||||
this.glitchBlocks = [];
|
||||
|
||||
this.init();
|
||||
}}
|
||||
|
||||
async init() {{
|
||||
try {{
|
||||
this.video1Layer = document.getElementById('video1Layer');
|
||||
this.video2Layer = document.getElementById('video2Layer');
|
||||
this.glitchLayer = document.getElementById('glitchLayer');
|
||||
this.scanlines = document.getElementById('scanlines');
|
||||
this.noiseOverlay = document.getElementById('noiseOverlay');
|
||||
|
||||
await this.createGlitchElements();
|
||||
this.ready = true;
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
console.log('🔥 GlitchController初始化完成, 样式=' + this.glitchStyle);
|
||||
}} catch (error) {{
|
||||
console.error('故障艺术系统初始化失败:', error);
|
||||
}}
|
||||
}}
|
||||
|
||||
async createGlitchElements() {{
|
||||
// 创建故障块元素
|
||||
this.createGlitchBlocks();
|
||||
|
||||
// 设置扫描线
|
||||
this.setupScanlines();
|
||||
|
||||
// 设置噪声覆盖
|
||||
this.setupNoiseOverlay();
|
||||
}}
|
||||
|
||||
createGlitchBlocks() {{
|
||||
// 创建故障块用于各种故障效果 - 增加数量以支持密集矩阵雨
|
||||
const blockCount = 50; // 大幅增加故障块数量
|
||||
this.glitchBlocks = [];
|
||||
|
||||
for (let i = 0; i < blockCount; i++) {{
|
||||
const block = document.createElement('div');
|
||||
block.style.position = 'absolute';
|
||||
block.style.backgroundColor = this.getRandomGlitchColor();
|
||||
block.style.mixBlendMode = 'difference';
|
||||
block.style.opacity = '0';
|
||||
block.style.pointerEvents = 'none';
|
||||
|
||||
this.glitchLayer.appendChild(block);
|
||||
this.glitchBlocks.push(block);
|
||||
}}
|
||||
}}
|
||||
|
||||
setupScanlines() {{
|
||||
// 动态扫描线效果
|
||||
if (this.scanlines) {{
|
||||
this.scanlines.style.animation = `scanlineMove ${{2 / this.scanlineFrequency}}s linear infinite`;
|
||||
|
||||
// 添加扫描线动画CSS
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes scanlineMove {{
|
||||
0% {{ transform: translateY(-100%); }}
|
||||
100% {{ transform: translateY(100vh); }}
|
||||
}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}}
|
||||
}}
|
||||
|
||||
setupNoiseOverlay() {{
|
||||
// 动态噪声覆盖
|
||||
if (this.noiseOverlay) {{
|
||||
this.noiseOverlay.style.opacity = this.noiseAmount;
|
||||
this.noiseOverlay.style.animation = 'noiseFlicker 0.1s infinite';
|
||||
|
||||
// 添加噪声闪烁动画CSS
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes noiseFlicker {{
|
||||
0%, 100% {{ opacity: ${{this.noiseAmount}}; }}
|
||||
50% {{ opacity: ${{this.noiseAmount * 1.5}}; }}
|
||||
}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}}
|
||||
}}
|
||||
|
||||
updateFrame(progress, texture1Base64, texture2Base64) {{
|
||||
if (!this.ready) return;
|
||||
|
||||
try {{
|
||||
// 更新视频图层
|
||||
this.updateVideoLayers(progress, texture1Base64, texture2Base64);
|
||||
|
||||
// 根据故障样式应用效果
|
||||
switch(this.glitchStyle) {{
|
||||
case 'signal_interference':
|
||||
this.applySignalInterference(progress);
|
||||
break;
|
||||
case 'data_moshing':
|
||||
this.applyDataMoshing(progress);
|
||||
break;
|
||||
case 'pixel_sorting':
|
||||
this.applyPixelSorting(progress);
|
||||
break;
|
||||
case 'matrix_rain':
|
||||
this.applyMatrixRain(progress);
|
||||
break;
|
||||
}}
|
||||
|
||||
// 应用通用故障效果
|
||||
this.applyCommonGlitchEffects(progress);
|
||||
}} catch (error) {{
|
||||
console.error('故障更新失败:', error);
|
||||
}}
|
||||
}}
|
||||
|
||||
updateVideoLayers(progress, texture1Base64, texture2Base64) {{
|
||||
// 更新视频图层背景
|
||||
this.video1Layer.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
this.video2Layer.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
|
||||
// 矩阵雨模式:纯闪烁切换,无叠化过渡
|
||||
if (this.glitchStyle === 'matrix_rain') {{
|
||||
// 基于进度的硬切换点
|
||||
const switchPoint = 0.5; // 中点切换
|
||||
const flickerZone = 0.3; // 闪烁区域范围
|
||||
|
||||
if (progress < switchPoint - flickerZone/2) {{
|
||||
// 前半段:显示第一个视频
|
||||
this.video1Layer.style.opacity = '1';
|
||||
this.video2Layer.style.opacity = '0';
|
||||
}} else if (progress > switchPoint + flickerZone/2) {{
|
||||
// 后半段:显示第二个视频
|
||||
this.video1Layer.style.opacity = '0';
|
||||
this.video2Layer.style.opacity = '1';
|
||||
}} else {{
|
||||
// 中间闪烁区域:快速切换
|
||||
const flickerProgress = (progress - (switchPoint - flickerZone/2)) / flickerZone;
|
||||
const flickerSpeed = 15; // 闪烁频率
|
||||
const flicker = Math.sin(flickerProgress * Math.PI * flickerSpeed) > 0;
|
||||
|
||||
if (flicker) {{
|
||||
this.video1Layer.style.opacity = '1';
|
||||
this.video2Layer.style.opacity = '0';
|
||||
}} else {{
|
||||
this.video1Layer.style.opacity = '0';
|
||||
this.video2Layer.style.opacity = '1';
|
||||
}}
|
||||
}}
|
||||
}} else {{
|
||||
// 其他模式:使用原来的叠化过渡
|
||||
const glitchProgress = this.calculateGlitchProgress(progress);
|
||||
this.video1Layer.style.opacity = 1 - glitchProgress;
|
||||
this.video2Layer.style.opacity = glitchProgress;
|
||||
}}
|
||||
}}
|
||||
|
||||
calculateGlitchProgress(progress) {{
|
||||
// 计算故障进度(非线性,有突变)
|
||||
const corruptionThreshold = this.corruptionRate;
|
||||
|
||||
if (progress < corruptionThreshold) {{
|
||||
return 0;
|
||||
}} else if (progress < corruptionThreshold + 0.2) {{
|
||||
// 故障爆发期
|
||||
const burstProgress = (progress - corruptionThreshold) / 0.2;
|
||||
return Math.pow(burstProgress, 0.5); // 快速上升
|
||||
}} else {{
|
||||
// 稳定期
|
||||
const stableProgress = (progress - corruptionThreshold - 0.2) / (1 - corruptionThreshold - 0.2);
|
||||
return 0.5 + stableProgress * 0.5;
|
||||
}}
|
||||
}}
|
||||
|
||||
applySignalInterference(progress) {{
|
||||
// 信号干扰效果 - 增强版
|
||||
const interferenceStrength = Math.sin(progress * Math.PI * 10) * this.glitchIntensity;
|
||||
const pulseInterference = Math.sin(progress * Math.PI * 25) * interferenceStrength * 0.8;
|
||||
const totalInterference = interferenceStrength + pulseInterference;
|
||||
|
||||
// 增强水平条纹干扰
|
||||
this.glitchBlocks.forEach((block, index) => {{
|
||||
if (Math.random() < Math.abs(totalInterference) * 0.6) {{ // 提高触发概率
|
||||
const stripeType = index % 4;
|
||||
|
||||
switch(stripeType) {{
|
||||
case 0: // 粗白色条纹
|
||||
block.style.left = '0px';
|
||||
block.style.top = (Math.random() * this.height) + 'px';
|
||||
block.style.width = this.width + 'px';
|
||||
block.style.height = (Math.random() * 12 + 8) + 'px'; // 更粗的条纹
|
||||
block.style.backgroundColor = '#ffffff';
|
||||
block.style.opacity = Math.random() * 0.9 + 0.3; // 更高透明度
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.boxShadow = '0 0 5px #ffffff'; // 发光效果
|
||||
break;
|
||||
|
||||
case 1: // 黑色干扰条纹
|
||||
block.style.left = '0px';
|
||||
block.style.top = (Math.random() * this.height) + 'px';
|
||||
block.style.width = this.width + 'px';
|
||||
block.style.height = (Math.random() * 8 + 4) + 'px';
|
||||
block.style.backgroundColor = '#000000';
|
||||
block.style.opacity = Math.random() * 0.8 + 0.4;
|
||||
block.style.mixBlendMode = 'multiply';
|
||||
break;
|
||||
|
||||
case 2: // 彩色信号条纹
|
||||
block.style.left = '0px';
|
||||
block.style.top = (Math.random() * this.height) + 'px';
|
||||
block.style.width = this.width + 'px';
|
||||
block.style.height = (Math.random() * 6 + 3) + 'px';
|
||||
const signalColors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'];
|
||||
block.style.backgroundColor = signalColors[Math.floor(Math.random() * signalColors.length)];
|
||||
block.style.opacity = Math.random() * 0.7 + 0.2;
|
||||
block.style.mixBlendMode = 'overlay';
|
||||
break;
|
||||
|
||||
case 3: // 渐变干扰条纹
|
||||
block.style.left = '0px';
|
||||
block.style.top = (Math.random() * this.height) + 'px';
|
||||
block.style.width = this.width + 'px';
|
||||
block.style.height = (Math.random() * 10 + 5) + 'px';
|
||||
block.style.background = `linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
#ffffff 20%,
|
||||
#000000 50%,
|
||||
#ffffff 80%,
|
||||
transparent 100%)`;
|
||||
block.style.opacity = Math.random() * 0.8 + 0.3;
|
||||
block.style.mixBlendMode = 'difference';
|
||||
break;
|
||||
}}
|
||||
|
||||
setTimeout(() => {{
|
||||
block.style.opacity = '0';
|
||||
block.style.boxShadow = '';
|
||||
}}, Math.random() * 100 + 30); // 更短的显示时间,更频繁
|
||||
}}
|
||||
}});
|
||||
|
||||
// 增强整体图像偏移
|
||||
const offsetX = Math.sin(progress * Math.PI * 20) * totalInterference * 15; // 更大的偏移
|
||||
const offsetY = Math.sin(progress * Math.PI * 15) * totalInterference * 5; // 添加垂直偏移
|
||||
this.video1Layer.style.transform = `translate(${{offsetX}}px, ${{offsetY}}px)`;
|
||||
this.video2Layer.style.transform = `translate(${{-offsetX}}px, ${{-offsetY}}px)`;
|
||||
|
||||
// 添加信号失真效果
|
||||
if (Math.random() < Math.abs(totalInterference) * 0.3) {{
|
||||
this.video1Layer.style.filter = `contrast(${{1.2 + Math.abs(totalInterference) * 0.5}}) brightness(${{1.1 + totalInterference * 0.2}})`;
|
||||
this.video2Layer.style.filter = `saturate(${{1.3 + Math.abs(totalInterference) * 0.4}})`;
|
||||
}}
|
||||
}}
|
||||
|
||||
applyDataMoshing(progress) {{
|
||||
// 数据混合效果
|
||||
const moshIntensity = progress * this.glitchIntensity;
|
||||
|
||||
// 创建马赛克效果
|
||||
this.video1Layer.style.filter = `contrast(${{1 + moshIntensity}}) saturate(${{1 + moshIntensity * 2}})`;
|
||||
this.video2Layer.style.filter = `hue-rotate(${{moshIntensity * 180}}deg) contrast(${{1.5}})`;
|
||||
|
||||
// 随机块状故障
|
||||
this.glitchBlocks.forEach((block, index) => {{
|
||||
if (Math.random() < moshIntensity * 0.1) {{
|
||||
const size = Math.random() * 80 + 20;
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = (Math.random() * this.height) + 'px';
|
||||
block.style.width = size + 'px';
|
||||
block.style.height = size + 'px';
|
||||
block.style.backgroundColor = this.getRandomGlitchColor();
|
||||
block.style.opacity = Math.random() * 0.6;
|
||||
block.style.mixBlendMode = 'multiply';
|
||||
|
||||
setTimeout(() => {{
|
||||
block.style.opacity = '0';
|
||||
}}, Math.random() * 300 + 100);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
applyPixelSorting(progress) {{
|
||||
// 像素排序效果
|
||||
const sortIntensity = Math.sin(progress * Math.PI) * this.glitchIntensity;
|
||||
|
||||
// 模拟像素排序的条纹效果
|
||||
this.glitchBlocks.forEach((block, index) => {{
|
||||
if (index < 10 && Math.random() < sortIntensity * 0.3) {{
|
||||
block.style.left = '0px';
|
||||
block.style.top = (index * this.height / 10) + 'px';
|
||||
block.style.width = this.width + 'px';
|
||||
block.style.height = (this.height / 10) + 'px';
|
||||
|
||||
// 创建渐变效果模拟像素排序
|
||||
const gradient = `linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
${{this.getRandomGlitchColor()}} 30%,
|
||||
transparent 70%,
|
||||
${{this.getRandomGlitchColor()}} 100%)`;
|
||||
block.style.background = gradient;
|
||||
block.style.opacity = sortIntensity * 0.4;
|
||||
block.style.mixBlendMode = 'overlay';
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
applyMatrixRain(progress) {{
|
||||
// 矩阵雨效果 - 增强版
|
||||
const rainIntensity = progress * this.glitchIntensity;
|
||||
const pulseRain = Math.sin(progress * Math.PI * 8) * rainIntensity * 0.5;
|
||||
const totalRainIntensity = rainIntensity + pulseRain;
|
||||
|
||||
// 创建密集细线矩阵雨滴
|
||||
this.glitchBlocks.forEach((block, index) => {{
|
||||
if (Math.random() < totalRainIntensity * 0.9) {{ // 进一步提高触发概率,超密集
|
||||
const rainType = index % 8; // 增加更多细线类型
|
||||
|
||||
switch(rainType) {{
|
||||
case 0: // 超细绿色数字雨
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-50px';
|
||||
block.style.width = '1px'; // 超细线条
|
||||
block.style.height = (Math.random() * 200 + 100) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.9 + 0.4; // 恢复之前的透明度
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.boxShadow = '0 0 10px #00ff00, 0 0 20px #00ff00'; // 恢复强发光
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.5 + 1}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 1: // 极细亮绿线
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-30px';
|
||||
block.style.width = '1px'; // 极细
|
||||
block.style.height = (Math.random() * 150 + 80) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = '1';
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.boxShadow = '0 0 15px #00ff00, 0 0 30px #00ff00, 0 0 45px #00ff00';
|
||||
block.style.filter = 'brightness(2)';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.2 + 0.8}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 2: // 细线渐变雨
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-40px';
|
||||
block.style.width = '1px'; // 细线
|
||||
block.style.height = (Math.random() * 180 + 90) + 'px';
|
||||
block.style.background = `linear-gradient(180deg,
|
||||
#00ff00 0%,
|
||||
#00cc00 30%,
|
||||
#008800 70%,
|
||||
transparent 100%)`;
|
||||
block.style.opacity = Math.random() * 0.8 + 0.3;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.8 + 1.2}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 3: // 短细线闪烁雨
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-20px';
|
||||
block.style.width = '1px'; // 细线
|
||||
block.style.height = (Math.random() * 120 + 60) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.7 + 0.2;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.5 + 1}}s linear forwards, matrixFlicker 0.1s infinite`;
|
||||
break;
|
||||
|
||||
case 4: // 密集细雨滴
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-60px';
|
||||
block.style.width = '1px'; // 最细
|
||||
block.style.height = (Math.random() * 300 + 150) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.9 + 0.5;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.boxShadow = '0 0 20px #00ff00';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 2 + 1.5}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 5: // 超密集微细线
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-35px';
|
||||
block.style.width = '1px'; // 微细线
|
||||
block.style.height = (Math.random() * 100 + 50) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.8 + 0.3;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.3 + 0.9}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 6: // 极短细线
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-25px';
|
||||
block.style.width = '1px'; // 极细
|
||||
block.style.height = (Math.random() * 80 + 40) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.7 + 0.4;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 1.1 + 0.7}}s linear forwards`;
|
||||
break;
|
||||
|
||||
case 7: // 快速细线雨
|
||||
block.style.left = (Math.random() * this.width) + 'px';
|
||||
block.style.top = '-15px';
|
||||
block.style.width = '1px'; // 细线
|
||||
block.style.height = (Math.random() * 60 + 30) + 'px';
|
||||
block.style.backgroundColor = '#00ff00';
|
||||
block.style.opacity = Math.random() * 0.6 + 0.3;
|
||||
block.style.mixBlendMode = 'screen';
|
||||
block.style.animation = `matrixFall ${{Math.random() * 0.9 + 0.6}}s linear forwards, matrixFlicker 0.1s infinite`;
|
||||
break;
|
||||
}}
|
||||
|
||||
setTimeout(() => {{
|
||||
block.style.opacity = '0';
|
||||
block.style.animation = '';
|
||||
block.style.boxShadow = '';
|
||||
block.style.filter = '';
|
||||
}}, Math.random() * 2500 + 1500); // 恢复之前的显示时间
|
||||
}}
|
||||
}});
|
||||
|
||||
// 添加增强矩阵动画
|
||||
if (!document.getElementById('matrixStyle')) {{
|
||||
const style = document.createElement('style');
|
||||
style.id = 'matrixStyle';
|
||||
style.textContent = `
|
||||
@keyframes matrixFall {{
|
||||
from {{
|
||||
transform: translateY(-100px);
|
||||
opacity: 0;
|
||||
}}
|
||||
10% {{
|
||||
opacity: 1;
|
||||
}}
|
||||
90% {{
|
||||
opacity: 1;
|
||||
}}
|
||||
to {{
|
||||
transform: translateY(${{this.height + 200}}px);
|
||||
opacity: 0;
|
||||
}}
|
||||
}}
|
||||
|
||||
@keyframes matrixFlicker {{
|
||||
0%, 100% {{ opacity: 0.3; }}
|
||||
50% {{ opacity: 1; }}
|
||||
}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}}
|
||||
|
||||
// 添加矩阵背景效果
|
||||
if (Math.random() < totalRainIntensity * 0.2) {{
|
||||
this.video1Layer.style.filter = `
|
||||
contrast(${{1.1 + totalRainIntensity * 0.3}})
|
||||
brightness(${{0.9 + totalRainIntensity * 0.2}})
|
||||
hue-rotate(${{totalRainIntensity * 10}}deg)
|
||||
`;
|
||||
this.video2Layer.style.filter = `
|
||||
contrast(${{1.2 + totalRainIntensity * 0.2}})
|
||||
saturate(${{0.8 + totalRainIntensity * 0.4}})
|
||||
`;
|
||||
}}
|
||||
|
||||
// 添加绿色闪光效果
|
||||
if (Math.random() < totalRainIntensity * 0.1) {{
|
||||
document.body.style.backgroundColor = '#003300';
|
||||
setTimeout(() => {{
|
||||
document.body.style.backgroundColor = '{background_color}';
|
||||
}}, 50);
|
||||
}}
|
||||
}}
|
||||
|
||||
applyCommonGlitchEffects(progress) {{
|
||||
// 应用通用故障效果
|
||||
|
||||
// 屏幕撕裂
|
||||
if (this.screenTear && Math.random() < progress * 0.1) {{
|
||||
const tearHeight = Math.random() * 50 + 10;
|
||||
const tearY = Math.random() * (this.height - tearHeight);
|
||||
|
||||
this.video1Layer.style.clipPath = `polygon(0 0, 100% 0, 100% ${{tearY}}px, 0 ${{tearY}}px)`;
|
||||
this.video2Layer.style.clipPath = `polygon(0 ${{tearY + tearHeight}}px, 100% ${{tearY + tearHeight}}px, 100% 100%, 0 100%)`;
|
||||
|
||||
setTimeout(() => {{
|
||||
this.video1Layer.style.clipPath = '';
|
||||
this.video2Layer.style.clipPath = '';
|
||||
}}, 100);
|
||||
}}
|
||||
|
||||
// 颜色溢出
|
||||
if (this.colorBleeding) {{
|
||||
const bleedIntensity = Math.sin(progress * Math.PI * 5) * this.glitchIntensity * 0.1;
|
||||
this.video1Layer.style.filter += ` saturate(${{1 + bleedIntensity}})`;
|
||||
this.video2Layer.style.filter += ` hue-rotate(${{bleedIntensity * 30}}deg)`;
|
||||
}}
|
||||
}}
|
||||
|
||||
getRandomGlitchColor() {{
|
||||
// 获取随机故障颜色
|
||||
const colors = [
|
||||
'#ff0000', '#00ff00', '#0000ff',
|
||||
'#ff00ff', '#ffff00', '#00ffff',
|
||||
'#ffffff', '#000000'
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}}
|
||||
}}
|
||||
|
||||
// 初始化控制器
|
||||
window.glitchController = new GlitchController();
|
||||
console.log('✅ GlitchController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
perspective: {perspective}px;
|
||||
perspective-origin: 50% 50%;
|
||||
}}
|
||||
|
||||
.book-container {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}}
|
||||
|
||||
.page {{
|
||||
position: absolute;
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
/* 当前页(底层,不动) */
|
||||
.current-page {{
|
||||
z-index: 1;
|
||||
}}
|
||||
|
||||
/* 下一页(底层,不动) */
|
||||
.next-page {{
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
}}
|
||||
|
||||
/* 翻转的页面(正面) */
|
||||
.turning-page {{
|
||||
z-index: 3;
|
||||
transform-origin: {transform_origin};
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
box-shadow: -5px 0 20px rgba(0,0,0,0.3);
|
||||
}}
|
||||
|
||||
/* 翻转的页面(背面) */
|
||||
.turning-page-back {{
|
||||
z-index: 3;
|
||||
transform-origin: {transform_origin};
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
transform: rotateY(180deg);
|
||||
}}
|
||||
|
||||
/* 页面阴影 */
|
||||
.page-shadow {{
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,0.3));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
z-index: 4;
|
||||
}}
|
||||
|
||||
/* 翻页卷曲效果 */
|
||||
.page-curl {{
|
||||
position: absolute;
|
||||
{curl_position}
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
border-color: transparent;
|
||||
{curl_border}: rgba(0,0,0,0.1);
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="book-container" id="bookContainer">
|
||||
<!-- 当前页 -->
|
||||
<div class="page current-page" id="currentPage"></div>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<div class="page next-page" id="nextPage"></div>
|
||||
|
||||
<!-- 翻转页面的正面 -->
|
||||
<div class="page turning-page" id="turningPage"></div>
|
||||
|
||||
<!-- 翻转页面的背面 -->
|
||||
<div class="page turning-page-back" id="turningPageBack"></div>
|
||||
|
||||
<!-- 阴影 -->
|
||||
<div class="page-shadow" id="pageShadow"></div>
|
||||
|
||||
<!-- 卷曲 -->
|
||||
<div class="page-curl" id="pageCurl"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class PageController {{
|
||||
constructor() {{
|
||||
this.container = document.getElementById('bookContainer');
|
||||
this.currentPage = document.getElementById('currentPage');
|
||||
this.nextPage = document.getElementById('nextPage');
|
||||
this.turningPage = document.getElementById('turningPage');
|
||||
this.turningPageBack = document.getElementById('turningPageBack');
|
||||
this.shadow = document.getElementById('pageShadow');
|
||||
this.curl = document.getElementById('pageCurl');
|
||||
this.curlSize = {page_curl_size};
|
||||
this.pageDirection = '{page_direction}';
|
||||
this.ready = true;
|
||||
|
||||
console.log('🎬 PageController初始化完成, 翻页方向=' + this.pageDirection + ', 卷曲度=' + this.curlSize);
|
||||
}}
|
||||
|
||||
updateTurn(progress) {{
|
||||
// progress: 0 -> 1
|
||||
|
||||
// 缓动函数:让翻页更自然(先慢后快再慢)
|
||||
const easeProgress = this.easeInOutCubic(progress);
|
||||
|
||||
// 翻页角度:0度 -> 180度
|
||||
const angle = easeProgress * 180;
|
||||
|
||||
// 根据翻页方向设置变换
|
||||
let frontTransform, backTransform;
|
||||
|
||||
switch(this.pageDirection) {{
|
||||
case 'right_to_left':
|
||||
// 从左到右翻页(默认)
|
||||
frontTransform = `rotateY(${{-angle}}deg)`;
|
||||
backTransform = `rotateY(${{180 - angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'left_to right':
|
||||
// 从右到左翻页(以右边为轴向右翻)
|
||||
frontTransform = `rotateY(${{angle}}deg)`;
|
||||
backTransform = `rotateY(${{180 + angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'top_to_bottom':
|
||||
// 从上到下翻页(顶部向下翻)
|
||||
frontTransform = `rotateX(${{-angle}}deg)`;
|
||||
backTransform = `rotateX(${{180 - angle}}deg)`;
|
||||
break;
|
||||
|
||||
case 'bottom_to_top':
|
||||
// 从下往上翻页(底部向上翻)
|
||||
frontTransform = `rotateX(${{angle}}deg)`;
|
||||
backTransform = `rotateX(${{180 + angle}}deg)`;
|
||||
break;
|
||||
|
||||
default:
|
||||
frontTransform = `rotateY(${{-angle}}deg)`;
|
||||
backTransform = `rotateY(${{180 - angle}}deg)`;
|
||||
}}
|
||||
|
||||
// 翻转的页面旋转
|
||||
this.turningPage.style.transform = frontTransform;
|
||||
this.turningPageBack.style.transform = backTransform;
|
||||
|
||||
// 下一页逐渐显示
|
||||
this.nextPage.style.opacity = easeProgress;
|
||||
|
||||
// 阴影效果(翻页时页面下的阴影)
|
||||
const shadowOpacity = Math.sin(easeProgress * Math.PI) * 0.5;
|
||||
this.shadow.style.opacity = shadowOpacity;
|
||||
|
||||
// 页面卷曲效果
|
||||
this.updateCurlEffect(progress, easeProgress);
|
||||
|
||||
// 强制重绘
|
||||
void this.container.offsetHeight;
|
||||
|
||||
console.log(`[JS] 翻页: progress=${{progress.toFixed(2)}}, angle=${{angle.toFixed(1)}}°, direction=${{this.pageDirection}}`);
|
||||
}}
|
||||
|
||||
updateCurlEffect(progress, easeProgress) {{
|
||||
const curlSize = this.curlSize * {width};
|
||||
|
||||
if (progress < 0.3) {{
|
||||
// 开始阶段:卷曲出现
|
||||
const curlProgress = progress / 0.3;
|
||||
this.curl.style.width = curlSize + 'px';
|
||||
this.curl.style.height = curlSize + 'px';
|
||||
this.curl.style.borderWidth = `0 ${{curlSize}}px ${{curlSize}}px 0`;
|
||||
this.curl.style.opacity = curlProgress * 0.8;
|
||||
}} else if (progress > 0.7) {{
|
||||
// 结束阶段:卷曲消失
|
||||
const curlProgress = 1 - ((progress - 0.7) / 0.3);
|
||||
this.curl.style.width = curlSize + 'px';
|
||||
this.curl.style.height = curlSize + 'px';
|
||||
this.curl.style.borderWidth = `0 ${{curlSize}}px ${{curlSize}}px 0`;
|
||||
this.curl.style.opacity = curlProgress * 0.8;
|
||||
}} else {{
|
||||
// 中间阶段:保持卷曲
|
||||
this.curl.style.width = curlSize + 'px';
|
||||
this.curl.style.height = curlSize + 'px';
|
||||
this.curl.style.borderWidth = `0 ${{curlSize}}px ${{curlSize}}px 0`;
|
||||
this.curl.style.opacity = 0.8;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 缓动函数:三次方缓入缓出
|
||||
easeInOutCubic(t) {{
|
||||
return t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}}
|
||||
}}
|
||||
|
||||
window.pageController = new PageController();
|
||||
console.log('✅ PageController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
background: {background_color};
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.shake-container {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.video-layer {{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}}
|
||||
|
||||
.video1 {{
|
||||
z-index: 2;
|
||||
}}
|
||||
|
||||
.video2 {{
|
||||
z-index: 1;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shake-container" id="shakeContainer">
|
||||
<div class="video-layer video2" id="video2Layer"></div>
|
||||
<div class="video-layer video1" id="video1Layer"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class ShakeController {{
|
||||
constructor() {{
|
||||
this.ready = false;
|
||||
this.shakeStyle = '{shake_style}';
|
||||
this.shakeIntensity = {shake_intensity};
|
||||
this.shakeFrequency = {shake_frequency};
|
||||
this.transitionPoint = {transition_point};
|
||||
this.fadeMix = {str(fade_mix).lower()};
|
||||
this.width = {width};
|
||||
this.height = {height};
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.container = document.getElementById('shakeContainer');
|
||||
this.video1Layer = document.getElementById('video1Layer');
|
||||
this.video2Layer = document.getElementById('video2Layer');
|
||||
this.ready = true;
|
||||
console.log('📳 ShakeController初始化完成, 样式=' + this.shakeStyle);
|
||||
}}
|
||||
|
||||
updateFrame(progress, texture1Base64, texture2Base64) {{
|
||||
// 更新背景图片
|
||||
this.video1Layer.style.backgroundImage = `url(${{texture1Base64}})`;
|
||||
this.video2Layer.style.backgroundImage = `url(${{texture2Base64}})`;
|
||||
|
||||
// 计算抖动强度(在转场点附近最强)
|
||||
const shakeAmount = this.calculateShakeAmount(progress);
|
||||
|
||||
// 应用抖动效果
|
||||
this.applyShakeEffect(progress, shakeAmount);
|
||||
|
||||
// 处理透明度和混合
|
||||
this.handleTransition(progress);
|
||||
}}
|
||||
|
||||
calculateShakeAmount(progress) {{
|
||||
// 在转场点附近抖动最强,两端较弱
|
||||
const distanceFromTransition = Math.abs(progress - this.transitionPoint);
|
||||
const maxDistance = Math.max(this.transitionPoint, 1 - this.transitionPoint);
|
||||
const normalizedDistance = distanceFromTransition / maxDistance;
|
||||
|
||||
// 使用反向二次函数,让转场点附近抖动最强
|
||||
return (1 - normalizedDistance * normalizedDistance) * this.shakeIntensity;
|
||||
}}
|
||||
|
||||
applyShakeEffect(progress, shakeAmount) {{
|
||||
// 基于时间和频率生成抖动值
|
||||
const time = progress * 10; // 放大时间尺度
|
||||
const frequency = this.shakeFrequency;
|
||||
|
||||
let shakeX = 0;
|
||||
let shakeY = 0;
|
||||
let shakeRotation = 0;
|
||||
let shakeScale = 1;
|
||||
|
||||
switch(this.shakeStyle) {{
|
||||
case 'random_shake':
|
||||
shakeX = (Math.random() - 0.5) * shakeAmount * 2;
|
||||
shakeY = (Math.random() - 0.5) * shakeAmount * 2;
|
||||
shakeRotation = (Math.random() - 0.5) * shakeAmount * 0.5;
|
||||
break;
|
||||
|
||||
case 'horizontal_shake':
|
||||
shakeX = Math.sin(time * frequency) * shakeAmount;
|
||||
shakeY = Math.sin(time * frequency * 0.3) * shakeAmount * 0.2;
|
||||
break;
|
||||
|
||||
case 'vertical_shake':
|
||||
shakeY = Math.sin(time * frequency) * shakeAmount;
|
||||
shakeX = Math.sin(time * frequency * 0.3) * shakeAmount * 0.2;
|
||||
break;
|
||||
|
||||
case 'rotation_shake':
|
||||
shakeRotation = Math.sin(time * frequency) * shakeAmount * 0.3;
|
||||
shakeX = Math.sin(time * frequency * 0.7) * shakeAmount * 0.3;
|
||||
shakeY = Math.sin(time * frequency * 0.5) * shakeAmount * 0.3;
|
||||
break;
|
||||
|
||||
case 'scale_shake':
|
||||
shakeScale = 1 + Math.sin(time * frequency) * shakeAmount * 0.02;
|
||||
shakeX = Math.sin(time * frequency * 0.8) * shakeAmount * 0.3;
|
||||
shakeY = Math.sin(time * frequency * 0.6) * shakeAmount * 0.3;
|
||||
break;
|
||||
|
||||
case 'combo_shake':
|
||||
// 组合多种抖动效果
|
||||
shakeX = Math.sin(time * frequency) * shakeAmount * 0.7 +
|
||||
(Math.random() - 0.5) * shakeAmount * 0.3;
|
||||
shakeY = Math.sin(time * frequency * 0.8) * shakeAmount * 0.7 +
|
||||
(Math.random() - 0.5) * shakeAmount * 0.3;
|
||||
shakeRotation = Math.sin(time * frequency * 0.6) * shakeAmount * 0.2;
|
||||
shakeScale = 1 + Math.sin(time * frequency * 1.2) * shakeAmount * 0.01;
|
||||
break;
|
||||
}}
|
||||
|
||||
// 应用变换到视频层
|
||||
const transform1 = `translate(${{shakeX}}px, ${{shakeY}}px) rotate(${{shakeRotation}}deg) scale(${{shakeScale}})`;
|
||||
const transform2 = `translate(${{-shakeX * 0.3}}px, ${{-shakeY * 0.3}}px) rotate(${{-shakeRotation * 0.3}}deg) scale(${{2 - shakeScale}})`;
|
||||
|
||||
this.video1Layer.style.transform = transform1;
|
||||
this.video2Layer.style.transform = transform2;
|
||||
}}
|
||||
|
||||
handleTransition(progress) {{
|
||||
if (this.fadeMix) {{
|
||||
// 渐变混合模式
|
||||
if (progress < this.transitionPoint) {{
|
||||
// 转场前:主要显示video1
|
||||
const fadeProgress = progress / this.transitionPoint;
|
||||
this.video1Layer.style.opacity = '1';
|
||||
this.video2Layer.style.opacity = (fadeProgress * 0.3).toString();
|
||||
}} else {{
|
||||
// 转场后:逐渐显示video2
|
||||
const fadeProgress = (progress - this.transitionPoint) / (1 - this.transitionPoint);
|
||||
this.video1Layer.style.opacity = (1 - fadeProgress).toString();
|
||||
this.video2Layer.style.opacity = '1';
|
||||
}}
|
||||
}} else {{
|
||||
// 硬切换模式
|
||||
if (progress < this.transitionPoint) {{
|
||||
this.video1Layer.style.opacity = '1';
|
||||
this.video2Layer.style.opacity = '0';
|
||||
}} else {{
|
||||
this.video1Layer.style.opacity = '0';
|
||||
this.video2Layer.style.opacity = '1';
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
// 初始化控制器
|
||||
window.shakeController = new ShakeController();
|
||||
console.log('✅ ShakeController initialized');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user