Add ComfyUI HighSync nodes

This commit is contained in:
magicwang1111
2026-05-25 11:57:19 +08:00
commit 2bf32d87a4
88 changed files with 123539 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""ComfyUI adapter utilities for HighSync."""
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
from .file_utils import normalize_path, resolve_path
CORE_FILES = (
"denoising_unet-500.pth",
"reference_unet-500.pth",
"audio_processor/whisper_tiny.pt",
)
ALLOW_PATTERNS = (
"denoising_unet-500.pth",
"reference_unet-500.pth",
"audio_processor/whisper_tiny.pt",
"sd-vae-ft-mse/**",
"sd-image-variations-diffusers/**",
)
def _directory_has_model_files(path):
if not path.is_dir():
return False
return any(candidate.is_file() for candidate in path.rglob("*"))
def validate_required_models(models_dir):
root = resolve_path(models_dir)
missing = []
for rel_path in CORE_FILES:
if not (root / rel_path).is_file():
missing.append(rel_path)
vae_dir = root / "sd-vae-ft-mse"
if not _directory_has_model_files(vae_dir) or not (vae_dir / "config.json").is_file():
missing.append("sd-vae-ft-mse/config.json")
base_dir = root / "sd-image-variations-diffusers"
if not _directory_has_model_files(base_dir) or not (base_dir / "unet" / "config.json").is_file():
missing.append("sd-image-variations-diffusers/unet/config.json")
if missing:
missing_text = ", ".join(missing)
raise FileNotFoundError(
f"HighSync model files are missing in {root}: {missing_text}. "
"Run the HighSync Download Models node with download=True, or download "
"https://huggingface.co/saeed-5959/high_sync into this folder."
)
return root
def download_or_check_models(models_dir, repo_id="saeed-5959/high_sync", download=False):
root = Path(normalize_path(models_dir))
if download:
try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise RuntimeError(
"huggingface_hub is required for HighSync Download Models. "
"Install this wrapper's requirements.txt first."
) from exc
root.mkdir(parents=True, exist_ok=True)
snapshot_download(
repo_id=repo_id,
local_dir=str(root),
allow_patterns=list(ALLOW_PATTERNS),
repo_type="model",
)
elif not root.exists():
raise FileNotFoundError(
f"models_dir does not exist: {root}. "
"Run HighSync Download Models with download=True or download the models manually."
)
return str(validate_required_models(root))
+45
View File
@@ -0,0 +1,45 @@
import shutil
import subprocess
from .file_utils import resolve_path
def check_ffmpeg():
ffmpeg = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
if ffmpeg is None:
raise RuntimeError("ffmpeg not found. Please install ffmpeg and add it to PATH.")
return ffmpeg
def run_ffmpeg(args, error_label):
command = [check_ffmpeg()] + [str(arg) for arg in args]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
stderr = result.stderr.strip()
raise RuntimeError(f"{error_label} failed with exit code {result.returncode}: {stderr}")
def mux_video_audio(video_path, audio_path, output_path):
output_file = resolve_path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
run_ffmpeg(
[
"-y",
"-i",
resolve_path(video_path),
"-i",
resolve_path(audio_path),
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"aac",
"-shortest",
output_file,
],
"audio/video mux",
)
return output_file
+92
View File
@@ -0,0 +1,92 @@
import os
import re
import shutil
import uuid
from datetime import datetime
from pathlib import Path
WINDOWS_DRIVE_RE = re.compile(r"^([A-Za-z]):[\\/](.*)$")
def is_windows_drive_path(path):
return isinstance(path, str) and WINDOWS_DRIVE_RE.match(path.strip()) is not None
def normalize_path(path):
if path is None:
return path
raw_path = str(path).strip().strip('"')
if raw_path == "":
return raw_path
match = WINDOWS_DRIVE_RE.match(raw_path)
if match and os.name != "nt":
drive = match.group(1).lower()
rest = match.group(2).replace("\\", "/")
return f"/mnt/{drive}/{rest}"
return os.path.abspath(os.path.expanduser(raw_path))
def resolve_path(path):
return Path(normalize_path(path)).expanduser().resolve()
def ensure_exists(path, label="path", is_dir=None):
if path is None or str(path).strip() == "":
raise FileNotFoundError(f"{label} is empty.")
resolved = resolve_path(path)
if is_dir is True and not resolved.is_dir():
raise FileNotFoundError(f"{label} does not exist: {resolved}")
if is_dir is False and not resolved.is_file():
raise FileNotFoundError(f"{label} does not exist: {resolved}")
if is_dir is None and not resolved.exists():
raise FileNotFoundError(f"{label} does not exist: {resolved}")
return resolved
def safe_copy(src, dst):
src_path = ensure_exists(src, "source file", is_dir=False)
dst_path = resolve_path(dst)
dst_path.parent.mkdir(parents=True, exist_ok=True)
if src_path.resolve() != dst_path.resolve():
shutil.copy2(src_path, dst_path)
return dst_path
def get_comfy_output_dir():
try:
import folder_paths
return Path(folder_paths.get_output_directory()).resolve()
except Exception:
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "custom_nodes").exists() and (parent / "output").exists():
return (parent / "output").resolve()
return Path("/mnt/d/ComfyUI/output").resolve()
def create_task_dir(prefix="highsync"):
output_root = get_comfy_output_dir() / "highsync"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix = uuid.uuid4().hex[:8]
task_id = f"{prefix}_{timestamp}_{suffix}"
task_dir = output_root / task_id
paths = {
"task_id": task_id,
"task_dir": task_dir,
"media_dir": task_dir / "media",
"logs_dir": task_dir / "logs",
"frames_dir": task_dir / "frames",
}
for key, value in paths.items():
if key.endswith("_dir"):
value.mkdir(parents=True, exist_ok=True)
return paths
+224
View File
@@ -0,0 +1,224 @@
import subprocess
from pathlib import Path
import numpy as np
import soundfile as sf
import torch
from .ffmpeg_utils import check_ffmpeg
from .file_utils import resolve_path
TARGET_FPS = 25
TARGET_AUDIO_SR = 16000
def _as_image_tensor(images):
if isinstance(images, list):
images = torch.stack(images)
if not isinstance(images, torch.Tensor):
images = torch.as_tensor(images)
if images.dim() != 4:
raise ValueError(f"Expected IMAGE tensor with shape [frames, height, width, channels], got {tuple(images.shape)}")
if images.shape[-1] < 3:
raise ValueError(f"Expected IMAGE tensor with at least 3 channels, got {tuple(images.shape)}")
return images.detach().cpu()
def images_to_uint8_rgb(images):
image_tensor = _as_image_tensor(images)[..., :3]
if image_tensor.dtype != torch.uint8:
image_tensor = image_tensor.float()
if image_tensor.numel() > 0 and float(image_tensor.max()) <= 1.0:
image_tensor = image_tensor * 255.0
image_tensor = image_tensor.clamp(0, 255).byte()
return np.ascontiguousarray(image_tensor.numpy())
def frames_uint8_rgb_to_image_tensor(frames):
if len(frames) == 0:
raise ValueError("No output frames were generated.")
array = np.ascontiguousarray(np.stack(frames).astype(np.float32) / 255.0)
return torch.from_numpy(array)
def resample_frames_to_fps(frames_rgb, input_fps, output_fps=TARGET_FPS, max_frames=0):
input_fps = int(input_fps)
output_fps = int(output_fps)
if input_fps <= 0 or output_fps <= 0:
raise ValueError("input_fps and output_fps must be positive.")
if len(frames_rgb) == 0:
raise ValueError("No IMAGE frames were provided.")
if input_fps == output_fps:
sampled = frames_rgb.copy()
else:
duration = len(frames_rgb) / float(input_fps)
target_count = max(1, int(round(duration * output_fps)))
indices = np.floor(np.arange(target_count) * input_fps / output_fps).astype(np.int64)
indices = np.clip(indices, 0, len(frames_rgb) - 1)
sampled = frames_rgb[indices]
max_frames = int(max_frames or 0)
if max_frames > 0:
sampled = sampled[:max_frames]
return np.ascontiguousarray(sampled)
def _audio_waveform_to_tensor(audio):
if not isinstance(audio, dict) or "waveform" not in audio or "sample_rate" not in audio:
raise ValueError("AUDIO input must be a dict with 'waveform' and 'sample_rate'.")
waveform = audio["waveform"]
if not isinstance(waveform, torch.Tensor):
waveform = torch.as_tensor(waveform)
waveform = waveform.detach().cpu().float()
if waveform.dim() == 3:
waveform = waveform[0]
elif waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
elif waveform.dim() != 2:
raise ValueError(
"Expected AUDIO waveform with shape [batch, channels, samples], "
f"[channels, samples], or [samples], got {tuple(waveform.shape)}"
)
return waveform.contiguous(), int(audio["sample_rate"])
def trim_or_pad_audio(audio, duration_sec=None, pad_to_duration=False):
waveform, sample_rate = _audio_waveform_to_tensor(audio)
if duration_sec is not None and duration_sec > 0:
target_samples = max(1, int(round(float(duration_sec) * sample_rate)))
if waveform.shape[-1] > target_samples:
waveform = waveform[:, :target_samples]
elif pad_to_duration and waveform.shape[-1] < target_samples:
pad = torch.zeros((waveform.shape[0], target_samples - waveform.shape[-1]), dtype=waveform.dtype)
waveform = torch.cat([waveform, pad], dim=-1)
return {"waveform": waveform.unsqueeze(0).contiguous(), "sample_rate": sample_rate}
def _resample_waveform(waveform, sample_rate, target_sample_rate):
if int(sample_rate) == int(target_sample_rate):
return waveform
try:
import torchaudio
resampler = torchaudio.transforms.Resample(
orig_freq=int(sample_rate),
new_freq=int(target_sample_rate),
)
return resampler(waveform)
except Exception as exc:
raise RuntimeError(
"Failed to resample audio with torchaudio. Install torchaudio or provide 16kHz audio."
) from exc
def save_audio_to_wav(audio, output_path, duration_sec=None, pad_to_duration=False, mono=False, sample_rate=None):
adjusted = trim_or_pad_audio(audio, duration_sec=duration_sec, pad_to_duration=pad_to_duration)
waveform = adjusted["waveform"].squeeze(0).clamp(-1.0, 1.0)
source_sample_rate = int(adjusted["sample_rate"])
if mono and waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
target_sample_rate = int(sample_rate or source_sample_rate)
waveform = _resample_waveform(waveform, source_sample_rate, target_sample_rate)
output_file = resolve_path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
audio_np = waveform.transpose(0, 1).numpy()
sf.write(str(output_file), audio_np, target_sample_rate, subtype="PCM_16")
return output_file
def encode_rgb_frames_to_mp4(frames_rgb, output_path, fps=TARGET_FPS):
output_file = resolve_path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
frames = np.ascontiguousarray(frames_rgb)
if frames.ndim != 4 or frames.shape[-1] != 3:
raise ValueError(f"Expected RGB frames with shape [frames, height, width, 3], got {frames.shape}")
frame_count, height, width, _ = frames.shape
if frame_count <= 0:
raise ValueError("No frames were provided to encode.")
command = [
check_ffmpeg(),
"-y",
"-f",
"rawvideo",
"-vcodec",
"rawvideo",
"-s",
f"{width}x{height}",
"-pix_fmt",
"rgb24",
"-r",
str(int(fps)),
"-i",
"-",
"-an",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
str(output_file),
]
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
for frame in frames:
process.stdin.write(np.ascontiguousarray(frame).tobytes())
_, stderr = process.communicate()
except BrokenPipeError:
_, stderr = process.communicate()
finally:
if process.stdin and not process.stdin.closed:
process.stdin.close()
if process.returncode != 0:
message = stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"Failed to encode HighSync output video: {message}")
return output_file
def denoise_audio_file(input_audio_path, output_audio_path, device, cache):
try:
from denoiser import pretrained
from denoiser.dsp import convert_audio
except ImportError as exc:
raise RuntimeError(
"denoiser and torchaudio are required when denoise_audio=True. "
"Install this wrapper's requirements.txt or disable denoise_audio."
) from exc
model = cache.get("denoiser_model")
model_device = torch.device(device)
if model is None:
model = pretrained.dns64().to(model_device)
model.eval()
cache["denoiser_model"] = model
audio_np, sample_rate = sf.read(str(Path(input_audio_path)), dtype="float32", always_2d=True)
wav = torch.from_numpy(audio_np.T.copy())
wav = convert_audio(wav.to(model_device), sample_rate, model.sample_rate, model.chin)
with torch.no_grad():
denoised = model(wav[None])[0]
output_file = resolve_path(output_audio_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
audio_np = denoised.detach().cpu().clamp(-1.0, 1.0).numpy()[0]
sf.write(str(output_file), audio_np, int(model.sample_rate), subtype="PCM_16")
return output_file
+148
View File
@@ -0,0 +1,148 @@
from pathlib import Path
from .download_utils import validate_required_models
_MODEL_CACHE = {}
def _get_repo_root():
return Path(__file__).resolve().parents[1]
def _torch_load(path, torch):
try:
return torch.load(path, map_location="cpu", weights_only=True)
except TypeError:
return torch.load(path, map_location="cpu")
def _get_torch_device(torch):
try:
import comfy.model_management as model_management
return model_management.get_torch_device()
except Exception:
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def _weight_dtype(torch, weight_dtype):
dtype_text = str(weight_dtype or "fp16").lower()
if dtype_text == "fp16":
return torch.float16
if dtype_text == "bf16":
return torch.bfloat16
if dtype_text == "fp32":
return torch.float32
raise ValueError(f"Unsupported HighSync weight_dtype: {weight_dtype}")
def _load_config(models_root, weight_dtype):
try:
from omegaconf import OmegaConf
except ImportError as exc:
raise RuntimeError("omegaconf is required to load HighSync config. Install requirements.txt first.") from exc
config_path = _get_repo_root() / "high_sync" / "configs" / "inference" / "default.yaml"
config = OmegaConf.load(str(config_path))
config.weight_dtype = str(weight_dtype)
config.denoising_unet_path = str(models_root / "denoising_unet-500.pth")
config.reference_unet_path = str(models_root / "reference_unet-500.pth")
config.base_model_path = str(models_root / "sd-image-variations-diffusers")
config.audio_model_path = str(models_root / "audio_processor" / "whisper_tiny.pt")
config.vae.model_path = str(models_root / "sd-vae-ft-mse")
config.mask_path = str(_get_repo_root() / "high_sync" / "masks" / "mask_64.png")
config.mask_512_path = str(_get_repo_root() / "high_sync" / "masks" / "mask_512.png")
return config
def _build_scheduler(config):
try:
from diffusers import DDIMScheduler
from omegaconf import OmegaConf
except ImportError as exc:
raise RuntimeError("diffusers and omegaconf are required for HighSync. Install requirements.txt first.") from exc
sched_kwargs = OmegaConf.to_container(config.noise_scheduler_kwargs, resolve=True)
if bool(config.enable_zero_snr):
sched_kwargs.update(
rescale_betas_zero_snr=True,
timestep_spacing="trailing",
prediction_type="v_prediction",
)
return DDIMScheduler(**sched_kwargs)
def load_highsync_model(models_dir, weight_dtype="fp16"):
try:
import torch
from diffusers import AutoencoderKL
except ImportError as exc:
raise RuntimeError(
"torch and diffusers are required to load HighSync. Install this wrapper's requirements.txt first."
) from exc
models_root = validate_required_models(models_dir)
device = _get_torch_device(torch)
dtype = _weight_dtype(torch, weight_dtype)
cache_key = (str(models_root), str(weight_dtype).lower(), str(device))
if cache_key in _MODEL_CACHE:
return _MODEL_CACHE[cache_key]
config = _load_config(models_root, weight_dtype)
from omegaconf import OmegaConf
from high_sync.image_processor import ImageProcessor
from high_sync.src.models.unet_2d_condition import UNet2DConditionModel
from high_sync.src.models.unet_3d_echo import EchoUNet3DConditionModel
from high_sync.src.models.whisper.audio2feature import load_audio_model
from high_sync.src.pipelines.face_animate import FaceAnimatePipeline
scheduler = _build_scheduler(config)
vae = AutoencoderKL.from_pretrained(str(config.vae.model_path))
reference_unet = UNet2DConditionModel.from_pretrained(str(config.base_model_path), subfolder="unet")
denoising_unet = EchoUNet3DConditionModel.from_pretrained_2d(
str(config.base_model_path),
"",
subfolder="unet",
unet_additional_kwargs=OmegaConf.to_container(config.unet_additional_kwargs, resolve=True),
)
reference_unet.load_state_dict(_torch_load(str(config.reference_unet_path), torch))
denoising_unet.load_state_dict(_torch_load(str(config.denoising_unet_path), torch))
vae.requires_grad_(False)
reference_unet.requires_grad_(False)
denoising_unet.requires_grad_(False)
vae.eval()
reference_unet.eval()
denoising_unet.eval()
pipeline = FaceAnimatePipeline(
vae=vae,
reference_unet=reference_unet,
denoising_unet=denoising_unet,
scheduler=scheduler,
)
pipeline.to(device=device, dtype=dtype)
audio_processor = load_audio_model(model_path=str(config.audio_model_path), device=device)
model = {
"models_dir": str(models_root),
"device": device,
"weight_dtype": dtype,
"config": config,
"pipeline": pipeline,
"audio_processor": audio_processor,
"image_processor_cls": ImageProcessor,
"denoiser_model": None,
}
_MODEL_CACHE[cache_key] = model
return model
+323
View File
@@ -0,0 +1,323 @@
import shutil
import cv2
import numpy as np
import torch
from .ffmpeg_utils import mux_video_audio
from .file_utils import create_task_dir
from .media_utils import (
TARGET_AUDIO_SR,
TARGET_FPS,
denoise_audio_file,
encode_rgb_frames_to_mp4,
frames_uint8_rgb_to_image_tensor,
images_to_uint8_rgb,
resample_frames_to_fps,
save_audio_to_wav,
trim_or_pad_audio,
)
def _maybe_throw_if_interrupted():
try:
import comfy.model_management as model_management
except Exception:
return
model_management.throw_exception_if_processing_interrupted()
def _empty_cache(device):
if isinstance(device, torch.device) and device.type == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
try:
import comfy.model_management as model_management
model_management.soft_empty_cache()
except Exception:
return
def create_mask(mask_path):
raw_mask = cv2.imread(str(mask_path))
if raw_mask is None:
raise FileNotFoundError(f"HighSync mask could not be read: {mask_path}")
mask = 1 - (raw_mask / 255.0)
h, w, c = mask.shape
new_mask = np.zeros((h, w, c + 1), dtype=np.float32)
new_mask[:, :, :c] = mask
new_mask[:, :, c] = mask[:, :, c - 1]
return torch.FloatTensor(new_mask).permute(2, 0, 1).unsqueeze(1)
def create_mask_alpha(width, height, coordinate):
left, top, right, bottom = coordinate
mask = np.ones((height, width, 3), dtype=np.float32)
mask[top:bottom, left:right] = 0
pad = min(int((right - left) / 15), int((bottom - top) / 15))
if pad <= 0:
return mask
for idx in range(left, right):
mask[bottom - pad:bottom, idx] = np.array([np.linspace(0, 1, pad)] * 3).transpose(1, 0)
mask[top:top + pad, idx] = np.array([np.linspace(1, 0, pad)] * 3).transpose(1, 0)
for idx in range(top, bottom):
mask[idx, right - pad:right] = np.array([np.linspace(0, 1, pad)] * 3).transpose(1, 0)
mask[idx, left:left + pad] = np.array([np.linspace(1, 0, pad)] * 3).transpose(1, 0)
for idx, start, end in zip(range(top, top + pad), np.linspace(1, 0.7, pad), np.linspace(0.7, 0, pad)):
mask[idx, left:left + pad] = np.array([np.linspace(start, end, pad)] * 3).transpose(1, 0)
mask[idx, right - pad:right] = np.array([np.linspace(end, start, pad)] * 3).transpose(1, 0)
for idx, start, end in zip(range(bottom - pad, bottom), np.linspace(0.7, 1, pad), np.linspace(0, 0.7, pad)):
mask[idx, left:left + pad] = np.array([np.linspace(start, end, pad)] * 3).transpose(1, 0)
mask[idx, right - pad:right] = np.array([np.linspace(end, start, pad)] * 3).transpose(1, 0)
return mask
def _prepare_audio_files(task, audio, duration_sec, trim_audio_to_video, denoise_audio, device, model_cache):
audio_for_video = trim_or_pad_audio(
audio,
duration_sec=duration_sec if trim_audio_to_video else None,
pad_to_duration=bool(trim_audio_to_video),
)
mux_audio_path = save_audio_to_wav(
audio_for_video,
task["media_dir"] / "mux_audio.wav",
)
feature_audio_path = save_audio_to_wav(
audio_for_video,
task["media_dir"] / "feature_audio_16k.wav",
mono=True,
sample_rate=TARGET_AUDIO_SR,
)
if denoise_audio:
feature_audio_path = denoise_audio_file(
feature_audio_path,
task["media_dir"] / "feature_audio_16k_denoised.wav",
device=device,
cache=model_cache,
)
return audio_for_video, mux_audio_path, feature_audio_path
def _ensure_highsync_model(highsync_model):
required = ("config", "pipeline", "audio_processor", "image_processor_cls", "device", "weight_dtype")
if not isinstance(highsync_model, dict) or any(key not in highsync_model for key in required):
raise ValueError("highsync_model must come from the HighSync Model Loader node.")
def _append_original_frame(output_frames_rgb, frame_bgr):
output_frames_rgb.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
def _run_generation_chunk(
pipeline,
image_processor,
config,
audio_fea_final,
frames_video,
output_frames_rgb,
count_img,
generator,
device,
weight_dtype,
inference_steps,
cfg_scale,
):
source_image_pixels_all, bbox_all, frame_list_all = image_processor.preprocess_frames_jump(frames_video)
img_size = (int(config.data.source_image.width), int(config.data.source_image.height))
sample_frames = int(config.data.n_sample_frames)
mask_path = str(config.mask_path)
for source_image_pixels_list, bbox_list, frame_list in zip(source_image_pixels_all, bbox_all, frame_list_all):
_maybe_throw_if_interrupted()
if not frame_list:
continue
if bbox_list[0] != (0, 0, 0, 0):
audio_tensor = audio_fea_final[:, count_img: count_img + len(frame_list)]
main_length = len(frame_list)
diff = sample_frames - main_length
if diff > 0:
source_image_pixels_list.extend([source_image_pixels_list[-1]] * diff)
audio_tensor = torch.cat((audio_tensor, audio_tensor[:, -1:].repeat(1, diff, 1, 1)), dim=1)
source_image_pixels = torch.cat(source_image_pixels_list, dim=0)
mask_main = create_mask(mask_path).unsqueeze(0).to(device=device, dtype=weight_dtype)
_empty_cache(device)
with torch.no_grad():
pipeline_output = pipeline(
gt_encode=source_image_pixels,
audio_tensor=audio_tensor,
mask_main=mask_main,
width=img_size[0],
height=img_size[1],
video_length=len(source_image_pixels_list),
num_inference_steps=int(inference_steps),
guidance_scale=float(cfg_scale),
generator=generator,
)
_empty_cache(device)
images_out = pipeline_output.videos.squeeze(0).permute(1, 2, 3, 0).cpu().numpy()[:main_length]
images_out = np.clip(images_out * 255, 0, 255).astype(np.uint8)
for image_out, bbox, frame in zip(images_out, bbox_list, frame_list):
frame_h, frame_w, _ = frame.shape
if bbox == (0, 0, 0, 0):
_append_original_frame(output_frames_rgb, frame)
else:
image_out_bgr = cv2.cvtColor(image_out, cv2.COLOR_RGB2BGR)
frame_in_out = frame.copy()
frame_in_out[bbox[1]:bbox[3], bbox[0]:bbox[2]] = cv2.resize(
image_out_bgr,
(bbox[2] - bbox[0], bbox[3] - bbox[1]),
)
mask = create_mask_alpha(frame_w, frame_h, bbox)
blended = (frame * mask + frame_in_out * (1 - mask)).astype(np.uint8)
_append_original_frame(output_frames_rgb, blended)
count_img += 1
else:
for frame in frame_list:
_append_original_frame(output_frames_rgb, frame)
count_img += 1
return count_img
def run_highsync(
highsync_model,
images,
audio,
input_fps=25,
max_frames=0,
inference_steps=None,
cfg_scale=None,
seed=42,
preprocess_chunk_frames=1500,
denoise_audio=True,
trim_audio_to_video=True,
keep_intermediate_outputs=True,
):
_ensure_highsync_model(highsync_model)
config = highsync_model["config"]
pipeline = highsync_model["pipeline"]
audio_processor = highsync_model["audio_processor"]
image_processor = highsync_model["image_processor_cls"]((int(config.data.source_image.width), int(config.data.source_image.height)))
device = torch.device(highsync_model["device"])
weight_dtype = highsync_model["weight_dtype"]
inference_steps = int(inference_steps if inference_steps is not None else config.inference_steps)
cfg_scale = float(cfg_scale if cfg_scale is not None else config.cfg_scale)
preprocess_chunk_frames = max(12, int(preprocess_chunk_frames))
task = create_task_dir()
frames_rgb = images_to_uint8_rgb(images)
frames_rgb = resample_frames_to_fps(frames_rgb, input_fps=input_fps, output_fps=TARGET_FPS, max_frames=max_frames)
frames_bgr = [cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) for frame in frames_rgb]
initial_duration_sec = len(frames_bgr) / float(TARGET_FPS)
_, _, feature_audio_path = _prepare_audio_files(
task=task,
audio=audio,
duration_sec=initial_duration_sec,
trim_audio_to_video=trim_audio_to_video,
denoise_audio=denoise_audio,
device=device,
model_cache=highsync_model,
)
whisper_feature = audio_processor.audio2feat(str(feature_audio_path))
whisper_chunks = audio_processor.feature2chunks(feature_array=whisper_feature, fps=TARGET_FPS)
audio_frame_num = int(whisper_chunks.shape[0])
video_length = min(len(frames_bgr), audio_frame_num)
if video_length <= 0:
raise RuntimeError("HighSync could not align any video frames with the provided audio.")
frames_bgr = frames_bgr[:video_length]
audio_fea_final = torch.as_tensor(whisper_chunks[:video_length], dtype=pipeline.vae.dtype, device=pipeline.vae.device)
audio_fea_final = audio_fea_final.unsqueeze(0)
final_duration_sec = video_length / float(TARGET_FPS)
output_audio, mux_audio_path, _ = _prepare_audio_files(
task=task,
audio=audio,
duration_sec=final_duration_sec,
trim_audio_to_video=trim_audio_to_video,
denoise_audio=False,
device=device,
model_cache=highsync_model,
)
torch.manual_seed(int(seed))
generator = torch.manual_seed(int(seed))
output_frames_rgb = []
frames_video = []
count_motion = 0
count_img = 0
while True:
_maybe_throw_if_interrupted()
if count_motion >= video_length:
finish = True
else:
frames_video.append(frames_bgr[count_motion])
count_motion += 1
finish = False
if len(frames_video) == 0:
break
if (count_motion % preprocess_chunk_frames == 0) or finish:
print(f"[HighSync] processing frames {count_img}-{count_motion} / {video_length}")
count_img = _run_generation_chunk(
pipeline=pipeline,
image_processor=image_processor,
config=config,
audio_fea_final=audio_fea_final,
frames_video=frames_video,
output_frames_rgb=output_frames_rgb,
count_img=count_img,
generator=generator,
device=device,
weight_dtype=weight_dtype,
inference_steps=inference_steps,
cfg_scale=cfg_scale,
)
frames_video = []
if finish:
break
if len(output_frames_rgb) == 0:
raise RuntimeError("HighSync finished without producing output frames.")
output_frames = np.ascontiguousarray(np.stack(output_frames_rgb))
no_audio_path = encode_rgb_frames_to_mp4(output_frames, task["media_dir"] / "highsync_no_audio.mp4", fps=TARGET_FPS)
final_video_path = mux_video_audio(no_audio_path, mux_audio_path, task["task_dir"] / "final.mp4")
output_images = frames_uint8_rgb_to_image_tensor(output_frames)
if not keep_intermediate_outputs:
for path in (task["media_dir"], task["frames_dir"], task["logs_dir"]):
shutil.rmtree(path, ignore_errors=True)
return output_images, output_audio, TARGET_FPS, str(final_video_path.resolve())