diff --git a/README.md b/README.md index 8f72248..0a4ad51 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ This node pack currently includes the following nodes: Fast image-to-video generation optimized for quick iteration. - **Seedance 1.5 Pro Image-to-Video, Text-to-Video** Higher-quality image-to-video generation using the Seedance 1.5 Pro model. +- **Seedance 2.0 Video** + - Supports text-to-video, first-frame I2V, first+last-frame I2V, and multimodal reference-to-video. + - Includes a preflight node for validating mutually exclusive media modes before spending credits. + - Current implementation intentionally targets verified `bytedance/seedance-2` only; `Seedance 2.0 Fast` remains pending due to upstream docs conflicts. ## LLM Nodes - **Gemini (LLM) [Experimental]** diff --git a/kie_api/seedance2_video.py b/kie_api/seedance2_video.py new file mode 100644 index 0000000..3756b5c --- /dev/null +++ b/kie_api/seedance2_video.py @@ -0,0 +1,436 @@ +"""Seedance 2.0 video helpers.""" + +import json +import time +from typing import Any + +import torch + +from .audio import _coerce_audio_to_wav_bytes +from .auth import _load_api_key +from .credits import _log_remaining_credits +from .jobs import _create_task, _poll_task_until_complete +from .log import _log +from .results import _extract_result_urls +from .upload import _image_tensor_to_png_bytes, _truncate_url, _upload_audio, _upload_image, _upload_video +from .validation import _validate_image_tensor_batch, _validate_prompt +from .video import _coerce_video_to_mp4_bytes, _download_video, _video_bytes_to_comfy_video + + +MODEL_NAME = "bytedance/seedance-2" +PROMPT_MAX_LENGTH = 5000 +ASPECT_RATIO_OPTIONS = ["16:9", "9:16", "1:1"] +RESOLUTION_OPTIONS = ["480p", "720p", "1080p"] +DURATION_OPTIONS = ["5", "10", "15"] + + +def _validation_error(message: str) -> RuntimeError: + return RuntimeError(f"Seedance 2.0 validation error: {message}") + + +def _validate_frame_image(images: torch.Tensor | None, label: str) -> torch.Tensor: + batch = _validate_image_tensor_batch(images) + if batch.shape[0] > 1: + raise _validation_error(f"{label} accepts a single image. Connect one image or slice the batch first.") + return batch + + +def _validate_options( + *, + aspect_ratio: str, + resolution: str, + duration: str, + generate_audio: bool, + return_last_frame: bool, + web_search: bool, +) -> None: + if aspect_ratio not in ASPECT_RATIO_OPTIONS: + raise _validation_error("Invalid aspect_ratio. Use the pinned enum options.") + if resolution not in RESOLUTION_OPTIONS: + raise _validation_error("Invalid resolution. Use the pinned enum options.") + if duration not in DURATION_OPTIONS: + raise _validation_error("Invalid duration. Use the pinned enum options.") + if not isinstance(generate_audio, bool): + raise _validation_error("generate_audio must be a boolean value.") + if not isinstance(return_last_frame, bool): + raise _validation_error("return_last_frame must be a boolean value.") + if not isinstance(web_search, bool): + raise _validation_error("web_search must be a boolean value.") + + +def _detect_scenario( + *, + first_frame: torch.Tensor | None, + last_frame: torch.Tensor | None, + reference_images: torch.Tensor | None, + reference_video: Any | None, + reference_audio: Any | None, +) -> str: + has_first = first_frame is not None + has_last = last_frame is not None + has_references = any(item is not None for item in (reference_images, reference_video, reference_audio)) + + if has_last and not has_first: + raise _validation_error("last_frame requires first_frame.") + + if has_first and has_last and has_references: + return "first_last_frame_with_references" + if has_first and has_references: + return "first_frame_with_references" + if has_first and has_last: + return "first_last_frame" + if has_first: + return "first_frame" + if has_references: + return "multimodal_reference" + return "text_to_video" + + +def _upload_single_frame(api_key: str, images: torch.Tensor | None, label: str, log: bool) -> str | None: + if images is None: + return None + + batch = _validate_frame_image(images, label) + _log(log, f"Uploading {label} for Seedance 2.0...") + image_url = _upload_image(api_key, _image_tensor_to_png_bytes(batch[0])) + _log(log, f"{label} upload success: {_truncate_url(image_url)}") + return image_url + + +def _upload_reference_images(api_key: str, images: torch.Tensor | None, log: bool) -> list[str]: + if images is None: + return [] + + batch = _validate_image_tensor_batch(images) + image_urls: list[str] = [] + _log(log, f"Uploading {batch.shape[0]} reference image(s) for Seedance 2.0...") + for idx in range(batch.shape[0]): + image_url = _upload_image(api_key, _image_tensor_to_png_bytes(batch[idx])) + image_urls.append(image_url) + _log(log, f"Reference image {idx + 1} upload success: {_truncate_url(image_url)}") + return image_urls + + +def _upload_reference_video(api_key: str, reference_video: Any | None, log: bool) -> list[str]: + if reference_video is None: + return [] + + video_bytes, source = _coerce_video_to_mp4_bytes(reference_video) + _log(log, f"Uploading reference video for Seedance 2.0 ({source})...") + video_url = _upload_video(api_key, video_bytes, filename="seedance2_reference.mp4") + _log(log, f"Reference video upload success: {_truncate_url(video_url)}") + return [video_url] + + +def _upload_reference_audio(api_key: str, reference_audio: Any | None, log: bool) -> list[str]: + if reference_audio is None: + return [] + + audio_bytes, source = _coerce_audio_to_wav_bytes(reference_audio) + _log(log, f"Uploading reference audio for Seedance 2.0 ({source})...") + audio_url = _upload_audio(api_key, audio_bytes, filename="seedance2_reference.wav") + _log(log, f"Reference audio upload success: {_truncate_url(audio_url)}") + return [audio_url] + + +def _build_seedance2_payload( + *, + prompt: str, + aspect_ratio: str, + resolution: str, + duration: str, + generate_audio: bool, + return_last_frame: bool, + web_search: bool, + first_frame: torch.Tensor | None, + last_frame: torch.Tensor | None, + reference_images: torch.Tensor | None, + reference_video: Any | None, + reference_audio: Any | None, + log: bool, +) -> tuple[dict[str, Any], str]: + _validate_prompt(prompt, max_length=PROMPT_MAX_LENGTH) + _validate_options( + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + web_search=web_search, + ) + + scenario = _detect_scenario( + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + ) + api_key = _load_api_key() + + input_payload: dict[str, Any] = { + "prompt": prompt, + "return_last_frame": return_last_frame, + "generate_audio": generate_audio, + "resolution": resolution, + "aspect_ratio": aspect_ratio, + "duration": int(duration), + "web_search": web_search, + } + + if scenario in { + "first_frame", + "first_last_frame", + "first_frame_with_references", + "first_last_frame_with_references", + }: + first_frame_url = _upload_single_frame(api_key, first_frame, "first_frame", log) + if first_frame_url: + input_payload["first_frame_url"] = first_frame_url + + if scenario in {"first_last_frame", "first_last_frame_with_references"}: + last_frame_url = _upload_single_frame(api_key, last_frame, "last_frame", log) + if last_frame_url: + input_payload["last_frame_url"] = last_frame_url + + if scenario in { + "multimodal_reference", + "first_frame_with_references", + "first_last_frame_with_references", + }: + reference_image_urls = _upload_reference_images(api_key, reference_images, log) + reference_video_urls = _upload_reference_video(api_key, reference_video, log) + reference_audio_urls = _upload_reference_audio(api_key, reference_audio, log) + + if reference_image_urls: + input_payload["reference_image_urls"] = reference_image_urls + if reference_video_urls: + input_payload["reference_video_urls"] = reference_video_urls + if reference_audio_urls: + input_payload["reference_audio_urls"] = reference_audio_urls + + return {"model": MODEL_NAME, "input": input_payload}, scenario + + +def summarize_seedance2_payload(payload: dict[str, Any]) -> dict[str, Any]: + normalized = _normalize_request_payload(payload) + payload_input = normalized.get("input", {}) + + reference_image_urls = list(payload_input.get("reference_image_urls") or []) + reference_video_urls = list(payload_input.get("reference_video_urls") or []) + reference_audio_urls = list(payload_input.get("reference_audio_urls") or []) + + fields_present = ["prompt", "return_last_frame", "generate_audio", "resolution", "aspect_ratio", "duration", "web_search"] + for field_name in ( + "first_frame_url", + "last_frame_url", + "reference_image_urls", + "reference_video_urls", + "reference_audio_urls", + ): + if field_name in payload_input: + fields_present.append(field_name) + + scenario = "text-to-video" + if payload_input.get("first_frame_url") and payload_input.get("last_frame_url") and ( + reference_image_urls or reference_video_urls or reference_audio_urls + ): + scenario = "first+last-frame with references" + elif payload_input.get("first_frame_url") and ( + reference_image_urls or reference_video_urls or reference_audio_urls + ): + scenario = "first-frame with references" + elif payload_input.get("first_frame_url") and payload_input.get("last_frame_url"): + scenario = "first+last-frame image-to-video" + elif payload_input.get("first_frame_url"): + scenario = "first-frame image-to-video" + elif reference_image_urls or reference_video_urls or reference_audio_urls: + scenario = "multimodal reference-to-video" + + return { + "scenario": scenario, + "fields_present": fields_present, + "has_first_frame": bool(payload_input.get("first_frame_url")), + "has_last_frame": bool(payload_input.get("last_frame_url")), + "reference_image_count": len(reference_image_urls), + "reference_video_count": len(reference_video_urls), + "reference_audio_count": len(reference_audio_urls), + "reference_image_aliases": [f"@Image{idx}" for idx in range(1, len(reference_image_urls) + 1)], + "reference_video_aliases": [f"@Video{idx}" for idx in range(1, len(reference_video_urls) + 1)], + "reference_audio_aliases": [f"@Audio{idx}" for idx in range(1, len(reference_audio_urls) + 1)], + } + + +def preflight_seedance2_payload( + *, + prompt: str, + aspect_ratio: str, + resolution: str, + duration: str, + generate_audio: bool, + return_last_frame: bool, + web_search: bool, + first_frame: torch.Tensor | None, + last_frame: torch.Tensor | None, + reference_images: torch.Tensor | None, + reference_video: Any | None, + reference_audio: Any | None, + log: bool, +) -> dict[str, Any]: + payload, _scenario = _build_seedance2_payload( + prompt=prompt, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + web_search=web_search, + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + log=log, + ) + return payload + + +def run_seedance2_video_payload( + *, + prompt: str, + aspect_ratio: str, + resolution: str, + duration: str, + generate_audio: bool, + return_last_frame: bool, + web_search: bool, + first_frame: torch.Tensor | None, + last_frame: torch.Tensor | None, + reference_images: torch.Tensor | None, + reference_video: Any | None, + reference_audio: Any | None, + log: bool, +) -> tuple[dict[str, Any], str]: + return _build_seedance2_payload( + prompt=prompt, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + web_search=web_search, + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + log=log, + ) + + +def _normalize_request_payload(payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + if normalized.get("model") != MODEL_NAME: + raise _validation_error(f"request payload model must be '{MODEL_NAME}'.") + + payload_input = normalized.get("input") + if not isinstance(payload_input, dict): + raise _validation_error("request payload must include an input object.") + + normalized_input = dict(payload_input) + if "reference_video_urls " in normalized_input and "reference_video_urls" not in normalized_input: + normalized_input["reference_video_urls"] = normalized_input.pop("reference_video_urls ") + + normalized["input"] = normalized_input + return normalized + + +def _select_video_url(result_urls: list[str]) -> str: + for url in result_urls: + lower = str(url).lower() + if any(lower.endswith(ext) for ext in (".mp4", ".mov", ".webm", ".m4v")): + return url + return result_urls[0] + + +def run_seedance2_video( + *, + prompt: str, + aspect_ratio: str, + resolution: str, + duration: str, + generate_audio: bool, + return_last_frame: bool, + web_search: bool, + first_frame: torch.Tensor | None, + last_frame: torch.Tensor | None, + reference_images: torch.Tensor | None, + reference_video: Any | None, + reference_audio: Any | None, + poll_interval_s: float, + timeout_s: int, + log: bool, +) -> Any: + payload, _scenario = run_seedance2_video_payload( + prompt=prompt, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + web_search=web_search, + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + log=log, + ) + return run_seedance2_video_from_request( + payload=payload, + poll_interval_s=poll_interval_s, + timeout_s=timeout_s, + log=log, + ) + + +def run_seedance2_video_from_request( + *, + payload: dict[str, Any], + poll_interval_s: float, + timeout_s: int, + log: bool, +) -> Any: + if not isinstance(payload, dict): + raise _validation_error("request payload must be an object.") + + payload = _normalize_request_payload(payload) + + _log(log, "Creating Seedance 2.0 task...") + start_time = time.time() + api_key = _load_api_key() + task_id, create_response_text = _create_task(api_key, payload) + _log(log, f"createTask response (elapsed={time.time() - start_time:.1f}s): {create_response_text}") + _log(log, f"Task created with ID {task_id}. Polling for completion...") + + record_data = _poll_task_until_complete( + api_key, + task_id, + poll_interval_s, + timeout_s, + log, + start_time, + ) + + result_urls = _extract_result_urls(record_data) + video_url = _select_video_url(result_urls) + if len(result_urls) > 1: + _log(log, f"Seedance 2.0 returned {len(result_urls)} result URLs; selecting video URL {video_url}") + else: + _log(log, f"Final video URL: {video_url}") + + video_bytes = _download_video(video_url) + video_output = _video_bytes_to_comfy_video(video_bytes) + + _log_remaining_credits(log, record_data, api_key, _log) + return video_output diff --git a/nodes.py b/nodes.py index 5bc40d9..7f9cb95 100644 --- a/nodes.py +++ b/nodes.py @@ -29,6 +29,15 @@ from .kie_api.seedream45_edit import ( QUALITY_OPTIONS as SEEDREAM_EDIT_QUALITY_OPTIONS, run_seedream45_edit, ) +from .kie_api.seedance2_video import ( + ASPECT_RATIO_OPTIONS as SEEDANCE2_ASPECT_RATIO_OPTIONS, + DURATION_OPTIONS as SEEDANCE2_DURATION_OPTIONS, + RESOLUTION_OPTIONS as SEEDANCE2_RESOLUTION_OPTIONS, + preflight_seedance2_payload, + summarize_seedance2_payload, + run_seedance2_video, + run_seedance2_video_from_request, +) from .kie_api.seedancev1pro_fast_i2v import KIE_SeedanceV1Pro_Fast_I2V from .kie_api.seedance15pro_i2v import KIE_Seedance15Pro_I2V from .kie_api.kling26_i2v import ( @@ -568,6 +577,214 @@ Outputs: time.sleep(backoff) +class KIE_Seedance2_Video: + HELP = """ +KIE Seedance 2.0 (Video) + +Generate a video with Seedance 2.0 using one of four supported scenarios: +- text-to-video +- first-frame image-to-video +- first+last-frame image-to-video +- multimodal reference-to-video + +Inputs: +- prompt: Required prompt text +- first_frame / last_frame: Optional frame controls (mutually exclusive with reference media) +- reference_images: Optional reference image batch +- reference_video: Optional reference video +- reference_audio: Optional reference audio +- generate_audio: Ask the endpoint to create audio +- return_last_frame: Ask the endpoint to include the last frame artifact +- aspect_ratio / resolution / duration / web_search +- seedance_data: Optional validated payload from Seedance 2.0 Preflight +- log: Console logging on/off + +Rules: +- last_frame requires first_frame +- first_frame/last_frame can be combined with reference media +- current implementation targets verified model `bytedance/seedance-2` +""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": ""}), + }, + "optional": { + "first_frame": ("IMAGE",), + "last_frame": ("IMAGE",), + "reference_images": ("IMAGE",), + "reference_video": ("VIDEO",), + "reference_audio": ("AUDIO",), + "generate_audio": ("BOOLEAN", {"default": False}), + "return_last_frame": ("BOOLEAN", {"default": False}), + "aspect_ratio": ("COMBO", {"options": SEEDANCE2_ASPECT_RATIO_OPTIONS, "default": "16:9"}), + "resolution": ("COMBO", {"options": SEEDANCE2_RESOLUTION_OPTIONS, "default": "720p"}), + "duration": ("COMBO", {"options": SEEDANCE2_DURATION_OPTIONS, "default": "15"}), + "web_search": ("BOOLEAN", {"default": False}), + "seedance_data": ("KIE_SEEDANCE2_REQUEST",), + "log": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("VIDEO",) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + CATEGORY = "kie/api" + + def generate( + self, + prompt: str, + first_frame: torch.Tensor | None = None, + last_frame: torch.Tensor | None = None, + reference_images: torch.Tensor | None = None, + reference_video: object | None = None, + reference_audio: object | None = None, + generate_audio: bool = False, + return_last_frame: bool = False, + aspect_ratio: str = "16:9", + resolution: str = "720p", + duration: str = "15", + web_search: bool = False, + seedance_data: dict | None = None, + log: bool = True, + poll_interval_s: float = 10.0, + timeout_s: int = 2000, + ): + if seedance_data is not None: + video_output = run_seedance2_video_from_request( + payload=seedance_data, + poll_interval_s=poll_interval_s, + timeout_s=timeout_s, + log=log, + ) + return (video_output,) + + video_output = run_seedance2_video( + prompt=prompt, + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + web_search=web_search, + poll_interval_s=poll_interval_s, + timeout_s=timeout_s, + log=log, + ) + return (video_output,) + + +class KIE_Seedance2_Preflight: + HELP = """ +KIE Seedance 2.0 Preflight + +Validate Seedance 2.0 inputs, upload media, and build the exact createTask payload +without submitting a generation task. + +Use this node before expensive Seedance 2.0 runs. + +Outputs: +- KIE_SEEDANCE2_REQUEST: validated payload object for chaining into KIE Seedance 2.0 (Video) +- STRING: payload_json +- STRING: notes +""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": ""}), + }, + "optional": { + "first_frame": ("IMAGE",), + "last_frame": ("IMAGE",), + "reference_images": ("IMAGE",), + "reference_video": ("VIDEO",), + "reference_audio": ("AUDIO",), + "generate_audio": ("BOOLEAN", {"default": False}), + "return_last_frame": ("BOOLEAN", {"default": False}), + "aspect_ratio": ("COMBO", {"options": SEEDANCE2_ASPECT_RATIO_OPTIONS, "default": "16:9"}), + "resolution": ("COMBO", {"options": SEEDANCE2_RESOLUTION_OPTIONS, "default": "720p"}), + "duration": ("COMBO", {"options": SEEDANCE2_DURATION_OPTIONS, "default": "15"}), + "web_search": ("BOOLEAN", {"default": False}), + "log": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("KIE_SEEDANCE2_REQUEST", "STRING", "STRING") + RETURN_NAMES = ("seedance_data", "payload_json", "notes") + FUNCTION = "preflight" + CATEGORY = "kie/helpers" + + def preflight( + self, + prompt: str, + first_frame: torch.Tensor | None = None, + last_frame: torch.Tensor | None = None, + reference_images: torch.Tensor | None = None, + reference_video: object | None = None, + reference_audio: object | None = None, + generate_audio: bool = False, + return_last_frame: bool = False, + aspect_ratio: str = "16:9", + resolution: str = "720p", + duration: str = "15", + web_search: bool = False, + log: bool = True, + ): + payload = preflight_seedance2_payload( + prompt=prompt, + first_frame=first_frame, + last_frame=last_frame, + reference_images=reference_images, + reference_video=reference_video, + reference_audio=reference_audio, + generate_audio=generate_audio, + return_last_frame=return_last_frame, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + web_search=web_search, + log=log, + ) + payload_input = payload.get("input", {}) + summary = summarize_seedance2_payload(payload) + + notes_lines = [ + "VALID: Seedance 2.0 preflight checks passed.", + "No createTask call was made.", + f"Scenario: {summary['scenario']}", + f"Aspect ratio: {payload_input.get('aspect_ratio')}", + f"Resolution: {payload_input.get('resolution')}", + f"Duration sent: {payload_input.get('duration')}s", + f"Generate audio: {bool(payload_input.get('generate_audio', False))}", + f"Return last frame: {bool(payload_input.get('return_last_frame', False))}", + f"Web search: {bool(payload_input.get('web_search', False))}", + f"First frame present: {summary['has_first_frame']}", + f"Last frame present: {summary['has_last_frame']}", + "Payload fields: " + ", ".join(summary["fields_present"]), + f"Reference image count: {summary['reference_image_count']}", + f"Reference video count: {summary['reference_video_count']}", + f"Reference audio count: {summary['reference_audio_count']}", + "Prompt alias note: @ImageN / @VideoN / @AudioN mapping is inferred from upload order and is not API-verified.", + ] + + if summary["reference_image_aliases"]: + notes_lines.append("Reference image aliases: " + ", ".join(summary["reference_image_aliases"])) + if summary["reference_video_aliases"]: + notes_lines.append("Reference video aliases: " + ", ".join(summary["reference_video_aliases"])) + if summary["reference_audio_aliases"]: + notes_lines.append("Reference audio aliases: " + ", ".join(summary["reference_audio_aliases"])) + return (payload, json.dumps(payload, indent=2, ensure_ascii=False), "\n".join(notes_lines)) + + class KIE_Kling25_I2V_Pro: HELP = """ KIE Kling 2.5 I2V Pro @@ -1404,7 +1621,7 @@ Outputs: "prompt": ("STRING", {"multiline": True, "default": ""}), }, "optional": { - "multi_shot_text": ("STRING", {"multiline": True, "default": cls.SHOTS_TEXT_PLACEHOLDER}), + "shots_text": ("STRING", {"multiline": True, "default": cls.SHOTS_TEXT_PLACEHOLDER}), "first_frame": ("IMAGE",), "last_frame": ("IMAGE",), "sound": ("BOOLEAN", {"default": True}), @@ -2053,6 +2270,8 @@ NODE_CLASS_MAPPINGS = { "KIE_GrokImagine_I2I": KIE_GrokImagine_I2I, "KIE_SeedanceV1Pro_Fast_I2V": KIE_SeedanceV1Pro_Fast_I2V, "KIE_Seedance15Pro_I2V": KIE_Seedance15Pro_I2V, + "KIE_Seedance2_Video": KIE_Seedance2_Video, + "KIE_Seedance2_Preflight": KIE_Seedance2_Preflight, "KIE_Kling25_I2V_Pro": KIE_Kling25_I2V_Pro, "KIE_Kling26_I2V": KIE_Kling26_I2V, "KIE_Kling26_T2V": KIE_Kling26_T2V, @@ -2082,6 +2301,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "KIE_GrokImagine_I2I": "KIE Grok Imagine (I2I)", "KIE_SeedanceV1Pro_Fast_I2V": "KIE Seedance V1 Pro Fast (I2V)", "KIE_Seedance15Pro_I2V": "KIE Seedance 1.5 Pro (I2V/T2V)", + "KIE_Seedance2_Video": "KIE Seedance 2.0 (Video)", + "KIE_Seedance2_Preflight": "KIE Seedance 2.0 Preflight", "KIE_Kling25_I2V_Pro": "KIE Kling 2.5 I2V Pro", "KIE_Kling26_I2V": "KIE Kling 2.6 (I2V)", "KIE_Kling26_T2V": "KIE Kling 2.6 (T2V)", diff --git a/web/docs/KIE_Seedance2_Preflight.md b/web/docs/KIE_Seedance2_Preflight.md new file mode 100644 index 0000000..507169e --- /dev/null +++ b/web/docs/KIE_Seedance2_Preflight.md @@ -0,0 +1,31 @@ +# KIE Seedance 2.0 Preflight + +Validate Seedance 2.0 inputs, upload required media, and build the exact createTask payload without submitting a generation task. + +> Status: Experimental support node for Seedance 2.0 development. + +## Purpose +- Catch scenario wiring errors before expensive video runs. +- Preview the exact payload sent to: + - `POST https://api.kie.ai/api/v1/jobs/createTask` + +## Inputs +- Same inputs as `KIE Seedance 2.0 (Video)`, except there is no `seedance_data` passthrough input. + +## Outputs +- `seedance_data` (KIE_SEEDANCE2_REQUEST): validated payload object for direct chaining into `KIE Seedance 2.0 (Video)`. +- `payload_json` (STRING): exact request payload after validation and upload URL resolution. +- `notes` (STRING): validation summary with resolved scenario and media counts. + +## Notes +- This node still uploads media so the payload contains real URLs. +- It does not submit `createTask`. +- Current implementation is intentionally scoped to `bytedance/seedance-2`. +- It reports exact payload fields and inferred alias order for references: + - `@Image1..N` for `reference_image_urls` + - `@Video1..N` for `reference_video_urls` + - `@Audio1..N` for `reference_audio_urls` +- Those aliases are based on upload order and are not yet a verified KIE API contract. +- The current `aspect_ratio`, `resolution`, and `duration` options are pinned to the public examples visible in KIE's docs and should be widened only after live API verification. +- KIE's public marketing copy mentions optional lens locking, but the visible request example does not expose a verified request field for it yet. +- The official `Seedance 2.0 Fast` page is not used yet because its published request example is still inconsistent. diff --git a/web/docs/KIE_Seedance2_Video.md b/web/docs/KIE_Seedance2_Video.md new file mode 100644 index 0000000..f478c4f --- /dev/null +++ b/web/docs/KIE_Seedance2_Video.md @@ -0,0 +1,51 @@ +# KIE Seedance 2.0 (Video) + +Generate Seedance 2.0 videos with the verified `bytedance/seedance-2` model. + +> Status: Experimental development pass. +> Credit Warning: Seedance 2.0 can take several minutes per job. Use `KIE Seedance 2.0 Preflight` first. + +## Inputs +- `prompt` (STRING, required): main prompt text. +- `first_frame` (IMAGE, optional): single first frame image. +- `last_frame` (IMAGE, optional): single last frame image. Requires `first_frame`. +- `reference_images` (IMAGE, optional): one or more reference images for multimodal reference mode. +- `reference_video` (VIDEO, optional): one reference video for multimodal reference mode. +- `reference_audio` (AUDIO, optional): one reference audio input for multimodal reference mode. +- `generate_audio` (BOOLEAN, optional): asks KIE to generate audio. +- `return_last_frame` (BOOLEAN, optional): asks KIE to include a last-frame artifact when supported. +- `aspect_ratio` (COMBO, optional): `16:9`, `9:16`, `1:1`. +- `resolution` (COMBO, optional): `480p`, `720p`, `1080p`. +- `duration` (COMBO, optional): `5`, `10`, `15`. +- `web_search` (BOOLEAN, optional): forwards KIE's web-search toggle. +- `seedance_data` (KIE_SEEDANCE2_REQUEST, optional): validated payload from preflight. When connected, this overrides direct field inputs. +- `log` (BOOLEAN, optional): enable helper logging. + +## Scenario Rules +- Text-to-video: + Connect no media inputs. +- First-frame image-to-video: + Connect `first_frame` only. +- First+last-frame image-to-video: + Connect `first_frame` and `last_frame`. +- Multimodal reference-to-video: + Connect any mix of `reference_images`, `reference_video`, and `reference_audio`. +- Hybrid control mode: + You can combine `first_frame` and `last_frame` with reference media in the same request. +- `last_frame` still requires `first_frame`. + +## Notes +- The current `aspect_ratio`, `resolution`, and `duration` option lists are pinned to the public KIE examples used for this implementation pass. + Widen them only after Windows-side live API verification confirms the accepted enum set. +- Preflight now reports the exact payload fields present plus inferred alias order such as `@Image1`, `@Video1`, and `@Audio1`. + Those aliases are treated as a likely model convention based on upload order, not a verified API contract. +- The official KIE docs currently show a typo with `reference_video_urls ` including a trailing space in the example payload. + This node normalizes that field when consuming prebuilt payloads. +- KIE's public marketing copy mentions optional lens locking, but the visible request example does not expose a verified request field for it yet. + This first pass intentionally leaves that transport field out rather than guessing. +- The official `Seedance 2.0 Fast` page is currently inconsistent and still shows `model: "bytedance/seedance-2"`. + This node intentionally targets only the verified non-fast model for now. +- If KIE returns multiple artifacts, the node selects the video URL and ignores extra artifacts in the ComfyUI output contract. + +## Output +- `video` (VIDEO): SaveVideo-compatible ComfyUI video output. diff --git a/web/docs/README.md b/web/docs/README.md index 5221a9d..8d316ce 100644 --- a/web/docs/README.md +++ b/web/docs/README.md @@ -22,6 +22,8 @@ Grok Imagine status: `T2I`, `I2I`, `T2V`, and `I2V` are implemented and document - Grok Imagine I2V: [`KIE_GrokImagine_I2V.md`](KIE_GrokImagine_I2V.md) - Seedance V1 Pro Fast I2V: [`KIE_SeedanceV1Pro_Fast_I2V.md`](KIE_SeedanceV1Pro_Fast_I2V.md) - Seedance 1.5 Pro I2V/T2V: [`KIE_Seedance15Pro_I2V.md`](KIE_Seedance15Pro_I2V.md) +- Seedance 2.0 Video: [`KIE_Seedance2_Video.md`](KIE_Seedance2_Video.md) +- Seedance 2.0 Preflight: [`KIE_Seedance2_Preflight.md`](KIE_Seedance2_Preflight.md) ## API Specs - Grok Imagine T2V spec: [`KIE_GrokImagine_T2V_Spec.md`](KIE_GrokImagine_T2V_Spec.md)