diff --git a/comfy_api_nodes/apis/heygen.py b/comfy_api_nodes/apis/heygen.py new file mode 100644 index 000000000..71b724872 --- /dev/null +++ b/comfy_api_nodes/apis/heygen.py @@ -0,0 +1,452 @@ +# (label, avatar_id, avatar_type, supported engines) +HEYGEN_AVATAR_LOOKS: list[tuple[str, str, str, tuple[str, ...]]] = [ + ( + "Annie Lounge Standing Side", + "Annie_Lounge_Standing_Side_public", + "studio_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Yara Modern Lecture Hall", + "fd6814ecc5e143cd899e615a80eaa2dc", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Brandon Business Sitting Front", + "Brandon_Business_Sitting_Front_public", + "studio_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Caroline Business Sitting Side", + "Caroline_Business_Sitting_Side_public", + "studio_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Ursula Lawyer Angle 4", + "f7173d2bb8584c00bfec6905c5e9a492", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Sofia Corporate Presenter 01 Angle 3", + "fe563971fd2d438e957372dac9e2be8c", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Seoyeon Health Nutrition Coach Angle 3", + "fe3c5d5028d941398d064b8fc64a2dea", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Sanne Fitness Coach Angle 4", + "d967f935a8bf4a0c8f0bccfd66c501d2", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ("Sander", "f5cd7b94056f495ca0610602d64a9aa3", "photo_avatar", ("avatar_v", "avatar_iv", "avatar_iii")), + ( + "Rupert Personal Development Coach Angle 4", + "f57b3e626adb4bc997b38f64884adce4", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Olivier Professor Angle 2", + "f6659bbb094b459c87c967edbb9ee481", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Obi Health Nutrition Coach Angle 5", + "f3dc2c38201d414382f506d2d8e8d029", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Matilda Modern Office Setting", + "fda889ac354a440da8dbecc410981273", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Mateo Traditional Law Office", + "ff172d6c499c4e47ba6fcc5de631e9fc", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Marlon Inviting Armchair Setting", + "f5a57db099ab462daa3e7c604a05dacc", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Margaret Professor Angle 1", + "fb472bc29ab04bcca576e3703978fecb", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Marek Therapy Coach Angle 3", + "e197768703f1463a93dc25ada1f421fb", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Maeve Warm, Professional Setting", + "faf66681d8cc48dc82c4283200b3e782", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ( + "Lorenzo Professor Angle 5", + "fc268dc244bb40d7a554663ce723dcf0", + "photo_avatar", + ("avatar_v", "avatar_iv", "avatar_iii"), + ), + ("Luca", "Luca_public", "studio_avatar", ("avatar_iii",)), + ("Bruce", "Bruce_public", "studio_avatar", ("avatar_iii",)), + ("Nico", "Nico_public", "studio_avatar", ("avatar_iii",)), + ("Lisa", "Lisa_public", "studio_avatar", ("avatar_iii",)), + ("Sophie", "Sophie_public", "studio_avatar", ("avatar_iii",)), + ("Aiko", "Aiko_public", "studio_avatar", ("avatar_iii",)), + ("Rebecca (portrait)", "Rebecca_public", "studio_avatar", ("avatar_iii",)), + ("Daphne in Grey blazer (portrait)", "Daphne_public_1", "studio_avatar", ("avatar_iii",)), + ("Bryce in Black t-shirt", "Bryce_public_5", "studio_avatar", ("avatar_iii",)), + ("Diora in White shirt", "Diora_public_3", "studio_avatar", ("avatar_iii",)), + ("Freja in White blazer", "Freja_public_1", "studio_avatar", ("avatar_iii",)), + ("Albert in Blue blazer", "Albert_public_2", "studio_avatar", ("avatar_iii",)), + ("Emery in Red blazer", "Emery_public_1", "studio_avatar", ("avatar_iii",)), + ("Minho in Blue shirt", "Minho_public_6", "studio_avatar", ("avatar_iii",)), + ("Aditya in Brown blazer", "Aditya_public_4", "studio_avatar", ("avatar_iii",)), + ("Nadim in Blue blazer", "Nadim_public_1", "studio_avatar", ("avatar_iii",)), + ("Iker in Black blazer", "Iker_public_1", "studio_avatar", ("avatar_iii",)), + ("Nour in Black blazer", "Nour_public_1", "studio_avatar", ("avatar_iii",)), + ("Saskia in Blue blazer", "Saskia_public_1", "studio_avatar", ("avatar_iii",)), + ("Lucien in Blue blazer", "Lucien_public_1", "studio_avatar", ("avatar_iii",)), + ("Esmond in Blue suit", "Esmond_public_3", "studio_avatar", ("avatar_iii",)), + ("Jinwoo in Blue suit", "Jinwoo_public_5", "studio_avatar", ("avatar_iii",)), + ("Annelore in Red sweater (portrait)", "Annelore_public_3", "studio_avatar", ("avatar_iii",)), + ("Bastien in Blue shirt", "Bastien_public_4", "studio_avatar", ("avatar_iii",)), + ("Zosia in Khaki blazer", "Zosia_public_3", "studio_avatar", ("avatar_iii",)), + ("Tahlia in Dark blue suit", "Tahlia_public_4", "studio_avatar", ("avatar_iii",)), +] +HEYGEN_AVATAR_OPTIONS = [x[0] for x in HEYGEN_AVATAR_LOOKS] +HEYGEN_AVATAR_MAP = {x[0]: (x[1], x[2], x[3]) for x in HEYGEN_AVATAR_LOOKS} + +# (label, voice_id) — Starfish-compatible voices for the TTS endpoint +HEYGEN_VOICE_TTS: list[tuple[str, str]] = [ + ("Chill Brian (English, male)", "d2f4f24783d04e22ab49ee8fdc3715e0"), + ("Zain (English, female)", "0047732240584155b1588455313e78ec"), + ("Narrator Mateo - Excited 🤩 (Spanish, male)", "0077225a877e457db4572ccaf245910b"), + ("Aria (English, female)", "007e1378fc454a9f976db570ba6164a7"), + ("Caryns (English, female)", "0082e70326864107823605db0d77f5e0"), + ("Klara (English, female)", "01209fdcd1c24a109c86dc24ee0f71c0"), + ("Bold Kasia - Excited 🤩 (Polish, female)", "015482a78b9a46ebae74bd0beb17765b"), + ("Shaun (English, male)", "01c42cddcfdc4665a57b8d89cba8ffc1"), + ("Senthil (English, male)", "01d674cfd32b4728a3fddd21b7e7d543"), + ("Cody (English, male)", "01f98ed43e6140349f47dbd37a416827"), + ("Saffron (English, female)", "0258bbc2cd8648cfa357adfb833f6d7b"), + ("Blanka - Lifelike (English, female)", "02880d1c6fd94b7799d91135581ed810"), + ("Rami (English, male)", "02d5366a90af4c7a87157808ff352e33"), + ("Rhodes (English, female)", "02dce0a169b3460084b6c914d18fb2c8"), + ("Michelle - Voice 1 (English, female)", "02X8sHnuxFpsq1caYWN0"), + ("Autumn - UGC 3 (English, female)", "03dca9ebfca441dba55fb14afa0791b7"), + ("Reassuring Rupert (English, male)", "03fcf8ecb0a94b6b94e9007edb7c35f8"), + ("Rose - UGC -2 (English, female)", "0495e14c2bd74eb3aeeef03583e0bce5"), + ("Derya - Lifelike - Broadcaster 🎙️ (English, female)", "04d0ae1d0af2489ca7d3bb402a39a890"), + ("Dynamic Derek (English, male)", "0516c2d857eb425c94e90b068241914e"), + ("Lotte (English, female)", "052fcfb83d1a4c2f8d0368c226fea4b9"), + ("Thanos - Broadcaster 🎙️ (English, male)", "054af44a167344d0af2722fdfef08d17"), + ("Marcia (English, female)", "05f19352e8f74b0392a8f411eba40de1"), + ("Camden (English, male)", "06468055edd4458aa131a1dfd813c1e9"), + ("Rumi (English, female)", "06672207805f41a9ad0af6797f8aa14b"), + ("Pippa (English, female)", "06b68c4dbb544935b9af984e80efa4fb"), + ("William Prescott - Broadcaster 🎙️ (English, male)", "06c816b952f14fa9b3a6c42aa151f731"), + ("Sammy (English, female)", "06e6facd99654b9dbb9308f67bf3a31c"), + ("Breezy Bagus (Indonesian, male)", "06e81a5d7c8b41818d3f0b38f7cf15a1"), + ("Ben (English, male)", "07ca39b243184dbcb82e7e0f0e524b21"), + ("Smooth Dev (English, male)", "07d2ba65847541feb97abc9b60181555"), + ("Daran inside booth (English, male)", "080d9383c0314056aef392892e009806"), + ("Peppy Stella (English, female)", "084760b4922a44599575c770070ec2d7"), + ("Silas (English, male)", "08f561403ec846dbbd8c691cc448f45a"), + ("Aditya (English, male)", "09c3d65e44e247dd8b78a97a903feb58"), + ("Christy (English, female)", "09d88c036bf449fa905900c08b235a37"), + ("Elio (English, male)", "0a0b38624ac64ec6afcd5842a977ca10"), + ("Luminous Laksh (Hindi, male)", "0adc547b76a5401c856274c379904eb7"), + ("Jeff (English, male)", "0add542e349f4ccaba6ecb3b7ced6034"), + ("Tahlia Brooks - Excited 🤩 (English, female)", "0b440d1ac2454d69a73302fc806522b1"), + ("Riya Mehta (Hindi, female)", "0b464b2f4e2249a4b5a05e60eaf41e7e"), + ("Ben Hart (English, male)", "0b47b5a637e944f9bfd49913999b344b"), + ("Skylar (English, female)", "0bbfbda5aa924a68a9d1da7b8496052a"), + ("Relaxed Reece (English, male)", "0c2151d538844c70a8b096de533f2828"), + ("Daniel (English, male)", "0c23804af39a4946ac6fda42bfff2738"), + ("Melani (English, female)", "0c54c6399ad64551a304e1a346677723"), + ("Clover (English, female)", "0ccb0bea067d4449ad367baeed7ea2e9"), + ("Pedro Lima - Serious 😐 (Portuguese, male)", "0d0e23e8170446e38b18a7380b2d30a8"), + ("Ana Carvalho (Portuguese, female)", "0d23c5b2f6004e909802a2e8bfcd52c2"), + ("Confident Connor - Excited 🤩 (English, male)", "0dd34c3eb79247238219eea35aeb58cd"), + ("Vibrant Victor (Spanish, male)", "1062976ea8bf42f4adc27c7e868b8fde"), + ("Young Olivier (French, male)", "1c5dc9a8f8cf4de0932f91d75f43a15d"), + ("Émile Noir (French, male)", "25a6a67280574d3da78e97b1935ebfc7"), + ("Steadfast Stefan (German, male)", "0eb85e6e8710473b82f7e88609ba3053"), + ("Deep Dieter (German, male)", "118949676b0a46629d1ad52981c3ef84"), + ("Serene Marco (Italian, male)", "72e922488a614041b5ab5f6ee07e3deb"), + ("Murmuring Matteo (Italian, male)", "755902b751654f30a6ef49e8bbcacfec"), + ("Gail in car (Multilingual, female)", "0214ac51f93e420f8711d568dcfbc50e"), + ("Daran outside walking (Multilingual, male)", "0ac81e725f4948dfa9638ceca216bcfa"), + ("BOB - Voice 1 (Chinese, unknown)", "dMkR1XwIkarpNqWUJLnX"), + ("Hakeem Hassan (Arabic, male)", "61a4359785664d01a59664ceb87ce6d4"), + ("Rami Idris (Arabic, male)", "a0bd2e5d41a74643be47ac75ca9171a2"), + ("Bold Kasia - Friendly 😊 (Polish, female)", "331624aec8b24a6c9287b8e16bdf54e8"), + ("Tranquil Tulin (Turkish, female)", "61646c861eb64e2d9036d8db51385356"), + ("Dynamic Derya (Turkish, female)", "664b73058b784aa89ddb2924c141d441"), + ("Quiet Dewa (Indonesian, male)", "1fa1193cf1d74f27ba58531c07ef9862"), + ("Cuong (Vietnamese, male)", "8af68d7ea38f4e7ca05cf46c3f7a590b"), +] +HEYGEN_VOICE_TTS_OPTIONS = [x[0] for x in HEYGEN_VOICE_TTS] +HEYGEN_VOICE_TTS_MAP = dict(HEYGEN_VOICE_TTS) + +# (label, voice_id) — top-ranked voices for video narration (any engine) +HEYGEN_VOICE_GENERAL: list[tuple[str, str]] = [ + ("Cassidy (English, female)", "16a09e4706f74997ba4ed05ea11470f6"), + ("Hope (English, female)", "42d00d4aac5441279d8536cd6b52c53c"), + ("Archer (English, male)", "453c20e1525a429080e2ad9e4b26f2cd"), + ("Brittney (English, female)", "4754e1ec667544b0bd18cdf4bec7d6a7"), + ("Mark (English, male)", "5d8c378ba8c3434586081a52ac368738"), + ("Andrew (English, male)", "6be73833ef9a4eb0aeee399b8fe9d62b"), + ("Spuds Oxley (English, male)", "76940a9adcd0490a9ce2cfe9a64a2664"), + ("Patrick (English, male)", "7e157ec62c9c45f1adca12faae72c86f"), + ("David Castlemore (English, male)", "828b59f834fd4c7188da322b6d9b6c75"), + ("Michael C (English, male)", "8661cd40d6c44c709e2d0031c0186ada"), + ("Adam Stone (English, male)", "88bb9ee1c81b466eb2a08fdde86d3619"), + ("Alex (English, male)", "897d6a9b2c844f56aa077238768fe10a"), + ("Monika Sogam (English, female)", "97dd67ab8ce242b6a9e7689cb00c6414"), + ("Jessica Anne Bogart (English, female)", "b966c31caf124c2a99f19ff1479c964f"), + ("John Doe (English, male)", "c4a8ceb7a2954500bc047fb092bcff3f"), + ("Ivy (English, female)", "cef3bc4e0a84424cafcde6f2cf466c97"), + ("Chill Brian (English, male)", "d2f4f24783d04e22ab49ee8fdc3715e0"), + ("Allison (English, female)", "f8c69e517f424cafaecde32dde57096b"), + ("Mia Starset (Norwegian, female)", "000466f8ac6d47a49f5743d50b3778de"), + ("William Shanks (Spanish, male)", "001248bb63f847888d37b766ee8b3a47"), + ("Zain (English, female)", "0047732240584155b1588455313e78ec"), + ("Jora Slobod (Romanian, male)", "00631519159a402ab5d8f719e51532bb"), + ("Narrator Mateo - Excited 🤩 (Spanish, male)", "0077225a877e457db4572ccaf245910b"), + ("Aria (English, female)", "007e1378fc454a9f976db570ba6164a7"), + ("Caryns (English, female)", "0082e70326864107823605db0d77f5e0"), + ("Klara (English, female)", "01209fdcd1c24a109c86dc24ee0f71c0"), + ("Son Tran (Vietnamese, male)", "0132f85950a94d11ba180f885101bf84"), + ("Bold Kasia - Excited 🤩 (Polish, female)", "015482a78b9a46ebae74bd0beb17765b"), + ("Marc Aurèle (French, male)", "018a94cf15574491a0bab7f6799ac15b"), + ("Shaun (English, male)", "01c42cddcfdc4665a57b8d89cba8ffc1"), + ("Senthil (English, male)", "01d674cfd32b4728a3fddd21b7e7d543"), + ("Cody (English, male)", "01f98ed43e6140349f47dbd37a416827"), + ("Saffron (English, female)", "0258bbc2cd8648cfa357adfb833f6d7b"), + ("Blanka - Lifelike (English, female)", "02880d1c6fd94b7799d91135581ed810"), + ("Rami (English, male)", "02d5366a90af4c7a87157808ff352e33"), + ("Rhodes (English, female)", "02dce0a169b3460084b6c914d18fb2c8"), + ("Michelle - Voice 1 (English, female)", "02X8sHnuxFpsq1caYWN0"), + ("Tuba (, female)", "034ca0c32b6542028748d6d365d90d6a"), + ("Autumn - UGC 3 (English, female)", "03dca9ebfca441dba55fb14afa0791b7"), + ("Reassuring Rupert (English, male)", "03fcf8ecb0a94b6b94e9007edb7c35f8"), +] +HEYGEN_VOICE_GENERAL_OPTIONS = [x[0] for x in HEYGEN_VOICE_GENERAL] +HEYGEN_VOICE_GENERAL_MAP = dict(HEYGEN_VOICE_GENERAL) + +HEYGEN_TRANSLATE_LANGUAGES = [ + "English", + "Spanish", + "Spanish (Spain)", + "Spanish (Mexico)", + "French", + "French (France)", + "German", + "German (Germany)", + "Portuguese", + "Portuguese (Brazil)", + "Italian", + "Italian (Italy)", + "Japanese", + "Japanese (Japan)", + "Korean", + "Chinese (Mandarin, Simplified)", + "Arabic", + "Hindi", + "Hindi (India)", + "Russian", + "Russian (Russia)", + "Dutch", + "Polish", + "Turkish", + "Indonesian", + "Vietnamese", + "Ukrainian", + "Afrikaans (South Africa)", + "Albanian (Albania)", + "Amharic (Ethiopia)", + "Arabic (Algeria)", + "Arabic (Bahrain)", + "Arabic (Egypt)", + "Arabic (Iraq)", + "Arabic (Jordan)", + "Arabic (Kuwait)", + "Arabic (Lebanon)", + "Arabic (Libya)", + "Arabic (Morocco)", + "Arabic (Oman)", + "Arabic (Qatar)", + "Arabic (Saudi Arabia)", + "Arabic (Syria)", + "Arabic (Tunisia)", + "Arabic (United Arab Emirates)", + "Arabic (World)", + "Arabic (Yemen)", + "Armenian (Armenia)", + "Azerbaijani (Latin, Azerbaijan)", + "Bangla (Bangladesh)", + "Basque", + "Belarusian (Belarus)", + "Bengali (India)", + "Bosnian (Bosnia and Herzegovina)", + "Bulgarian", + "Bulgarian (Bulgaria)", + "Burmese (Myanmar)", + "Catalan", + "Chinese (Cantonese, Traditional)", + "Chinese (Jilu Mandarin, Simplified)", + "Chinese (Northeastern Mandarin, Simplified)", + "Chinese (Southwestern Mandarin, Simplified)", + "Chinese (Taiwanese Mandarin, Traditional)", + "Chinese (Wu, Simplified)", + "Chinese (Zhongyuan Mandarin Henan, Simplified)", + "Chinese (Zhongyuan Mandarin Shaanxi, Simplified)", + "Croatian", + "Croatian (Croatia)", + "Czech", + "Czech (Czechia)", + "Danish", + "Danish (Denmark)", + "Dutch (Belgium)", + "Dutch (Netherlands)", + "English (Australia)", + "English (Canada)", + "English (Hong Kong SAR)", + "English (India)", + "English (Ireland)", + "English (Kenya)", + "English (New Zealand)", + "English (Nigeria)", + "English (Philippines)", + "English (Singapore)", + "English (South Africa)", + "English (Tanzania)", + "English (UK)", + "English (United States)", + "Estonian (Estonia)", + "Filipino", + "Filipino (Cebuano)", + "Filipino (Philippines)", + "Finnish", + "Finnish (Finland)", + "French (Belgium)", + "French (Canada)", + "French (Switzerland)", + "Galician", + "Georgian (Georgia)", + "German (Austria)", + "German (Switzerland)", + "Greek", + "Greek (Greece)", + "Gujarati (India)", + "Haitian Creole (Haiti)", + "Hebrew (Israel)", + "Hungarian (Hungary)", + "Icelandic (Iceland)", + "Indonesian (Indonesia)", + "Irish (Ireland)", + "Javanese (Latin, Indonesia)", + "Kannada (India)", + "Kazakh (Kazakhstan)", + "Khmer (Cambodia)", + "Konkani (India)", + "Korean (Korea)", + "Lao (Laos)", + "Latin (Vatican City)", + "Latvian (Latvia)", + "Lithuanian (Lithuania)", + "Luxembourgish (Luxembourg)", + "Macedonian (North Macedonia)", + "Maithili (India)", + "Malagasy (Madagascar)", + "Malay", + "Malay (Malaysia)", + "Malayalam (India)", + "Maltese (Malta)", + "Mandarin", + "Marathi (India)", + "Mongolian (Mongolia)", + "Nepali (Nepal)", + "Norwegian Bokmål (Norway)", + "Norwegian Nynorsk (Norway)", + "Odia (India)", + "Pashto (Afghanistan)", + "Persian (Iran)", + "Polish (Poland)", + "Portuguese (Portugal)", + "Punjabi (India)", + "Romanian", + "Romanian (Romania)", + "Serbian (Latin, Serbia)", + "Sindhi (India)", + "Sinhala (Sri Lanka)", + "Slovak", + "Slovak (Slovakia)", + "Slovenian (Slovenia)", + "Somali (Somalia)", + "Spanish (Argentina)", + "Spanish (Bolivia)", + "Spanish (Chile)", + "Spanish (Colombia)", + "Spanish (Costa Rica)", + "Spanish (Cuba)", + "Spanish (Dominican Republic)", + "Spanish (Ecuador)", + "Spanish (El Salvador)", + "Spanish (Equatorial Guinea)", + "Spanish (Guatemala)", + "Spanish (Honduras)", + "Spanish (Latin America)", + "Spanish (Nicaragua)", + "Spanish (Panama)", + "Spanish (Paraguay)", + "Spanish (Peru)", + "Spanish (Puerto Rico)", + "Spanish (United States)", + "Spanish (Uruguay)", + "Spanish (Venezuela)", + "Sundanese (Indonesia)", + "Swahili (Kenya)", + "Swahili (Tanzania)", + "Swedish", + "Swedish (Sweden)", + "Tamil", + "Tamil (India)", + "Tamil (Malaysia)", + "Tamil (Singapore)", + "Tamil (Sri Lanka)", + "Telugu (India)", + "Thai (Thailand)", + "Turkish (Türkiye)", + "Ukrainian (Ukraine)", + "Urdu (India)", + "Urdu (Pakistan)", + "Uzbek (Latin, Uzbekistan)", + "Vietnamese (Vietnam)", + "Welsh (United Kingdom)", + "Zulu (South Africa)", +] diff --git a/comfy_api_nodes/nodes_heygen.py b/comfy_api_nodes/nodes_heygen.py new file mode 100644 index 000000000..abc23c66d --- /dev/null +++ b/comfy_api_nodes/nodes_heygen.py @@ -0,0 +1,751 @@ +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)" + + +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 + 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 + 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", "4k"], + 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.Combo.Input( + "avatar", + options=HEYGEN_AVATAR_OPTIONS, + tooltip="Avatar look to present the video (curated from HeyGen's public library).", + ), + 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( + "engine", + options=["auto", "avatar_iv", "avatar_iii", "avatar_v"], + default="auto", + optional=True, + tooltip="Rendering engine. 'auto' picks the best engine the avatar supports " + "(Avatar IV preferred). Avatar V is highest fidelity, Avatar III is the most affordable.", + ), + IO.Combo.Input( + "resolution", + options=["720p", "1080p", "4k"], + default="1080p", + optional=True, + tooltip="Output video resolution. 4k requires the Avatar III engine.", + ), + 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, + avatar: str, + speech: dict, + custom_avatar_id: str = "", + engine: str = "auto", + resolution: str = "1080p", + aspect_ratio: str = "auto", + background_color: str = "", + seed: int = 0, + ) -> IO.NodeOutput: + avatar_id, engine_type = await _resolve_avatar(cls, avatar, custom_avatar_id, 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}"), + # A missing status means the look needed no training and is ready. + 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()