mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2026-09-04 11:17:14 +08:00
276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
from inspect import cleandoc
|
|
from typing import Union, Optional
|
|
import logging
|
|
|
|
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,
|
|
)
|
|
from comfy_api_nodes.apis.client import (
|
|
ApiEndpoint,
|
|
HttpMethod,
|
|
SynchronousOperation,
|
|
PollingOperation,
|
|
EmptyRequest,
|
|
)
|
|
from comfy_api_nodes.apinode_utils import (
|
|
download_url_to_bytesio,
|
|
upload_images_to_comfyapi,
|
|
)
|
|
from comfy.comfy_types.node_typing import IO, ComfyNodeABC
|
|
from comfy_api.input_impl import VideoFromFile
|
|
from comfy_api_nodes.mapper_utils import model_field_to_node_input
|
|
|
|
PATH_IMAGE_TO_VIDEO = "/proxy/runway/image-to-video"
|
|
PATH_GET_TASK_STATUS = "/proxy/runway/tasks"
|
|
|
|
|
|
class RunwayApiError(Exception):
|
|
"""Base exception for Runway API errors."""
|
|
|
|
pass
|
|
|
|
|
|
def extract_progress_from_task_status(response: TaskStatusResponse) -> float:
|
|
if hasattr(response, "progress") and response.progress is not None:
|
|
return response.progress * 100
|
|
return None
|
|
|
|
|
|
class RunwayImageToVideoNode(ComfyNodeABC):
|
|
"""
|
|
Runway Image to Video Node.
|
|
"""
|
|
|
|
@staticmethod
|
|
def is_ratio_supported(model: str, ratio: str) -> bool:
|
|
"""
|
|
Checks if the chosen aspect ratio is supported by the chosen model.
|
|
"""
|
|
if model != "gen3a_turbo" and ratio in [
|
|
"1280:768",
|
|
"768:1280",
|
|
]:
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def is_end_frame_supported(model: str) -> bool:
|
|
"""
|
|
Checks if the chosen model supports the end frame input.
|
|
"""
|
|
return model == "gen3a_turbo"
|
|
|
|
@staticmethod
|
|
def is_valid_prompt(prompt: str) -> bool:
|
|
return bool(prompt)
|
|
|
|
@staticmethod
|
|
def is_valid_initial_response(response: RunwayImageToVideoResponse) -> bool:
|
|
return bool(response.id)
|
|
|
|
@staticmethod
|
|
def is_valid_image(image: torch.Tensor) -> bool:
|
|
"""https://docs.dev.runwayml.com/assets/inputs/#common-error-reasons"""
|
|
return image.shape[2] < 8000 and image.shape[1] < 8000
|
|
|
|
@staticmethod
|
|
def is_valid_video_response(response: RunwayImageToVideoResponse) -> bool:
|
|
return response.output and len(response.output) > 0
|
|
|
|
@staticmethod
|
|
def poll_for_task_status(task_id: str, auth_token: str) -> TaskStatusResponse:
|
|
"""
|
|
Polls the Runway API endpoint until the task reaches a terminal state.
|
|
"""
|
|
polling_operation = PollingOperation(
|
|
poll_endpoint=ApiEndpoint(
|
|
path=f"{PATH_GET_TASK_STATUS}/{task_id}",
|
|
method=HttpMethod.GET,
|
|
request_model=EmptyRequest,
|
|
response_model=TaskStatusResponse,
|
|
),
|
|
completed_statuses=[
|
|
TaskStatus.SUCCEEDED.value,
|
|
],
|
|
failed_statuses=[
|
|
TaskStatus.FAILED.value,
|
|
TaskStatus.CANCELLED.value,
|
|
],
|
|
progress_extractor=extract_progress_from_task_status,
|
|
status_extractor=lambda response: (response.status.value),
|
|
auth_token=auth_token,
|
|
)
|
|
return polling_operation.execute()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"model": model_field_to_node_input(
|
|
IO.COMBO, RunwayImageToVideoRequest, "model", enum_type=Model
|
|
),
|
|
"prompt": model_field_to_node_input(
|
|
IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
|
|
),
|
|
"duration": model_field_to_node_input(
|
|
IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
|
|
),
|
|
"ratio": model_field_to_node_input(
|
|
IO.COMBO, RunwayImageToVideoRequest, "ratio", enum_type=AspectRatio
|
|
),
|
|
"seed": model_field_to_node_input(
|
|
IO.INT, RunwayImageToVideoRequest, "seed", control_after_generate=True
|
|
),
|
|
},
|
|
"optional": {
|
|
"start_frame": (
|
|
IO.IMAGE,
|
|
{"tooltip": "Start frame to be used for the video"},
|
|
),
|
|
"end_frame": (
|
|
IO.IMAGE,
|
|
{
|
|
"tooltip": "End frame to be used for the video. Supported for gen3a_turbo only."
|
|
},
|
|
),
|
|
},
|
|
"hidden": {"auth_token": "AUTH_TOKEN_COMFY_ORG"},
|
|
}
|
|
|
|
RETURN_TYPES = ("VIDEO",)
|
|
FUNCTION = "api_call"
|
|
CATEGORY = "api node/video/Runway"
|
|
DESCRIPTION = cleandoc(__doc__ or "")
|
|
API_NODE = True
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(
|
|
cls,
|
|
model: str,
|
|
ratio: str,
|
|
) -> Union[str, bool]:
|
|
if not RunwayImageToVideoNode.is_ratio_supported(model, ratio):
|
|
return "Invalid aspect ratio for the chosen model. 1280:768 and 768:1280 are only supported for gen3a_turbo."
|
|
return True
|
|
|
|
def api_call(
|
|
self,
|
|
model: str,
|
|
prompt: str,
|
|
duration: str,
|
|
ratio: str,
|
|
seed: int,
|
|
start_frame: Optional[torch.Tensor] = None,
|
|
end_frame: Optional[torch.Tensor] = None,
|
|
auth_token: Optional[str] = None,
|
|
) -> tuple[VideoFromFile]:
|
|
# Validate manually because optional inputs are not passed to VALIDATE_INPUTS.
|
|
if start_frame is None and end_frame is None:
|
|
message = "Start frame and end frame cannot both be empty."
|
|
raise RunwayApiError(message)
|
|
if end_frame is not None and not RunwayImageToVideoNode.is_end_frame_supported(
|
|
model
|
|
):
|
|
message = "End frame is only supported for gen3a_turbo model."
|
|
raise RunwayApiError(message)
|
|
|
|
prompt_images_tensors: list[torch.Tensor] = []
|
|
if start_frame is not None:
|
|
if not RunwayImageToVideoNode.is_valid_image(start_frame):
|
|
message = "Start frame is not a valid image."
|
|
raise RunwayApiError(message)
|
|
prompt_images_tensors.append(start_frame)
|
|
|
|
if end_frame != None:
|
|
if not RunwayImageToVideoNode.is_valid_image(end_frame):
|
|
message = "End frame is not a valid image."
|
|
raise RunwayApiError(message)
|
|
prompt_images_tensors.append(end_frame)
|
|
|
|
# stack tensors
|
|
prompt_images_tensor = torch.cat(prompt_images_tensors, dim=0)
|
|
|
|
download_urls = upload_images_to_comfyapi(
|
|
prompt_images_tensor,
|
|
max_images=2,
|
|
auth_token=auth_token,
|
|
mime_type="image/png",
|
|
)
|
|
|
|
# Create a list of detailed image objects
|
|
prompt_image_details: list[RunwayPromptImageDetailedObject] = [
|
|
RunwayPromptImageDetailedObject(uri=str(download_urls[0]), position="first")
|
|
]
|
|
if len(download_urls) > 1:
|
|
prompt_image_details.append(
|
|
RunwayPromptImageDetailedObject(
|
|
uri=str(download_urls[1]), position="last"
|
|
)
|
|
)
|
|
|
|
# Wrap the list in the main object if details exist
|
|
prompt_image_object: Optional[RunwayPromptImageObject] = None
|
|
if prompt_image_details:
|
|
prompt_image_object = RunwayPromptImageObject(root=prompt_image_details)
|
|
|
|
initial_operation = SynchronousOperation(
|
|
endpoint=ApiEndpoint(
|
|
path=PATH_IMAGE_TO_VIDEO,
|
|
method=HttpMethod.POST,
|
|
request_model=RunwayImageToVideoRequest,
|
|
response_model=RunwayImageToVideoResponse,
|
|
),
|
|
request=RunwayImageToVideoRequest(
|
|
promptText=prompt,
|
|
seed=seed,
|
|
model=Model(model),
|
|
duration=Duration(duration),
|
|
ratio=AspectRatio(ratio),
|
|
promptImage=prompt_image_object,
|
|
),
|
|
auth_token=auth_token,
|
|
)
|
|
|
|
initial_response = initial_operation.execute()
|
|
if not RunwayImageToVideoNode.is_valid_initial_response(initial_response):
|
|
error_message = "Invalid initial response from Runway API."
|
|
logging.error(error_message)
|
|
raise RunwayApiError(error_message)
|
|
|
|
task_id = initial_response.id
|
|
logging.debug("Runway task submitted. Task ID: %s", task_id)
|
|
|
|
final_response = self.poll_for_task_status(task_id, auth_token)
|
|
if not RunwayImageToVideoNode.is_valid_video_response(final_response):
|
|
error_message = "Runway task succeeded but no video data found in response."
|
|
logging.error(error_message)
|
|
raise RunwayApiError(error_message)
|
|
|
|
video_url = final_response.output[0]
|
|
logging.debug("Attempting to download video from URL: %s", video_url)
|
|
|
|
video_io = download_url_to_bytesio(video_url)
|
|
if video_io is None:
|
|
error_msg = f"Failed to download video from {video_url}"
|
|
logging.error(error_msg)
|
|
raise RunwayApiError(error_msg)
|
|
return (VideoFromFile(video_io),)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"RunwayImageToVideoNode": RunwayImageToVideoNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"RunwayImageToVideoNode": "Runway Image to Video",
|
|
}
|