feat: add Add Soundtrack node and cross-fade to Simple Video Combiner

- Add new ComfyAddSoundtrack node for mixing soundtrack audio with video while preserving original audio, including volume adjustment, trimming, and optional original file deletion
- Update ComfySimpleVideoCombiner to include cross-fade duration parameter for smoother video transitions
- Enhance README with detailed documentation for the new soundtrack feature, improving user guidance and node discoverability
- Register new node in __init__.py mappings for proper integration into ComfyUI workflow

This expands audio/video processing capabilities, allowing users to create richer multimedia content with professional audio mixing and seamless video edits.
This commit is contained in:
scofano
2026-01-17 17:48:53 -03:00
parent 3e93441ffd
commit 7ea4ff508e
4 changed files with 261 additions and 6 deletions
+60
View File
@@ -952,6 +952,66 @@ Uses properly constructed FFmpeg filter chains:
</details>
<details>
<summary>18. Add Soundtrack ➜ Mix soundtrack audio with video while preserving original audio.</summary>
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
</details>
# 📦 **Installation**
+3
View File
@@ -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"]
+172
View File
@@ -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,)
+26 -6
View File
@@ -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")