import re import torch from typing_extensions import override from comfy_api.latest import IO, ComfyExtension, Input from comfy_api_nodes.apis.pixverse import ( PixverseTextVideoRequest, PixverseImageVideoRequest, PixverseTransitionVideoRequest, PixverseImageUploadResponse, PixverseMediaUploadResponse, PixverseVideoResponse, PixverseGenerationStatusResponse, PixverseAspectRatio, PixverseImageReference, PixverseQuality, PixverseDuration, PixverseMotionMode, PixverseReferenceType, PixverseStatus, PixverseV6AspectRatio, PixverseV6ExtendVideoRequest, PixverseV6FusionVideoRequest, PixverseV6ImageVideoRequest, PixverseV6Style, PixverseV6TextVideoRequest, PixverseV6TransitionVideoRequest, PixverseVideoReference, PixverseIO, pixverse_templates, ) from comfy_api_nodes.util import ( ApiEndpoint, download_url_to_video_output, poll_op, sync_op, upload_images_to_comfyapi, upload_video_to_comfyapi, validate_string, validate_video_dimensions, validate_video_duration, ) AVERAGE_DURATION_T2V = 32 AVERAGE_DURATION_I2V = 30 AVERAGE_DURATION_T2T = 52 V6_MAX_PROMPT_LENGTH = 5000 V6_MAX_NEGATIVE_PROMPT_LENGTH = 2048 V6_MIN_DURATION = 1 V6_MAX_DURATION = 15 V6_MAX_SUBJECTS = 8 V6_MAX_BACKGROUNDS = 2 V6_MAX_REFERENCE_IMAGES_OMNI = 10 V6_MAX_REFERENCE_IMAGES_PLAIN = 7 V6_MAX_REFERENCE_VIDEOS = 2 V6_MAX_REFERENCE_VIDEO_SECONDS = 15 V6_EXTEND_SOURCE_SECONDS_LIMIT = 40 V6_MAX_SOURCE_VIDEO_SIDE = 1920 PIXVERSE_STATUS_LABELS = { PixverseStatus.successful: "completed", PixverseStatus.generating: "generating", PixverseStatus.deleted: "deleted", PixverseStatus.contents_moderation: "content moderation failed", PixverseStatus.failed: "generation failed", } PIXVERSE_COMPLETED_STATUSES = ["completed"] PIXVERSE_FAILED_STATUSES = ["deleted", "content moderation failed", "generation failed"] def _pixverse_status(response) -> str: resp = getattr(response, "Resp", None) status = getattr(resp, "status", None) return PIXVERSE_STATUS_LABELS.get(status, f"unknown status {status}") async def upload_video_to_pixverse(cls: type[IO.ComfyNode], video: Input.Video) -> int: response_upload = await sync_op( cls, ApiEndpoint(path="/proxy/pixverse/media/upload", method="POST"), response_model=PixverseMediaUploadResponse, data={"file_url": await upload_video_to_comfyapi(cls, video)}, content_type="multipart/form-data", ) if response_upload.Resp is None or response_upload.Resp.media_id is None: raise Exception(f"PixVerse video upload request failed: '{response_upload.ErrMsg}'") return response_upload.Resp.media_id async def upload_image_to_pixverse(cls: type[IO.ComfyNode], image: torch.Tensor) -> int: image_urls = await upload_images_to_comfyapi(cls, image, max_images=1) response_upload = await sync_op( cls, ApiEndpoint(path="/proxy/pixverse/image/upload", method="POST"), response_model=PixverseImageUploadResponse, data={"image_url": image_urls[0]}, content_type="multipart/form-data", ) if response_upload.Resp is None or response_upload.Resp.img_id is None: raise Exception(f"PixVerse image upload request failed: '{response_upload.ErrMsg}'") return response_upload.Resp.img_id class PixverseTemplateNode(IO.ComfyNode): """ Select template for PixVerse Video generation. """ @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseTemplateNode", display_name="PixVerse Template", category="partner/video/PixVerse", inputs=[ IO.Combo.Input("template", options=list(pixverse_templates.keys())), ], outputs=[IO.Custom(PixverseIO.TEMPLATE).Output(display_name="pixverse_template")], ) @classmethod def execute(cls, template: str) -> IO.NodeOutput: template_id = pixverse_templates.get(template, None) if template_id is None: raise Exception(f"Template '{template}' is not recognized.") return IO.NodeOutput(template_id) class PixverseTextToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseTextToVideoNode", display_name="PixVerse Text to Video", category="partner/video/PixVerse", description="Generates videos based on prompt and output_size.", inputs=[ IO.String.Input( "prompt", multiline=True, default="", tooltip="Prompt for the video generation", ), IO.Combo.Input( "aspect_ratio", options=PixverseAspectRatio, ), IO.Combo.Input( "quality", options=PixverseQuality, default=PixverseQuality.res_540p, ), IO.Combo.Input( "duration_seconds", options=PixverseDuration, ), IO.Combo.Input( "motion_mode", options=PixverseMotionMode, ), IO.Int.Input( "seed", default=0, min=0, max=2147483647, control_after_generate=True, tooltip="Seed for video generation.", ), IO.String.Input( "negative_prompt", default="", multiline=True, tooltip="An optional text description of undesired elements on an image.", optional=True, ), IO.Custom(PixverseIO.TEMPLATE).Input( "pixverse_template", tooltip="An optional template to influence style of generation, created by the PixVerse Template node.", optional=True, ), ], 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_VIDEO, ) @classmethod async def execute( cls, prompt: str, aspect_ratio: str, quality: str, duration_seconds: int, motion_mode: str, seed, negative_prompt: str = None, pixverse_template: int = None, ) -> IO.NodeOutput: validate_string(prompt, strip_whitespace=False, min_length=1) # 1080p is limited to 5 seconds duration # only normal motion_mode supported for 1080p or for non-5 second duration if quality == PixverseQuality.res_1080p: motion_mode = PixverseMotionMode.normal duration_seconds = PixverseDuration.dur_5 elif duration_seconds != PixverseDuration.dur_5: motion_mode = PixverseMotionMode.normal response_api = await sync_op( cls, ApiEndpoint(path="/proxy/pixverse/video/text/generate", method="POST"), response_model=PixverseVideoResponse, data=PixverseTextVideoRequest( prompt=prompt, aspect_ratio=aspect_ratio, quality=quality, duration=duration_seconds, motion_mode=motion_mode, negative_prompt=negative_prompt if negative_prompt else None, template_id=pixverse_template, seed=seed, ), ) if response_api.Resp is None: raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") response_poll = await poll_op( cls, ApiEndpoint(path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}"), response_model=PixverseGenerationStatusResponse, completed_statuses=PIXVERSE_COMPLETED_STATUSES, failed_statuses=PIXVERSE_FAILED_STATUSES, status_extractor=_pixverse_status, estimated_duration=AVERAGE_DURATION_T2V, ) return IO.NodeOutput(await download_url_to_video_output(response_poll.Resp.url)) class PixverseImageToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseImageToVideoNode", display_name="PixVerse Image to Video", category="partner/video/PixVerse", description="Generates videos based on prompt and output_size.", inputs=[ IO.Image.Input("image"), IO.String.Input( "prompt", multiline=True, default="", tooltip="Prompt for the video generation", ), IO.Combo.Input( "quality", options=PixverseQuality, default=PixverseQuality.res_540p, ), IO.Combo.Input( "duration_seconds", options=PixverseDuration, ), IO.Combo.Input( "motion_mode", options=PixverseMotionMode, ), IO.Int.Input( "seed", default=0, min=0, max=2147483647, control_after_generate=True, tooltip="Seed for video generation.", ), IO.String.Input( "negative_prompt", default="", multiline=True, tooltip="An optional text description of undesired elements on an image.", optional=True, ), IO.Custom(PixverseIO.TEMPLATE).Input( "pixverse_template", tooltip="An optional template to influence style of generation, created by the PixVerse Template node.", optional=True, ), ], 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_VIDEO, ) @classmethod async def execute( cls, image: torch.Tensor, prompt: str, quality: str, duration_seconds: int, motion_mode: str, seed, negative_prompt: str = None, pixverse_template: int = None, ) -> IO.NodeOutput: validate_string(prompt, strip_whitespace=False) img_id = await upload_image_to_pixverse(cls, image) # 1080p is limited to 5 seconds duration # only normal motion_mode supported for 1080p or for non-5 second duration if quality == PixverseQuality.res_1080p: motion_mode = PixverseMotionMode.normal duration_seconds = PixverseDuration.dur_5 elif duration_seconds != PixverseDuration.dur_5: motion_mode = PixverseMotionMode.normal response_api = await sync_op( cls, ApiEndpoint(path="/proxy/pixverse/video/img/generate", method="POST"), response_model=PixverseVideoResponse, data=PixverseImageVideoRequest( img_id=img_id, prompt=prompt, quality=quality, duration=duration_seconds, motion_mode=motion_mode, negative_prompt=negative_prompt if negative_prompt else None, template_id=pixverse_template, seed=seed, ), ) if response_api.Resp is None: raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") response_poll = await poll_op( cls, ApiEndpoint(path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}"), response_model=PixverseGenerationStatusResponse, completed_statuses=PIXVERSE_COMPLETED_STATUSES, failed_statuses=PIXVERSE_FAILED_STATUSES, status_extractor=_pixverse_status, estimated_duration=AVERAGE_DURATION_I2V, ) return IO.NodeOutput(await download_url_to_video_output(response_poll.Resp.url)) class PixverseTransitionVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseTransitionVideoNode", display_name="PixVerse Transition Video", category="partner/video/PixVerse", description="Generates videos based on prompt and output_size.", inputs=[ IO.Image.Input("first_frame"), IO.Image.Input("last_frame"), IO.String.Input( "prompt", multiline=True, default="", tooltip="Prompt for the video generation", ), IO.Combo.Input( "quality", options=PixverseQuality, default=PixverseQuality.res_540p, ), IO.Combo.Input( "duration_seconds", options=PixverseDuration, ), IO.Combo.Input( "motion_mode", options=PixverseMotionMode, ), IO.Int.Input( "seed", default=0, min=0, max=2147483647, control_after_generate=True, tooltip="Seed for video generation.", ), IO.String.Input( "negative_prompt", default="", multiline=True, tooltip="An optional text description of undesired elements on an image.", optional=True, ), ], 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_VIDEO, ) @classmethod async def execute( cls, first_frame: torch.Tensor, last_frame: torch.Tensor, prompt: str, quality: str, duration_seconds: int, motion_mode: str, seed, negative_prompt: str = None, ) -> IO.NodeOutput: validate_string(prompt, strip_whitespace=False) first_frame_id = await upload_image_to_pixverse(cls, first_frame) last_frame_id = await upload_image_to_pixverse(cls, last_frame) # 1080p is limited to 5 seconds duration # only normal motion_mode supported for 1080p or for non-5 second duration if quality == PixverseQuality.res_1080p: motion_mode = PixverseMotionMode.normal duration_seconds = PixverseDuration.dur_5 elif duration_seconds != PixverseDuration.dur_5: motion_mode = PixverseMotionMode.normal response_api = await sync_op( cls, ApiEndpoint(path="/proxy/pixverse/video/transition/generate", method="POST"), response_model=PixverseVideoResponse, data=PixverseTransitionVideoRequest( first_frame_img=first_frame_id, last_frame_img=last_frame_id, prompt=prompt, quality=quality, duration=duration_seconds, motion_mode=motion_mode, negative_prompt=negative_prompt if negative_prompt else None, seed=seed, ), ) if response_api.Resp is None: raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'") response_poll = await poll_op( cls, ApiEndpoint(path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}"), response_model=PixverseGenerationStatusResponse, completed_statuses=PIXVERSE_COMPLETED_STATUSES, failed_statuses=PIXVERSE_FAILED_STATUSES, status_extractor=_pixverse_status, estimated_duration=AVERAGE_DURATION_T2V, ) return IO.NodeOutput(await download_url_to_video_output(response_poll.Resp.url)) PRICE_BADGE_VIDEO = IO.PriceBadge( depends_on=IO.PriceBadgeDepends(widgets=["duration_seconds", "quality", "motion_mode"]), expr=""" ( $prices := { "5": { "1080p": {"normal": 1.716}, "720p": {"normal": 0.858, "fast": 1.716}, "540p": {"normal": 0.6435, "fast": 1.287}, "360p": {"normal": 0.6435, "fast": 1.287} }, "8": { "720p": {"normal": 1.716}, "540p": {"normal": 1.287}, "360p": {"normal": 1.287} } }; $quality := $lowercase($string($lookup(widgets, "quality"))); $duration := $quality = "1080p" ? "5" : $string($lookup(widgets, "duration_seconds")); $motion := $quality = "1080p" or $duration != "5" ? "normal" : $lowercase($string($lookup(widgets, "motion_mode"))); $price := $lookup($lookup($lookup($prices, $duration), $quality), $motion); $type($price) = "number" ? {"type":"usd","usd": $price} : undefined ) """, ) PIXVERSE_MODELS = { "PixVerse V6": "v6", } PRICE_BADGE_PIXVERSE = IO.PriceBadge( depends_on=IO.PriceBadgeDepends( widgets=["model", "model.quality", "model.duration_seconds", "model.generate_audio"] ), expr=""" ( $prices := { "pixverse v6": { "360p": {"false": 0.0715, "true": 0.1001}, "540p": {"false": 0.1001, "true": 0.1287}, "720p": {"false": 0.1287, "true": 0.1716}, "1080p": {"false": 0.2574, "true": 0.3289} } }; $model := $lookup(widgets, "model"); $table := $type($model) = "string" ? $lookup($prices, $lowercase($model)) : undefined; $quality := $lookup(widgets, "model.quality"); $row := $type($table) = "object" and $type($quality) = "string" ? $lookup($table, $lowercase($quality)) : undefined; $audio := $string($lookup(widgets, "model.generate_audio")) = "true" ? "true" : "false"; $pps := $type($row) = "object" ? $lookup($row, $audio) : undefined; $durationRaw := $lookup(widgets, "model.duration_seconds"); $duration := $type($durationRaw) in ["string", "number"] ? $number($durationRaw) : undefined; $type($pps) = "number" and $type($duration) = "number" ? {"type":"usd","usd": $pps * $duration} : undefined ) """, ) PRICE_BADGE_PIXVERSE_FUSION = IO.PriceBadge( depends_on=IO.PriceBadgeDepends( widgets=["model", "model.quality", "model.duration_seconds", "model.generate_audio"], input_groups=["videos"], ), expr=""" ( $rates := { "pixverse v6": { "360p": {"false": 0.0715, "true": 0.1001}, "540p": {"false": 0.1001, "true": 0.1287}, "720p": {"false": 0.1287, "true": 0.1716}, "1080p": {"false": 0.2574, "true": 0.3289} } }; $model := $lookup(widgets, "model"); $table := $type($model) = "string" ? $lookup($rates, $lowercase($model)) : undefined; $quality := $lookup(widgets, "model.quality"); $row := $type($table) = "object" and $type($quality) = "string" ? $lookup($table, $lowercase($quality)) : undefined; $audio := $string($lookup(widgets, "model.generate_audio")) = "true" ? "true" : "false"; $pps := $type($row) = "object" ? $lookup($row, $audio) : undefined; $hasVideo := $exists(inputGroups) and $lookup(inputGroups, "videos") > 0; $durationRaw := $lookup(widgets, "model.duration_seconds"); $duration := $type($durationRaw) in ["string", "number"] ? $number($durationRaw) : undefined; $type($pps) = "number" ? ($hasVideo ? {"type":"usd","usd": $pps * 2, "format":{"suffix":"/second of reference video"}} : ($type($duration) = "number" ? {"type":"usd","usd": $pps * $duration} : undefined)) : undefined ) """, ) def _pixverse6_inputs( *, aspect_ratio_options: list | None = None, with_multi_clip: bool = False, prompt_tooltip: str = "Prompt for the video generation.", quality_tooltip: str = "Output resolution. Sets the long edge: 360p is 640px, 540p 1024px, " "720p 1280px, 1080p 1920px.", ) -> list: inputs = [ IO.String.Input("prompt", multiline=True, default="", tooltip=prompt_tooltip), ] if aspect_ratio_options is not None: inputs.append( IO.Combo.Input( "aspect_ratio", options=aspect_ratio_options, tooltip="Output aspect ratio.", ) ) inputs += [ IO.Combo.Input( "quality", options=PixverseQuality, default=PixverseQuality.res_720p, tooltip=quality_tooltip, ), IO.Int.Input( "duration_seconds", default=5, min=V6_MIN_DURATION, max=V6_MAX_DURATION, tooltip="Length of the generated video in seconds.", ), IO.Boolean.Input( "generate_audio", default=True, tooltip="Generate a native audio track together with the video.", ), ] if with_multi_clip: inputs.append( IO.Boolean.Input( "multi_clip", default=False, tooltip="Let the model cut the video into several shots instead of one continuous take.", ) ) inputs += [ IO.Int.Input( "seed", default=42, min=0, max=2147483647, control_after_generate=True, tooltip="Seed for video generation. PixVerse records it but does not reproduce a run from it.", ), IO.String.Input( "negative_prompt", default="", multiline=True, optional=True, tooltip="An optional text description of undesired elements in the video.", ), IO.Combo.Input( "style", options=PixverseV6Style, default=PixverseV6Style.none, optional=True, tooltip="An optional visual style applied to the whole video.", ), ] return inputs def _pixverse_model_input(**kwargs) -> IO.DynamicCombo.Input: return IO.DynamicCombo.Input( "model", options=[IO.DynamicCombo.Option("PixVerse V6", _pixverse6_inputs(**kwargs))], tooltip="Model and generation settings.", ) def _validate_prompts(prompt: str, negative_prompt: str | None) -> None: validate_string(prompt, strip_whitespace=True, min_length=1, max_length=V6_MAX_PROMPT_LENGTH) if negative_prompt and len(negative_prompt) > V6_MAX_NEGATIVE_PROMPT_LENGTH: raise ValueError( f"Negative prompt must be at most {V6_MAX_NEGATIVE_PROMPT_LENGTH} characters, " f"got {len(negative_prompt)}." ) def _model_style(model: dict) -> str | None: style = model.get("style") if not style or style == PixverseV6Style.none: return None return style def _model_common(model: dict) -> dict: _validate_prompts(model["prompt"], model.get("negative_prompt")) return { "model": PIXVERSE_MODELS[model["model"]], "prompt": model["prompt"], "quality": model["quality"], "duration": model["duration_seconds"], "generate_audio_switch": model["generate_audio"], "negative_prompt": model.get("negative_prompt") or None, "style": _model_style(model), "seed": model["seed"], } def _pixverse_error(response) -> Exception: code = response.ErrCode message = response.ErrMsg or "unknown error" if code == 500044: return Exception( "PixVerse is already running the maximum number of simultaneous generations. " "Try again in a moment." ) if code == 500090: return Exception("PixVerse rejected the request: the provider account is out of credits.") if code == 500063: return Exception(f"PixVerse content moderation rejected the request: {message}") return Exception(f"PixVerse request failed ({code}): '{message}'") async def _pixverse_generate(cls: type[IO.ComfyNode], path: str, request) -> IO.NodeOutput: response_api = await sync_op( cls, ApiEndpoint(path=path, method="POST"), response_model=PixverseVideoResponse, data=request, ) if response_api.Resp is None: raise _pixverse_error(response_api) response_poll = await poll_op( cls, ApiEndpoint(path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}"), response_model=PixverseGenerationStatusResponse, completed_statuses=PIXVERSE_COMPLETED_STATUSES, failed_statuses=PIXVERSE_FAILED_STATUSES, status_extractor=_pixverse_status, ) return IO.NodeOutput(await download_url_to_video_output(response_poll.Resp.url)) class PixverseV6TextToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseV6TextToVideoNode", display_name="PixVerse V6 Text to Video", category="partner/video/PixVerse", description="Generates a video from a text prompt with PixVerse, optionally with native audio.", inputs=[ _pixverse_model_input( aspect_ratio_options=PixverseV6AspectRatio, with_multi_clip=True, ), ], 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_PIXVERSE, ) @classmethod async def execute(cls, model: dict) -> IO.NodeOutput: return await _pixverse_generate( cls, "/proxy/pixverse/video/text/generate", PixverseV6TextVideoRequest( **_model_common(model), aspect_ratio=model["aspect_ratio"], generate_multi_clip_switch=model["multi_clip"], ), ) class PixverseV6ImageToVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseV6ImageToVideoNode", display_name="PixVerse V6 Image to Video", category="partner/video/PixVerse", description="Animates an image with PixVerse, optionally with native audio. " "The output keeps the aspect ratio of the input image.", inputs=[ IO.Image.Input("image"), _pixverse_model_input(with_multi_clip=True), ], 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_PIXVERSE, ) @classmethod async def execute(cls, image: torch.Tensor, model: dict) -> IO.NodeOutput: common = _model_common(model) return await _pixverse_generate( cls, "/proxy/pixverse/video/img/generate", PixverseV6ImageVideoRequest( **common, img_id=await upload_image_to_pixverse(cls, image), generate_multi_clip_switch=model["multi_clip"], ), ) class PixverseV6FirstLastFrameNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseV6FirstLastFrameNode", display_name="PixVerse V6 First-Last-Frame to Video", category="partner/video/PixVerse", description="Generates a video that transitions from a first frame to a last frame with PixVerse, " "optionally with native audio. The output keeps the aspect ratio of the first frame.", inputs=[ IO.Image.Input("first_frame"), IO.Image.Input("last_frame"), _pixverse_model_input(prompt_tooltip="Prompt describing the transition."), ], 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_PIXVERSE, ) @classmethod async def execute(cls, first_frame: torch.Tensor, last_frame: torch.Tensor, model: dict) -> IO.NodeOutput: common = _model_common(model) return await _pixverse_generate( cls, "/proxy/pixverse/video/transition/generate", PixverseV6TransitionVideoRequest( **common, first_frame_img=await upload_image_to_pixverse(cls, first_frame), last_frame_img=await upload_image_to_pixverse(cls, last_frame), ), ) class PixverseV6ExtendVideoNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="PixverseV6ExtendVideoNode", display_name="PixVerse V6 Extend Video", category="partner/video/PixVerse", description="Continues an existing video with PixVerse, optionally with native audio. The source must " "be under 40 seconds and at most 1920px on either side. The output keeps the source's resolution, so " "quality sets how well the continuation is rendered rather than the frame size.", inputs=[ IO.Video.Input("video", tooltip="Video to continue."), _pixverse_model_input( prompt_tooltip="Prompt describing how the video should continue.", quality_tooltip="Render quality of the generated continuation: 1080p looks markedly better than " "540p or 360p. It never resizes - the output keeps the source video's resolution.", ), ], 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_PIXVERSE, ) @classmethod async def execute(cls, video: Input.Video, model: dict) -> IO.NodeOutput: common = _model_common(model) source_seconds = video.get_duration() if source_seconds >= V6_EXTEND_SOURCE_SECONDS_LIMIT: raise ValueError( f"PixVerse extends videos shorter than {V6_EXTEND_SOURCE_SECONDS_LIMIT} seconds; " f"this one is {source_seconds:.2f}s." ) validate_video_dimensions(video, max_width=V6_MAX_SOURCE_VIDEO_SIDE, max_height=V6_MAX_SOURCE_VIDEO_SIDE) return await _pixverse_generate( cls, "/proxy/pixverse/video/extend/generate", PixverseV6ExtendVideoRequest( **common, video_media_id=await upload_video_to_pixverse(cls, video), ), ) def _rewrite_reference_tags(prompt: str, ref_names: set[str]) -> str: def repl(match: re.Match) -> str: name = match.group(1).lower() + match.group(2) if name not in ref_names: available = ", ".join(f"@{n}" for n in sorted(ref_names)) or "none" raise ValueError(f"@{match.group(1)}{match.group(2)} is not connected. Available references: {available}.") return f"@{name} " return re.sub( r"(? IO.Schema: return IO.Schema( node_id="PixverseV6FusionVideoNode", display_name="PixVerse V6 Fusion (Reference to Video)", category="partner/video/PixVerse", description="Composes a video from reference subjects, backgrounds and videos with PixVerse. " "Place a reference in the scene by naming it in the prompt, for example " "'@Subject1 walks through @Background1'. Connecting a reference video switches the model to Omni mode, " "where the output length matches the longest reference video.", inputs=[ IO.Autogrow.Input( "subjects", template=IO.Autogrow.TemplateNames( IO.Image.Input("subject"), names=[f"subject{i}" for i in range(1, V6_MAX_SUBJECTS + 1)], min=0, ), tooltip="Reference images of the subjects to place in the scene.", ), IO.Autogrow.Input( "backgrounds", template=IO.Autogrow.TemplateNames( IO.Image.Input("background"), names=[f"background{i}" for i in range(1, V6_MAX_BACKGROUNDS + 1)], min=0, ), tooltip="Reference images of the scene the subjects are placed into.", ), IO.Autogrow.Input( "videos", template=IO.Autogrow.TemplateNames( IO.Video.Input("video"), names=[f"video{i}" for i in range(1, V6_MAX_REFERENCE_VIDEOS + 1)], min=0, ), tooltip="Reference videos to borrow subjects, motion, framing or style from. " "At most two, at most 15 seconds in total.", ), _pixverse_model_input( aspect_ratio_options=[*[ratio.value for ratio in PixverseV6AspectRatio], "auto"], prompt_tooltip="Prompt for the video generation. Refer to connected references as " "@Subject1, @Background1, @Video1.", ), ], 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_PIXVERSE_FUSION, ) @classmethod async def execute( cls, subjects: IO.Autogrow.Type, backgrounds: IO.Autogrow.Type, videos: IO.Autogrow.Type, model: dict, ) -> IO.NodeOutput: common = _model_common(model) aspect_ratio = model["aspect_ratio"] subjects = subjects or {} backgrounds = backgrounds or {} videos = videos or {} if not subjects and not backgrounds and not videos: raise ValueError("Connect at least one subject, background or reference video.") is_omni = bool(videos) image_count = len(subjects) + len(backgrounds) max_images = V6_MAX_REFERENCE_IMAGES_OMNI if is_omni else V6_MAX_REFERENCE_IMAGES_PLAIN if image_count > max_images: raise ValueError( f"PixVerse accepts at most {max_images} reference images " f"{'in Omni mode' if is_omni else 'without a reference video'}, got {image_count}." ) if aspect_ratio == "auto" and not is_omni: raise ValueError("aspect_ratio 'auto' requires at least one connected reference video.") total_video_seconds = 0.0 for video in videos.values(): validate_video_duration(video, max_duration=V6_MAX_REFERENCE_VIDEO_SECONDS) total_video_seconds += video.get_duration() if total_video_seconds > V6_MAX_REFERENCE_VIDEO_SECONDS: raise ValueError( f"Reference videos must total at most {V6_MAX_REFERENCE_VIDEO_SECONDS} seconds, " f"got {total_video_seconds:.2f}." ) common["prompt"] = _rewrite_reference_tags( common["prompt"], set(subjects) | set(backgrounds) | set(videos) ) common["duration"] = 0 if is_omni else common["duration"] image_references = [] for ref_name, image in subjects.items(): image_references.append( PixverseImageReference( img_id=await upload_image_to_pixverse(cls, image), ref_name=ref_name, type=PixverseReferenceType.subject, ) ) for ref_name, image in backgrounds.items(): image_references.append( PixverseImageReference( img_id=await upload_image_to_pixverse(cls, image), ref_name=ref_name, type=PixverseReferenceType.background, ) ) video_references = [] for ref_name, video in videos.items(): video_references.append( PixverseVideoReference( ref_name=ref_name, video_media_id=await upload_video_to_pixverse(cls, video), ) ) return await _pixverse_generate( cls, "/proxy/pixverse/video/fusion/generate", PixverseV6FusionVideoRequest( **common, aspect_ratio=aspect_ratio, image_references=image_references or None, video_references=video_references or None, reference_mode="omni" if is_omni else None, ), ) class PixVerseExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ PixverseTextToVideoNode, PixverseImageToVideoNode, PixverseTransitionVideoNode, PixverseTemplateNode, PixverseV6TextToVideoNode, PixverseV6ImageToVideoNode, PixverseV6FirstLastFrameNode, PixverseV6ExtendVideoNode, PixverseV6FusionVideoNode, ] async def comfy_entrypoint() -> PixVerseExtension: return PixVerseExtension()