diff --git a/README.md b/README.md index 12f1cad..364d9e6 100644 --- a/README.md +++ b/README.md @@ -952,6 +952,66 @@ Uses properly constructed FFmpeg filter chains: +
+18. Add Soundtrack ➜ Mix soundtrack audio with video while preserving original audio. + +Mix a soundtrack with video audio while preserving original audio +Source: *comfy_add_soundtrack.py* + +### **What it does** + +Takes a video file and an audio file, applies volume adjustment only to the soundtrack, trims the soundtrack to match video duration, mixes it with the video's original audio, and outputs a new video file with the same duration. + +### **Key Features** + +* Preserves the video's original audio unchanged +* Applies volume adjustment only to the added soundtrack +* Automatically trims soundtrack to match video duration +* Supports very low volume levels (down to -100 dB) +* Optional deletion of the original video file after processing +* Automatic output filename generation with incrementing numbers +* Progress indication during processing + +### **Inputs** + +| Name | Type | Description | +| --------------------| ------- | ------------------------------------------------ | +| `video_path` | STRING | Full path to the input video file | +| `audio_path` | STRING | Full path to the soundtrack audio file | +| `volume_db` | FLOAT | Soundtrack volume adjustment in dB (default: -20.0, min: -100.0) | +| `delete_original` | BOOLEAN | Delete original video file after processing (default: False) | +| `output_path` | STRING | Optional custom output path (default: auto-generated) | + +### **Outputs** + +* `output_video_path` – Full path to the processed video file with mixed audio + +### **How it works** + +1. Validates input video and audio files exist +2. Probes video duration using ffprobe +3. Trims the soundtrack audio to match video duration +4. Applies volume adjustment to the soundtrack using ffmpeg volume filter +5. Mixes the adjusted soundtrack with the video's original audio using amix filter +6. Re-encodes the video with mixed audio streams +7. Optionally deletes the original video file if toggled +8. Saves the output to ComfyUI's default output directory with unique filename + +### **Audio Processing** + +* Uses ffmpeg's `atrim` filter to trim soundtrack to exact video duration +* Applies volume adjustment with `volume` filter (supports negative dB values down to -100) +* Mixes audio streams using `amix` filter with longest duration and no dropout transition +* Preserves all original video audio characteristics + +### **Filename Generation** + +* Auto-generates names like `original_name_with_soundtrack.mp4` +* Adds incrementing numbers to avoid overwriting existing files +* Uses ComfyUI's default output directory structure + +
+ # 📦 **Installation** diff --git a/__init__.py b/__init__.py index 1a74269..6a7efc2 100644 --- a/__init__.py +++ b/__init__.py @@ -18,6 +18,7 @@ from .comfy_close_up_image import CloseUpImageNode from .comfy_video_loop_extender import VideoLoopExtenderNode from .comfy_image_sequence_overlay import ImageSequenceOverlay from .comfy_video_overlay_from_file import VideoOverlayFromFile +from .comfy_add_soundtrack import ComfyAddSoundtrack NODE_CLASS_MAPPINGS = { @@ -41,6 +42,7 @@ NODE_CLASS_MAPPINGS = { "VideoLoopExtenderNode": VideoLoopExtenderNode, "ImageSequenceOverlay": ImageSequenceOverlay, "VideoOverlayFromFile": VideoOverlayFromFile, + "ComfyAddSoundtrack": ComfyAddSoundtrack, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -64,6 +66,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "VideoLoopExtenderNode": "Video Loop Extender", "ImageSequenceOverlay": "Image Sequence Overlay", "VideoOverlayFromFile": "Video Overlay (File Input)", + "ComfyAddSoundtrack": "Add Soundtrack", } __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/comfy_add_soundtrack.py b/comfy_add_soundtrack.py new file mode 100644 index 0000000..f6c8463 --- /dev/null +++ b/comfy_add_soundtrack.py @@ -0,0 +1,172 @@ +import os +import ffmpeg +from pathlib import Path + +class ComfyAddSoundtrack: + """ + A ComfyUI-compatible node that adds a soundtrack to a video without changing the original audio. + Applies volume adjustment only to the soundtrack and mixes it with the video's audio. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "video_path": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Full path to video file" + }), + "audio_path": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Full path to audio file" + }), + "volume_db": ("FLOAT", { + "default": -20.0, + "min": -100.0, + "step": 1.0, + "label": "Soundtrack volume (dB)" + }), + "delete_original": ("BOOLEAN", { + "default": False, + "label": "Delete original video after processing" + }), + }, + "optional": { + "output_path": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Output video path (optional)" + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("output_video_path",) + FUNCTION = "add_soundtrack" + CATEGORY = "Simple Video Effects" + OUTPUT_NODE = True + + def __init__(self): + self.output_dir = self._get_default_output_directory() + + def _get_default_output_directory(self) -> str: + try: + current_path = Path(__file__).resolve() + comfy_root = None + for parent in current_path.parents: + if parent.name == "ComfyUI": + comfy_root = parent.parent + break + + if comfy_root: + fallback_dir = comfy_root / "ComfyUI" / "output" + else: + fallback_dir = Path(os.getcwd()) / "output" + + os.makedirs(fallback_dir, exist_ok=True) + return str(fallback_dir) + except Exception: + fallback_dir = os.getcwd() + print(f"Using fallback output directory: {fallback_dir}") + return fallback_dir + + def _get_video_duration(self, video_path: str) -> float: + try: + probe = ffmpeg.probe(video_path) + except ffmpeg.Error as e: + msg = e.stderr.decode() if getattr(e, "stderr", None) else str(e) + raise RuntimeError(f"Failed to probe video '{video_path}': {msg}") + except Exception as e: + raise RuntimeError(f"Failed to probe video '{video_path}': {str(e)}") + + fmt = probe.get("format", {}) + if "duration" not in fmt: + raise RuntimeError( + f"Could not determine duration for video '{video_path}' " + f"(no 'duration' in ffprobe output)." + ) + + try: + return float(fmt["duration"]) + except Exception as e: + raise RuntimeError( + f"Invalid duration value for video '{video_path}': " + f"{fmt['duration']} ({e})" + ) + + def get_unique_filename(self, output_path: str) -> str: + base, ext = os.path.splitext(output_path) + index = 1 + unique_path = output_path + + while os.path.exists(unique_path): + unique_path = f"{base}_{index}{ext}" + index += 1 + + return unique_path + + def add_soundtrack( + self, + video_path: str, + audio_path: str, + volume_db: float = -20.0, + delete_original: bool = False, + output_path: str = "", + ) -> tuple: + video_path = video_path.strip() + audio_path = audio_path.strip() + + if not os.path.isfile(video_path): + raise ValueError(f"Video file does not exist: {video_path}") + + if not os.path.isfile(audio_path): + raise ValueError(f"Audio file does not exist: {audio_path}") + + # Get video duration + video_duration = self._get_video_duration(video_path) + + # Set output path + if not output_path.strip(): + base_name = os.path.splitext(os.path.basename(video_path))[0] + "_with_soundtrack.mp4" + output_path = os.path.join(self.output_dir, base_name) + + if not output_path.lower().endswith(".mp4"): + output_path += ".mp4" + + output_path = self.get_unique_filename(output_path) + + try: + print("Starting soundtrack addition processing...") + # Load inputs + video = ffmpeg.input(video_path) + audio = ffmpeg.input(audio_path) + + # Trim audio to video duration and apply volume + audio = audio.filter('atrim', 0, video_duration).filter('volume', f'{volume_db}dB') + + # Mix audios + mixed_audio = ffmpeg.filter([video.audio, audio], 'amix', inputs=2, duration='longest', dropout_transition=0) + + # Output with mixed audio + out = ffmpeg.output(video.video, mixed_audio, output_path, vcodec="libx264", acodec="aac") + out = out.overwrite_output() + out.run(quiet=True) + print("Soundtrack addition processing complete.") + + except ffmpeg.Error as e: + if getattr(e, "stderr", None) is not None: + raise RuntimeError(f"FFmpeg error: {e.stderr.decode()}") + else: + raise RuntimeError(f"FFmpeg error: {str(e)}") + + # Delete original video if requested + if delete_original: + try: + os.remove(video_path) + print(f"Deleted original video: {video_path}") + except Exception as e: + print(f"Warning: Could not delete original video '{video_path}': {e}") + + return (output_path,) diff --git a/comfy_simple_video_combiner.py b/comfy_simple_video_combiner.py index 27bd8b3..77d6c9d 100644 --- a/comfy_simple_video_combiner.py +++ b/comfy_simple_video_combiner.py @@ -31,6 +31,12 @@ class ComfySimpleVideoCombiner: "multiline": False, "placeholder": "*.mp4" }), + "cross_fade": ("FLOAT", { + "default": 0.5, + "min": 0.0, + "step": 0.1, + "label": "Cross fade duration (seconds) - 0 for no fade" + }), "use_gpu": ("BOOLEAN", { "default": True, "label": "Use GPU (NVENC encoder)" @@ -108,6 +114,7 @@ class ComfySimpleVideoCombiner: directory_path: str, output_filename: str, file_pattern: str, + cross_fade: float = 0.5, use_gpu: bool = True, ) -> tuple: @@ -126,6 +133,8 @@ class ComfySimpleVideoCombiner: if not output_filename.lower().endswith(".mp4"): output_filename += ".mp4" + durations = [self._get_video_duration(str(v)) for v in video_files] + output_path = os.path.join(self.output_dir, output_filename) output_path = self.get_unique_filename(output_path) @@ -143,13 +152,24 @@ class ComfySimpleVideoCombiner: for a in a_streams ] - # IMPORTANT: interleave v/a per clip - streams = [] - for v, a in zip(v_streams, a_streams): - streams.extend([v, a]) + if cross_fade == 0: + # IMPORTANT: interleave v/a per clip + streams = [] + for v, a in zip(v_streams, a_streams): + streams.extend([v, a]) - out_nodes = ffmpeg.concat(*streams, v=1, a=1).node - v_out, a_out = out_nodes[0], out_nodes[1] + out_nodes = ffmpeg.concat(*streams, v=1, a=1).node + v_out, a_out = out_nodes[0], out_nodes[1] + else: + combined_v = v_streams[0] + combined_a = a_streams[0] + total_duration = durations[0] + for i in range(1, len(v_streams)): + offset = total_duration - cross_fade + combined_v = ffmpeg.filter([combined_v, v_streams[i]], 'xfade', transition='fade', duration=cross_fade, offset=offset) + combined_a = ffmpeg.filter([combined_a, a_streams[i]], 'acrossfade', duration=cross_fade) + total_duration += durations[i] - cross_fade + v_out, a_out = combined_v, combined_a if use_gpu: out = ffmpeg.output(v_out, a_out, output_path, vcodec="h264_nvenc", acodec="aac")