From 5207fa96c8a9fa6fb0185fd5a439e62b454b3ba1 Mon Sep 17 00:00:00 2001 From: Purz <97489706+purzbeats@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:11:15 -0400 Subject: [PATCH] [Partner Nodes] feat(ByteDance): declare Seedance 2.5 omni task type Seedance 2.5 folds three jobs into one endpoint: reference-to-video, video editing, and video extension. We never sent omni_reference_task_type, so every job ran as "auto" and the model inferred the job from the prompt. Extension was reachable that way but not usable: ratio defaults to 16:9 and an extend requires "adaptive", and we only coerced to adaptive for edits, so the default extend run violated the constraint and failed asynchronously after the reference upload and the queue wait. Replace the video_editing boolean with a task_type combo mapped onto omni_reference_task_type, and drive the coercion from it. An edit inherits the source clip's aspect ratio and length; an extension inherits the aspect ratio but keeps the requested duration, which is the only constraint separating the two. Also: - reject edit/extend with no reference video before the upload runs, rather than paying for the upload and a round trip first - tailor the TaskTypeConstraint error to the selected task type, and handle TaskTypeMismatch, which was previously unhandled - offer mov output, the provider's recommendation for edit and extend - rename the price badge's widget dependency and its JSONata together, since missing either leaves the badge silently stale The field is 2.5 only. Seedance 2.0 has no task_type widget, falls through to "auto", and the field is omitted from its requests. The text-to-video and first/last-frame nodes share the request builder and are unaffected. Cloud needs no change: the BytePlus proxy forwards the raw request body and has no OpenAPI validator on the route, so the new field passes through untouched. --- comfy_api_nodes/apis/bytedance.py | 1 + comfy_api_nodes/nodes_bytedance.py | 94 ++++++++++++++----- .../bytedance_seedance2_test.py | 69 ++++++++++++++ 3 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 tests-unit/comfy_api_nodes_test/bytedance_seedance2_test.py diff --git a/comfy_api_nodes/apis/bytedance.py b/comfy_api_nodes/apis/bytedance.py index 7ee83e5f3c4..96b72b008b9 100644 --- a/comfy_api_nodes/apis/bytedance.py +++ b/comfy_api_nodes/apis/bytedance.py @@ -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): diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index 09fc445d8d6..c44334b687d 100644 --- a/comfy_api_nodes/nodes_bytedance.py +++ b/comfy_api_nodes/nodes_bytedance.py @@ -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", @@ -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( @@ -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, ) @@ -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 ? ( @@ -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( @@ -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( @@ -2289,11 +2310,25 @@ 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 @@ -2301,7 +2336,7 @@ async def _seedance2_poll_video_task( 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 @@ -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( @@ -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)) diff --git a/tests-unit/comfy_api_nodes_test/bytedance_seedance2_test.py b/tests-unit/comfy_api_nodes_test/bytedance_seedance2_test.py new file mode 100644 index 00000000000..1d41d71b20b --- /dev/null +++ b/tests-unit/comfy_api_nodes_test/bytedance_seedance2_test.py @@ -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