Add Seedance 2.0 node and preflight alignment
This commit is contained in:
@@ -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]**
|
||||
|
||||
@@ -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
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user