Files
AEmotionStudio-ComfyUI-Disc…/shared/media/video_encoder.py
T
AEmotionStudioandClaude Opus 4.5 76da850b3d refactor(phase3): extract video encoding utilities to shared modules
- Create shared/media/format_utils.py with format detection and validation
- Create shared/media/video_encoder.py with FFmpegEncoder and PILEncoder classes
- Extract validate_video_for_discord to shared utility
- Extract ffmpeg detection to shared detect_ffmpeg() function
- Extract Discord video optimization to shared optimize_video_for_discord()
- Remove duplicate code from video_node.py (-109 lines)

Phase 3 summary:
- video_node.py: 1201 -> 1092 lines (9% reduction this phase)
- Total reduction from original: 1562 -> 1092 lines (30% reduction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 23:38:58 -08:00

511 lines
16 KiB
Python

"""
Video encoding utilities for ComfyUI-DiscordSend
Provides FFmpeg-based video encoding with fallback to PIL for GIF/WebP.
"""
import os
import subprocess
from typing import List, Tuple, Optional, Iterator, Any, Callable
from uuid import uuid4
import numpy as np
def detect_ffmpeg() -> Optional[str]:
"""
Detect FFmpeg binary location.
Returns:
Path to FFmpeg executable, or None if not found
"""
ffmpeg_path = None
# Try imageio-ffmpeg first (common in Python environments)
try:
import imageio_ffmpeg
ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
print(f"Found ffmpeg via imageio_ffmpeg: {ffmpeg_path}")
return ffmpeg_path
except (ImportError, Exception):
pass
# Try system PATH
try:
from shutil import which
ffmpeg_path = which("ffmpeg")
if ffmpeg_path:
print(f"Found ffmpeg in system path: {ffmpeg_path}")
return ffmpeg_path
except Exception:
pass
return None
class FFmpegEncoder:
"""
FFmpeg-based video encoder supporting multiple formats.
"""
def __init__(self, ffmpeg_path: Optional[str] = None):
"""
Initialize the encoder.
Args:
ffmpeg_path: Path to FFmpeg executable (auto-detected if None)
"""
self.ffmpeg_path = ffmpeg_path or detect_ffmpeg()
if not self.ffmpeg_path:
raise RuntimeError("FFmpeg not found. Install ffmpeg or imageio-ffmpeg.")
def encode(
self,
images: List[np.ndarray],
output_path: str,
format_ext: str,
frame_rate: float = 24.0,
quality: int = 85,
lossless: bool = False,
loop_count: int = 0,
codec: Optional[str] = None,
progress_callback: Optional[Callable[[int, int], None]] = None
) -> str:
"""
Encode images to video using FFmpeg.
Args:
images: List of numpy arrays (H, W, C) in uint8 format
output_path: Output file path
format_ext: Output format extension (mp4, webm, gif)
frame_rate: Frame rate in FPS
quality: Quality level 1-100
lossless: Use lossless encoding if supported
loop_count: Number of loops (0 = infinite for GIF)
codec: Specific codec to use (h264, h265, vp9, etc.)
progress_callback: Optional callback(current, total) for progress
Returns:
Path to the encoded file
"""
if not images:
raise ValueError("No images provided for encoding")
# Get dimensions from first image
height, width = images[0].shape[:2]
has_alpha = images[0].shape[2] == 4 if len(images[0].shape) > 2 else False
# Determine input pixel format
i_pix_fmt = "rgba" if has_alpha else "rgb24"
dimensions = f"{width}x{height}"
# Build FFmpeg arguments
args = self._build_ffmpeg_args(
format_ext=format_ext,
dimensions=dimensions,
frame_rate=frame_rate,
quality=quality,
lossless=lossless,
loop_count=loop_count,
i_pix_fmt=i_pix_fmt,
has_alpha=has_alpha,
codec=codec,
output_path=output_path
)
# Execute encoding
self._execute_encoding(args, images, i_pix_fmt, progress_callback)
return output_path
def _build_ffmpeg_args(
self,
format_ext: str,
dimensions: str,
frame_rate: float,
quality: int,
lossless: bool,
loop_count: int,
i_pix_fmt: str,
has_alpha: bool,
codec: Optional[str],
output_path: str
) -> List[str]:
"""Build FFmpeg command arguments."""
# Loop arguments
loop_args = []
if format_ext == "gif":
loop_args = ["-loop", "0" if loop_count == 0 else str(loop_count)]
# Base input arguments
args = [
self.ffmpeg_path, "-v", "error",
"-f", "rawvideo",
"-pix_fmt", i_pix_fmt,
"-s", dimensions,
"-r", str(frame_rate),
"-i", "-"
] + loop_args
# Format-specific encoding arguments
if format_ext == "gif":
args.extend(self._get_gif_args(quality))
elif format_ext == "mp4":
args.extend(self._get_mp4_args(quality, lossless, codec))
elif format_ext == "webm":
args.extend(self._get_webm_args(quality, lossless, has_alpha))
else:
# Default to MP4-like encoding
args.extend(self._get_mp4_args(quality, lossless, codec))
# Add output path
args.extend(["-y", output_path])
return args
def _get_gif_args(self, quality: int) -> List[str]:
"""Get FFmpeg arguments for GIF encoding."""
# Use palettegen for better quality
if quality >= 80:
return [
"-vf", "split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=sierra2",
"-f", "gif"
]
else:
return [
"-vf", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
"-f", "gif"
]
def _get_mp4_args(self, quality: int, lossless: bool, codec: Optional[str]) -> List[str]:
"""Get FFmpeg arguments for MP4 encoding."""
args = []
# Determine codec
use_h265 = codec == "h265" or codec == "hevc"
if lossless:
if use_h265:
args.extend(["-c:v", "libx265", "-x265-params", "lossless=1"])
else:
args.extend(["-c:v", "libx264", "-crf", "0"])
else:
# Map quality (1-100) to CRF (51-0 for h264, lower is better)
crf = int(51 - (quality / 100 * 33)) # Maps 1->51, 100->18
if use_h265:
args.extend(["-c:v", "libx265", "-crf", str(crf + 5)]) # H.265 uses different CRF scale
else:
args.extend(["-c:v", "libx264", "-crf", str(crf)])
# Always use yuv420p for Discord compatibility
args.extend(["-pix_fmt", "yuv420p", "-movflags", "faststart"])
return args
def _get_webm_args(self, quality: int, lossless: bool, has_alpha: bool) -> List[str]:
"""Get FFmpeg arguments for WebM encoding."""
args = ["-c:v", "libvpx-vp9"]
if lossless:
args.extend(["-lossless", "1"])
else:
# Map quality to CRF (63-0 for VP9)
crf = int(63 - (quality / 100 * 33)) # Maps 1->63, 100->30
args.extend(["-crf", str(crf), "-b:v", "0"])
# Pixel format - support alpha if present
pix_fmt = "yuva420p" if has_alpha else "yuv420p"
args.extend(["-pix_fmt", pix_fmt])
# VP9 threading
args.extend(["-row-mt", "1"])
return args
def _execute_encoding(
self,
args: List[str],
images: List[np.ndarray],
i_pix_fmt: str,
progress_callback: Optional[Callable[[int, int], None]] = None
) -> None:
"""Execute FFmpeg process and feed frames."""
total_frames = len(images)
# Create image chunk iterator for memory efficiency
def image_chunks() -> Iterator[bytes]:
for i, img in enumerate(images):
# Ensure contiguous array for subprocess
chunk = np.ascontiguousarray(img)
if progress_callback:
progress_callback(i + 1, total_frames)
yield chunk.tobytes()
# Start FFmpeg process
process = subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Feed frames
try:
for chunk in image_chunks():
process.stdin.write(chunk)
process.stdin.close()
process.wait()
if process.returncode != 0:
stderr = process.stderr.read().decode('utf-8', errors='ignore')
raise RuntimeError(f"FFmpeg encoding failed: {stderr}")
finally:
if process.stdin:
process.stdin.close()
if process.stdout:
process.stdout.close()
if process.stderr:
process.stderr.close()
class PILEncoder:
"""
PIL-based encoder for GIF and WebP formats.
Fallback when FFmpeg is not available.
"""
def encode(
self,
images: List[Any], # PIL Images or numpy arrays
output_path: str,
format_ext: str,
frame_rate: float = 24.0,
quality: int = 85,
lossless: bool = False,
loop_count: int = 0,
tensor_to_numpy_func: Optional[Callable] = None
) -> str:
"""
Encode images using PIL.
Args:
images: List of PIL Images or numpy arrays
output_path: Output file path
format_ext: Output format (gif, webp)
frame_rate: Frame rate in FPS
quality: Quality level 1-100
lossless: Use lossless encoding for WebP
loop_count: Number of loops (0 = infinite)
tensor_to_numpy_func: Optional function to convert tensors to numpy
Returns:
Path to the encoded file
"""
from PIL import Image
# Convert to PIL images if needed
pil_images = []
for img in images:
if hasattr(img, 'shape'): # numpy array or tensor
if tensor_to_numpy_func and hasattr(img, 'cpu'):
img = tensor_to_numpy_func(img)
elif hasattr(img, 'numpy'):
img = img.numpy()
pil_images.append(Image.fromarray(img.astype(np.uint8)))
else:
pil_images.append(img)
if not pil_images:
raise ValueError("No images provided for encoding")
# Calculate frame duration in milliseconds
duration = int(1000 / frame_rate)
if format_ext.lower() == "gif":
self._encode_gif(pil_images, output_path, duration, loop_count)
elif format_ext.lower() == "webp":
self._encode_webp(pil_images, output_path, duration, loop_count, quality, lossless)
else:
# Single frame fallback
pil_images[0].save(output_path, format=format_ext.upper())
return output_path
def _encode_gif(
self,
images: List[Any],
output_path: str,
duration: int,
loop_count: int
) -> None:
"""Encode images as GIF."""
durations = [duration] * len(images)
images[0].save(
output_path,
format="GIF",
append_images=images[1:] if len(images) > 1 else [],
save_all=True,
duration=durations,
loop=0 if loop_count == 0 else loop_count,
optimize=False
)
def _encode_webp(
self,
images: List[Any],
output_path: str,
duration: int,
loop_count: int,
quality: int,
lossless: bool
) -> None:
"""Encode images as WebP."""
save_kwargs = {
"format": "WEBP",
"append_images": images[1:] if len(images) > 1 else [],
"save_all": True,
"duration": duration,
"loop": 0 if loop_count == 0 else loop_count,
}
if lossless:
save_kwargs["lossless"] = True
else:
save_kwargs["quality"] = quality
images[0].save(output_path, **save_kwargs)
def optimize_video_for_discord(
input_file: str,
ffmpeg_path: str,
temp_dir: str
) -> Optional[str]:
"""
Create a Discord-optimized version of a video file.
Args:
input_file: Path to the input video file
ffmpeg_path: Path to FFmpeg executable
temp_dir: Directory for temporary files
Returns:
Path to the optimized file, or None if optimization failed
"""
format_ext = os.path.splitext(input_file)[1].lstrip('.').lower()
discord_optimized_file = os.path.join(temp_dir, f"discord_optimized_{uuid4()}.{format_ext}")
try:
if format_ext == "mp4":
optimize_args = [
ffmpeg_path, "-i", input_file,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "faststart", "-preset", "fast",
"-profile:v", "baseline", "-level", "3.0",
"-crf", "23",
"-c:a", "aac", "-b:a", "128k",
"-y", discord_optimized_file
]
elif format_ext == "webm":
optimize_args = [
ffmpeg_path, "-i", input_file,
"-c:v", "libvpx-vp9",
"-pix_fmt", "yuv420p",
"-crf", "30", "-b:v", "0",
"-deadline", "good",
"-c:a", "libopus", "-b:a", "96k",
"-y", discord_optimized_file
]
elif format_ext == "gif":
optimize_args = [
ffmpeg_path, "-i", input_file,
"-vf", "fps=15,scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-y", discord_optimized_file
]
else:
print(f"No optimization rules for format: {format_ext}")
return None
print(f"Creating Discord-optimized version of {format_ext.upper()} file...")
result = subprocess.run(
optimize_args,
capture_output=True,
text=True
)
if result.returncode == 0 and os.path.exists(discord_optimized_file):
print(f"Discord-optimized file created: {discord_optimized_file}")
return discord_optimized_file
else:
print(f"Optimization failed: {result.stderr}")
return None
except Exception as e:
print(f"Error during Discord optimization: {e}")
return None
def mux_audio_to_video(
video_path: str,
audio_waveform: np.ndarray,
sample_rate: int,
format_ext: str,
ffmpeg_path: str,
output_path: str,
channels: int = 2
) -> bool:
"""
Mux audio into a video file.
Args:
video_path: Path to the video file
audio_waveform: Audio data as numpy array
sample_rate: Audio sample rate
format_ext: Video format extension
ffmpeg_path: Path to FFmpeg executable
output_path: Output path for the muxed file
channels: Number of audio channels
Returns:
True if successful, False otherwise
"""
try:
# Determine audio codec based on format
if format_ext == "mp4":
audio_pass = ["-c:a", "aac", "-b:a", "192k"]
elif format_ext == "webm":
audio_pass = ["-c:a", "libopus", "-b:a", "128k"]
else:
audio_pass = ["-c:a", "libopus", "-b:a", "128k"]
mux_args = [
ffmpeg_path, "-v", "error", "-y",
"-i", video_path,
"-ar", str(sample_rate),
"-ac", str(channels),
"-f", "f32le",
"-i", "-",
"-c:v", "copy"
] + audio_pass + ["-shortest", output_path]
# Ensure contiguous array for subprocess
audio_data = np.ascontiguousarray(audio_waveform)
result = subprocess.run(
mux_args,
input=memoryview(audio_data),
capture_output=True
)
if result.returncode == 0:
print(f"Successfully muxed audio to video: {output_path}")
return True
else:
print(f"Audio muxing failed: {result.stderr.decode('utf-8', errors='ignore')}")
return False
except Exception as e:
print(f"Error muxing audio: {e}")
return False