Delete py directory

This commit is contained in:
yicheng
2025-10-14 15:41:16 +08:00
committed by GitHub
parent 4f37077c37
commit 4aeb7ddc77
14 changed files with 0 additions and 8512 deletions
-411
View File
@@ -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",
}
-542
View File
@@ -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",
}
-289
View File
@@ -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"
}
-671
View File
@@ -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",
}
-428
View File
@@ -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",
}
-381
View File
@@ -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",
}
-694
View File
@@ -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",
}
-615
View File
@@ -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",
}
-453
View File
@@ -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
-989
View File
@@ -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",
}
-608
View File
@@ -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",
}
-532
View File
@@ -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",
}
-494
View File
@@ -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",
}