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:
@@ -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**
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,)
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user