fix(v3,api-nodes): V3 schema typing; corrected Pika API nodes (#10265)

This commit is contained in:
Alexander Piskun 2025-10-10 01:15:03 +03:00 committed by GitHub
parent 139addd53c
commit f3d5d328a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 309 additions and 312 deletions

View File

@ -8,8 +8,8 @@ from comfy_api.internal.async_to_sync import create_sync_class
from comfy_api.latest._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput from comfy_api.latest._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
from comfy_api.latest._input_impl import VideoFromFile, VideoFromComponents from comfy_api.latest._input_impl import VideoFromFile, VideoFromComponents
from comfy_api.latest._util import VideoCodec, VideoContainer, VideoComponents from comfy_api.latest._util import VideoCodec, VideoContainer, VideoComponents
from comfy_api.latest._io import _IO as io #noqa: F401 from . import _io as io
from comfy_api.latest._ui import _UI as ui #noqa: F401 from . import _ui as ui
# from comfy_api.latest._resources import _RESOURCES as resources #noqa: F401 # from comfy_api.latest._resources import _RESOURCES as resources #noqa: F401
from comfy_execution.utils import get_executing_context from comfy_execution.utils import get_executing_context
from comfy_execution.progress import get_progress_state, PreviewImageTuple from comfy_execution.progress import get_progress_state, PreviewImageTuple
@ -114,6 +114,8 @@ if TYPE_CHECKING:
ComfyAPISync: Type[comfy_api.latest.generated.ComfyAPISyncStub.ComfyAPISyncStub] ComfyAPISync: Type[comfy_api.latest.generated.ComfyAPISyncStub.ComfyAPISyncStub]
ComfyAPISync = create_sync_class(ComfyAPI_latest) ComfyAPISync = create_sync_class(ComfyAPI_latest)
comfy_io = io # create the new alias for io
__all__ = [ __all__ = [
"ComfyAPI", "ComfyAPI",
"ComfyAPISync", "ComfyAPISync",
@ -121,4 +123,7 @@ __all__ = [
"InputImpl", "InputImpl",
"Types", "Types",
"ComfyExtension", "ComfyExtension",
"io",
"comfy_io",
"ui",
] ]

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Union from typing import Optional, Union, IO
import io import io
import av import av
from comfy_api.util import VideoContainer, VideoCodec, VideoComponents from comfy_api.util import VideoContainer, VideoCodec, VideoComponents
@ -23,7 +23,7 @@ class VideoInput(ABC):
@abstractmethod @abstractmethod
def save_to( def save_to(
self, self,
path: str, path: Union[str, IO[bytes]],
format: VideoContainer = VideoContainer.AUTO, format: VideoContainer = VideoContainer.AUTO,
codec: VideoCodec = VideoCodec.AUTO, codec: VideoCodec = VideoCodec.AUTO,
metadata: Optional[dict] = None metadata: Optional[dict] = None

View File

@ -1582,78 +1582,78 @@ class _UIOutput(ABC):
... ...
class _IO: __all__ = [
FolderType = FolderType "FolderType",
UploadType = UploadType "UploadType",
RemoteOptions = RemoteOptions "RemoteOptions",
NumberDisplay = NumberDisplay "NumberDisplay",
comfytype = staticmethod(comfytype) "comfytype",
Custom = staticmethod(Custom) "Custom",
Input = Input "Input",
WidgetInput = WidgetInput "WidgetInput",
Output = Output "Output",
ComfyTypeI = ComfyTypeI "ComfyTypeI",
ComfyTypeIO = ComfyTypeIO "ComfyTypeIO",
#---------------------------------
# Supported Types # Supported Types
Boolean = Boolean "Boolean",
Int = Int "Int",
Float = Float "Float",
String = String "String",
Combo = Combo "Combo",
MultiCombo = MultiCombo "MultiCombo",
Image = Image "Image",
WanCameraEmbedding = WanCameraEmbedding "WanCameraEmbedding",
Webcam = Webcam "Webcam",
Mask = Mask "Mask",
Latent = Latent "Latent",
Conditioning = Conditioning "Conditioning",
Sampler = Sampler "Sampler",
Sigmas = Sigmas "Sigmas",
Noise = Noise "Noise",
Guider = Guider "Guider",
Clip = Clip "Clip",
ControlNet = ControlNet "ControlNet",
Vae = Vae "Vae",
Model = Model "Model",
ClipVision = ClipVision "ClipVision",
ClipVisionOutput = ClipVisionOutput "ClipVisionOutput",
AudioEncoder = AudioEncoder "AudioEncoder",
AudioEncoderOutput = AudioEncoderOutput "AudioEncoderOutput",
StyleModel = StyleModel "StyleModel",
Gligen = Gligen "Gligen",
UpscaleModel = UpscaleModel "UpscaleModel",
Audio = Audio "Audio",
Video = Video "Video",
SVG = SVG "SVG",
LoraModel = LoraModel "LoraModel",
LossMap = LossMap "LossMap",
Voxel = Voxel "Voxel",
Mesh = Mesh "Mesh",
Hooks = Hooks "Hooks",
HookKeyframes = HookKeyframes "HookKeyframes",
TimestepsRange = TimestepsRange "TimestepsRange",
LatentOperation = LatentOperation "LatentOperation",
FlowControl = FlowControl "FlowControl",
Accumulation = Accumulation "Accumulation",
Load3DCamera = Load3DCamera "Load3DCamera",
Load3D = Load3D "Load3D",
Load3DAnimation = Load3DAnimation "Load3DAnimation",
Photomaker = Photomaker "Photomaker",
Point = Point "Point",
FaceAnalysis = FaceAnalysis "FaceAnalysis",
BBOX = BBOX "BBOX",
SEGS = SEGS "SEGS",
AnyType = AnyType "AnyType",
MultiType = MultiType "MultiType",
#--------------------------------- # Other classes
HiddenHolder = HiddenHolder "HiddenHolder",
Hidden = Hidden "Hidden",
NodeInfoV1 = NodeInfoV1 "NodeInfoV1",
NodeInfoV3 = NodeInfoV3 "NodeInfoV3",
Schema = Schema "Schema",
ComfyNode = ComfyNode "ComfyNode",
NodeOutput = NodeOutput "NodeOutput",
add_to_dict_v1 = staticmethod(add_to_dict_v1) "add_to_dict_v1",
add_to_dict_v3 = staticmethod(add_to_dict_v3) "add_to_dict_v3",
]

View File

@ -449,15 +449,16 @@ class PreviewText(_UIOutput):
return {"text": (self.value,)} return {"text": (self.value,)}
class _UI: __all__ = [
SavedResult = SavedResult "SavedResult",
SavedImages = SavedImages "SavedImages",
SavedAudios = SavedAudios "SavedAudios",
ImageSaveHelper = ImageSaveHelper "ImageSaveHelper",
AudioSaveHelper = AudioSaveHelper "AudioSaveHelper",
PreviewImage = PreviewImage "PreviewImage",
PreviewMask = PreviewMask "PreviewMask",
PreviewAudio = PreviewAudio "PreviewAudio",
PreviewVideo = PreviewVideo "PreviewVideo",
PreviewUI3D = PreviewUI3D "PreviewUI3D",
PreviewText = PreviewText "PreviewText",
]

View File

@ -269,7 +269,7 @@ def tensor_to_bytesio(
mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4'). mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4').
Returns: Returns:
Named BytesIO object containing the image data. Named BytesIO object containing the image data, with pointer set to the start of buffer.
""" """
if not mime_type: if not mime_type:
mime_type = "image/png" mime_type = "image/png"

View File

@ -98,7 +98,7 @@ import io
import os import os
import socket import socket
from aiohttp.client_exceptions import ClientError, ClientResponseError from aiohttp.client_exceptions import ClientError, ClientResponseError
from typing import Dict, Type, Optional, Any, TypeVar, Generic, Callable, Tuple from typing import Type, Optional, Any, TypeVar, Generic, Callable
from enum import Enum from enum import Enum
import json import json
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
@ -175,7 +175,7 @@ class ApiClient:
max_retries: int = 3, max_retries: int = 3,
retry_delay: float = 1.0, retry_delay: float = 1.0,
retry_backoff_factor: float = 2.0, retry_backoff_factor: float = 2.0,
retry_status_codes: Optional[Tuple[int, ...]] = None, retry_status_codes: Optional[tuple[int, ...]] = None,
session: Optional[aiohttp.ClientSession] = None, session: Optional[aiohttp.ClientSession] = None,
): ):
self.base_url = base_url self.base_url = base_url
@ -199,9 +199,9 @@ class ApiClient:
@staticmethod @staticmethod
def _create_json_payload_args( def _create_json_payload_args(
data: Optional[Dict[str, Any]] = None, data: Optional[dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
) -> Dict[str, Any]: ) -> dict[str, Any]:
return { return {
"json": data, "json": data,
"headers": headers, "headers": headers,
@ -209,11 +209,11 @@ class ApiClient:
def _create_form_data_args( def _create_form_data_args(
self, self,
data: Dict[str, Any] | None, data: dict[str, Any] | None,
files: Dict[str, Any] | None, files: dict[str, Any] | None,
headers: Optional[Dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
multipart_parser: Callable | None = None, multipart_parser: Callable | None = None,
) -> Dict[str, Any]: ) -> dict[str, Any]:
if headers and "Content-Type" in headers: if headers and "Content-Type" in headers:
del headers["Content-Type"] del headers["Content-Type"]
@ -254,9 +254,9 @@ class ApiClient:
@staticmethod @staticmethod
def _create_urlencoded_form_data_args( def _create_urlencoded_form_data_args(
data: Dict[str, Any], data: dict[str, Any],
headers: Optional[Dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
) -> Dict[str, Any]: ) -> dict[str, Any]:
headers = headers or {} headers = headers or {}
headers["Content-Type"] = "application/x-www-form-urlencoded" headers["Content-Type"] = "application/x-www-form-urlencoded"
return { return {
@ -264,7 +264,7 @@ class ApiClient:
"headers": headers, "headers": headers,
} }
def get_headers(self) -> Dict[str, str]: def get_headers(self) -> dict[str, str]:
"""Get headers for API requests, including authentication if available""" """Get headers for API requests, including authentication if available"""
headers = {"Content-Type": "application/json", "Accept": "application/json"} headers = {"Content-Type": "application/json", "Accept": "application/json"}
@ -275,7 +275,7 @@ class ApiClient:
return headers return headers
async def _check_connectivity(self, target_url: str) -> Dict[str, bool]: async def _check_connectivity(self, target_url: str) -> dict[str, bool]:
""" """
Check connectivity to determine if network issues are local or server-related. Check connectivity to determine if network issues are local or server-related.
@ -316,14 +316,14 @@ class ApiClient:
self, self,
method: str, method: str,
path: str, path: str,
params: Optional[Dict[str, Any]] = None, params: Optional[dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None, data: Optional[dict[str, Any]] = None,
files: Optional[Dict[str, Any] | list[tuple[str, Any]]] = None, files: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
headers: Optional[Dict[str, str]] = None, headers: Optional[dict[str, str]] = None,
content_type: str = "application/json", content_type: str = "application/json",
multipart_parser: Callable | None = None, multipart_parser: Callable | None = None,
retry_count: int = 0, # Used internally for tracking retries retry_count: int = 0, # Used internally for tracking retries
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Make an HTTP request to the API with automatic retries for transient errors. Make an HTTP request to the API with automatic retries for transient errors.
@ -485,7 +485,7 @@ class ApiClient:
retry_delay: Initial delay between retries in seconds retry_delay: Initial delay between retries in seconds
retry_backoff_factor: Multiplier for the delay after each retry retry_backoff_factor: Multiplier for the delay after each retry
""" """
headers: Dict[str, str] = {} headers: dict[str, str] = {}
skip_auto_headers: set[str] = set() skip_auto_headers: set[str] = set()
if content_type: if content_type:
headers["Content-Type"] = content_type headers["Content-Type"] = content_type
@ -558,7 +558,7 @@ class ApiClient:
*req_meta, *req_meta,
retry_count: int, retry_count: int,
response_content: dict | str = "", response_content: dict | str = "",
) -> Dict[str, Any]: ) -> dict[str, Any]:
status_code = exc.status status_code = exc.status
if status_code == 401: if status_code == 401:
user_friendly = "Unauthorized: Please login first to use this node." user_friendly = "Unauthorized: Please login first to use this node."
@ -659,7 +659,7 @@ class ApiEndpoint(Generic[T, R]):
method: HttpMethod, method: HttpMethod,
request_model: Type[T], request_model: Type[T],
response_model: Type[R], response_model: Type[R],
query_params: Optional[Dict[str, Any]] = None, query_params: Optional[dict[str, Any]] = None,
): ):
"""Initialize an API endpoint definition. """Initialize an API endpoint definition.
@ -684,11 +684,11 @@ class SynchronousOperation(Generic[T, R]):
self, self,
endpoint: ApiEndpoint[T, R], endpoint: ApiEndpoint[T, R],
request: T, request: T,
files: Optional[Dict[str, Any] | list[tuple[str, Any]]] = None, files: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
api_base: str | None = None, api_base: str | None = None,
auth_token: Optional[str] = None, auth_token: Optional[str] = None,
comfy_api_key: Optional[str] = None, comfy_api_key: Optional[str] = None,
auth_kwargs: Optional[Dict[str, str]] = None, auth_kwargs: Optional[dict[str, str]] = None,
timeout: float = 7200.0, timeout: float = 7200.0,
verify_ssl: bool = True, verify_ssl: bool = True,
content_type: str = "application/json", content_type: str = "application/json",
@ -729,7 +729,7 @@ class SynchronousOperation(Generic[T, R]):
) )
try: try:
request_dict: Optional[Dict[str, Any]] request_dict: Optional[dict[str, Any]]
if isinstance(self.request, EmptyRequest): if isinstance(self.request, EmptyRequest):
request_dict = None request_dict = None
else: else:
@ -782,14 +782,14 @@ class PollingOperation(Generic[T, R]):
poll_endpoint: ApiEndpoint[EmptyRequest, R], poll_endpoint: ApiEndpoint[EmptyRequest, R],
completed_statuses: list[str], completed_statuses: list[str],
failed_statuses: list[str], failed_statuses: list[str],
status_extractor: Callable[[R], str], status_extractor: Callable[[R], Optional[str]],
progress_extractor: Callable[[R], float] | None = None, progress_extractor: Callable[[R], Optional[float]] | None = None,
result_url_extractor: Callable[[R], str] | None = None, result_url_extractor: Callable[[R], Optional[str]] | None = None,
request: Optional[T] = None, request: Optional[T] = None,
api_base: str | None = None, api_base: str | None = None,
auth_token: Optional[str] = None, auth_token: Optional[str] = None,
comfy_api_key: Optional[str] = None, comfy_api_key: Optional[str] = None,
auth_kwargs: Optional[Dict[str, str]] = None, auth_kwargs: Optional[dict[str, str]] = None,
poll_interval: float = 5.0, poll_interval: float = 5.0,
max_poll_attempts: int = 120, # Default max polling attempts (10 minutes with 5s interval) max_poll_attempts: int = 120, # Default max polling attempts (10 minutes with 5s interval)
max_retries: int = 3, # Max retries per individual API call max_retries: int = 3, # Max retries per individual API call

View File

@ -0,0 +1,100 @@
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
class Pikaffect(str, Enum):
Cake_ify = "Cake-ify"
Crumble = "Crumble"
Crush = "Crush"
Decapitate = "Decapitate"
Deflate = "Deflate"
Dissolve = "Dissolve"
Explode = "Explode"
Eye_pop = "Eye-pop"
Inflate = "Inflate"
Levitate = "Levitate"
Melt = "Melt"
Peel = "Peel"
Poke = "Poke"
Squish = "Squish"
Ta_da = "Ta-da"
Tear = "Tear"
class PikaBodyGenerate22C2vGenerate22PikascenesPost(BaseModel):
aspectRatio: Optional[float] = Field(None, description='Aspect ratio (width / height)')
duration: Optional[int] = Field(5)
ingredientsMode: str = Field(...)
negativePrompt: Optional[str] = Field(None)
promptText: Optional[str] = Field(None)
resolution: Optional[str] = Field('1080p')
seed: Optional[int] = Field(None)
class PikaGenerateResponse(BaseModel):
video_id: str = Field(...)
class PikaBodyGenerate22I2vGenerate22I2vPost(BaseModel):
duration: Optional[int] = 5
negativePrompt: Optional[str] = Field(None)
promptText: Optional[str] = Field(None)
resolution: Optional[str] = '1080p'
seed: Optional[int] = Field(None)
class PikaBodyGenerate22KeyframeGenerate22PikaframesPost(BaseModel):
duration: Optional[int] = Field(None, ge=5, le=10)
negativePrompt: Optional[str] = Field(None)
promptText: str = Field(...)
resolution: Optional[str] = '1080p'
seed: Optional[int] = Field(None)
class PikaBodyGenerate22T2vGenerate22T2vPost(BaseModel):
aspectRatio: Optional[float] = Field(
1.7777777777777777,
description='Aspect ratio (width / height)',
ge=0.4,
le=2.5,
)
duration: Optional[int] = 5
negativePrompt: Optional[str] = Field(None)
promptText: str = Field(...)
resolution: Optional[str] = '1080p'
seed: Optional[int] = Field(None)
class PikaBodyGeneratePikadditionsGeneratePikadditionsPost(BaseModel):
negativePrompt: Optional[str] = Field(None)
promptText: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
class PikaBodyGeneratePikaffectsGeneratePikaffectsPost(BaseModel):
negativePrompt: Optional[str] = Field(None)
pikaffect: Optional[str] = None
promptText: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
class PikaBodyGeneratePikaswapsGeneratePikaswapsPost(BaseModel):
negativePrompt: Optional[str] = Field(None)
promptText: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
modifyRegionRoi: Optional[str] = Field(None)
class PikaStatusEnum(str, Enum):
queued = "queued"
started = "started"
finished = "finished"
failed = "failed"
class PikaVideoResponse(BaseModel):
id: str = Field(...)
progress: Optional[int] = Field(None)
status: PikaStatusEnum
url: Optional[str] = Field(None)

View File

@ -8,30 +8,17 @@ from __future__ import annotations
from io import BytesIO from io import BytesIO
import logging import logging
from typing import Optional, TypeVar from typing import Optional, TypeVar
from enum import Enum
import numpy as np
import torch import torch
from typing_extensions import override from typing_extensions import override
from comfy_api.latest import ComfyExtension, io as comfy_io from comfy_api.latest import ComfyExtension, comfy_io
from comfy_api.input_impl import VideoFromFile
from comfy_api.input_impl.video_types import VideoCodec, VideoContainer, VideoInput from comfy_api.input_impl.video_types import VideoCodec, VideoContainer, VideoInput
from comfy_api_nodes.apinode_utils import ( from comfy_api_nodes.apinode_utils import (
download_url_to_video_output, download_url_to_video_output,
tensor_to_bytesio, tensor_to_bytesio,
) )
from comfy_api_nodes.apis import ( from comfy_api_nodes.apis import pika_defs
PikaBodyGenerate22C2vGenerate22PikascenesPost,
PikaBodyGenerate22I2vGenerate22I2vPost,
PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
PikaBodyGenerate22T2vGenerate22T2vPost,
PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
PikaGenerateResponse,
PikaVideoResponse,
)
from comfy_api_nodes.apis.client import ( from comfy_api_nodes.apis.client import (
ApiEndpoint, ApiEndpoint,
EmptyRequest, EmptyRequest,
@ -55,116 +42,36 @@ PATH_PIKASCENES = f"/proxy/pika/generate/{PIKA_API_VERSION}/pikascenes"
PATH_VIDEO_GET = "/proxy/pika/videos" PATH_VIDEO_GET = "/proxy/pika/videos"
class PikaDurationEnum(int, Enum): async def execute_task(
integer_5 = 5 initial_operation: SynchronousOperation[R, pika_defs.PikaGenerateResponse],
integer_10 = 10
class PikaResolutionEnum(str, Enum):
field_1080p = "1080p"
field_720p = "720p"
class Pikaffect(str, Enum):
Cake_ify = "Cake-ify"
Crumble = "Crumble"
Crush = "Crush"
Decapitate = "Decapitate"
Deflate = "Deflate"
Dissolve = "Dissolve"
Explode = "Explode"
Eye_pop = "Eye-pop"
Inflate = "Inflate"
Levitate = "Levitate"
Melt = "Melt"
Peel = "Peel"
Poke = "Poke"
Squish = "Squish"
Ta_da = "Ta-da"
Tear = "Tear"
class PikaApiError(Exception):
"""Exception for Pika API errors."""
pass
def is_valid_video_response(response: PikaVideoResponse) -> bool:
"""Check if the video response is valid."""
return hasattr(response, "url") and response.url is not None
def is_valid_initial_response(response: PikaGenerateResponse) -> bool:
"""Check if the initial response is valid."""
return hasattr(response, "video_id") and response.video_id is not None
async def poll_for_task_status(
task_id: str,
auth_kwargs: Optional[dict[str, str]] = None, auth_kwargs: Optional[dict[str, str]] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
) -> PikaGenerateResponse: ) -> comfy_io.NodeOutput:
polling_operation = PollingOperation( task_id = (await initial_operation.execute()).video_id
final_response: pika_defs.PikaVideoResponse = await PollingOperation(
poll_endpoint=ApiEndpoint( poll_endpoint=ApiEndpoint(
path=f"{PATH_VIDEO_GET}/{task_id}", path=f"{PATH_VIDEO_GET}/{task_id}",
method=HttpMethod.GET, method=HttpMethod.GET,
request_model=EmptyRequest, request_model=EmptyRequest,
response_model=PikaVideoResponse, response_model=pika_defs.PikaVideoResponse,
), ),
completed_statuses=[ completed_statuses=["finished"],
"finished",
],
failed_statuses=["failed", "cancelled"], failed_statuses=["failed", "cancelled"],
status_extractor=lambda response: ( status_extractor=lambda response: (response.status.value if response.status else None),
response.status.value if response.status else None progress_extractor=lambda response: (response.progress if hasattr(response, "progress") else None),
),
progress_extractor=lambda response: (
response.progress if hasattr(response, "progress") else None
),
auth_kwargs=auth_kwargs, auth_kwargs=auth_kwargs,
result_url_extractor=lambda response: ( result_url_extractor=lambda response: (response.url if hasattr(response, "url") else None),
response.url if hasattr(response, "url") else None
),
node_id=node_id, node_id=node_id,
estimated_duration=60 estimated_duration=60,
) max_poll_attempts=240,
return await polling_operation.execute() ).execute()
if not final_response.url:
error_msg = f"Pika task {task_id} succeeded but no video data found in response:\n{final_response}"
async def execute_task(
initial_operation: SynchronousOperation[R, PikaGenerateResponse],
auth_kwargs: Optional[dict[str, str]] = None,
node_id: Optional[str] = None,
) -> tuple[VideoFromFile]:
"""Executes the initial operation then polls for the task status until it is completed.
Args:
initial_operation: The initial operation to execute.
auth_kwargs: The authentication token(s) to use for the API call.
Returns:
A tuple containing the video file as a VIDEO output.
"""
initial_response = await initial_operation.execute()
if not is_valid_initial_response(initial_response):
error_msg = f"Pika initial request failed. Code: {initial_response.code}, Message: {initial_response.message}, Data: {initial_response.data}"
logging.error(error_msg) logging.error(error_msg)
raise PikaApiError(error_msg) raise Exception(error_msg)
video_url = final_response.url
task_id = initial_response.video_id
final_response = await poll_for_task_status(task_id, auth_kwargs, node_id=node_id)
if not is_valid_video_response(final_response):
error_msg = (
f"Pika task {task_id} succeeded but no video data found in response."
)
logging.error(error_msg)
raise PikaApiError(error_msg)
video_url = str(final_response.url)
logging.info("Pika task %s succeeded. Video URL: %s", task_id, video_url) logging.info("Pika task %s succeeded. Video URL: %s", task_id, video_url)
return comfy_io.NodeOutput(await download_url_to_video_output(video_url))
return (await download_url_to_video_output(video_url),)
def get_base_inputs_types() -> list[comfy_io.Input]: def get_base_inputs_types() -> list[comfy_io.Input]:
@ -173,16 +80,12 @@ def get_base_inputs_types() -> list[comfy_io.Input]:
comfy_io.String.Input("prompt_text", multiline=True), comfy_io.String.Input("prompt_text", multiline=True),
comfy_io.String.Input("negative_prompt", multiline=True), comfy_io.String.Input("negative_prompt", multiline=True),
comfy_io.Int.Input("seed", min=0, max=0xFFFFFFFF, control_after_generate=True), comfy_io.Int.Input("seed", min=0, max=0xFFFFFFFF, control_after_generate=True),
comfy_io.Combo.Input( comfy_io.Combo.Input("resolution", options=["1080p", "720p"], default="1080p"),
"resolution", options=PikaResolutionEnum, default=PikaResolutionEnum.field_1080p comfy_io.Combo.Input("duration", options=[5, 10], default=5),
),
comfy_io.Combo.Input(
"duration", options=PikaDurationEnum, default=PikaDurationEnum.integer_5
),
] ]
class PikaImageToVideoV2_2(comfy_io.ComfyNode): class PikaImageToVideo(comfy_io.ComfyNode):
"""Pika 2.2 Image to Video Node.""" """Pika 2.2 Image to Video Node."""
@classmethod @classmethod
@ -215,14 +118,9 @@ class PikaImageToVideoV2_2(comfy_io.ComfyNode):
resolution: str, resolution: str,
duration: int, duration: int,
) -> comfy_io.NodeOutput: ) -> comfy_io.NodeOutput:
# Convert image to BytesIO
image_bytes_io = tensor_to_bytesio(image) image_bytes_io = tensor_to_bytesio(image)
image_bytes_io.seek(0)
pika_files = {"image": ("image.png", image_bytes_io, "image/png")} pika_files = {"image": ("image.png", image_bytes_io, "image/png")}
pika_request_data = pika_defs.PikaBodyGenerate22I2vGenerate22I2vPost(
# Prepare non-file data
pika_request_data = PikaBodyGenerate22I2vGenerate22I2vPost(
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
seed=seed, seed=seed,
@ -237,8 +135,8 @@ class PikaImageToVideoV2_2(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_IMAGE_TO_VIDEO, path=PATH_IMAGE_TO_VIDEO,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGenerate22I2vGenerate22I2vPost, request_model=pika_defs.PikaBodyGenerate22I2vGenerate22I2vPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=pika_request_data, request=pika_request_data,
files=pika_files, files=pika_files,
@ -248,7 +146,7 @@ class PikaImageToVideoV2_2(comfy_io.ComfyNode):
return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id) return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id)
class PikaTextToVideoNodeV2_2(comfy_io.ComfyNode): class PikaTextToVideoNode(comfy_io.ComfyNode):
"""Pika Text2Video v2.2 Node.""" """Pika Text2Video v2.2 Node."""
@classmethod @classmethod
@ -296,10 +194,10 @@ class PikaTextToVideoNodeV2_2(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_TEXT_TO_VIDEO, path=PATH_TEXT_TO_VIDEO,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGenerate22T2vGenerate22T2vPost, request_model=pika_defs.PikaBodyGenerate22T2vGenerate22T2vPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=PikaBodyGenerate22T2vGenerate22T2vPost( request=pika_defs.PikaBodyGenerate22T2vGenerate22T2vPost(
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
seed=seed, seed=seed,
@ -313,7 +211,7 @@ class PikaTextToVideoNodeV2_2(comfy_io.ComfyNode):
return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id) return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id)
class PikaScenesV2_2(comfy_io.ComfyNode): class PikaScenes(comfy_io.ComfyNode):
"""PikaScenes v2.2 Node.""" """PikaScenes v2.2 Node."""
@classmethod @classmethod
@ -389,7 +287,6 @@ class PikaScenesV2_2(comfy_io.ComfyNode):
image_ingredient_4: Optional[torch.Tensor] = None, image_ingredient_4: Optional[torch.Tensor] = None,
image_ingredient_5: Optional[torch.Tensor] = None, image_ingredient_5: Optional[torch.Tensor] = None,
) -> comfy_io.NodeOutput: ) -> comfy_io.NodeOutput:
# Convert all passed images to BytesIO
all_image_bytes_io = [] all_image_bytes_io = []
for image in [ for image in [
image_ingredient_1, image_ingredient_1,
@ -399,16 +296,14 @@ class PikaScenesV2_2(comfy_io.ComfyNode):
image_ingredient_5, image_ingredient_5,
]: ]:
if image is not None: if image is not None:
image_bytes_io = tensor_to_bytesio(image) all_image_bytes_io.append(tensor_to_bytesio(image))
image_bytes_io.seek(0)
all_image_bytes_io.append(image_bytes_io)
pika_files = [ pika_files = [
("images", (f"image_{i}.png", image_bytes_io, "image/png")) ("images", (f"image_{i}.png", image_bytes_io, "image/png"))
for i, image_bytes_io in enumerate(all_image_bytes_io) for i, image_bytes_io in enumerate(all_image_bytes_io)
] ]
pika_request_data = PikaBodyGenerate22C2vGenerate22PikascenesPost( pika_request_data = pika_defs.PikaBodyGenerate22C2vGenerate22PikascenesPost(
ingredientsMode=ingredients_mode, ingredientsMode=ingredients_mode,
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
@ -425,8 +320,8 @@ class PikaScenesV2_2(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_PIKASCENES, path=PATH_PIKASCENES,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGenerate22C2vGenerate22PikascenesPost, request_model=pika_defs.PikaBodyGenerate22C2vGenerate22PikascenesPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=pika_request_data, request=pika_request_data,
files=pika_files, files=pika_files,
@ -477,22 +372,16 @@ class PikAdditionsNode(comfy_io.ComfyNode):
negative_prompt: str, negative_prompt: str,
seed: int, seed: int,
) -> comfy_io.NodeOutput: ) -> comfy_io.NodeOutput:
# Convert video to BytesIO
video_bytes_io = BytesIO() video_bytes_io = BytesIO()
video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264) video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
video_bytes_io.seek(0) video_bytes_io.seek(0)
# Convert image to BytesIO
image_bytes_io = tensor_to_bytesio(image) image_bytes_io = tensor_to_bytesio(image)
image_bytes_io.seek(0)
pika_files = { pika_files = {
"video": ("video.mp4", video_bytes_io, "video/mp4"), "video": ("video.mp4", video_bytes_io, "video/mp4"),
"image": ("image.png", image_bytes_io, "image/png"), "image": ("image.png", image_bytes_io, "image/png"),
} }
pika_request_data = pika_defs.PikaBodyGeneratePikadditionsGeneratePikadditionsPost(
# Prepare non-file data
pika_request_data = PikaBodyGeneratePikadditionsGeneratePikadditionsPost(
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
seed=seed, seed=seed,
@ -505,8 +394,8 @@ class PikAdditionsNode(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_PIKADDITIONS, path=PATH_PIKADDITIONS,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost, request_model=pika_defs.PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=pika_request_data, request=pika_request_data,
files=pika_files, files=pika_files,
@ -529,11 +418,25 @@ class PikaSwapsNode(comfy_io.ComfyNode):
category="api node/video/Pika", category="api node/video/Pika",
inputs=[ inputs=[
comfy_io.Video.Input("video", tooltip="The video to swap an object in."), comfy_io.Video.Input("video", tooltip="The video to swap an object in."),
comfy_io.Image.Input("image", tooltip="The image used to replace the masked object in the video."), comfy_io.Image.Input(
comfy_io.Mask.Input("mask", tooltip="Use the mask to define areas in the video to replace"), "image",
comfy_io.String.Input("prompt_text", multiline=True), tooltip="The image used to replace the masked object in the video.",
comfy_io.String.Input("negative_prompt", multiline=True), optional=True,
comfy_io.Int.Input("seed", min=0, max=0xFFFFFFFF, control_after_generate=True), ),
comfy_io.Mask.Input(
"mask",
tooltip="Use the mask to define areas in the video to replace.",
optional=True,
),
comfy_io.String.Input("prompt_text", multiline=True, optional=True),
comfy_io.String.Input("negative_prompt", multiline=True, optional=True),
comfy_io.Int.Input("seed", min=0, max=0xFFFFFFFF, control_after_generate=True, optional=True),
comfy_io.String.Input(
"region_to_modify",
multiline=True,
optional=True,
tooltip="Plaintext description of the object / region to modify.",
),
], ],
outputs=[comfy_io.Video.Output()], outputs=[comfy_io.Video.Output()],
hidden=[ hidden=[
@ -548,41 +451,29 @@ class PikaSwapsNode(comfy_io.ComfyNode):
async def execute( async def execute(
cls, cls,
video: VideoInput, video: VideoInput,
image: torch.Tensor, image: Optional[torch.Tensor] = None,
mask: torch.Tensor, mask: Optional[torch.Tensor] = None,
prompt_text: str, prompt_text: str = "",
negative_prompt: str, negative_prompt: str = "",
seed: int, seed: int = 0,
region_to_modify: str = "",
) -> comfy_io.NodeOutput: ) -> comfy_io.NodeOutput:
# Convert video to BytesIO
video_bytes_io = BytesIO() video_bytes_io = BytesIO()
video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264) video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
video_bytes_io.seek(0) video_bytes_io.seek(0)
# Convert mask to binary mask with three channels
mask = torch.round(mask)
mask = mask.repeat(1, 3, 1, 1)
# Convert 3-channel binary mask to BytesIO
mask_bytes_io = BytesIO()
mask_bytes_io.write(mask.numpy().astype(np.uint8))
mask_bytes_io.seek(0)
# Convert image to BytesIO
image_bytes_io = tensor_to_bytesio(image)
image_bytes_io.seek(0)
pika_files = { pika_files = {
"video": ("video.mp4", video_bytes_io, "video/mp4"), "video": ("video.mp4", video_bytes_io, "video/mp4"),
"image": ("image.png", image_bytes_io, "image/png"),
"modifyRegionMask": ("mask.png", mask_bytes_io, "image/png"),
} }
if mask is not None:
pika_files["modifyRegionMask"] = ("mask.png", tensor_to_bytesio(mask), "image/png")
if image is not None:
pika_files["image"] = ("image.png", tensor_to_bytesio(image), "image/png")
# Prepare non-file data pika_request_data = pika_defs.PikaBodyGeneratePikaswapsGeneratePikaswapsPost(
pika_request_data = PikaBodyGeneratePikaswapsGeneratePikaswapsPost(
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
seed=seed, seed=seed,
modifyRegionRoi=region_to_modify if region_to_modify else None,
) )
auth = { auth = {
"auth_token": cls.hidden.auth_token_comfy_org, "auth_token": cls.hidden.auth_token_comfy_org,
@ -590,10 +481,10 @@ class PikaSwapsNode(comfy_io.ComfyNode):
} }
initial_operation = SynchronousOperation( initial_operation = SynchronousOperation(
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_PIKADDITIONS, path=PATH_PIKASWAPS,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost, request_model=pika_defs.PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=pika_request_data, request=pika_request_data,
files=pika_files, files=pika_files,
@ -616,7 +507,7 @@ class PikaffectsNode(comfy_io.ComfyNode):
inputs=[ inputs=[
comfy_io.Image.Input("image", tooltip="The reference image to apply the Pikaffect to."), comfy_io.Image.Input("image", tooltip="The reference image to apply the Pikaffect to."),
comfy_io.Combo.Input( comfy_io.Combo.Input(
"pikaffect", options=Pikaffect, default="Cake-ify" "pikaffect", options=pika_defs.Pikaffect, default="Cake-ify"
), ),
comfy_io.String.Input("prompt_text", multiline=True), comfy_io.String.Input("prompt_text", multiline=True),
comfy_io.String.Input("negative_prompt", multiline=True), comfy_io.String.Input("negative_prompt", multiline=True),
@ -648,10 +539,10 @@ class PikaffectsNode(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_PIKAFFECTS, path=PATH_PIKAFFECTS,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGeneratePikaffectsGeneratePikaffectsPost, request_model=pika_defs.PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=PikaBodyGeneratePikaffectsGeneratePikaffectsPost( request=pika_defs.PikaBodyGeneratePikaffectsGeneratePikaffectsPost(
pikaffect=pikaffect, pikaffect=pikaffect,
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
@ -664,7 +555,7 @@ class PikaffectsNode(comfy_io.ComfyNode):
return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id) return await execute_task(initial_operation, auth_kwargs=auth, node_id=cls.hidden.unique_id)
class PikaStartEndFrameNode2_2(comfy_io.ComfyNode): class PikaStartEndFrameNode(comfy_io.ComfyNode):
"""PikaFrames v2.2 Node.""" """PikaFrames v2.2 Node."""
@classmethod @classmethod
@ -711,10 +602,10 @@ class PikaStartEndFrameNode2_2(comfy_io.ComfyNode):
endpoint=ApiEndpoint( endpoint=ApiEndpoint(
path=PATH_PIKAFRAMES, path=PATH_PIKAFRAMES,
method=HttpMethod.POST, method=HttpMethod.POST,
request_model=PikaBodyGenerate22KeyframeGenerate22PikaframesPost, request_model=pika_defs.PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
response_model=PikaGenerateResponse, response_model=pika_defs.PikaGenerateResponse,
), ),
request=PikaBodyGenerate22KeyframeGenerate22PikaframesPost( request=pika_defs.PikaBodyGenerate22KeyframeGenerate22PikaframesPost(
promptText=prompt_text, promptText=prompt_text,
negativePrompt=negative_prompt, negativePrompt=negative_prompt,
seed=seed, seed=seed,
@ -732,13 +623,13 @@ class PikaApiNodesExtension(ComfyExtension):
@override @override
async def get_node_list(self) -> list[type[comfy_io.ComfyNode]]: async def get_node_list(self) -> list[type[comfy_io.ComfyNode]]:
return [ return [
PikaImageToVideoV2_2, PikaImageToVideo,
PikaTextToVideoNodeV2_2, PikaTextToVideoNode,
PikaScenesV2_2, PikaScenes,
PikAdditionsNode, PikAdditionsNode,
PikaSwapsNode, PikaSwapsNode,
PikaffectsNode, PikaffectsNode,
PikaStartEndFrameNode2_2, PikaStartEndFrameNode,
] ]