| | """Runway API Nodes
|
| |
|
| | API Docs:
|
| | - https://docs.dev.runwayml.com/api/#tag/Task-management/paths/~1v1~1tasks~1%7Bid%7D/delete
|
| |
|
| | User Guides:
|
| | - https://help.runwayml.com/hc/en-us/sections/30265301423635-Gen-3-Alpha
|
| | - https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video
|
| | - https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo
|
| | - https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3
|
| |
|
| | """
|
| |
|
| | from typing import Union, Optional, Any
|
| | from typing_extensions import override
|
| | from enum import Enum
|
| |
|
| | import torch
|
| |
|
| | from comfy_api_nodes.apis import (
|
| | RunwayImageToVideoRequest,
|
| | RunwayImageToVideoResponse,
|
| | RunwayTaskStatusResponse as TaskStatusResponse,
|
| | RunwayTaskStatusEnum as TaskStatus,
|
| | RunwayModelEnum as Model,
|
| | RunwayDurationEnum as Duration,
|
| | RunwayAspectRatioEnum as AspectRatio,
|
| | RunwayPromptImageObject,
|
| | RunwayPromptImageDetailedObject,
|
| | RunwayTextToImageRequest,
|
| | RunwayTextToImageResponse,
|
| | Model4,
|
| | ReferenceImage,
|
| | RunwayTextToImageAspectRatioEnum,
|
| | )
|
| | from comfy_api_nodes.apis.client import (
|
| | ApiEndpoint,
|
| | HttpMethod,
|
| | SynchronousOperation,
|
| | PollingOperation,
|
| | EmptyRequest,
|
| | )
|
| | from comfy_api_nodes.apinode_utils import (
|
| | upload_images_to_comfyapi,
|
| | download_url_to_video_output,
|
| | image_tensor_pair_to_batch,
|
| | validate_string,
|
| | download_url_to_image_tensor,
|
| | )
|
| | from comfy_api.input_impl import VideoFromFile
|
| | from comfy_api.latest import ComfyExtension, io as comfy_io
|
| | from comfy_api_nodes.util.validation_utils import validate_image_dimensions, validate_image_aspect_ratio
|
| |
|
| | PATH_IMAGE_TO_VIDEO = "/proxy/runway/image_to_video"
|
| | PATH_TEXT_TO_IMAGE = "/proxy/runway/text_to_image"
|
| | PATH_GET_TASK_STATUS = "/proxy/runway/tasks"
|
| |
|
| | AVERAGE_DURATION_I2V_SECONDS = 64
|
| | AVERAGE_DURATION_FLF_SECONDS = 256
|
| | AVERAGE_DURATION_T2I_SECONDS = 41
|
| |
|
| |
|
| | class RunwayApiError(Exception):
|
| | """Base exception for Runway API errors."""
|
| |
|
| | pass
|
| |
|
| |
|
| | class RunwayGen4TurboAspectRatio(str, Enum):
|
| | """Aspect ratios supported for Image to Video API when using gen4_turbo model."""
|
| |
|
| | field_1280_720 = "1280:720"
|
| | field_720_1280 = "720:1280"
|
| | field_1104_832 = "1104:832"
|
| | field_832_1104 = "832:1104"
|
| | field_960_960 = "960:960"
|
| | field_1584_672 = "1584:672"
|
| |
|
| |
|
| | class RunwayGen3aAspectRatio(str, Enum):
|
| | """Aspect ratios supported for Image to Video API when using gen3a_turbo model."""
|
| |
|
| | field_768_1280 = "768:1280"
|
| | field_1280_768 = "1280:768"
|
| |
|
| |
|
| | def get_video_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
|
| | """Returns the video URL from the task status response if it exists."""
|
| | if hasattr(response, "output") and len(response.output) > 0:
|
| | return response.output[0]
|
| | return None
|
| |
|
| |
|
| | async def poll_until_finished(
|
| | auth_kwargs: dict[str, str],
|
| | api_endpoint: ApiEndpoint[Any, TaskStatusResponse],
|
| | estimated_duration: Optional[int] = None,
|
| | node_id: Optional[str] = None,
|
| | ) -> TaskStatusResponse:
|
| | """Polls the Runway API endpoint until the task reaches a terminal state, then returns the response."""
|
| | return await PollingOperation(
|
| | poll_endpoint=api_endpoint,
|
| | completed_statuses=[
|
| | TaskStatus.SUCCEEDED.value,
|
| | ],
|
| | failed_statuses=[
|
| | TaskStatus.FAILED.value,
|
| | TaskStatus.CANCELLED.value,
|
| | ],
|
| | status_extractor=lambda response: response.status.value,
|
| | auth_kwargs=auth_kwargs,
|
| | result_url_extractor=get_video_url_from_task_status,
|
| | estimated_duration=estimated_duration,
|
| | node_id=node_id,
|
| | progress_extractor=extract_progress_from_task_status,
|
| | ).execute()
|
| |
|
| |
|
| | def extract_progress_from_task_status(
|
| | response: TaskStatusResponse,
|
| | ) -> Union[float, None]:
|
| | if hasattr(response, "progress") and response.progress is not None:
|
| | return response.progress * 100
|
| | return None
|
| |
|
| |
|
| | def get_image_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
|
| | """Returns the image URL from the task status response if it exists."""
|
| | if hasattr(response, "output") and len(response.output) > 0:
|
| | return response.output[0]
|
| | return None
|
| |
|
| |
|
| | async def get_response(
|
| | task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None, estimated_duration: Optional[int] = None
|
| | ) -> TaskStatusResponse:
|
| | """Poll the task status until it is finished then get the response."""
|
| | return await poll_until_finished(
|
| | auth_kwargs,
|
| | ApiEndpoint(
|
| | path=f"{PATH_GET_TASK_STATUS}/{task_id}",
|
| | method=HttpMethod.GET,
|
| | request_model=EmptyRequest,
|
| | response_model=TaskStatusResponse,
|
| | ),
|
| | estimated_duration=estimated_duration,
|
| | node_id=node_id,
|
| | )
|
| |
|
| |
|
| | async def generate_video(
|
| | request: RunwayImageToVideoRequest,
|
| | auth_kwargs: dict[str, str],
|
| | node_id: Optional[str] = None,
|
| | estimated_duration: Optional[int] = None,
|
| | ) -> VideoFromFile:
|
| | initial_operation = SynchronousOperation(
|
| | endpoint=ApiEndpoint(
|
| | path=PATH_IMAGE_TO_VIDEO,
|
| | method=HttpMethod.POST,
|
| | request_model=RunwayImageToVideoRequest,
|
| | response_model=RunwayImageToVideoResponse,
|
| | ),
|
| | request=request,
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| |
|
| | initial_response = await initial_operation.execute()
|
| |
|
| | final_response = await get_response(initial_response.id, auth_kwargs, node_id, estimated_duration)
|
| | if not final_response.output:
|
| | raise RunwayApiError("Runway task succeeded but no video data found in response.")
|
| |
|
| | video_url = get_video_url_from_task_status(final_response)
|
| | return await download_url_to_video_output(video_url)
|
| |
|
| |
|
| | class RunwayImageToVideoNodeGen3a(comfy_io.ComfyNode):
|
| |
|
| | @classmethod
|
| | def define_schema(cls):
|
| | return comfy_io.Schema(
|
| | node_id="RunwayImageToVideoNodeGen3a",
|
| | display_name="Runway Image to Video (Gen3a Turbo)",
|
| | category="api node/video/Runway",
|
| | description="Generate a video from a single starting frame using Gen3a Turbo model. "
|
| | "Before diving in, review these best practices to ensure that "
|
| | "your input selections will set your generation up for success: "
|
| | "https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo.",
|
| | inputs=[
|
| | comfy_io.String.Input(
|
| | "prompt",
|
| | multiline=True,
|
| | default="",
|
| | tooltip="Text prompt for the generation",
|
| | ),
|
| | comfy_io.Image.Input(
|
| | "start_frame",
|
| | tooltip="Start frame to be used for the video",
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "duration",
|
| | options=[model.value for model in Duration],
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "ratio",
|
| | options=[model.value for model in RunwayGen3aAspectRatio],
|
| | ),
|
| | comfy_io.Int.Input(
|
| | "seed",
|
| | default=0,
|
| | min=0,
|
| | max=4294967295,
|
| | step=1,
|
| | control_after_generate=True,
|
| | display_mode=comfy_io.NumberDisplay.number,
|
| | tooltip="Random seed for generation",
|
| | ),
|
| | ],
|
| | outputs=[
|
| | comfy_io.Video.Output(),
|
| | ],
|
| | hidden=[
|
| | comfy_io.Hidden.auth_token_comfy_org,
|
| | comfy_io.Hidden.api_key_comfy_org,
|
| | comfy_io.Hidden.unique_id,
|
| | ],
|
| | is_api_node=True,
|
| | )
|
| |
|
| | @classmethod
|
| | async def execute(
|
| | cls,
|
| | prompt: str,
|
| | start_frame: torch.Tensor,
|
| | duration: str,
|
| | ratio: str,
|
| | seed: int,
|
| | ) -> comfy_io.NodeOutput:
|
| | validate_string(prompt, min_length=1)
|
| | validate_image_dimensions(start_frame, max_width=7999, max_height=7999)
|
| | validate_image_aspect_ratio(start_frame, min_aspect_ratio=0.5, max_aspect_ratio=2.0)
|
| |
|
| | auth_kwargs = {
|
| | "auth_token": cls.hidden.auth_token_comfy_org,
|
| | "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| | }
|
| |
|
| | download_urls = await upload_images_to_comfyapi(
|
| | start_frame,
|
| | max_images=1,
|
| | mime_type="image/png",
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| |
|
| | return comfy_io.NodeOutput(
|
| | await generate_video(
|
| | RunwayImageToVideoRequest(
|
| | promptText=prompt,
|
| | seed=seed,
|
| | model=Model("gen3a_turbo"),
|
| | duration=Duration(duration),
|
| | ratio=AspectRatio(ratio),
|
| | promptImage=RunwayPromptImageObject(
|
| | root=[
|
| | RunwayPromptImageDetailedObject(
|
| | uri=str(download_urls[0]), position="first"
|
| | )
|
| | ]
|
| | ),
|
| | ),
|
| | auth_kwargs=auth_kwargs,
|
| | node_id=cls.hidden.unique_id,
|
| | )
|
| | )
|
| |
|
| |
|
| | class RunwayImageToVideoNodeGen4(comfy_io.ComfyNode):
|
| |
|
| | @classmethod
|
| | def define_schema(cls):
|
| | return comfy_io.Schema(
|
| | node_id="RunwayImageToVideoNodeGen4",
|
| | display_name="Runway Image to Video (Gen4 Turbo)",
|
| | category="api node/video/Runway",
|
| | description="Generate a video from a single starting frame using Gen4 Turbo model. "
|
| | "Before diving in, review these best practices to ensure that "
|
| | "your input selections will set your generation up for success: "
|
| | "https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video.",
|
| | inputs=[
|
| | comfy_io.String.Input(
|
| | "prompt",
|
| | multiline=True,
|
| | default="",
|
| | tooltip="Text prompt for the generation",
|
| | ),
|
| | comfy_io.Image.Input(
|
| | "start_frame",
|
| | tooltip="Start frame to be used for the video",
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "duration",
|
| | options=[model.value for model in Duration],
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "ratio",
|
| | options=[model.value for model in RunwayGen4TurboAspectRatio],
|
| | ),
|
| | comfy_io.Int.Input(
|
| | "seed",
|
| | default=0,
|
| | min=0,
|
| | max=4294967295,
|
| | step=1,
|
| | control_after_generate=True,
|
| | display_mode=comfy_io.NumberDisplay.number,
|
| | tooltip="Random seed for generation",
|
| | ),
|
| | ],
|
| | outputs=[
|
| | comfy_io.Video.Output(),
|
| | ],
|
| | hidden=[
|
| | comfy_io.Hidden.auth_token_comfy_org,
|
| | comfy_io.Hidden.api_key_comfy_org,
|
| | comfy_io.Hidden.unique_id,
|
| | ],
|
| | is_api_node=True,
|
| | )
|
| |
|
| | @classmethod
|
| | async def execute(
|
| | cls,
|
| | prompt: str,
|
| | start_frame: torch.Tensor,
|
| | duration: str,
|
| | ratio: str,
|
| | seed: int,
|
| | ) -> comfy_io.NodeOutput:
|
| | validate_string(prompt, min_length=1)
|
| | validate_image_dimensions(start_frame, max_width=7999, max_height=7999)
|
| | validate_image_aspect_ratio(start_frame, min_aspect_ratio=0.5, max_aspect_ratio=2.0)
|
| |
|
| | auth_kwargs = {
|
| | "auth_token": cls.hidden.auth_token_comfy_org,
|
| | "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| | }
|
| |
|
| | download_urls = await upload_images_to_comfyapi(
|
| | start_frame,
|
| | max_images=1,
|
| | mime_type="image/png",
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| |
|
| | return comfy_io.NodeOutput(
|
| | await generate_video(
|
| | RunwayImageToVideoRequest(
|
| | promptText=prompt,
|
| | seed=seed,
|
| | model=Model("gen4_turbo"),
|
| | duration=Duration(duration),
|
| | ratio=AspectRatio(ratio),
|
| | promptImage=RunwayPromptImageObject(
|
| | root=[
|
| | RunwayPromptImageDetailedObject(
|
| | uri=str(download_urls[0]), position="first"
|
| | )
|
| | ]
|
| | ),
|
| | ),
|
| | auth_kwargs=auth_kwargs,
|
| | node_id=cls.hidden.unique_id,
|
| | estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
|
| | )
|
| | )
|
| |
|
| |
|
| | class RunwayFirstLastFrameNode(comfy_io.ComfyNode):
|
| |
|
| | @classmethod
|
| | def define_schema(cls):
|
| | return comfy_io.Schema(
|
| | node_id="RunwayFirstLastFrameNode",
|
| | display_name="Runway First-Last-Frame to Video",
|
| | category="api node/video/Runway",
|
| | description="Upload first and last keyframes, draft a prompt, and generate a video. "
|
| | "More complex transitions, such as cases where the Last frame is completely different "
|
| | "from the First frame, may benefit from the longer 10s duration. "
|
| | "This would give the generation more time to smoothly transition between the two inputs. "
|
| | "Before diving in, review these best practices to ensure that your input selections "
|
| | "will set your generation up for success: "
|
| | "https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3.",
|
| | inputs=[
|
| | comfy_io.String.Input(
|
| | "prompt",
|
| | multiline=True,
|
| | default="",
|
| | tooltip="Text prompt for the generation",
|
| | ),
|
| | comfy_io.Image.Input(
|
| | "start_frame",
|
| | tooltip="Start frame to be used for the video",
|
| | ),
|
| | comfy_io.Image.Input(
|
| | "end_frame",
|
| | tooltip="End frame to be used for the video. Supported for gen3a_turbo only.",
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "duration",
|
| | options=[model.value for model in Duration],
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "ratio",
|
| | options=[model.value for model in RunwayGen3aAspectRatio],
|
| | ),
|
| | comfy_io.Int.Input(
|
| | "seed",
|
| | default=0,
|
| | min=0,
|
| | max=4294967295,
|
| | step=1,
|
| | control_after_generate=True,
|
| | display_mode=comfy_io.NumberDisplay.number,
|
| | tooltip="Random seed for generation",
|
| | ),
|
| | ],
|
| | outputs=[
|
| | comfy_io.Video.Output(),
|
| | ],
|
| | hidden=[
|
| | comfy_io.Hidden.auth_token_comfy_org,
|
| | comfy_io.Hidden.api_key_comfy_org,
|
| | comfy_io.Hidden.unique_id,
|
| | ],
|
| | is_api_node=True,
|
| | )
|
| |
|
| | @classmethod
|
| | async def execute(
|
| | cls,
|
| | prompt: str,
|
| | start_frame: torch.Tensor,
|
| | end_frame: torch.Tensor,
|
| | duration: str,
|
| | ratio: str,
|
| | seed: int,
|
| | ) -> comfy_io.NodeOutput:
|
| | validate_string(prompt, min_length=1)
|
| | validate_image_dimensions(start_frame, max_width=7999, max_height=7999)
|
| | validate_image_dimensions(end_frame, max_width=7999, max_height=7999)
|
| | validate_image_aspect_ratio(start_frame, min_aspect_ratio=0.5, max_aspect_ratio=2.0)
|
| | validate_image_aspect_ratio(end_frame, min_aspect_ratio=0.5, max_aspect_ratio=2.0)
|
| |
|
| | auth_kwargs = {
|
| | "auth_token": cls.hidden.auth_token_comfy_org,
|
| | "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| | }
|
| |
|
| | stacked_input_images = image_tensor_pair_to_batch(start_frame, end_frame)
|
| | download_urls = await upload_images_to_comfyapi(
|
| | stacked_input_images,
|
| | max_images=2,
|
| | mime_type="image/png",
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| | if len(download_urls) != 2:
|
| | raise RunwayApiError("Failed to upload one or more images to comfy api.")
|
| |
|
| | return comfy_io.NodeOutput(
|
| | await generate_video(
|
| | RunwayImageToVideoRequest(
|
| | promptText=prompt,
|
| | seed=seed,
|
| | model=Model("gen3a_turbo"),
|
| | duration=Duration(duration),
|
| | ratio=AspectRatio(ratio),
|
| | promptImage=RunwayPromptImageObject(
|
| | root=[
|
| | RunwayPromptImageDetailedObject(
|
| | uri=str(download_urls[0]), position="first"
|
| | ),
|
| | RunwayPromptImageDetailedObject(
|
| | uri=str(download_urls[1]), position="last"
|
| | ),
|
| | ]
|
| | ),
|
| | ),
|
| | auth_kwargs=auth_kwargs,
|
| | node_id=cls.hidden.unique_id,
|
| | estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
|
| | )
|
| | )
|
| |
|
| |
|
| | class RunwayTextToImageNode(comfy_io.ComfyNode):
|
| |
|
| | @classmethod
|
| | def define_schema(cls):
|
| | return comfy_io.Schema(
|
| | node_id="RunwayTextToImageNode",
|
| | display_name="Runway Text to Image",
|
| | category="api node/image/Runway",
|
| | description="Generate an image from a text prompt using Runway's Gen 4 model. "
|
| | "You can also include reference image to guide the generation.",
|
| | inputs=[
|
| | comfy_io.String.Input(
|
| | "prompt",
|
| | multiline=True,
|
| | default="",
|
| | tooltip="Text prompt for the generation",
|
| | ),
|
| | comfy_io.Combo.Input(
|
| | "ratio",
|
| | options=[model.value for model in RunwayTextToImageAspectRatioEnum],
|
| | ),
|
| | comfy_io.Image.Input(
|
| | "reference_image",
|
| | tooltip="Optional reference image to guide the generation",
|
| | optional=True,
|
| | ),
|
| | ],
|
| | outputs=[
|
| | comfy_io.Image.Output(),
|
| | ],
|
| | hidden=[
|
| | comfy_io.Hidden.auth_token_comfy_org,
|
| | comfy_io.Hidden.api_key_comfy_org,
|
| | comfy_io.Hidden.unique_id,
|
| | ],
|
| | is_api_node=True,
|
| | )
|
| |
|
| | @classmethod
|
| | async def execute(
|
| | cls,
|
| | prompt: str,
|
| | ratio: str,
|
| | reference_image: Optional[torch.Tensor] = None,
|
| | ) -> comfy_io.NodeOutput:
|
| | validate_string(prompt, min_length=1)
|
| |
|
| | auth_kwargs = {
|
| | "auth_token": cls.hidden.auth_token_comfy_org,
|
| | "comfy_api_key": cls.hidden.api_key_comfy_org,
|
| | }
|
| |
|
| |
|
| | reference_images = None
|
| | if reference_image is not None:
|
| | validate_image_dimensions(reference_image, max_width=7999, max_height=7999)
|
| | validate_image_aspect_ratio(reference_image, min_aspect_ratio=0.5, max_aspect_ratio=2.0)
|
| | download_urls = await upload_images_to_comfyapi(
|
| | reference_image,
|
| | max_images=1,
|
| | mime_type="image/png",
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| | reference_images = [ReferenceImage(uri=str(download_urls[0]))]
|
| |
|
| | request = RunwayTextToImageRequest(
|
| | promptText=prompt,
|
| | model=Model4.gen4_image,
|
| | ratio=ratio,
|
| | referenceImages=reference_images,
|
| | )
|
| |
|
| | initial_operation = SynchronousOperation(
|
| | endpoint=ApiEndpoint(
|
| | path=PATH_TEXT_TO_IMAGE,
|
| | method=HttpMethod.POST,
|
| | request_model=RunwayTextToImageRequest,
|
| | response_model=RunwayTextToImageResponse,
|
| | ),
|
| | request=request,
|
| | auth_kwargs=auth_kwargs,
|
| | )
|
| |
|
| | initial_response = await initial_operation.execute()
|
| |
|
| |
|
| | final_response = await get_response(
|
| | initial_response.id,
|
| | auth_kwargs=auth_kwargs,
|
| | node_id=cls.hidden.unique_id,
|
| | estimated_duration=AVERAGE_DURATION_T2I_SECONDS,
|
| | )
|
| | if not final_response.output:
|
| | raise RunwayApiError("Runway task succeeded but no image data found in response.")
|
| |
|
| | return comfy_io.NodeOutput(await download_url_to_image_tensor(get_image_url_from_task_status(final_response)))
|
| |
|
| |
|
| | class RunwayExtension(ComfyExtension):
|
| | @override
|
| | async def get_node_list(self) -> list[type[comfy_io.ComfyNode]]:
|
| | return [
|
| | RunwayFirstLastFrameNode,
|
| | RunwayImageToVideoNodeGen3a,
|
| | RunwayImageToVideoNodeGen4,
|
| | RunwayTextToImageNode,
|
| | ]
|
| |
|
| | async def comfy_entrypoint() -> RunwayExtension:
|
| | return RunwayExtension()
|
| |
|