Files
comfyui_o1key/nodes/veo_video.py
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

427 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Google Veo 视频生成节点
ComfyUI 自定义节点,调用 Veo API 生成视频
"""
import os
import re
import time
from typing import Optional, Tuple
import torch
from ..utils.image_utils import tensor_to_pil
from ..utils.config import get_runtime_config_signature
from ..clients.veo_client import VeoClient
from ..models_config import (
get_enabled_veo_models,
VEO_MODELS,
VEO_RESOLUTION_MAP,
)
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
FOLDER_PATHS_AVAILABLE = False
try:
from comfy.utils import ProgressBar
PROGRESS_BAR_AVAILABLE = True
except ImportError:
PROGRESS_BAR_AVAILABLE = False
print("⚠️ GoogleVeo: comfy.utils.ProgressBar 不可用,将只使用终端进度显示")
def _get_video_output_dir() -> str:
"""获取视频输出目录: ComfyUI/output/video"""
if FOLDER_PATHS_AVAILABLE:
base = folder_paths.get_output_directory()
else:
plugin_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base = os.path.join(os.path.dirname(os.path.dirname(plugin_dir)), "output")
video_dir = os.path.join(base, "video")
os.makedirs(video_dir, exist_ok=True)
return video_dir
def _get_next_counter(directory: str, prefix: str) -> int:
"""扫描目录,获取下一个可用的文件计数器"""
if not os.path.exists(directory):
return 1
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)")
max_counter = 0
for f in os.listdir(directory):
m = pattern.match(f)
if m:
max_counter = max(max_counter, int(m.group(1)))
return max_counter + 1
def _fit_image_to_target(image, target_size: str):
"""
将参考图片按 "等比缩放覆盖 + 居中裁剪" 策略适配到目标分辨率。
"""
from PIL import Image as PILImage
parts = target_size.lower().split("x")
target_w, target_h = int(parts[0]), int(parts[1])
src_w, src_h = image.size
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if abs(src_ratio - target_ratio) < 0.01 and src_w <= target_w and src_h <= target_h:
return image
print(f"Veo: 参考图片 {src_w}x{src_h} (比例 {src_ratio:.2f}) → 目标 {target_w}x{target_h} (比例 {target_ratio:.2f})")
resample = PILImage.Resampling.LANCZOS if hasattr(PILImage, "Resampling") else PILImage.LANCZOS
if src_ratio > target_ratio:
scale = target_h / src_h
new_w = round(src_w * scale)
new_h = target_h
image = image.resize((new_w, new_h), resample=resample)
left = (new_w - target_w) // 2
image = image.crop((left, 0, left + target_w, target_h))
else:
scale = target_w / src_w
new_w = target_w
new_h = round(src_h * scale)
image = image.resize((new_w, new_h), resample=resample)
top = (new_h - target_h) // 2
image = image.crop((0, top, target_w, top + target_h))
print(f"Veo: 参考图片已适配为 {image.size[0]}x{image.size[1]}")
return image
def _compress_image_to_bytes(image, target_size: Optional[str] = None) -> bytes:
"""
将 PIL Image 适配目标分辨率并编码为 PNG 字节
"""
from io import BytesIO
if image.mode != "RGB":
image = image.convert("RGB")
if target_size:
image = _fit_image_to_target(image, target_size)
buffered = BytesIO()
image.save(buffered, format="PNG")
size_kb = buffered.tell() / 1024
print(f"Veo: 参考图片编码为 PNG,{size_kb:.0f} KB ({image.size[0]}x{image.size[1]})")
return buffered.getvalue()
class GoogleVeo:
"""
Google Veo 视频生成节点
功能:
- 文生视频:基于提示词生成视频
- 图生视频:基于首帧/尾帧/参考图生成视频
- 异步轮询:自动等待生成完成并下载
"""
def __init__(self):
self.client = None
self._client_config_signature = None
@classmethod
def INPUT_TYPES(cls):
enabled_models = get_enabled_veo_models()
if not enabled_models:
enabled_models = ["请在 models_config.py 中启用 Veo 模型"]
# 分辨率选项
resolution_options = ["720p", "1080p", "4K"]
# 宽高比选项
aspect_ratio_options = ["16:9", "9:16"]
# 视频秒数选项
seconds_options = ["4", "6", "8"]
return {
"required": {
"prompt": ("STRING", {
"default": "A calico cat playing a piano on stage",
"multiline": True,
}),
"模型": (enabled_models, {
"default": enabled_models[0] if enabled_models else "Veo3.1",
}),
"分辨率": (resolution_options, {
"default": "720p",
}),
"宽高比": (aspect_ratio_options, {
"default": "9:16",
}),
"视频时长": (seconds_options, {
"default": "8",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
}),
"生成数量": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1,
}),
},
"optional": {
"首帧": ("IMAGE",),
"尾帧": ("IMAGE",),
"参考图": ("IMAGE",),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("预览视频",)
FUNCTION = "generate_video"
CATEGORY = "video/generation"
DESCRIPTION = (
"Google Veo 视频生成节点。\n"
"支持文生视频和图生视频(图生视频支持首帧、尾帧、参考图)。\n"
"视频保存到 ComfyUI/output/video/ 目录。\n\n"
"【模型说明】\n"
"• Veo3.1:Google 最新视频生成模型\n\n"
"【分辨率说明】\n"
"• 720p:标清\n"
"• 1080p:高清\n"
"• 4K:超高清\n\n"
"【时长说明】\n"
"• 4秒:短视频\n"
"• 6秒:标准\n"
"• 8秒:长视频(默认)\n\n"
"【图生视频说明】\n"
"• 首帧:视频开始的第一帧图像\n"
"• 尾帧:视频结束时的最后一帧图像\n"
"• 参考图:参考图像(与首帧/尾帧配合使用)\n"
"• 至少需要提供首帧或参考图之一"
)
def generate_video(
self,
prompt: str,
模型: str,
**kwargs,
) -> Tuple[str]:
分辨率 = kwargs.pop("分辨率", "720p")
宽高比 = kwargs.pop("宽高比", "9:16")
视频时长 = kwargs.pop("视频时长", "8")
seed = kwargs.pop("seed", 0)
生成数量 = kwargs.pop("生成数量", 1)
start_time = time.time()
# 解析视频时长
seconds = int(视频时长)
# 解析分辨率和宽高比,映射到模型名称
size_key = f"{分辨率}_{宽高比}"
actual_size = VEO_RESOLUTION_MAP.get(size_key)
if not actual_size:
# 默认值
actual_size = "720x1280" # 720p 9:16
# 检查是否有参考图输入
首帧 = kwargs.get("首帧")
尾帧 = kwargs.get("尾帧")
参考图 = kwargs.get("参考图")
has_image = 首帧 is not None or 尾帧 is not None or 参考图 is not None
# 根据是否有图片选择模型前缀
if has_image:
model_prefix = "veo3.1"
else:
model_prefix = "veo3.1"
# 构建完整模型名称
# 格式: veo3.1-portrait / veo3.1-landscape / veo3.1-portrait-fl / veo3.1-landscape-fl 等
if 分辨率 == "720p":
res_suffix = ""
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
elif 分辨率 == "1080p":
res_suffix = "-hd"
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
else: # 4K
res_suffix = "-4k"
if 宽高比 == "9:16":
orientation = "portrait"
else:
orientation = "landscape"
# 图生视频添加 -fl 后缀
if has_image:
model_suffix = f"-{orientation}-fl{res_suffix}"
else:
model_suffix = f"-{orientation}{res_suffix}"
model = f"{model_prefix}{model_suffix}"
# 准备图片字节
first_frame_bytes = None
last_frame_bytes = None
reference_bytes = None
if 首帧 is not None:
pil_images = tensor_to_pil(首帧)
if pil_images:
first_frame_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
if 尾帧 is not None:
pil_images = tensor_to_pil(尾帧)
if pil_images:
last_frame_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
if 参考图 is not None:
pil_images = tensor_to_pil(参考图)
if pil_images:
reference_bytes = _compress_image_to_bytes(pil_images[0], target_size=actual_size)
mode_str = "图生视频" if has_image else "文生视频"
print(f"Veo: {mode_str} | 并发{生成数量}个 | 模型: {model} | {seconds}秒 | {分辨率} {宽高比}")
# 准备保存路径
video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "veo")
# ProgressBar
pbar = None
if PROGRESS_BAR_AVAILABLE:
pbar = ProgressBar(生成数量 if 生成数量 > 1 else 100)
try:
config_signature = get_runtime_config_signature()
if self.client is None or config_signature != self._client_config_signature:
self.client = VeoClient()
self._client_config_signature = config_signature
if 生成数量 == 1:
save_path = os.path.join(video_dir, f"veo_{counter:05d}.mp4")
last_progress = [0]
def progress_callback(progress_pct: int):
print(
f"\rVeo: 生成中... 进度: {progress_pct}%",
end="", flush=True
)
if pbar is not None and progress_pct > last_progress[0]:
pbar.update(progress_pct - last_progress[0])
last_progress[0] = progress_pct
def on_stage(stage: str):
if stage == "submitting":
print("Veo: 正在提交视频生成任务...")
elif stage.startswith("submitted:"):
vid = stage.split(":", 1)[1]
print(f"Veo: 视频任务已提交,ID: {vid}")
elif stage == "polling":
print("Veo: 等待视频生成...")
elif stage == "downloading":
print("")
print("Veo: 视频生成完成,正在下载...")
result_path = self.client.generate_video_sync(
prompt=prompt,
model=model,
seconds=seconds,
size=actual_size,
save_path=save_path,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
progress_callback=progress_callback,
on_stage=on_stage,
)
result_paths = [result_path]
else:
save_paths = [
os.path.join(video_dir, f"veo_{counter + i:05d}.mp4")
for i in range(生成数量)
]
success_count = [0]
def batch_progress_callback(current: int, total: int, success: bool, error_msg):
if success:
success_count[0] += 1
print(f"Veo: 第 {current}/{total} 个视频完成 ✓")
else:
print(f"Veo: 第 {current}/{total} 个视频失败 ✗")
if error_msg:
print(f"原始错误详情:\n{error_msg}")
if pbar is not None:
pbar.update(1)
print(f"Veo: 正在并发提交 {生成数量} 个视频任务,请耐心等待...")
result_paths = self.client.generate_batch_videos_sync(
prompt=prompt,
model=model,
seconds=seconds,
size=actual_size,
save_paths=save_paths,
first_frame_bytes=first_frame_bytes,
last_frame_bytes=last_frame_bytes,
reference_bytes=reference_bytes,
seed=seed,
progress_callback=batch_progress_callback,
)
elapsed = time.time() - start_time
time_str = f"{elapsed:.2f}s" if elapsed >= 1 else f"{elapsed:.3f}s"
print(f"Veo: 完成!总耗时 {time_str} | 已生成 {len(result_paths)} 个视频")
for p in result_paths:
print(f" → {p}")
output_path = "\n".join(result_paths)
return (output_path,)
except ValueError as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise ValueError(error_msg) from None
except RuntimeError as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise RuntimeError(error_msg) from None
except Exception as e:
error_msg = str(e)
print(f"\nVeo: ❌ {error_msg}")
raise type(e)(error_msg) from None
finally:
if self.client is not None:
try:
balance_data = self.client.query_balance_sync()
balance_info = self.client.format_balance_info(balance_data)
print(f"Veo: {balance_info}")
except Exception:
pass
NODE_CLASS_MAPPINGS = {
"GoogleVeo": GoogleVeo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GoogleVeo": "Google Veo - ab",
}