$ cat node-template.py
Video Lipsync Legacy
// Changes lipsync from existing video using input audio speech. Uses LatentSync 1.6 to synchronize lip movements to the provided audio track.
Process
Video
template.py
1import os2import sys3import json4import time5import random6import copy7import traceback89try:10 import requests11except ImportError:12 import subprocess13 subprocess.check_call([sys.executable, "-m", "pip", "install", "requests"])14 import requests1516COMFYUI_API_URL = os.getenv("COMFYUI_API_URL", "http://192.168.1.39:8188")17INPUT_DIR = "/data/input"18OUTPUT_DIR = "/data/output"1920# ---------- LatentSync 1.6 workflow (EM_audio_to_video_laten_v1.1) ----------21WORKFLOW = {22 "54": {23 "inputs": {24 "seed": 266,25 "lips_expression": 1.5,26 "inference_steps": 20,27 "images": ["55", 0],28 "audio": ["55", 1],29 },30 "class_type": "LatentSyncNode",31 "_meta": {"title": "LatentSync1.6 Node"},32 },33 "55": {34 "inputs": {35 "mode": "pingpong",36 "fps": 25,37 "silent_padding_sec": 0.5,38 "images": ["60", 0],39 "audio": ["61", 0],40 },41 "class_type": "VideoLengthAdjuster",42 "_meta": {"title": "Video Length Adjuster"},43 },44 "58": {45 "inputs": {46 "frame_rate": 25,47 "loop_count": 0,48 "filename_prefix": "video/ComfyUI",49 "format": "video/h264-mp4",50 "pix_fmt": "yuv420p",51 "crf": 19,52 "save_metadata": True,53 "pingpong": False,54 "save_output": True,55 "images": ["54", 0],56 "audio": ["54", 1],57 },58 "class_type": "VHS_VideoCombine",59 "_meta": {"title": "Video Combine (Upload) \U0001f3a5\U0001d5e5\U0001d5db\U0001d5e6"},60 },61 "60": {62 "inputs": {63 "video": "",64 "force_rate": 0,65 "custom_width": 0,66 "custom_height": 0,67 "frame_load_cap": 0,68 "skip_first_frames": 0,69 "select_every_nth": 1,70 "format": "AnimateDiff",71 },72 "class_type": "VHS_LoadVideo",73 "_meta": {"title": "Load Video (Upload) \U0001f3a5\U0001d5e5\U0001d5db\U0001d5e6"},74 },75 "61": {76 "inputs": {77 "audio": "",78 "start_time": 0,79 "duration": 0,80 },81 "class_type": "VHS_LoadAudioUpload",82 "_meta": {"title": "Load Audio (Upload)\U0001f3a5\U0001d5e5\U0001d5db\U0001d5e6"},83 },84}858687def upload_file_to_comfyui(local_path: str, content_type: str) -> str:88 """Upload a local file to ComfyUI and return the uploaded filename."""89 with open(local_path, "rb") as f:90 resp = requests.post(91 f"{COMFYUI_API_URL}/upload/image",92 files={"image": (os.path.basename(local_path), f, content_type)},93 timeout=30,94 )95 resp.raise_for_status()96 data = resp.json()97 return data["name"]9899100def detect_video_mime(filename: str) -> str:101 """Detect MIME type from video file extension."""102 ext = os.path.splitext(filename)[1].lower()103 mime_map = {104 ".mp4": "video/mp4",105 ".avi": "video/x-msvideo",106 ".mov": "video/quicktime",107 ".mkv": "video/x-matroska",108 ".webm": "video/webm",109 ".wmv": "video/x-ms-wmv",110 }111 return mime_map.get(ext, "application/octet-stream")112113114def detect_audio_mime(filename: str) -> str:115 """Detect MIME type from audio file extension."""116 ext = os.path.splitext(filename)[1].lower()117 mime_map = {118 ".mp3": "audio/mpeg",119 ".wav": "audio/wav",120 ".ogg": "audio/ogg",121 ".flac": "audio/flac",122 ".aac": "audio/aac",123 ".m4a": "audio/mp4",124 }125 return mime_map.get(ext, "application/octet-stream")126127128def build_workflow(video_name: str, audio_name: str) -> dict:129 """Build the lipsync workflow with the given uploaded filenames."""130 wf = copy.deepcopy(WORKFLOW)131132 # Video (node 60): inject uploaded filename and prevent resizing133 wf["60"]["inputs"]["video"] = video_name134 wf["60"]["inputs"]["force_size"] = "Disabled"135136 # Audio (node 61): inject uploaded filename137 wf["61"]["inputs"]["audio"] = audio_name138139 # Seed (node 54): randomize140 wf["54"]["inputs"]["seed"] = random.randint(0, 2**31 - 1)141142 return wf143144145def submit_prompt(workflow: dict) -> str:146 """Submit workflow to ComfyUI and return prompt_id."""147 resp = requests.post(148 f"{COMFYUI_API_URL}/prompt",149 json={"prompt": workflow},150 timeout=30,151 )152 if resp.status_code != 200:153 try:154 error_detail = resp.json()155 except Exception:156 error_detail = resp.text157 raise RuntimeError(158 f"ComfyUI /prompt returned {resp.status_code}: {json.dumps(error_detail, indent=2) if isinstance(error_detail, dict) else error_detail}"159 )160 data = resp.json()161162 # ComfyUI returns 200 even when nodes have validation errors163 node_errors = data.get("node_errors", {})164 if node_errors:165 raise RuntimeError(166 f"ComfyUI workflow has node errors: {json.dumps(node_errors, indent=2)}"167 )168169 return data["prompt_id"]170171172def wait_for_result(prompt_id: str, timeout: int = 900, poll_interval: int = 3) -> dict:173 """Poll ComfyUI history until the prompt completes with outputs."""174 deadline = time.time() + timeout175 empty_complete_retries = 0176 max_empty_retries = 3 # grace period for output serialization lag177178 while time.time() < deadline:179 resp = requests.get(180 f"{COMFYUI_API_URL}/history/{prompt_id}",181 timeout=10,182 )183 resp.raise_for_status()184 history = resp.json()185186 if prompt_id in history:187 prompt_data = history[prompt_id]188 status = prompt_data.get("status", {})189190 if status.get("status_str") == "error":191 messages = status.get("messages", [])192 raise RuntimeError(193 f"ComfyUI prompt failed: {json.dumps(messages, indent=2)}"194 )195196 if status.get("completed", False):197 if prompt_data.get("outputs"):198 return prompt_data199200 # Completed but no outputs — retry briefly for race condition201 empty_complete_retries += 1202 if empty_complete_retries >= max_empty_retries:203 raise RuntimeError(204 f"ComfyUI prompt completed but produced no outputs. "205 f"This usually means a node failed silently (missing custom node or model). "206 f"Status: {json.dumps(status, indent=2)}"207 )208209 time.sleep(poll_interval)210211 raise TimeoutError(f"ComfyUI prompt {prompt_id} did not complete within {timeout}s")212213214def download_output_video(prompt_data: dict, output_dir: str) -> str:215 """Download the generated video from ComfyUI."""216 outputs = prompt_data.get("outputs", {})217 for node_id, node_output in outputs.items():218 # VHS/SaveVideo convention: check 'gifs' key first, then 'videos' fallback219 video_list = node_output.get("gifs") or node_output.get("videos") or node_output.get("images") or []220 if video_list:221 vid_info = video_list[0]222 filename = vid_info["filename"]223 subfolder = vid_info.get("subfolder", "")224 vid_type = vid_info.get("type", "output")225226 resp = requests.get(227 f"{COMFYUI_API_URL}/view",228 params={229 "filename": filename,230 "subfolder": subfolder,231 "type": vid_type,232 },233 timeout=120,234 )235 resp.raise_for_status()236237 out_filename = f"generated_{filename}"238 out_path = os.path.join(output_dir, out_filename)239 with open(out_path, "wb") as f:240 f.write(resp.content)241242 return out_filename243244 raise RuntimeError(245 f"No output video found in ComfyUI response. Available outputs: {json.dumps(outputs, indent=2)}"246 )247248249def main():250 try:251 input_json = sys.stdin.read()252 execution_input = json.loads(input_json)253 inputs = execution_input.get("inputs", {})254255 video = inputs.get("video", "")256 audio = inputs.get("audio", "")257258 if not video:259 raise ValueError("Video input is required")260 if not audio:261 raise ValueError("Audio input is required")262263 os.makedirs(OUTPUT_DIR, exist_ok=True)264265 # Upload video to ComfyUI266 video_path = os.path.join(INPUT_DIR, video)267 if not os.path.exists(video_path):268 raise FileNotFoundError(f"Input video not found: {video_path}")269 video_mime = detect_video_mime(video)270 comfyui_video_name = upload_file_to_comfyui(video_path, video_mime)271272 # Upload audio to ComfyUI273 audio_path = os.path.join(INPUT_DIR, audio)274 if not os.path.exists(audio_path):275 raise FileNotFoundError(f"Input audio not found: {audio_path}")276 audio_mime = detect_audio_mime(audio)277 comfyui_audio_name = upload_file_to_comfyui(audio_path, audio_mime)278279 # Build workflow, submit, wait, download280 workflow = build_workflow(comfyui_video_name, comfyui_audio_name)281 prompt_id = submit_prompt(workflow)282 prompt_data = wait_for_result(prompt_id)283 out_filename = download_output_video(prompt_data, OUTPUT_DIR)284285 # Log metadata to stderr286 print(f"prompt_id={prompt_id}", file=sys.stderr)287288 # Flat output — keys match OUTPUT_SCHEMA289 output = {290 "video": out_filename,291 }292 print(json.dumps(output, indent=2))293294 except Exception as e:295 error_output = {296 "error": str(e),297 "errorType": type(e).__name__,298 "traceback": traceback.format_exc(),299 }300 print(json.dumps(error_output), file=sys.stderr)301 sys.exit(1)302303304if __name__ == "__main__":305 main()