Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions comfy_api_nodes/apis/bytedance.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class Seedance2TaskCreationRequest(BaseModel):
seed: int | None = Field(None, ge=0, le=2147483647)
watermark: bool | None = Field(None)
output_format: str | None = Field(None)
omni_reference_task_type: Literal["reference", "edit", "extend"] | None = Field(None)


class TaskCreationResponse(BaseModel):
Expand Down
94 changes: 72 additions & 22 deletions comfy_api_nodes/nodes_bytedance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2069,7 +2069,7 @@ def _seedance2_text_inputs(resolutions: list[str], default_ratio: str = "16:9"):
]


def _seedance25_text_inputs(with_ratio: bool = True, with_video_editing: bool = False):
def _seedance25_text_inputs(with_ratio: bool = True, with_task_type: bool = False):
return [
IO.String.Input(
"prompt",
Expand Down Expand Up @@ -2112,30 +2112,39 @@ def _seedance25_text_inputs(with_ratio: bool = True, with_video_editing: bool =
),
*(
[
IO.Boolean.Input(
"video_editing",
default=False,
tooltip="Enable when the prompt edits a connected reference video, for example "
"replacing an object in it. The output then keeps the source clip's own length "
"and aspect ratio, and the duration and ratio widgets are ignored. Leave "
"disabled to generate a new video, or to extend one to the duration you set.",
IO.Combo.Input(
"task_type",
options=["auto", "reference", "edit", "extend"],
default="auto",
tooltip="Which job to run against the connected references.\n"
"auto: let the model infer the job from the prompt and the references.\n"
"reference: generate a new video guided by the references.\n"
"edit: change the visuals or audio of a connected video. The output keeps that "
"clip's length and aspect ratio, so the duration and ratio widgets are ignored.\n"
"extend: continue a connected video to the duration you set. Describe the "
"direction, forward or backward, in the prompt. The aspect ratio is taken from "
"the source clip, so the ratio widget is ignored.\n"
"Naming the job instead of leaving it on auto validates the request when it is "
"submitted, rather than failing part-way through generation.",
)
]
if with_video_editing
if with_task_type
else []
),
IO.Combo.Input(
"output_format",
options=["mp4"],
options=["mp4", "mov"],
default="mp4",
tooltip="Container format of the output video.",
tooltip="Container format of the output video. MOV preserves color and brightness more "
"precisely and is the provider's recommendation for edit and extend, where the output "
"has to match footage you already have. Some players do not support it.",
),
]


def _seedance25_reference_inputs():
return [
*_seedance25_text_inputs(with_video_editing=True),
*_seedance25_text_inputs(with_task_type=True),
IO.Autogrow.Input(
"reference_images",
template=IO.Autogrow.TemplateNames(
Expand Down Expand Up @@ -2196,17 +2205,27 @@ def _seedance2_build_request(
watermark: bool,
ratio: str,
) -> Seedance2TaskCreationRequest:
video_editing = bool(model.get("video_editing"))
# Only the reference node offers task_type; the text-to-video and first/last-frame nodes
# share this builder and have no such widget, so an absent key means a plain generation.
task_type = model.get("task_type", "auto")
duration = model["duration"]
if task_type == "edit":
# An edit always inherits the source clip's aspect ratio and length.
ratio, duration = "adaptive", -1
elif task_type == "extend":
# An extension inherits the aspect ratio but keeps the requested length.
ratio = "adaptive"
return Seedance2TaskCreationRequest(
model=model_id,
content=content,
generate_audio=model["generate_audio"],
resolution=model["resolution"],
ratio="adaptive" if video_editing else ratio,
duration=-1 if video_editing else model["duration"],
ratio=ratio,
duration=duration,
seed=seed,
watermark=watermark,
output_format=model.get("output_format"),
omni_reference_task_type=None if task_type == "auto" else task_type,
)


Expand All @@ -2216,7 +2235,7 @@ def _seedance2_build_request(
$res := $lookup(widgets, "model.resolution");
$ratio := $lookup(widgets, "model.ratio");
$dur := $lookup(widgets, "model.duration");
$auto := $lookup(widgets, "model.video_editing") = true;
$auto := $lookup(widgets, "model.task_type") = "edit";
$hasVideo := __HAS_VIDEO__;
$ready := $type($m) = "string" and $type($res) = "string" and ($auto or $type($dur) = "number");
$ready ? (
Expand Down Expand Up @@ -2261,6 +2280,7 @@ def _seedance2_build_request(

_SEEDANCE_AUDIO_POLICY_CODE = "OutputAudioSensitiveContentDetected.PolicyViolation"
_SEEDANCE_TASK_TYPE_CONSTRAINT_CODE = "InvalidParameter.TaskTypeConstraint"
_SEEDANCE_TASK_TYPE_MISMATCH_CODE = "InvalidParameter.TaskTypeMismatch"


async def _seedance2_poll_video_task(
Expand All @@ -2269,6 +2289,7 @@ async def _seedance2_poll_video_task(
model_id: str,
resolution: str,
has_video_input: bool,
task_type: str = "auto",
) -> TaskStatusResponse:
try:
return await poll_op(
Expand All @@ -2289,19 +2310,33 @@ async def _seedance2_poll_video_task(
"to get a silent video, or adjust the prompt and try again."
) from exc
if _SEEDANCE_TASK_TYPE_CONSTRAINT_CODE in str(exc):
if task_type == "auto":
raise ValueError(
"Seedance read this prompt as editing the reference video, and an edit always "
"takes its duration and aspect ratio from that video. Set task_type on this "
"node to the job you actually want, then run again. Use 'extend' to continue "
"the reference video, 'reference' to generate a new one from it, or 'edit' to "
"change it in place."
) from exc
raise ValueError(
"Seedance read this prompt as editing the reference video, and an edit always "
"takes its duration and aspect ratio from that video. Enable video_editing on "
"this node and run again, or reword the prompt so it describes a new video "
"rather than a change to the reference one."
f"The request does not satisfy the constraints for a '{task_type}' task. "
"An edit takes its duration and aspect ratio from the reference video, and an "
"extension takes its aspect ratio from it. Check that a reference video is "
"connected and that its length is within the model's limits."
) from exc
if _SEEDANCE_TASK_TYPE_MISMATCH_CODE in str(exc):
raise ValueError(
f"You asked for a '{task_type}' task, but Seedance read the prompt as a different "
"job. Reword the prompt so it clearly describes that job, or set task_type to "
"'auto' to let the model decide."
) from exc
raise


def _seedance2_price_badge(with_reference_videos: bool) -> IO.PriceBadge:
widgets = ["model", "model.resolution", "model.ratio", "model.duration"]
if with_reference_videos:
widgets.append("model.video_editing")
widgets.append("model.task_type")
has_video = (
'$exists(inputGroups) and $lookup(inputGroups, "model.reference_videos") > 0'
if with_reference_videos
Expand Down Expand Up @@ -2747,6 +2782,16 @@ async def execute(
if model_id != "dreamina-seedance-2-5-260628" or not (reference_audios or reference_audio_assets):
raise ValueError("At least one reference image or video or asset is required.")

# Checked before the reference assets are uploaded, so an unsatisfiable request fails
# immediately rather than after a full upload and a round trip to the provider.
task_type = model.get("task_type", "auto")
if task_type in ("edit", "extend") and not reference_videos and not reference_video_assets:
raise ValueError(
f"A '{task_type}' task needs at least one reference video. Connect the video you "
f"want to {'change' if task_type == 'edit' else 'continue'}, or set task_type to "
"'reference' to generate a new video from the references you have."
)

total_images = len(reference_images) + len(reference_image_assets)
if total_images > limits["max_images"]:
raise ValueError(
Expand Down Expand Up @@ -2893,7 +2938,12 @@ async def execute(
response_model=TaskCreationResponse,
)
response = await _seedance2_poll_video_task(
cls, initial_response.id, model_id, model["resolution"], has_video_input=has_video_input
cls,
initial_response.id,
model_id,
model["resolution"],
has_video_input=has_video_input,
task_type=task_type,
)
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))

Expand Down
69 changes: 69 additions & 0 deletions tests-unit/comfy_api_nodes_test/bytedance_seedance2_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pytest
import torch

from comfy.cli_args import args

if not torch.cuda.is_available():
args.cpu = True

from comfy_api_nodes.apis.bytedance import TaskTextContent # noqa: E402
from comfy_api_nodes.nodes_bytedance import ( # noqa: E402
_seedance2_build_request,
_seedance2_price_badge,
)

SEEDANCE_25 = "dreamina-seedance-2-5-260628"


def build(**overrides):
"""Build a Seedance 2.x request from the widget values a node would collect."""
model = {
"prompt": "continue the shot",
"resolution": "720p",
"ratio": "16:9",
"duration": 8,
"generate_audio": True,
"output_format": "mp4",
}
model.update(overrides)
return _seedance2_build_request(
model, SEEDANCE_25, [TaskTextContent(text="continue the shot")], 0, False, ratio=model["ratio"]
)


@pytest.mark.parametrize(
"task_type,expected_ratio,expected_duration,expected_field",
[
# An absent key is the text-to-video and first/last-frame path: they share this
# builder but have no task_type widget, so nothing may be coerced or sent.
(None, "16:9", 8, None),
("auto", "16:9", 8, None),
("reference", "16:9", 8, "reference"),
# An edit inherits both the aspect ratio and the length of the source clip.
("edit", "adaptive", -1, "edit"),
# An extension inherits only the aspect ratio; the requested length survives.
("extend", "adaptive", 8, "extend"),
],
)
def test_task_type_coercion(task_type, expected_ratio, expected_duration, expected_field):
request = build() if task_type is None else build(task_type=task_type)
assert request.ratio == expected_ratio
assert request.duration == expected_duration
assert request.omni_reference_task_type == expected_field


def test_extend_preserves_requested_duration():
"""Duration is the only constraint separating extend from edit, so it must survive."""
assert build(task_type="extend", duration=27).duration == 27


def test_seedance_20_omits_the_field():
"""omni_reference_task_type is a 2.5 field; the 2.0 inputs carry no task_type widget."""
assert build().omni_reference_task_type is None


def test_price_badge_tracks_task_type():
"""Both the dependency list and the JSONata must move together or the badge goes stale."""
badge = _seedance2_price_badge(with_reference_videos=True)
assert "model.task_type" in badge.depends_on.widgets
assert 'model.task_type") = "edit"' in badge.expr
Loading