diff --git a/nodes/video_node.py b/nodes/video_node.py index b95a83c..eb84cc8 100644 --- a/nodes/video_node.py +++ b/nodes/video_node.py @@ -2787,6 +2787,119 @@ class SeedanceProImageToVideoNode: ) +class Seedance25VideoToVideoNode: + """Curated Seedance 2.5 video editing node.""" + + ENDPOINT = "bytedance/seedance-2.5/reference-to-video" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video": ( + "VIDEO", + { + "tooltip": "Source video to edit. URL-backed VIDEO inputs are passed through without re-uploading.", + }, + ), + "prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": "Describe the edits to apply to the source video.", + }, + ), + }, + "optional": { + "resolution": ( + ["480p", "720p", "1080p"], + {"default": "720p", "tooltip": "Output video resolution."}, + ), + "generate_audio": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Generate synchronized audio for the edited video.", + }, + ), + "bitrate_mode": ( + ["standard", "high"], + { + "default": "standard", + "tooltip": "Use the standard bitrate or request a larger, higher-quality encode.", + }, + ), + "seed": ( + "INT", + { + "default": -1, + "min": -1, + "max": 2147483647, + "tooltip": "Random seed for reproducible results; -1 lets the API choose.", + }, + ), + "force_rerun": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Bypass the persistent result cache and submit a new fal request.", + }, + ), + }, + } + + RETURN_TYPES = ("VIDEO", "STRING") + RETURN_NAMES = ("video", "video_url") + FUNCTION = "edit_video" + CATEGORY = "FAL/VideoGeneration" + DESCRIPTION = ( + "Edit one video with Seedance 2.5. The endpoint is always called with " + "task='editing' and the source as a single video_urls entry." + ) + + @classmethod + def IS_CHANGED(cls, force_rerun=False, **_kwargs): + if force_rerun: + return float("nan") + return False + + def edit_video( + self, + video, + prompt, + resolution="720p", + generate_audio=True, + bitrate_mode="standard", + seed=-1, + force_rerun=False, + ): + try: + uploaded_url = MediaUtils.upload_video(video) + arguments = { + "prompt": prompt, + "task": "editing", + "video_urls": [uploaded_url], + "resolution": resolution, + "generate_audio": generate_audio, + "bitrate_mode": bitrate_mode, + } + if seed != -1: + arguments["seed"] = seed + + result = ApiHandler.submit_and_get_result( + self.ENDPOINT, + arguments, + skip_cache=bool(force_rerun), + ) + video_url = MediaUtils.require_http_url( + result["video"]["url"], self.ENDPOINT + ) + return (MediaUtils.video_from_url(video_url), video_url) + except Exception as e: + return ApiHandler.handle_video_generation_error(self.ENDPOINT, e) + + class Veo3Node: @classmethod def INPUT_TYPES(cls): @@ -3733,6 +3846,7 @@ NODE_CLASS_MAPPINGS = { "DYWanFun22_fal": DYWanFun22Node, "DYWanUpscaler_fal": DYWanUpscalerNode, "SeedanceImageToVideo_fal": SeedanceImageToVideoNode, + "Seedance25VideoToVideo_fal": Seedance25VideoToVideoNode, "SeedanceProImageToVideo_fal": SeedanceProImageToVideoNode, "SeedanceTextToVideo_fal": SeedanceTextToVideoNode, "Veo3_fal": Veo3Node, @@ -3779,6 +3893,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Veo2ImageToVideo_fal": "Google Veo2 Image-to-Video (fal)", "WanPro_fal": "Wan Pro Image-to-Video (fal)", "SeedanceImageToVideo_fal": "Seedance Image-to-Video (fal)", + "Seedance25VideoToVideo_fal": "Seedance 2.5 Video-to-Video (fal)", "SeedanceProImageToVideo_fal": "Seedance Pro Image-to-Video (fal)", "SeedanceTextToVideo_fal": "Seedance Text-to-Video (fal)", "Veo3_fal": "Veo3 Video Generation (fal)", diff --git a/tests/test_seedance25_video_to_video.py b/tests/test_seedance25_video_to_video.py new file mode 100644 index 0000000..83d955a --- /dev/null +++ b/tests/test_seedance25_video_to_video.py @@ -0,0 +1,127 @@ +"""Focused tests for the curated Seedance 2.5 video editing node.""" + +from __future__ import annotations + +import math +import sys + + +class _URLVideo: + def __init__(self, url: str): + self.url = url + + def get_stream_source(self): + return self.url + + +def _node_and_module(pack): + node_cls = pack.NODE_CLASS_MAPPINGS["Seedance25VideoToVideo_fal"] + return node_cls, sys.modules[node_cls.__module__] + + +def test_seedance25_video_to_video_schema_and_registration(pack): + node_cls, _module = _node_and_module(pack) + inputs = node_cls.INPUT_TYPES() + + assert inputs["required"]["video"][0] == "VIDEO" + assert inputs["required"]["prompt"][0] == "STRING" + assert set(inputs["optional"]) == { + "resolution", + "generate_audio", + "bitrate_mode", + "seed", + "force_rerun", + } + assert node_cls.RETURN_TYPES == ("VIDEO", "STRING") + assert node_cls.RETURN_NAMES == ("video", "video_url") + assert pack.NODE_DISPLAY_NAME_MAPPINGS["Seedance25VideoToVideo_fal"] == ( + "Seedance 2.5 Video-to-Video (fal)" + ) + + +def test_seedance25_edit_payload_outputs_and_force_rerun(pack, monkeypatch): + node_cls, module = _node_and_module(pack) + submitted = {} + native_output = object() + + monkeypatch.setattr( + module.MediaUtils, + "upload_video", + staticmethod(lambda _video: "https://fal.media/input.mp4"), + ) + monkeypatch.setattr( + module.MediaUtils, + "video_from_url", + staticmethod(lambda url: native_output if url.endswith("output.mp4") else None), + ) + + def submit(endpoint, arguments, *, skip_cache=False): + submitted.update( + endpoint=endpoint, arguments=arguments, skip_cache=skip_cache + ) + return {"video": {"url": "https://fal.media/output.mp4"}} + + monkeypatch.setattr( + module.ApiHandler, "submit_and_get_result", staticmethod(submit) + ) + + result = node_cls().edit_video( + object(), + "Turn the daytime scene into night", + resolution="1080p", + generate_audio=False, + bitrate_mode="high", + seed=123, + force_rerun=True, + ) + + assert submitted == { + "endpoint": "bytedance/seedance-2.5/reference-to-video", + "arguments": { + "prompt": "Turn the daytime scene into night", + "task": "editing", + "video_urls": ["https://fal.media/input.mp4"], + "resolution": "1080p", + "generate_audio": False, + "bitrate_mode": "high", + "seed": 123, + }, + "skip_cache": True, + } + assert result == (native_output, "https://fal.media/output.mp4") + assert math.isnan(node_cls.IS_CHANGED(force_rerun=True)) + + +def test_seedance25_url_backed_video_passes_through_without_upload( + pack, monkeypatch +): + node_cls, module = _node_and_module(pack) + source_url = "https://fal.media/already-uploaded.mp4" + captured = {} + + def unexpected_upload(_value): + raise AssertionError("URL-backed VIDEO input must not be re-uploaded") + + monkeypatch.setattr( + module.ImageUtils, "upload_file", staticmethod(unexpected_upload) + ) + monkeypatch.setattr( + module.MediaUtils, + "video_from_url", + staticmethod(lambda _url: object()), + ) + + def submit(_endpoint, arguments, *, skip_cache=False): + captured.update(arguments) + assert skip_cache is False + return {"video": {"url": "https://fal.media/output.mp4"}} + + monkeypatch.setattr( + module.ApiHandler, "submit_and_get_result", staticmethod(submit) + ) + + node_cls().edit_video(_URLVideo(source_url), "Restyle as watercolor") + + assert captured["video_urls"] == [source_url] + assert captured["task"] == "editing" + assert "seed" not in captured