| import uuid |
|
|
| import torch |
| from typing_extensions import override |
|
|
| from comfy_api.latest import IO, ComfyExtension, Input |
| from comfy_api_nodes.apis.heygen import ( |
| HEYGEN_AVATAR_MAP, |
| HEYGEN_AVATAR_OPTIONS, |
| HEYGEN_TRANSLATE_LANGUAGES, |
| HEYGEN_VOICE_GENERAL_MAP, |
| HEYGEN_VOICE_GENERAL_OPTIONS, |
| HEYGEN_VOICE_TTS_MAP, |
| HEYGEN_VOICE_TTS_OPTIONS, |
| ) |
| from comfy_api_nodes.util import ( |
| ApiEndpoint, |
| audio_bytes_to_audio_input, |
| download_url_as_bytesio, |
| download_url_to_image_tensor, |
| download_url_to_video_output, |
| downscale_image_tensor_by_max_side, |
| get_number_of_images, |
| poll_op_raw, |
| sync_op_raw, |
| upload_audio_to_comfyapi, |
| upload_image_to_comfyapi, |
| upload_images_to_comfyapi, |
| upload_video_to_comfyapi, |
| validate_string, |
| ) |
| from server import PromptServer |
|
|
| _VIDEOS_PATH = "/proxy/heygen/v3/videos" |
| _TRANSLATIONS_PATH = "/proxy/heygen/v3/video-translations" |
| _SPEECH_PATH = "/proxy/heygen/v3/voices/speech" |
| _AVATARS_PATH = "/proxy/heygen/v3/avatars" |
| _LOOKS_PATH = "/proxy/heygen/v3/avatars/looks" |
|
|
| _DEFAULT_VOICE_OPTION = "(avatar's default voice)" |
|
|
| _AVATARS_BY_ENGINE = { |
| e: [label for label, (_aid, _atype, engines) in HEYGEN_AVATAR_MAP.items() if e in engines] |
| for e in ("avatar_iv", "avatar_iii", "avatar_v") |
| } |
|
|
|
|
| async def _apply_speech_source(cls: type[IO.ComfyNode], payload: dict, speech: dict, require_voice: bool) -> None: |
| """Fill script/audio speech fields of a /v3/videos payload from the DynamicCombo dict.""" |
| if speech["speech"] == "audio": |
| payload["audio_url"] = await upload_audio_to_comfyapi( |
| cls, speech["audio"], container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg" |
| ) |
| elif speech["speech"] == "script": |
| validate_string(speech["text"], strip_whitespace=True, min_length=1, max_length=5000) |
| payload["script"] = speech["text"] |
| voice_id = speech.get("custom_voice_id", "").strip() |
| if not voice_id and speech["voice"] != _DEFAULT_VOICE_OPTION: |
| voice_id = HEYGEN_VOICE_GENERAL_MAP[speech["voice"]] |
| if voice_id: |
| payload["voice_id"] = voice_id |
| elif require_voice: |
| raise ValueError("A voice is required when driving the video with a text script.") |
| speed = speech.get("voice_speed", 1.0) |
| if speed != 1.0: |
| payload["voice_settings"] = {"speed": round(speed, 2)} |
|
|
|
|
| async def _create_and_poll_video(cls: type[IO.ComfyNode], payload: dict) -> dict: |
| """POST a /v3/videos payload, poll until terminal, and return the final video data.""" |
| created = await sync_op_raw( |
| cls, |
| ApiEndpoint(path=_VIDEOS_PATH, method="POST", headers={"Idempotency-Key": uuid.uuid4().hex}), |
| data=payload, |
| ) |
| video_id = (created.get("data") or {}).get("video_id") |
| if not video_id: |
| raise ValueError(f"HeyGen did not return a video_id: {created}") |
| final = await poll_op_raw( |
| cls, |
| ApiEndpoint(path=f"{_VIDEOS_PATH}/{video_id}"), |
| status_extractor=lambda r: (r.get("data") or {}).get("status"), |
| queued_statuses=["pending", "waiting"], |
| poll_interval=5.0, |
| ) |
| data = final["data"] |
| if not data.get("video_url"): |
| raise ValueError(f"HeyGen returned no video_url for video {video_id}.") |
| return data |
|
|
|
|
| async def _resolve_avatar( |
| cls: type[IO.ComfyNode], avatar_label: str, custom_avatar_id: str, engine_choice: str |
| ) -> tuple[str, str | None]: |
| """Resolve (avatar_id, engine_type) from the combo/override + engine widgets.""" |
| custom_avatar_id = custom_avatar_id.strip() |
| if custom_avatar_id: |
| look = ( |
| await sync_op_raw( |
| cls, |
| ApiEndpoint(path=f"{_LOOKS_PATH}/{custom_avatar_id}"), |
| final_label_on_success=None, |
| ) |
| ).get("data") or {} |
| avatar_id = custom_avatar_id |
| avatar_label = look.get("name") or custom_avatar_id |
| supported = look.get("supported_api_engines") or [] |
| else: |
| avatar_id, avatar_type, supported = HEYGEN_AVATAR_MAP[avatar_label] |
|
|
| if engine_choice == "auto": |
| engine = next((e for e in ("avatar_iv", "avatar_iii", "avatar_v") if e in supported), None) |
| else: |
| engine = engine_choice |
| if supported and engine not in supported: |
| raise ValueError( |
| f"Avatar '{avatar_label}' does not support the {engine} engine " |
| f"(supported: {', '.join(supported)}). Set engine to 'auto' to pick " |
| "a compatible engine automatically." |
| ) |
| return avatar_id, engine |
|
|
|
|
| class HeyGenTalkingPhotoNode(IO.ComfyNode): |
| """Animate a still image of a person into a lip-synced talking video.""" |
|
|
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="HeyGenTalkingPhotoNode", |
| display_name="HeyGen Talking Photo", |
| category="partner/video/HeyGen", |
| description="Animate any image of a person into a lip-synced talking video " |
| "(HeyGen Avatar IV). Drive it with a text script or your own audio.", |
| inputs=[ |
| IO.Image.Input( |
| "image", |
| tooltip="Image of a person to animate. Downscaled automatically if larger than 2K.", |
| ), |
| IO.DynamicCombo.Input( |
| "speech", |
| display_name="speech source", |
| options=[ |
| IO.DynamicCombo.Option( |
| "script", |
| [ |
| IO.String.Input( |
| "text", |
| multiline=True, |
| default="", |
| tooltip="Text for the avatar to speak (up to 5000 characters). " |
| "The generated speech must be at least 1 second long.", |
| ), |
| IO.Combo.Input( |
| "voice", |
| options=HEYGEN_VOICE_GENERAL_OPTIONS, |
| tooltip="Voice for the script (HeyGen's most popular voices).", |
| ), |
| IO.String.Input( |
| "custom_voice_id", |
| default="", |
| optional=True, |
| tooltip="Optional HeyGen voice ID. When set, overrides the voice selected above. " |
| "Any voice from HeyGen's library (2000+) can be used.", |
| ), |
| IO.Float.Input( |
| "voice_speed", |
| default=1.0, |
| min=0.5, |
| max=1.5, |
| step=0.05, |
| optional=True, |
| tooltip="Speech speed multiplier.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "audio", |
| [ |
| IO.Audio.Input( |
| "audio", |
| tooltip="Audio for the avatar to lip-sync, up to 10 minutes.", |
| ), |
| ], |
| ), |
| ], |
| tooltip="Drive the avatar with a text script (HeyGen text-to-speech) or your own audio.", |
| ), |
| IO.Combo.Input( |
| "resolution", |
| options=["720p", "1080p"], |
| default="1080p", |
| optional=True, |
| tooltip="Output video resolution.", |
| ), |
| IO.Combo.Input( |
| "aspect_ratio", |
| options=["auto", "16:9", "9:16", "1:1", "4:5", "5:4"], |
| default="auto", |
| optional=True, |
| tooltip="Output aspect ratio. 'auto' follows the input image.", |
| ), |
| IO.Combo.Input( |
| "expressiveness", |
| options=["low", "medium", "high"], |
| default="low", |
| optional=True, |
| tooltip="How expressive the animated face and gestures are.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=2147483647, |
| control_after_generate=True, |
| optional=True, |
| tooltip="Not sent to HeyGen; change it to force a re-run.", |
| ), |
| ], |
| 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=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.0715,"format":{"suffix":"/second"}}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| image: Input.Image, |
| speech: dict, |
| resolution: str = "1080p", |
| aspect_ratio: str = "auto", |
| expressiveness: str = "low", |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| image = downscale_image_tensor_by_max_side(image, max_side=2000) |
| image_url = await upload_image_to_comfyapi(cls, image, mime_type="image/png", total_pixels=None) |
| payload = { |
| "type": "image", |
| "image": {"type": "url", "url": image_url}, |
| "resolution": resolution, |
| "aspect_ratio": aspect_ratio, |
| "expressiveness": expressiveness, |
| "title": "ComfyUI Talking Photo", |
| } |
| await _apply_speech_source(cls, payload, speech, require_voice=True) |
| video = await _create_and_poll_video(cls, payload) |
| return IO.NodeOutput(await download_url_to_video_output(video["video_url"])) |
|
|
|
|
| class HeyGenAvatarVideoNode(IO.ComfyNode): |
| """Generate a presenter video from a HeyGen avatar look.""" |
|
|
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="HeyGenAvatarVideoNode", |
| display_name="HeyGen Avatar Video", |
| category="partner/video/HeyGen", |
| description="Generate a talking-presenter video from a HeyGen avatar. " |
| "Includes HeyGen's most popular public avatars; any look ID can be supplied as an override.", |
| inputs=[ |
| IO.DynamicCombo.Input( |
| "engine", |
| options=[ |
| IO.DynamicCombo.Option( |
| "auto", |
| [ |
| IO.Combo.Input( |
| "avatar", |
| options=HEYGEN_AVATAR_OPTIONS, |
| tooltip="Avatar look to present the video (curated from HeyGen's " |
| "public library). The best engine the look supports is chosen " |
| "automatically.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "avatar_iv", |
| [ |
| IO.Combo.Input( |
| "avatar", |
| options=_AVATARS_BY_ENGINE["avatar_iv"], |
| tooltip="Avatar looks that support the Avatar IV engine.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "avatar_iii", |
| [ |
| IO.Combo.Input( |
| "avatar", |
| options=_AVATARS_BY_ENGINE["avatar_iii"], |
| tooltip="Avatar looks that support the Avatar III engine.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "avatar_v", |
| [ |
| IO.Combo.Input( |
| "avatar", |
| options=_AVATARS_BY_ENGINE["avatar_v"], |
| tooltip="Avatar looks that support the Avatar V engine.", |
| ), |
| ], |
| ), |
| ], |
| tooltip="Rendering engine; each choice lists only the avatars that support it. " |
| "'auto' offers every avatar and picks its best engine (Avatar IV preferred). " |
| "Avatar V is highest fidelity, Avatar III is the most affordable.", |
| ), |
| IO.String.Input( |
| "custom_avatar_id", |
| default="", |
| optional=True, |
| tooltip="Optional HeyGen avatar look ID. When set, overrides the avatar selected above. " |
| "Any of HeyGen's 3000+ public looks (or your private avatars) can be used.", |
| ), |
| IO.DynamicCombo.Input( |
| "speech", |
| display_name="speech source", |
| options=[ |
| IO.DynamicCombo.Option( |
| "script", |
| [ |
| IO.String.Input( |
| "text", |
| multiline=True, |
| default="", |
| tooltip="Text for the avatar to speak (up to 5000 characters). " |
| "The generated speech must be at least 1 second long.", |
| ), |
| IO.Combo.Input( |
| "voice", |
| options=[_DEFAULT_VOICE_OPTION] + HEYGEN_VOICE_GENERAL_OPTIONS, |
| tooltip="Voice for the script. The default option uses the voice HeyGen assigned to the avatar.", |
| ), |
| IO.String.Input( |
| "custom_voice_id", |
| default="", |
| optional=True, |
| tooltip="Optional HeyGen voice ID. When set, overrides the voice selected above. " |
| "Any voice from HeyGen's library (2000+) can be used.", |
| ), |
| IO.Float.Input( |
| "voice_speed", |
| default=1.0, |
| min=0.5, |
| max=1.5, |
| step=0.05, |
| optional=True, |
| tooltip="Speech speed multiplier.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "audio", |
| [ |
| IO.Audio.Input( |
| "audio", |
| tooltip="Audio for the avatar to lip-sync, up to 10 minutes.", |
| ), |
| ], |
| ), |
| ], |
| tooltip="Drive the avatar with a text script (HeyGen text-to-speech) or your own audio.", |
| ), |
| IO.Combo.Input( |
| "resolution", |
| options=["720p", "1080p"], |
| default="1080p", |
| optional=True, |
| tooltip="Output video resolution.", |
| ), |
| IO.Combo.Input( |
| "aspect_ratio", |
| options=["auto", "16:9", "9:16", "1:1", "4:5", "5:4"], |
| default="auto", |
| optional=True, |
| tooltip="Output aspect ratio. 'auto' follows the avatar's source footage.", |
| ), |
| IO.String.Input( |
| "background_color", |
| default="", |
| optional=True, |
| tooltip="Optional solid background color as a hex code (e.g. '#00ff00'). " |
| "Leave empty for the avatar's own background.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=2147483647, |
| control_after_generate=True, |
| optional=True, |
| tooltip="Not sent to HeyGen; change it to force a re-run.", |
| ), |
| ], |
| 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=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["engine"]), |
| expr=""" |
| widgets.engine = "avatar_iii" |
| ? {"type":"range_usd","min_usd":0.023881,"max_usd":0.061919,"format":{"suffix":"/second"}} |
| : widgets.engine = "avatar_v" |
| ? {"type":"usd","usd":0.095381,"format":{"suffix":"/second"}} |
| : widgets.engine = "avatar_iv" |
| ? {"type":"range_usd","min_usd":0.0715,"max_usd":0.095381,"format":{"suffix":"/second"}} |
| : {"type":"range_usd","min_usd":0.023881,"max_usd":0.095381,"format":{"suffix":"/second"}} |
| """, |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| engine: dict, |
| speech: dict, |
| custom_avatar_id: str = "", |
| resolution: str = "1080p", |
| aspect_ratio: str = "auto", |
| background_color: str = "", |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| avatar_id, engine_type = await _resolve_avatar(cls, engine["avatar"], custom_avatar_id, engine["engine"]) |
| payload = { |
| "type": "avatar", |
| "avatar_id": avatar_id, |
| "resolution": resolution, |
| "aspect_ratio": aspect_ratio, |
| "title": "ComfyUI Avatar Video", |
| } |
| if engine_type: |
| payload["engine"] = {"type": engine_type} |
| background_color = background_color.strip() |
| if background_color: |
| if not background_color.startswith("#"): |
| raise ValueError("background_color must be a hex color code like '#00ff00'.") |
| payload["background"] = {"type": "color", "value": background_color} |
| await _apply_speech_source(cls, payload, speech, require_voice=False) |
| video = await _create_and_poll_video(cls, payload) |
| return IO.NodeOutput(await download_url_to_video_output(video["video_url"])) |
|
|
|
|
| class HeyGenCreateAvatarNode(IO.ComfyNode): |
| """Create a reusable HeyGen avatar from a photo or a text prompt.""" |
|
|
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="HeyGenCreateAvatarNode", |
| display_name="HeyGen Create Avatar", |
| category="partner/video/HeyGen", |
| description="Create your own reusable HeyGen avatar from a photo of a person or " |
| "from a text prompt (a generated character). Feed the resulting avatar_id into " |
| "HeyGen Avatar Video's custom_avatar_id — and save the ID somewhere to reuse the " |
| "avatar in future workflows.", |
| inputs=[ |
| IO.DynamicCombo.Input( |
| "source", |
| options=[ |
| IO.DynamicCombo.Option( |
| "prompt", |
| [ |
| IO.String.Input( |
| "prompt", |
| multiline=True, |
| default="", |
| tooltip="Description of the avatar to generate (up to 1000 characters).", |
| ), |
| IO.Autogrow.Input( |
| "reference_images", |
| template=IO.Autogrow.TemplateNames( |
| IO.Image.Input("ref_image"), |
| names=[f"ref_image_{i}" for i in range(1, 4)], |
| min=0, |
| ), |
| tooltip="Up to 3 reference images guiding the generated look.", |
| ), |
| ], |
| ), |
| IO.DynamicCombo.Option( |
| "photo", |
| [ |
| IO.Image.Input( |
| "identity_photo", |
| tooltip="Photo of the person to turn into an avatar. " |
| "Downscaled automatically if larger than 2K.", |
| ), |
| ], |
| ), |
| ], |
| tooltip="Generate a new character from a text prompt, or create the avatar " |
| "from a connected photo of a person.", |
| ), |
| ], |
| outputs=[ |
| IO.String.Output( |
| display_name="avatar_id", |
| tooltip="Avatar look ID. Pass it to HeyGen Avatar Video's custom_avatar_id; " |
| "save it to reuse the avatar later.", |
| ), |
| IO.Image.Output(display_name="preview"), |
| ], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":1.43}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| source: dict, |
| ) -> IO.NodeOutput: |
| payload: dict = {"name": "ComfyUI Avatar"} |
| if source["source"] == "photo": |
| image = downscale_image_tensor_by_max_side(source["identity_photo"], max_side=2000) |
| image_url = await upload_image_to_comfyapi(cls, image, mime_type="image/png", total_pixels=None) |
| payload["type"] = "photo" |
| payload["file"] = {"type": "url", "url": image_url} |
| else: |
| validate_string(source["prompt"], strip_whitespace=True, min_length=1, max_length=1000) |
| payload["type"] = "prompt" |
| payload["prompt"] = source["prompt"] |
| ref_tensors = [t for t in (source.get("reference_images") or {}).values() if t is not None] |
| if ref_tensors: |
| n_images = sum(get_number_of_images(t) for t in ref_tensors) |
| if n_images > 3: |
| raise ValueError(f"HeyGen accepts at most 3 reference images; got {n_images}.") |
| scaled = [downscale_image_tensor_by_max_side(t, max_side=2000) for t in ref_tensors] |
| ref_urls = await upload_images_to_comfyapi( |
| cls, scaled, max_images=3, mime_type="image/png", total_pixels=None |
| ) |
| payload["reference_images"] = [{"type": "url", "url": u} for u in ref_urls] |
| created = await sync_op_raw( |
| cls, |
| ApiEndpoint(path=_AVATARS_PATH, method="POST"), |
| data=payload, |
| ) |
| look_id = ((created.get("data") or {}).get("avatar_item") or {}).get("id") |
| if not look_id: |
| raise ValueError(f"HeyGen did not return an avatar: {created}") |
| final = await poll_op_raw( |
| cls, |
| ApiEndpoint(path=f"{_LOOKS_PATH}/{look_id}"), |
| |
| status_extractor=lambda r: (r.get("data") or {}).get("status") or "completed", |
| failed_statuses=["failed", "pending_consent"], |
| poll_interval=5.0, |
| ) |
| data = final["data"] |
| if data.get("preview_image_url"): |
| preview = await download_url_to_image_tensor(data["preview_image_url"]) |
| else: |
| preview = torch.zeros(1, 64, 64, 3) |
| PromptServer.instance.send_progress_text( |
| f"Please save the avatar_id for reuse.\n\navatar_id: {look_id}", |
| cls.hidden.unique_id, |
| ) |
| return IO.NodeOutput(look_id, preview) |
|
|
|
|
| class HeyGenVideoTranslateNode(IO.ComfyNode): |
| """Translate a spoken video into another language with voice cloning and lip sync.""" |
|
|
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="HeyGenVideoTranslateNode", |
| display_name="HeyGen Video Translate", |
| category="partner/video/HeyGen", |
| description="Translate a spoken video into another language. Clones the original " |
| "speaker's voice and re-animates the mouth to match the translated speech.", |
| inputs=[ |
| IO.Video.Input( |
| "video", |
| tooltip="Video with speech to translate.", |
| ), |
| IO.Combo.Input( |
| "output_language", |
| options=HEYGEN_TRANSLATE_LANGUAGES, |
| tooltip="Target language for the translated video.", |
| ), |
| IO.Combo.Input( |
| "mode", |
| options=["speed", "precision"], |
| default="speed", |
| tooltip="'speed' is faster; 'precision' produces higher-quality lip sync at twice the price.", |
| ), |
| IO.Boolean.Input( |
| "translate_audio_only", |
| default=False, |
| optional=True, |
| tooltip="Only swap the audio track, keeping the original mouth movements (no lip sync).", |
| ), |
| IO.Int.Input( |
| "speaker_count", |
| default=0, |
| min=0, |
| max=10, |
| optional=True, |
| tooltip="Number of speakers in the video. 0 = detect automatically.", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=2147483647, |
| control_after_generate=True, |
| optional=True, |
| tooltip="Not sent to HeyGen; change it to force a re-run.", |
| ), |
| ], |
| 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=IO.PriceBadge( |
| depends_on=IO.PriceBadgeDepends(widgets=["mode"]), |
| expr="""{"type":"usd","usd": widgets.mode = "precision" ? 0.095381 : 0.047619,""" |
| """"format":{"suffix":"/second"}}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| video: Input.Video, |
| output_language: str, |
| mode: str, |
| translate_audio_only: bool = False, |
| speaker_count: int = 0, |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| video_url = await upload_video_to_comfyapi(cls, video) |
| payload = { |
| "video": {"type": "url", "url": video_url}, |
| "output_languages": [output_language], |
| "mode": mode, |
| "translate_audio_only": translate_audio_only, |
| "title": "ComfyUI Video Translate", |
| } |
| if speaker_count > 0: |
| payload["speaker_num"] = speaker_count |
| created = await sync_op_raw( |
| cls, |
| ApiEndpoint(path=_TRANSLATIONS_PATH, method="POST"), |
| data=payload, |
| ) |
| translation_ids = (created.get("data") or {}).get("video_translation_ids") or [] |
| if not translation_ids: |
| raise ValueError(f"HeyGen did not return a translation ID: {created}") |
| final = await poll_op_raw( |
| cls, |
| ApiEndpoint(path=f"{_TRANSLATIONS_PATH}/{translation_ids[0]}"), |
| status_extractor=lambda r: (r.get("data") or {}).get("status"), |
| queued_statuses=["pending"], |
| poll_interval=5.0, |
| ) |
| data = final["data"] |
| if not data.get("video_url"): |
| raise ValueError(f"HeyGen returned no video_url for translation {translation_ids[0]}.") |
| return IO.NodeOutput(await download_url_to_video_output(data["video_url"])) |
|
|
|
|
| class HeyGenTextToSpeechNode(IO.ComfyNode): |
| """Synthesize speech audio from text with HeyGen's Starfish TTS engine.""" |
|
|
| @classmethod |
| def define_schema(cls) -> IO.Schema: |
| return IO.Schema( |
| node_id="HeyGenTextToSpeechNode", |
| display_name="HeyGen Text to Speech", |
| category="partner/audio/HeyGen", |
| description="Generate speech audio from text using HeyGen's Starfish TTS engine. " |
| "Includes HeyGen's most popular voices across 17 languages.", |
| inputs=[ |
| IO.String.Input( |
| "text", |
| multiline=True, |
| default="", |
| tooltip="Text to synthesize (up to 5000 characters). The generated speech " |
| "must be at least 1 second long.", |
| ), |
| IO.Combo.Input( |
| "voice", |
| options=HEYGEN_VOICE_TTS_OPTIONS, |
| tooltip="Voice to use (curated from HeyGen's most popular Starfish-compatible voices).", |
| ), |
| IO.String.Input( |
| "custom_voice_id", |
| default="", |
| optional=True, |
| tooltip="Optional HeyGen voice ID. When set, overrides the voice selected above. " |
| "The voice must support the Starfish engine.", |
| ), |
| IO.Float.Input( |
| "speed", |
| default=1.0, |
| min=0.5, |
| max=2.0, |
| step=0.05, |
| optional=True, |
| tooltip="Speech speed multiplier.", |
| ), |
| IO.Boolean.Input( |
| "ssml", |
| default=False, |
| optional=True, |
| tooltip="Treat the text as SSML markup (for pauses, emphasis, and pronunciation control).", |
| ), |
| IO.Int.Input( |
| "seed", |
| default=42, |
| min=0, |
| max=2147483647, |
| control_after_generate=True, |
| optional=True, |
| tooltip="Not sent to HeyGen; change it to force a re-run.", |
| ), |
| ], |
| outputs=[IO.Audio.Output()], |
| hidden=[ |
| IO.Hidden.auth_token_comfy_org, |
| IO.Hidden.api_key_comfy_org, |
| IO.Hidden.unique_id, |
| ], |
| is_api_node=True, |
| price_badge=IO.PriceBadge( |
| expr="""{"type":"usd","usd":0.00095381,"format":{"approximate":true,"suffix":"/second"}}""", |
| ), |
| ) |
|
|
| @classmethod |
| async def execute( |
| cls, |
| text: str, |
| voice: str, |
| custom_voice_id: str = "", |
| speed: float = 1.0, |
| ssml: bool = False, |
| seed: int = 0, |
| ) -> IO.NodeOutput: |
| validate_string(text, strip_whitespace=True, min_length=1, max_length=5000) |
| payload = { |
| "text": text, |
| "voice_id": custom_voice_id.strip() or HEYGEN_VOICE_TTS_MAP[voice], |
| "speed": round(speed, 2), |
| } |
| if ssml: |
| payload["input_type"] = "ssml" |
| response = await sync_op_raw( |
| cls, |
| ApiEndpoint(path=_SPEECH_PATH, method="POST"), |
| data=payload, |
| ) |
| audio_url = (response.get("data") or {}).get("audio_url") |
| if not audio_url: |
| raise ValueError(f"HeyGen did not return an audio_url: {response}") |
| audio_bytes = await download_url_as_bytesio(audio_url) |
| return IO.NodeOutput(audio_bytes_to_audio_input(audio_bytes.getvalue())) |
|
|
|
|
| class HeyGenExtension(ComfyExtension): |
| @override |
| async def get_node_list(self) -> list[type[IO.ComfyNode]]: |
| return [ |
| HeyGenTalkingPhotoNode, |
| HeyGenAvatarVideoNode, |
| HeyGenCreateAvatarNode, |
| HeyGenVideoTranslateNode, |
| HeyGenTextToSpeechNode, |
| ] |
|
|
|
|
| async def comfy_entrypoint() -> HeyGenExtension: |
| return HeyGenExtension() |
|
|