from typing_extensions import override from comfy_api.latest import IO, ComfyExtension, Input from comfy_api_nodes.apis.sync_so import ( SyncActiveSpeakerDetection, SyncGeneration, SyncGenerationOptions, SyncGenerationRequest, SyncInputItem, ) from comfy_api_nodes.util import ( ApiEndpoint, download_url_to_video_output, downscale_image_tensor, downscale_image_tensor_by_max_side, get_image_dimensions, get_number_of_images, poll_op, sync_op, upload_audio_to_comfyapi, upload_image_to_comfyapi, upload_video_to_comfyapi, validate_audio_duration, ) class SyncLipSyncNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="SyncLipSyncNode", display_name="sync.so Lip Sync", category="partner/video/sync.so", description=( "Re-sync mouth movement in a video to new speech audio using sync.so. " "Handles close-ups, profiles and obstructions automatically while preserving " "the speaker's expression. Cost scales with output duration." ), inputs=[ IO.Video.Input( "video", tooltip="Footage of the speaker to re-sync. Up to 4K (4096x2160); " "a constant frame rate of 24/25/30 fps works best.", ), IO.Audio.Input( "audio", tooltip="Speech audio to sync the mouth to.", ), IO.Int.Input( "seed", default=42, min=0, max=2147483647, control_after_generate=True, tooltip="Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed.", ), IO.DynamicCombo.Input( "model", options=[ IO.DynamicCombo.Option( "sync-3", [ IO.Combo.Input( "sync_mode", options=["bounce", "cut_off", "loop", "silence", "remap"], default="bounce", tooltip=( "How to handle a duration mismatch between video and audio; " "this also sets the output length. " "bounce: video plays forward then backward until the audio ends " "(output = audio length). " "loop: video restarts until the audio ends (output = audio length). " "remap: video is time-stretched to match the audio (output = audio length). " "cut_off: the longer track is trimmed (output = shorter length). " "silence: nothing is trimmed; the shorter track is padded " "(output = longer length)." ), ), IO.Combo.Input( "speaker_selection", options=["default", "auto-detect", "coordinates"], default="default", tooltip=( "Which face to lipsync when several people are visible. " "default: let the model decide. " "auto-detect: detect and follow the active speaker. " "coordinates: target the face at pixel (speaker_x, speaker_y) " "in the frame chosen by speaker_frame." ), ), IO.Int.Input( "speaker_frame", default=0, min=0, max=1_000_000, advanced=True, tooltip="Video frame used to locate the speaker. " "Only used when speaker_selection is 'coordinates'.", ), IO.Int.Input( "speaker_x", default=0, min=0, max=4096, advanced=True, tooltip="X pixel coordinate of the speaker's face. " "Only used when speaker_selection is 'coordinates'.", ), IO.Int.Input( "speaker_y", default=0, min=0, max=4096, advanced=True, tooltip="Y pixel coordinate of the speaker's face. " "Only used when speaker_selection is 'coordinates'.", ), ], ) ], tooltip="sync.so generation model.", ), ], 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.19019,"format":{"approximate":true,"suffix":"/second"}}""", ), ) @classmethod async def execute( cls, video: Input.Video, audio: Input.Audio, seed: int, model: dict, ) -> IO.NodeOutput: try: width, height = video.get_dimensions() except Exception: width = height = None if width and height and (max(width, height) > 4096 or width * height > 4096 * 2160): raise ValueError( f"sync.so rejects videos above 4K (4096x2160); got {width}x{height}. Downscale the video first." ) validate_audio_duration(audio, max_duration=600) if model["speaker_selection"] == "auto-detect": speaker_detection = SyncActiveSpeakerDetection(auto_detect=True) elif model["speaker_selection"] == "coordinates": speaker_detection = SyncActiveSpeakerDetection( frame_number=model["speaker_frame"], coordinates=[model["speaker_x"], model["speaker_y"]], ) else: speaker_detection = None video_url = await upload_video_to_comfyapi(cls, video, max_duration=600) audio_url = await upload_audio_to_comfyapi(cls, audio) generation = await sync_op( cls, ApiEndpoint(path="/proxy/synclabs/v2/generate", method="POST"), response_model=SyncGeneration, data=SyncGenerationRequest( model=model["model"], input=[ SyncInputItem(type="video", url=video_url), SyncInputItem(type="audio", url=audio_url), ], options=SyncGenerationOptions( sync_mode=model["sync_mode"], active_speaker_detection=speaker_detection, ), ), ) generation = await poll_op( cls, ApiEndpoint(path=f"/proxy/synclabs/v2/generate/{generation.id}"), response_model=SyncGeneration, status_extractor=lambda g: g.status, completed_statuses=["COMPLETED", "FAILED", "REJECTED"], failed_statuses=[], queued_statuses=["PENDING"], poll_interval=10.0, ) if generation.status != "COMPLETED": code = f" [{generation.errorCode}]" if generation.errorCode else "" raise ValueError( f"sync.so generation {generation.status.lower()}{code}: " f"{generation.error or 'no error details provided'}" ) if not generation.outputUrl: raise ValueError("sync.so generation completed but no output URL was returned.") return IO.NodeOutput(await download_url_to_video_output(generation.outputUrl)) class SyncTalkingImageNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( node_id="SyncTalkingImageNode", display_name="sync.so Talking Image", category="partner/video/sync.so", description=( "Animate a still portrait into a talking video driven by speech audio, " "using sync.so's sync-3 model. The output duration matches the audio. " "Cost scales with output duration." ), inputs=[ IO.Image.Input( "image", tooltip="A single image with a clearly visible face, up to 4K (4096x2160).", ), IO.Audio.Input( "audio", tooltip="Speech audio driving the talking video; the output duration matches it. " "Chain any TTS node here to drive the animation from text.", ), IO.String.Input( "prompt", multiline=True, default="", tooltip="Optional guidance for how the portrait comes to life, e.g. " "'make the subject smile and look at the camera'. " "Leave empty for natural talking motion.", ), IO.Int.Input( "seed", default=0, min=0, max=2147483647, control_after_generate=True, tooltip="Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed.", ), IO.DynamicCombo.Input( "model", options=[ IO.DynamicCombo.Option( "sync-3", [ IO.Combo.Input( "speaker_selection", options=["default", "coordinates"], default="default", tooltip=( "Which face to animate when several people are visible. " "default: let the model decide. " "coordinates: target the face at pixel (speaker_x, speaker_y) " "in the image. Auto-detection is not supported for images." ), ), IO.Int.Input( "speaker_x", default=0, min=0, max=4096, advanced=True, tooltip="X pixel coordinate of the speaker's face. " "Only used when speaker_selection is 'coordinates'.", ), IO.Int.Input( "speaker_y", default=0, min=0, max=4096, advanced=True, tooltip="Y pixel coordinate of the speaker's face. " "Only used when speaker_selection is 'coordinates'.", ), IO.Boolean.Input( "auto_downscale", default=True, advanced=True, tooltip="Automatically downscale the image if it exceeds the 4K " "(4096x2160) input limit; speaker coordinates are scaled to match. " "When disabled, an oversized image raises an error instead.", ), ], ) ], tooltip="sync.so generation model. Image input is exclusive to sync-3.", ), ], 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.19019,"format":{"approximate":true,"suffix":"/second"}}""", ), ) @classmethod async def execute( cls, image: Input.Image, audio: Input.Audio, prompt: str, seed: int, model: dict, ) -> IO.NodeOutput: if get_number_of_images(image) != 1: raise ValueError("Exactly one image is required; got a batch. Pick one frame first.") validate_audio_duration(audio, max_duration=600) height, width = get_image_dimensions(image) speaker_x, speaker_y = model["speaker_x"], model["speaker_y"] if max(width, height) > 4096 or width * height > 4096 * 2160: if not model["auto_downscale"]: raise ValueError( f"sync.so rejects images above 4K (4096x2160); got {width}x{height}. " "Downscale the image first or enable auto_downscale." ) image = downscale_image_tensor(image, total_pixels=4096 * 2160) image = downscale_image_tensor_by_max_side(image, max_side=4096) new_height, new_width = get_image_dimensions(image) # speaker coordinates are given in the original image's pixel space speaker_x = min(new_width - 1, round(speaker_x * new_width / width)) speaker_y = min(new_height - 1, round(speaker_y * new_height / height)) if model["speaker_selection"] == "coordinates": speaker_detection = SyncActiveSpeakerDetection( frame_number=0, # images have a single frame; auto_detect is rejected by the API coordinates=[speaker_x, speaker_y], ) else: speaker_detection = None image_url = await upload_image_to_comfyapi(cls, image, mime_type="image/png", total_pixels=None) audio_url = await upload_audio_to_comfyapi(cls, audio) generation = await sync_op( cls, ApiEndpoint(path="/proxy/synclabs/v2/generate", method="POST"), response_model=SyncGeneration, data=SyncGenerationRequest( model=model["model"], input=[ SyncInputItem(type="image", url=image_url), SyncInputItem(type="audio", url=audio_url), ], options=SyncGenerationOptions( i2v_prompt=prompt.strip() or None, active_speaker_detection=speaker_detection, ), ), ) generation = await poll_op( cls, ApiEndpoint(path=f"/proxy/synclabs/v2/generate/{generation.id}"), response_model=SyncGeneration, status_extractor=lambda g: g.status, completed_statuses=["COMPLETED", "FAILED", "REJECTED"], failed_statuses=[], queued_statuses=["PENDING"], poll_interval=10.0, ) if generation.status != "COMPLETED": code = f" [{generation.errorCode}]" if generation.errorCode else "" raise ValueError( f"sync.so generation {generation.status.lower()}{code}: " f"{generation.error or 'no error details provided'}" ) if not generation.outputUrl: raise ValueError("sync.so generation completed but no output URL was returned.") return IO.NodeOutput(await download_url_to_video_output(generation.outputUrl)) class SyncExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ SyncLipSyncNode, SyncTalkingImageNode, ] async def comfy_entrypoint() -> SyncExtension: return SyncExtension()