mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-17 20:08:42 +08:00
392 lines
17 KiB
Python
392 lines
17 KiB
Python
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()
|