193 lines
10 KiB
Python
193 lines
10 KiB
Python
from urllib.parse import urlsplit
|
|
import re
|
|
|
|
import comfy.model_management
|
|
|
|
from .api.capabilities import CUSTOM_VOICE_LIST_TYPE, ELEMENT_LIST_TYPE, NODE_CATEGORY
|
|
from .api.video_v4 import build_payload, submit_and_wait, validate_material_counts
|
|
from .nodes import (_image_batch_to_base64_list, _normalize_element_list, _normalize_voice_list,
|
|
_runtime_client, _upload_video_reference)
|
|
|
|
|
|
def _common_inputs():
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"multiline": True, "default": "", "tooltip": "用 @image_1、@video_1、@element_1、@voice_1 引用对应素材。"}),
|
|
"resolution": (["720p", "1080p"], {"default": "1080p"}),
|
|
"duration": (["auto"] + [str(n) for n in range(3, 31)], {"default": "10"}),
|
|
},
|
|
"optional": {
|
|
"watermark": ("BOOLEAN", {"default": False}),
|
|
"callback_url": ("STRING", {"default": ""}),
|
|
"external_task_id": ("STRING", {"default": ""}),
|
|
},
|
|
}
|
|
|
|
|
|
def _frame_inputs():
|
|
return {
|
|
"first_frame": ("IMAGE", {"tooltip": "单张首帧,所有图片按首帧→中间帧→尾帧→参考图统一编号。"}),
|
|
"middle_frames": ("IMAGE", {"tooltip": "中间帧批次,每张占用一个 image_N 编号;接在首帧之后。"}),
|
|
"last_frame": ("IMAGE", {"tooltip": "单张尾帧,接在所有中间帧之后编号。"}),
|
|
}
|
|
|
|
|
|
def _frames(first_frame, middle_frames, last_frame):
|
|
contents = []
|
|
for kind, images in (("first_frame", first_frame), ("middle_frame", middle_frames), ("last_frame", last_frame)):
|
|
if images is None:
|
|
continue
|
|
encoded = _image_batch_to_base64_list(images)
|
|
if kind != "middle_frame" and len(encoded) != 1:
|
|
raise ValueError(f"{kind} requires a single image; use middle_frames for a batch.")
|
|
for index, value in enumerate(encoded, 1):
|
|
identifier = f"middle_frame_{index}" if kind == "middle_frame" else kind
|
|
contents.append({"type": kind, "url": value, "id": identifier})
|
|
return contents
|
|
|
|
|
|
def _video_url(value):
|
|
value = value.strip()
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("Video references must be HTTP(S) URLs, or connect a VIDEO input.")
|
|
return value
|
|
|
|
|
|
def _unify_image_ids(prompt, contents):
|
|
aliases = {}
|
|
result = []
|
|
for item in contents:
|
|
if item["type"] in {"first_frame", "middle_frame", "last_frame", "refer_image"}:
|
|
new_id = f"image_{len(aliases) + 1}"
|
|
aliases[item["id"]] = new_id
|
|
item = {**item, "id": new_id}
|
|
result.append(item)
|
|
# A named keyframe marker identifies the legacy namespace. Replace once,
|
|
# including its old reference-image IDs, so substitutions cannot cascade.
|
|
pattern = r"@(first_frame|middle_frame_[1-9]\d*|last_frame|image_[1-9]\d*)(?![A-Za-z0-9_])"
|
|
tokens = re.findall(pattern, prompt or "")
|
|
if any(name in aliases and not name.startswith("image_") for name in tokens):
|
|
prompt = re.sub(pattern, lambda m: "@" + aliases.get(m[1], m[1]), prompt)
|
|
return prompt, result
|
|
|
|
|
|
def _run(kind, prompt, resolution, duration, aspect_ratio=None, contents=None, **options):
|
|
payload = build_payload(kind, prompt, resolution, duration, aspect_ratio, contents, **options)
|
|
with _runtime_client() as client:
|
|
result = submit_and_wait(client, kind, payload,
|
|
interrupt_check=comfy.model_management.throw_exception_if_processing_interrupted)
|
|
return result.url, result.video_id
|
|
|
|
|
|
class Kling4Text2Video:
|
|
CATEGORY = NODE_CATEGORY + "/Kling 4.0"
|
|
FUNCTION = "generate"
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("url", "video_id")
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = _common_inputs()
|
|
inputs["required"]["aspect_ratio"] = (["21:9", "16:9", "9:16", "1:1"], {"default": "16:9"})
|
|
inputs["required"]["prompt"][1]["tooltip"] = "描述画面、镜头、动作、声音和负向要求。"
|
|
return inputs
|
|
|
|
def generate(self, prompt, resolution="1080p", duration="10", aspect_ratio="16:9",
|
|
watermark=False, callback_url="", external_task_id=""):
|
|
return _run("text", prompt, resolution, duration, aspect_ratio,
|
|
watermark=watermark, callback_url=callback_url, external_task_id=external_task_id)
|
|
|
|
|
|
class Kling4Image2Video(Kling4Text2Video):
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = _common_inputs()
|
|
inputs["optional"].update(_frame_inputs())
|
|
return inputs
|
|
|
|
def generate(self, prompt, resolution="1080p", duration="10", first_frame=None,
|
|
middle_frames=None, last_frame=None, watermark=False, callback_url="", external_task_id=""):
|
|
prompt, contents = _unify_image_ids(prompt, _frames(first_frame, middle_frames, last_frame))
|
|
return _run("image", prompt, resolution, duration,
|
|
contents=contents,
|
|
watermark=watermark, callback_url=callback_url, external_task_id=external_task_id)
|
|
|
|
|
|
class Kling4OmniVideo(Kling4Text2Video):
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = _common_inputs()
|
|
inputs["required"]["aspect_ratio"] = (["adaptive", "21:9", "16:9", "9:16", "1:1"], {"default": "adaptive"})
|
|
inputs["optional"].update(_frame_inputs())
|
|
inputs["optional"].update({
|
|
"reference_images": ("IMAGE", {"tooltip": "旧参考图片批次,接在关键帧之后统一分配 image_N。"}),
|
|
"base_video": ("VIDEO", {"tooltip": "待编辑视频;有此输入时为 @video_1。与 base_video_url 二选一。"}),
|
|
"base_video_url": ("STRING", {"default": ""}),
|
|
"feature_video": ("VIDEO", {"tooltip": "特征参考视频;接在 base_video 后编号,无 base_video 时为 @video_1。"}),
|
|
"feature_video_urls": ("STRING", {"multiline": True, "default": "", "tooltip": "每行一个特征视频 URL,接在 VIDEO 输入之后继续编号。"}),
|
|
"element_list": (ELEMENT_LIST_TYPE, {"tooltip": "现有主体列表:@element_1、@element_2…"}),
|
|
"custom_voice_list": (CUSTOM_VOICE_LIST_TYPE, {"tooltip": "现有音色列表:@voice_1、@voice_2…"}),
|
|
})
|
|
for index in range(1, 11):
|
|
inputs["optional"][f"image_{index}"] = ("IMAGE", {"tooltip": "单张参考图;引用编号在所有关键帧之后,查看接口旁标签。"})
|
|
for index in range(1, 6):
|
|
inputs["optional"][f"feature_video_{index}"] = ("VIDEO", {"tooltip": "参考视频,实际 @video 编号显示在连接口旁。"})
|
|
return inputs
|
|
|
|
def generate(self, prompt, resolution="1080p", duration="10", aspect_ratio="adaptive",
|
|
first_frame=None, middle_frames=None, last_frame=None, reference_images=None,
|
|
base_video=None, base_video_url="", feature_video=None, feature_video_urls="",
|
|
element_list=None, custom_voice_list=None, watermark=False, callback_url="", external_task_id="", **dynamic_inputs):
|
|
if base_video is not None and base_video_url.strip():
|
|
raise ValueError("Provide base_video or base_video_url, not both.")
|
|
urls = [_video_url(line) for line in feature_video_urls.splitlines() if line.strip()]
|
|
base_url = _video_url(base_video_url) if base_video_url.strip() else None
|
|
contents = _frames(first_frame, middle_frames, last_frame)
|
|
dynamic_images = []
|
|
dynamic_videos = []
|
|
for name, value in dynamic_inputs.items():
|
|
if value is None:
|
|
continue
|
|
match = re.fullmatch(r"(image|feature_video)_([1-9]\d*)", name)
|
|
if not match:
|
|
raise ValueError(f"Unknown Kling 4.0 input: {name}")
|
|
kind, index = match[1], int(match[2])
|
|
if index > (10 if kind == "image" else 5):
|
|
raise ValueError(f"Kling 4.0 input index out of range: {name}")
|
|
(dynamic_images if kind == "image" else dynamic_videos).append((index, value))
|
|
if dynamic_images and reference_images is not None:
|
|
raise ValueError("Use image_N inputs or legacy reference_images, not both.")
|
|
for index, image in sorted(dynamic_images):
|
|
encoded = _image_batch_to_base64_list(image)
|
|
if len(encoded) != 1:
|
|
raise ValueError(f"image_{index} requires a single image; connect each reference separately.")
|
|
contents.append({"type": "refer_image", "url": encoded[0], "id": f"image_{index}"})
|
|
if reference_images is not None:
|
|
for index, value in enumerate(_image_batch_to_base64_list(reference_images), 1):
|
|
contents.append({"type": "refer_image", "url": value, "id": f"image_{index}"})
|
|
videos = []
|
|
if base_video is not None or base_url:
|
|
videos.append(("base_video", base_video if base_video is not None else base_url, 1))
|
|
offset = len(videos)
|
|
if feature_video is not None:
|
|
videos.append(("feature_video", feature_video, offset + 1))
|
|
offset += 1
|
|
for index, video in sorted(dynamic_videos):
|
|
videos.append(("feature_video", video, offset + index))
|
|
next_index = max((v[2] for v in videos), default=0) + 1
|
|
videos.extend(("feature_video", url, next_index + i) for i, url in enumerate(urls))
|
|
for kind, value, index in videos:
|
|
contents.append({"type": kind, "url": value, "id": f"video_{index}"})
|
|
for index, element in enumerate(_normalize_element_list(element_list) or [], 1):
|
|
contents.append({"type": "element", "element_id": element["element_id"], "id": f"element_{index}"})
|
|
for index, voice in enumerate(_normalize_voice_list(custom_voice_list) or [], 1):
|
|
contents.append({"type": "voice", "voice_id": voice["voice_id"], "id": f"voice_{index}"})
|
|
validate_material_counts(contents)
|
|
prompt, contents = _unify_image_ids(prompt, contents)
|
|
for item in contents:
|
|
if item["type"] in {"base_video", "feature_video"} and not isinstance(item["url"], str):
|
|
item["url"] = _upload_video_reference(item["url"])
|
|
return _run("omni", prompt, resolution, duration, aspect_ratio, contents,
|
|
watermark=watermark, callback_url=callback_url, external_task_id=external_task_id)
|