mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-29 09:28:35 -05:00
310 lines
11 KiB
Python
310 lines
11 KiB
Python
from typing_extensions import override
|
|
|
|
from comfy_api.latest import IO, ComfyExtension, Input
|
|
from comfy_api_nodes.apis.pruna import (
|
|
PrunaPredictionRequest,
|
|
PrunaPredictionResponse,
|
|
PrunaPredictionStatusResponse,
|
|
PrunaVideoInput,
|
|
)
|
|
from comfy_api_nodes.util import (
|
|
ApiEndpoint,
|
|
download_url_to_video_output,
|
|
poll_op,
|
|
sync_op,
|
|
upload_audio_to_comfyapi,
|
|
upload_image_to_comfyapi,
|
|
validate_audio_duration,
|
|
validate_images_aspect_ratio_closeness,
|
|
validate_string,
|
|
)
|
|
|
|
PREDICTIONS_PATH = "/proxy/pruna/v1/predictions"
|
|
VIDEO_MODELS = ["p-video-2"]
|
|
ASPECT_RATIOS = ["16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1:1"]
|
|
RESOLUTIONS = ["720p", "1080p"]
|
|
FPS_OPTIONS = ["24", "48"]
|
|
DURATIONS = ["auto"] + [str(i) for i in range(1, 21)]
|
|
MAX_PROMPT_LENGTH = 5000
|
|
MIN_AUDIO_DURATION = 1.0
|
|
|
|
|
|
def _generation_inputs() -> list:
|
|
return [
|
|
IO.Combo.Input(
|
|
"duration",
|
|
options=DURATIONS,
|
|
default="5",
|
|
tooltip="Length of the video in seconds. 'auto' lets the model choose the length from the prompt. "
|
|
"Ignored when audio is connected: the video then follows the audio length, rounded up to a whole "
|
|
"second, up to 20 seconds.",
|
|
),
|
|
IO.Combo.Input(
|
|
"resolution",
|
|
options=RESOLUTIONS,
|
|
default="720p",
|
|
tooltip="Output resolution. 720p renders about 0.9 megapixels (1280x704 at 16:9), "
|
|
"1080p about 2 megapixels (1920x1088 at 16:9).",
|
|
),
|
|
IO.Combo.Input(
|
|
"fps",
|
|
options=FPS_OPTIONS,
|
|
default="24",
|
|
tooltip="Frames per second. 48 fps is not available with draft at 1080p.",
|
|
),
|
|
IO.Boolean.Input(
|
|
"draft",
|
|
default=False,
|
|
tooltip="Faster, less detailed render, billed at 60% of the standard rate.",
|
|
),
|
|
IO.Boolean.Input(
|
|
"generate_audio",
|
|
default=True,
|
|
tooltip="Generate a soundtrack for the video. Ignored when audio is connected, "
|
|
"which becomes the soundtrack instead.",
|
|
),
|
|
IO.Boolean.Input(
|
|
"enhance_prompt",
|
|
default=True,
|
|
advanced=True,
|
|
tooltip="Rewrite the prompt with more detail before generation; short prompts need it. "
|
|
"Turn it off to reproduce a result exactly with the same seed.",
|
|
),
|
|
IO.Audio.Input(
|
|
"audio",
|
|
optional=True,
|
|
tooltip="Audio that drives the motion and becomes the soundtrack. At least 1 second long; "
|
|
"audio longer than 20 seconds is truncated. Sets the video length instead of duration.",
|
|
),
|
|
IO.Int.Input(
|
|
"seed",
|
|
default=42,
|
|
min=0,
|
|
max=2147483647,
|
|
step=1,
|
|
display_mode=IO.NumberDisplay.number,
|
|
control_after_generate=True,
|
|
tooltip="Seed for the generation. The same seed reproduces a result exactly only when "
|
|
"enhance_prompt is off.",
|
|
),
|
|
]
|
|
|
|
|
|
def _text_to_video_option(model_id: str) -> IO.DynamicCombo.Option:
|
|
return IO.DynamicCombo.Option(
|
|
model_id,
|
|
[
|
|
IO.String.Input(
|
|
"prompt",
|
|
multiline=True,
|
|
default="",
|
|
tooltip=f"Describes the video, its motion and its sound. Up to {MAX_PROMPT_LENGTH} characters.",
|
|
),
|
|
IO.Combo.Input(
|
|
"aspect_ratio",
|
|
options=ASPECT_RATIOS,
|
|
default="16:9",
|
|
tooltip="Aspect ratio of the output video.",
|
|
),
|
|
*_generation_inputs(),
|
|
],
|
|
)
|
|
|
|
|
|
def _image_to_video_option(model_id: str) -> IO.DynamicCombo.Option:
|
|
return IO.DynamicCombo.Option(
|
|
model_id,
|
|
[
|
|
IO.Image.Input(
|
|
"first_frame",
|
|
tooltip="Image the video starts from. The output keeps the aspect ratio of this image.",
|
|
),
|
|
IO.Image.Input(
|
|
"last_frame",
|
|
optional=True,
|
|
tooltip="Image the video ends on. Its aspect ratio must be close to the first frame's.",
|
|
),
|
|
IO.String.Input(
|
|
"prompt",
|
|
multiline=True,
|
|
default="",
|
|
tooltip=f"Describes how the scene moves and sounds. Up to {MAX_PROMPT_LENGTH} characters.",
|
|
),
|
|
*_generation_inputs(),
|
|
],
|
|
)
|
|
|
|
|
|
def _price_badge() -> IO.PriceBadge:
|
|
return IO.PriceBadge(
|
|
depends_on=IO.PriceBadgeDepends(
|
|
widgets=["model", "model.duration", "model.resolution", "model.draft"],
|
|
inputs=["model.audio"],
|
|
),
|
|
expr="""
|
|
(
|
|
$draft := $lookup(widgets, "model.draft");
|
|
$rate := $lookup(widgets, "model.resolution") = "1080p"
|
|
? ($draft ? 0.0429 : 0.0715)
|
|
: ($draft ? 0.02145 : 0.03575);
|
|
$duration := $lookup(widgets, "model.duration");
|
|
$audio := $lookup(inputs, "model.audio");
|
|
($audio and $audio.connected) or $type($duration) != "string" or $duration = "auto"
|
|
? {"type":"usd","usd": $rate, "format": {"suffix": "/second"}}
|
|
: {"type":"usd","usd": $rate * $number($duration)}
|
|
)
|
|
""",
|
|
)
|
|
|
|
|
|
def _validate_generation_inputs(model: dict) -> None:
|
|
validate_string(model["prompt"], strip_whitespace=True, min_length=1, max_length=MAX_PROMPT_LENGTH)
|
|
if model["resolution"] == "1080p" and model["draft"] and model["fps"] == "48":
|
|
raise ValueError("48 fps is not available with draft at 1080p; turn off draft or use 24 fps.")
|
|
if model.get("audio") is not None:
|
|
validate_audio_duration(model["audio"], MIN_AUDIO_DURATION)
|
|
|
|
|
|
async def _upload_audio(cls: type[IO.ComfyNode], audio: Input.Audio | None) -> str | None:
|
|
if audio is None:
|
|
return None
|
|
return await upload_audio_to_comfyapi(
|
|
cls, audio, container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg"
|
|
)
|
|
|
|
|
|
def _video_input(model: dict, **media: str | None) -> PrunaVideoInput:
|
|
return PrunaVideoInput(
|
|
prompt=model["prompt"],
|
|
duration=None if model["duration"] == "auto" else int(model["duration"]),
|
|
resolution=model["resolution"],
|
|
fps=int(model["fps"]),
|
|
draft=model["draft"],
|
|
save_audio=model["generate_audio"],
|
|
prompt_upsampling=model["enhance_prompt"],
|
|
seed=model["seed"],
|
|
**media,
|
|
)
|
|
|
|
|
|
async def _generate_video(cls: type[IO.ComfyNode], model_id: str, video_input: PrunaVideoInput) -> IO.NodeOutput:
|
|
submitted = await sync_op(
|
|
cls,
|
|
ApiEndpoint(path=f"{PREDICTIONS_PATH}/{model_id}", method="POST"),
|
|
response_model=PrunaPredictionResponse,
|
|
data=PrunaPredictionRequest(input=video_input),
|
|
)
|
|
result = await poll_op(
|
|
cls,
|
|
ApiEndpoint(path=f"{PREDICTIONS_PATH}/status/{submitted.id}"),
|
|
response_model=PrunaPredictionStatusResponse,
|
|
status_extractor=lambda r: r.status,
|
|
completed_statuses=["succeeded"],
|
|
failed_statuses=["failed", "canceled"],
|
|
queued_statuses=["starting"],
|
|
)
|
|
if not result.generation_url:
|
|
raise Exception("The prediction succeeded but returned no video.")
|
|
return IO.NodeOutput(await download_url_to_video_output(result.generation_url))
|
|
|
|
|
|
class PrunaTextToVideoNode(IO.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return IO.Schema(
|
|
node_id="PrunaTextToVideoNode",
|
|
display_name="Pruna P-Video-2 Text to Video",
|
|
category="partner/video/Pruna",
|
|
description="Generates a video from a text prompt using Pruna video models, "
|
|
"with optional generated sound or an audio track to drive it.",
|
|
inputs=[
|
|
IO.DynamicCombo.Input(
|
|
"model",
|
|
options=[_text_to_video_option(model_id) for model_id in VIDEO_MODELS],
|
|
tooltip="Model to use.",
|
|
),
|
|
],
|
|
outputs=[
|
|
IO.Video.Output(),
|
|
],
|
|
hidden=[
|
|
IO.Hidden.auth_token_comfy_org,
|
|
IO.Hidden.api_key_comfy_org,
|
|
IO.Hidden.unique_id,
|
|
],
|
|
is_api_node=True,
|
|
price_badge=_price_badge(),
|
|
)
|
|
|
|
@classmethod
|
|
async def execute(cls, model: dict):
|
|
_validate_generation_inputs(model)
|
|
audio_url = await _upload_audio(cls, model.get("audio"))
|
|
return await _generate_video(
|
|
cls,
|
|
model["model"],
|
|
_video_input(model, aspect_ratio=model["aspect_ratio"], audio=audio_url),
|
|
)
|
|
|
|
|
|
class PrunaImageToVideoNode(IO.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return IO.Schema(
|
|
node_id="PrunaImageToVideoNode",
|
|
display_name="Pruna P-Video-2 Image to Video",
|
|
category="partner/video/Pruna",
|
|
description="Animates an image into a video using Pruna video models, "
|
|
"with an optional last frame, generated sound or audio track.",
|
|
inputs=[
|
|
IO.DynamicCombo.Input(
|
|
"model",
|
|
options=[_image_to_video_option(model_id) for model_id in VIDEO_MODELS],
|
|
tooltip="Model to use.",
|
|
),
|
|
],
|
|
outputs=[
|
|
IO.Video.Output(),
|
|
],
|
|
hidden=[
|
|
IO.Hidden.auth_token_comfy_org,
|
|
IO.Hidden.api_key_comfy_org,
|
|
IO.Hidden.unique_id,
|
|
],
|
|
is_api_node=True,
|
|
price_badge=_price_badge(),
|
|
)
|
|
|
|
@classmethod
|
|
async def execute(cls, model: dict):
|
|
_validate_generation_inputs(model)
|
|
if model.get("last_frame") is not None:
|
|
validate_images_aspect_ratio_closeness(model["first_frame"], model["last_frame"], min_rel=0.8, max_rel=1.25)
|
|
first_frame_url = await upload_image_to_comfyapi(
|
|
cls, model["first_frame"], mime_type="image/png", wait_label="Uploading first frame"
|
|
)
|
|
last_frame_url = None
|
|
if model.get("last_frame") is not None:
|
|
last_frame_url = await upload_image_to_comfyapi(
|
|
cls, model["last_frame"], mime_type="image/png", wait_label="Uploading last frame"
|
|
)
|
|
audio_url = await _upload_audio(cls, model.get("audio"))
|
|
return await _generate_video(
|
|
cls,
|
|
model["model"],
|
|
_video_input(model, image=first_frame_url, last_frame_image=last_frame_url, audio=audio_url),
|
|
)
|
|
|
|
|
|
class PrunaExtension(ComfyExtension):
|
|
@override
|
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
|
return [
|
|
PrunaTextToVideoNode,
|
|
PrunaImageToVideoNode,
|
|
]
|
|
|
|
|
|
async def comfy_entrypoint() -> PrunaExtension:
|
|
return PrunaExtension()
|