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.
This commit is contained in:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+579
View File
@@ -0,0 +1,579 @@
"""MiniMax-H3 video generation through a New API gateway."""
import json
import os
import tempfile
from typing import Optional
import folder_paths
from comfy_api.latest import InputImpl, io
from ..clients.minimax_h3_client import MiniMaxH3Client
from ..utils.config import get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
from ..utils.minimax_h3_media import (
MAX_REFERENCE_AUDIOS,
MAX_REFERENCE_IMAGES,
MAX_REFERENCE_VIDEOS,
validate_image,
validate_reference_audios,
validate_reference_videos,
)
from ..utils.r2_uploader import upload_audio, upload_image, upload_video
MODEL_ID = "MiniMax-H3"
MODEL_MAX_ID = "MiniMax-H3-MAX"
MODEL_OPTIONS = [MODEL_ID, MODEL_MAX_ID]
MODE_TEXT = "文生视频"
MODE_FIRST = "首帧图生视频"
MODE_LAST = "尾帧图生视频"
MODE_FIRST_LAST = "首尾帧生视频"
MODE_REFERENCE = "参考素材生视频"
RESOLUTION_OPTIONS = ["2K", "768P", "480P"]
MODEL_RESOLUTIONS = {
MODEL_ID: {"768P", "2K"},
MODEL_MAX_ID: {"480P", "768P"},
}
MODEL_DURATION_RANGES = {
MODEL_ID: (4, 15),
MODEL_MAX_ID: (5, 15),
}
RATIO_OPTIONS = ["16:9", "21:9", "4:3", "1:1", "3:4", "9:16"]
REFERENCE_RATIO_OPTIONS = ["adaptive", *RATIO_OPTIONS]
MAX_PROMPT_CHARS = 7000
MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
MAX_REFERENCE_MATERIALS = 12
MAX_SEED = 2**31 - 1
def _validate_prompt(prompt: str) -> str:
prompt = (prompt or "").strip()
if not prompt:
raise ValueError("MiniMax H3 提示词不能为空。")
if len(prompt) > MAX_PROMPT_CHARS:
raise ValueError(
f"MiniMax H3 提示词最多 {MAX_PROMPT_CHARS} 字符,当前为 {len(prompt)} 字符。"
)
return prompt
def _validate_seed(seed: int) -> int:
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= MAX_SEED:
raise ValueError(f"MiniMax H3 seed 必须是 0{MAX_SEED} 的整数。")
return seed
def _validate_generation_options(
model: str,
resolution: str,
duration: int,
mode: Optional[str] = None,
) -> None:
if model not in MODEL_OPTIONS:
raise ValueError(f"不支持的 MiniMax 模型:{model}")
allowed_resolutions = MODEL_RESOLUTIONS[model]
if resolution not in allowed_resolutions:
allowed_text = "".join(
value for value in RESOLUTION_OPTIONS if value in allowed_resolutions
)
raise ValueError(f"{model} 分辨率仅支持 {allowed_text}")
minimum_duration, maximum_duration = MODEL_DURATION_RANGES[model]
if (
isinstance(duration, bool)
or not isinstance(duration, int)
or not minimum_duration <= duration <= maximum_duration
):
raise ValueError(
f"{model} 时长必须是 {minimum_duration}{maximum_duration} 的整数。"
)
if model == MODEL_MAX_ID and mode == MODE_REFERENCE:
raise ValueError("MiniMax-H3-MAX 不支持图片、视频或音频参考素材模式。")
def _validate_reference_counts(
reference_images,
reference_videos,
reference_audios,
) -> None:
if len(reference_images) > MAX_REFERENCE_IMAGES:
raise ValueError(f"参考图片最多 {MAX_REFERENCE_IMAGES} 张。")
if len(reference_videos) > MAX_REFERENCE_VIDEOS:
raise ValueError(f"参考视频最多 {MAX_REFERENCE_VIDEOS} 个。")
if len(reference_audios) > MAX_REFERENCE_AUDIOS:
raise ValueError(f"参考音频最多 {MAX_REFERENCE_AUDIOS} 个。")
total = len(reference_images) + len(reference_videos) + len(reference_audios)
if total > MAX_REFERENCE_MATERIALS:
raise ValueError(
f"参考图片、视频和音频合计最多 {MAX_REFERENCE_MATERIALS} 个,当前为 {total} 个。"
)
def _validate_image_tensor(image, label: str):
if image is None:
raise ValueError(f"{label}不能为空。")
ndim = getattr(image, "dim", lambda: None)()
if ndim == 4 and int(image.shape[0]) != 1:
raise ValueError(f"{label}仅支持单张图片,当前批次包含 {int(image.shape[0])} 张。")
def _image_to_pil(image, label: str):
_validate_image_tensor(image, label)
images = tensor_to_pil(image)
if not images:
raise ValueError(f"无法读取{label}")
result = images[0]
if result.mode not in ("RGB", "RGBA"):
result = result.convert("RGB")
validate_image(result, label)
return result
def _validate_body_size(body: dict) -> None:
body_size = len(
json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
if body_size > MAX_REQUEST_BODY_BYTES:
raise ValueError(
f"MiniMax H3 请求体大小 {body_size / 1024 / 1024:.2f} MB 超过 64 MB 限制。"
)
def build_request_body(
*,
prompt: str,
resolution: str,
duration: int,
mode: str,
model: str = MODEL_ID,
seed: int = 0,
ratio: Optional[str] = None,
first_url: Optional[str] = None,
last_url: Optional[str] = None,
reference_image_urls: Optional[list[str]] = None,
reference_video_urls: Optional[list[str]] = None,
reference_audio_urls: Optional[list[str]] = None,
) -> dict:
"""Build and validate the documented MiniMax H3 / H3 Max request body."""
prompt = _validate_prompt(prompt)
seed = _validate_seed(seed)
_validate_generation_options(model, resolution, duration, mode)
reference_image_urls = [url for url in (reference_image_urls or []) if url]
reference_video_urls = [url for url in (reference_video_urls or []) if url]
reference_audio_urls = [url for url in (reference_audio_urls or []) if url]
_validate_reference_counts(
reference_image_urls,
reference_video_urls,
reference_audio_urls,
)
has_first_last_material = bool(first_url or last_url)
has_reference_material = bool(
reference_image_urls or reference_video_urls or reference_audio_urls
)
content = [{"type": "text", "text": prompt}]
if mode == MODE_TEXT:
if has_first_last_material or has_reference_material:
raise ValueError("文生视频不能同时使用首尾帧或参考素材。")
if ratio not in RATIO_OPTIONS:
raise ValueError("MiniMax H3 文生视频必须选择具体宽高比,不能使用 adaptive。")
request_ratio = ratio
elif mode == MODE_FIRST:
if last_url or has_reference_material:
raise ValueError("首帧图生视频不能同时使用尾帧或参考素材。")
if not first_url:
raise ValueError("首帧图生视频必须连接首帧图片。")
content.append({
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
})
request_ratio = "adaptive"
elif mode == MODE_LAST:
if first_url or has_reference_material:
raise ValueError("尾帧图生视频不能同时使用首帧或参考素材。")
if not last_url:
raise ValueError("尾帧图生视频必须连接尾帧图片。")
content.append({
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
})
request_ratio = "adaptive"
elif mode == MODE_FIRST_LAST:
if has_reference_material:
raise ValueError("首尾帧模式和参考素材模式不能混用。")
if not first_url or not last_url:
raise ValueError("首尾帧生视频必须同时连接首帧和尾帧图片。")
content.extend([
{
"type": "image_url",
"image_url": {"url": first_url},
"role": "first_frame",
},
{
"type": "image_url",
"image_url": {"url": last_url},
"role": "last_frame",
},
])
request_ratio = "adaptive"
elif mode == MODE_REFERENCE:
if has_first_last_material:
raise ValueError("参考素材模式和首尾帧模式不能混用。")
if not has_reference_material:
raise ValueError("参考素材生视频至少需要一张图片、一个视频或一段音频。")
content.extend(
{
"type": "image_url",
"image_url": {"url": url},
"role": "reference_image",
}
for url in reference_image_urls
)
content.extend(
{
"type": "video_url",
"video_url": {"url": url},
"role": "reference_video",
}
for url in reference_video_urls
)
content.extend(
{
"type": "audio_url",
"audio_url": {"url": url},
"role": "reference_audio",
}
for url in reference_audio_urls
)
request_ratio = ratio or "adaptive"
if request_ratio not in REFERENCE_RATIO_OPTIONS:
raise ValueError(f"MiniMax H3 参考素材模式不支持宽高比:{request_ratio}")
else:
raise ValueError(f"不支持的 MiniMax H3 生成模式:{mode}")
body = {
"model": model,
"content": content,
"resolution": resolution,
"duration": duration,
"ratio": request_ratio,
"seed": seed,
}
_validate_body_size(body)
return body
def _build_mode_input():
reference_images = io.Autogrow.Input(
"参考图片组",
template=io.Autogrow.TemplateNames(
input=io.Image.Input("参考图片"),
names=[f"参考图片{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
min=0,
),
tooltip="可动态连接,最多 9 张。",
)
reference_videos = io.Autogrow.Input(
"参考视频组",
template=io.Autogrow.TemplateNames(
input=io.Video.Input("参考视频"),
names=[f"参考视频{i}" for i in range(1, MAX_REFERENCE_VIDEOS + 1)],
min=0,
),
tooltip="可动态连接,最多 3 个;每段 2~15 秒,总时长不超过 15 秒。",
)
reference_audios = io.Autogrow.Input(
"参考音频组",
template=io.Autogrow.TemplateNames(
input=io.Audio.Input("参考音频"),
names=[f"参考音频{i}" for i in range(1, MAX_REFERENCE_AUDIOS + 1)],
min=0,
),
tooltip="可动态连接,最多 3 段;每段 2~15 秒,总时长不超过 15 秒。",
)
return io.DynamicCombo.Input(
"生成模式",
options=[
io.DynamicCombo.Option(
MODE_TEXT,
[
io.Combo.Input(
"宽高比",
options=RATIO_OPTIONS,
default="16:9",
tooltip="文生视频必须使用具体比例,不能使用 adaptive。",
)
],
),
io.DynamicCombo.Option(
MODE_FIRST,
[io.Image.Input("首帧图片", tooltip="输入图片决定视频比例。")],
),
io.DynamicCombo.Option(
MODE_LAST,
[io.Image.Input("尾帧图片", tooltip="输入图片决定视频比例。")],
),
io.DynamicCombo.Option(
MODE_FIRST_LAST,
[
io.Image.Input("首帧图片"),
io.Image.Input("尾帧图片"),
],
),
io.DynamicCombo.Option(
MODE_REFERENCE,
[
reference_images,
reference_videos,
reference_audios,
io.Combo.Input(
"宽高比",
options=REFERENCE_RATIO_OPTIONS,
default="adaptive",
tooltip="参考素材模式默认 adaptive,也可指定具体比例。",
),
],
),
],
tooltip="首尾帧模式与参考素材模式互斥。",
)
def _collect_autogrow(value) -> list:
"""Collect connected values while tolerating a legacy single input value."""
if value is None:
return []
if isinstance(value, dict):
return [item for item in value.values() if item is not None]
return [value]
def _make_progress_callbacks():
try:
from comfy.utils import ProgressBar
pbar = ProgressBar(100)
except Exception:
pbar = None
last_progress = -1
def set_progress(value: int):
nonlocal last_progress
value = max(0, min(100, int(value)))
if value <= last_progress:
return
last_progress = value
if pbar:
pbar.update_absolute(value, 100)
def on_stage(stage: str):
if stage == "submitting":
print("[MiniMax H3] 正在创建任务...")
set_progress(0)
elif stage.startswith("submitted:"):
print(f"[MiniMax H3] 任务已进入队列:{stage.split(':', 1)[1]}")
elif stage == "downloading":
print("[MiniMax H3] 生成成功,正在立即下载临时 CDN 视频...")
elif stage == "done":
set_progress(100)
def on_progress(progress: int):
# New API returns the authoritative task percentage in data.progress.
# Mirror it directly in ComfyUI while preventing stale poll responses
# from moving the node progress bar backwards.
set_progress(progress)
return on_stage, on_progress
class MiniMaxH3Video(io.ComfyNode):
"""MiniMax H3 / H3 Max text, frame, and reference video generation."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxH3Video",
display_name="MiniMax H3 / H3 Max 视频生成",
category="comfyui_o1key/Video",
description=(
"通过 New API 网关调用 MiniMax-H3 或 MiniMax-H3-MAXH3 支持多模态参考,H3 Max 支持文生和首尾帧模式。"
),
inputs=[
io.String.Input(
"提示词",
multiline=True,
default="",
placeholder="描述画面、动作、镜头与声音...",
tooltip="必填,最多 7000 字符。",
),
_build_mode_input(),
io.Combo.Input(
"分辨率",
options=RESOLUTION_OPTIONS,
default="2K",
),
io.Int.Input(
"时长",
default=5,
min=4,
max=15,
step=1,
display_mode=io.NumberDisplay.slider,
tooltip="H3 支持 415 秒;H3 Max 支持 515 秒。",
),
io.Combo.Input(
"模型",
options=MODEL_OPTIONS,
default=MODEL_ID,
tooltip="H3 支持 768P/2K 和多模态参考;H3 Max 支持 480P/768P,不支持参考素材模式。",
),
io.Int.Input(
"seed",
default=0,
min=0,
max=MAX_SEED,
step=1,
display_mode=io.NumberDisplay.number,
control_after_generate=io.ControlAfterGenerate.randomize,
tooltip="原生随机种子;相同参数与 seed 可用于复现结果。",
),
],
outputs=[io.Video.Output(display_name="视频")],
not_idempotent=True,
search_aliases=["MiniMax H3", "MiniMax H3 Max", "海螺视频", "H3 视频"],
accept_all_inputs=True,
)
@classmethod
async def execute(
cls,
提示词,
生成模式,
分辨率,
时长,
模型=MODEL_ID,
seed=0,
**_kwargs,
) -> io.NodeOutput:
prompt = _validate_prompt(提示词)
if not isinstance(生成模式, dict):
raise ValueError("MiniMax H3 生成模式参数无效。")
mode = 生成模式.get("生成模式")
_validate_generation_options(模型, 分辨率, int(时长), mode)
base_url = get_base_url_by_route()
first_url = None
last_url = None
reference_image_urls = []
reference_video_urls = []
reference_audio_urls = []
if mode == MODE_FIRST:
first_image = 生成模式.get("首帧图片")
first_url = await upload_image(
_image_to_pil(first_image, "首帧图片"),
base_url=base_url,
)
elif mode == MODE_LAST:
last_image = 生成模式.get("尾帧图片")
last_url = await upload_image(
_image_to_pil(last_image, "尾帧图片"),
base_url=base_url,
)
elif mode == MODE_FIRST_LAST:
first_image = 生成模式.get("首帧图片")
last_image = 生成模式.get("尾帧图片")
first_url = await upload_image(
_image_to_pil(first_image, "首帧图片"),
base_url=base_url,
)
last_url = await upload_image(
_image_to_pil(last_image, "尾帧图片"),
base_url=base_url,
)
elif mode == MODE_REFERENCE:
reference_images = _collect_autogrow(
生成模式.get("参考图片组", 生成模式.get("参考图片"))
)
reference_videos = _collect_autogrow(
生成模式.get("参考视频组", 生成模式.get("参考视频"))
)
reference_audios = _collect_autogrow(
生成模式.get("参考音频组", 生成模式.get("参考音频"))
)
_validate_reference_counts(
reference_images,
reference_videos,
reference_audios,
)
if not reference_images and not reference_videos and not reference_audios:
raise ValueError("参考素材生视频至少需要连接一种参考素材。")
reference_pil_images = [
_image_to_pil(image, f"参考图片{index}")
for index, image in enumerate(reference_images, start=1)
]
validate_reference_videos(reference_videos)
validate_reference_audios(reference_audios)
for image in reference_pil_images:
reference_image_urls.append(await upload_image(image, base_url=base_url))
for video in reference_videos:
reference_video_urls.append(await upload_video(video, base_url=base_url))
for audio in reference_audios:
reference_audio_urls.append(await upload_audio(audio, base_url=base_url))
body = build_request_body(
prompt=prompt,
resolution=分辨率,
duration=int(时长),
mode=mode,
model=模型,
seed=int(seed),
ratio=生成模式.get("宽高比"),
first_url=first_url,
last_url=last_url,
reference_image_urls=reference_image_urls,
reference_video_urls=reference_video_urls,
reference_audio_urls=reference_audio_urls,
)
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
fd, save_path = tempfile.mkstemp(
suffix=".mp4",
prefix="minimax_h3_",
dir=temp_dir,
)
os.close(fd)
on_stage, on_progress = _make_progress_callbacks()
client = MiniMaxH3Client(base_url=base_url)
try:
result_path, _task_id = await client.generate_async(
body=body,
save_path=save_path,
on_stage=on_stage,
on_progress=on_progress,
)
return io.NodeOutput(InputImpl.VideoFromFile(result_path))
except BaseException:
try:
if os.path.isfile(save_path):
os.remove(save_path)
except OSError:
pass
raise
NODE_CLASS_MAPPINGS = {"MiniMaxH3Video": MiniMaxH3Video}
NODE_DISPLAY_NAME_MAPPINGS = {"MiniMaxH3Video": "MiniMax H3 / H3 Max 视频生成"}