Download utils/video.py from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 59.8 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/utils/video.py
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/utils/video.py
-
curl -L -o video.py https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/utils/video.py
59.8 kB
| from __future__ import annotations | |
| import io | |
| import json | |
| import logging | |
| import math | |
| import os | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import urllib.request | |
| from .media import AUDIO_EXTENSIONS | |
| from pathlib import Path | |
| from dataclasses import dataclass | |
| from fractions import Fraction | |
| from typing import Any, Callable | |
| import folder_paths | |
| import torch | |
| def passthrough_video_media( | |
| videos: list[object] | object, | |
| frame_count: int, | |
| fps: float, | |
| ) -> tuple[torch.Tensor, dict[str, Any]]: | |
| """Read video zero at the project frame rate without changing its timing.""" | |
| candidates = videos if isinstance(videos, list) else [videos] | |
| video = candidates[0] if candidates else None | |
| if video is None: | |
| raise ValueError("Passthrough requires a video reference at index 0") | |
| if frame_count < 1 or not math.isfinite(fps) or fps <= 0: | |
| raise ValueError("Passthrough requires a positive timeline length and fps") | |
| try: | |
| components = video.get_components() | |
| except (AttributeError, RuntimeError, TypeError, ValueError) as error: | |
| raise ValueError("Unable to read passthrough video reference") from error | |
| images = components.images | |
| source_fps = float(components.frame_rate) | |
| if not isinstance(images, torch.Tensor) or images.ndim != 4 or images.shape[0] == 0: | |
| raise ValueError("Passthrough video has no valid frames") | |
| if not math.isfinite(source_fps) or source_fps <= 0: | |
| raise ValueError("Passthrough video has an invalid frame rate") | |
| if images.shape[0] / source_fps + 1e-6 < frame_count / fps: | |
| raise ValueError("Passthrough video must cover the complete task timeline") | |
| indices = torch.floor( | |
| torch.arange(frame_count, device=images.device) * source_fps / fps | |
| ).long().clamp(max=images.shape[0] - 1) | |
| output_images = images.index_select(0, indices) | |
| audio = components.audio | |
| waveform = audio.get("waveform") if isinstance(audio, dict) else None | |
| sample_rate = audio.get("sample_rate") if isinstance(audio, dict) else None | |
| if not isinstance(waveform, torch.Tensor) or not isinstance(sample_rate, int) or sample_rate <= 0: | |
| sample_rate = 44100 | |
| waveform = torch.zeros((1, 2, 0), dtype=torch.float32) | |
| sample_count = max(1, round(frame_count / fps * sample_rate)) | |
| waveform = waveform[..., :sample_count] | |
| if waveform.shape[-1] < sample_count: | |
| waveform = torch.nn.functional.pad(waveform, (0, sample_count - waveform.shape[-1])) | |
| return output_images, {"waveform": waveform, "sample_rate": sample_rate} | |
| logger = logging.getLogger(__name__) | |
| FFMPEG_RESIZE_METHODS = frozenset({"stretch", "resize", "pad", "pad (white)", "crop"}) | |
| _VIDEO_EXTENSIONS = frozenset({".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"}) | |
| _FFMPEG_TIMEOUT_SECONDS = 60.0 | |
| def _video_output_suffix(path: str) -> str: | |
| """Return a standard video suffix, ignoring ComfyUI URL-style annotations.""" | |
| clean_path = path.split("?", 1)[0].split("&", 1)[0] | |
| suffix = os.path.splitext(clean_path)[1].lower() | |
| if suffix in _VIDEO_EXTENSIONS: | |
| return suffix | |
| return ".mp4" | |
| def resolve_video_path( | |
| source_type: str, | |
| file_path: str | None, | |
| local_path: str | None, | |
| url: str | None, | |
| ) -> str | io.BytesIO: | |
| """Resolve a multitrack video reference to a VideoFromFile source.""" | |
| if source_type == "url" and url: | |
| try: | |
| with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 | |
| return io.BytesIO(response.read()) | |
| except Exception as exc: | |
| raise RuntimeError(f"Failed to load video URL: {url}") from exc | |
| if source_type == "output" and file_path: | |
| resolved = os.path.join(folder_paths.get_output_directory(), file_path) | |
| elif source_type == "input" and file_path: | |
| resolved = folder_paths.get_annotated_filepath(file_path) | |
| elif source_type == "local" and local_path: | |
| resolved = local_path | |
| else: | |
| raise ValueError(f"Unsupported or incomplete video source: {source_type!r}") | |
| if not os.path.isfile(resolved): | |
| raise FileNotFoundError(f"Video file not found: {resolved}") | |
| return resolved | |
| def _ffmpeg_resize_filter(width: int, height: int, method: str) -> str | None: | |
| if method == "stretch": | |
| return f"scale={width}:{height}" | |
| if method in {"resize", "pad", "pad (white)"}: | |
| color = "white" if method == "pad (white)" else "black" | |
| return ( | |
| f"scale={width}:{height}:force_original_aspect_ratio=decrease," | |
| f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:{color}" | |
| ) | |
| if method == "crop": | |
| return ( | |
| f"scale={width}:{height}:force_original_aspect_ratio=increase," | |
| f"crop={width}:{height}" | |
| ) | |
| return None | |
| def resize_video_with_ffmpeg( | |
| source: str, | |
| width: int, | |
| height: int, | |
| method: str, | |
| progress_callback: Callable[[float], None] | None = None, | |
| ) -> str | None: | |
| """Resize a file-backed video with FFmpeg, returning a temp output path.""" | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| video_filter = _ffmpeg_resize_filter(width, height, method) | |
| if ffmpeg is None or video_filter is None or not os.path.isfile(source): | |
| return None | |
| if width % 2 or height % 2: | |
| return None | |
| duration = ffprobe_info(source).get("duration") | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=".mp4", | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-i", | |
| source, | |
| "-map", | |
| "0:v:0", | |
| "-map", | |
| "0:a?", | |
| "-vf", | |
| video_filter, | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "veryfast", | |
| "-crf", | |
| "18", | |
| "-c:a", | |
| "aac", | |
| "-b:a", | |
| "192k", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-map_metadata", | |
| "0", | |
| "-movflags", | |
| "+faststart", | |
| "-progress", | |
| "pipe:1", | |
| "-nostats", | |
| output_path, | |
| ] | |
| if progress_callback is not None: | |
| progress_callback(0.0) | |
| try: | |
| with tempfile.TemporaryFile() as stderr_file: | |
| process = subprocess.Popen( | |
| command, | |
| stdout=subprocess.PIPE, | |
| stderr=stderr_file, | |
| text=True, | |
| ) | |
| if process.stdout is not None: | |
| for line in process.stdout: | |
| key, separator, raw_value = line.strip().partition("=") | |
| if separator and key == "out_time_us" and duration: | |
| out_time_us = _parse_positive_float(raw_value) | |
| if out_time_us is None: | |
| continue | |
| ratio = min(1.0, max(0.0, out_time_us / (float(duration) * 1_000_000))) | |
| if progress_callback is not None: | |
| progress_callback(ratio) | |
| return_code = process.wait() | |
| if return_code != 0: | |
| stderr_file.seek(0) | |
| error_text = stderr_file.read().decode(errors="replace")[-600:] | |
| logger.warning("FFmpeg video resize failed: %s", error_text) | |
| os.unlink(output_path) | |
| return None | |
| except (OSError, ValueError) as exc: | |
| logger.warning("FFmpeg video resize failed: %s", exc) | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| return None | |
| if progress_callback is not None: | |
| progress_callback(1.0) | |
| return output_path | |
| def render_single_video_segment_with_ffmpeg( | |
| source: str, | |
| source_start_frame: int, | |
| frame_count: int, | |
| frame_rate: float, | |
| width: int, | |
| height: int, | |
| resize_method: str, | |
| *, | |
| audio_volume_db: float = 0.0, | |
| audio_muted: bool = False, | |
| ) -> str | None: | |
| """Render one task-length clip without a full-timeline overlay graph.""" | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| resize_filter = _ffmpeg_resize_filter(width, height, resize_method) | |
| if ( | |
| ffmpeg is None or resize_filter is None or not os.path.isfile(source) | |
| or frame_count <= 0 or frame_rate <= 0 or width % 2 or height % 2 | |
| ): | |
| return None | |
| duration = frame_count / frame_rate | |
| source_start = max(0, source_start_frame) / frame_rate | |
| has_audio = ffprobe_info(source).get("has_audio") is True | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=".mp4", dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| command = [ | |
| ffmpeg, "-y", "-nostdin", "-loglevel", "error", | |
| "-ss", str(source_start), "-t", str(duration), "-i", source, | |
| "-map", "0:v:0", | |
| ] | |
| if has_audio: | |
| command.extend(["-map", "0:a:0"]) | |
| command.extend([ | |
| "-vf", | |
| f"fps=fps={frame_rate}:start_time=0,{resize_filter}," | |
| f"tpad=stop_mode=clone:stop_duration={duration}," | |
| f"trim=end_frame={frame_count},setpts=PTS-STARTPTS", | |
| ]) | |
| if has_audio: | |
| try: | |
| volume_db = float(audio_volume_db) | |
| except (TypeError, ValueError): | |
| volume_db = 0.0 | |
| if not math.isfinite(volume_db): | |
| volume_db = 0.0 | |
| volume_filter = "volume=0" if audio_muted else f"volume={volume_db:g}dB" | |
| command.extend([ | |
| "-af", | |
| f"aresample=44100:first_pts=0,atrim=duration={duration}," | |
| f"asetpts=PTS-STARTPTS,{volume_filter}," | |
| f"apad=pad_dur={duration},atrim=duration={duration}", | |
| ]) | |
| command.extend([ | |
| "-frames:v", str(frame_count), "-t", str(duration), | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", | |
| "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", | |
| "-movflags", "+faststart", output_path, | |
| ]) | |
| try: | |
| result = subprocess.run( | |
| command, capture_output=True, timeout=_FFMPEG_TIMEOUT_SECONDS, | |
| ) | |
| except (OSError, subprocess.TimeoutExpired) as exc: | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise RuntimeError(f"FFmpeg single video segment render failed: {exc}") from exc | |
| if result.returncode != 0: | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise RuntimeError( | |
| "FFmpeg single video segment render failed: " | |
| + result.stderr.decode(errors="replace")[-600:] | |
| ) | |
| return output_path | |
| def merge_video_track_with_ffmpeg( | |
| segments: list[dict], | |
| total_length: int, | |
| frame_rate: float, | |
| width: int, | |
| height: int, | |
| resize_method: str | None = None, | |
| *, | |
| failure_reason: list[str] | None = None, | |
| ) -> str | None: | |
| """Compose file-backed segments on a black full-length timeline. | |
| Returns ``None`` when composition is impossible so callers can fall back | |
| to another strategy. Callers that need to surface the real cause instead | |
| pass a ``failure_reason`` list, which receives one human-readable entry | |
| per failed precondition (missing FFmpeg, missing source files, ...) or | |
| the FFmpeg stderr tail when the command itself fails. | |
| """ | |
| def record(reason: str) -> None: | |
| if failure_reason is not None: | |
| failure_reason.append(reason) | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| resize_filter = _ffmpeg_resize_filter(width, height, resize_method) if resize_method else None | |
| if ffmpeg is None: | |
| record( | |
| "FFmpeg executable not found (searched PATH for ffmpeg/ffmpeg.exe, " | |
| r"C:\ffmpeg\bin, %LOCALAPPDATA%\Programs\ffmpeg\bin). " | |
| "Install FFmpeg, ensure ffmpeg.exe is on PATH and restart ComfyUI" | |
| ) | |
| return None | |
| if total_length <= 0: | |
| record(f"timeline is empty (total_length={total_length} frames)") | |
| return None | |
| if frame_rate <= 0: | |
| record(f"invalid frame rate {frame_rate}") | |
| return None | |
| if width % 2 or height % 2: | |
| record( | |
| f"canvas size {width}x{height} has odd dimensions " | |
| "(video encoders require even width and height)" | |
| ) | |
| return None | |
| if resize_method is not None and resize_filter is None: | |
| record(f"resize method '{resize_method}' is not supported by this FFmpeg build") | |
| return None | |
| missing_sources = [ | |
| str(segment.get("source", "")) | |
| for segment in segments | |
| if not os.path.isfile(str(segment.get("source", ""))) | |
| ] | |
| if missing_sources: | |
| record( | |
| "segment source file(s) missing: " | |
| + " | ".join(missing_sources[:5]) | |
| + (f" (+{len(missing_sources) - 5} more)" if len(missing_sources) > 5 else "") | |
| ) | |
| return None | |
| missing_audio = [ | |
| str(segment["audio_source"]) | |
| for segment in segments | |
| if segment.get("audio_source") is not None | |
| and not os.path.isfile(str(segment["audio_source"])) | |
| ] | |
| if missing_audio: | |
| record( | |
| "locked audio file(s) missing: " | |
| + " | ".join(missing_audio[:5]) | |
| + (f" (+{len(missing_audio) - 5} more)" if len(missing_audio) > 5 else "") | |
| ) | |
| return None | |
| total_seconds = total_length / frame_rate | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=".mp4", | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-nostdin", | |
| "-loglevel", "error", | |
| "-f", | |
| "lavfi", | |
| "-i", | |
| f"color=c=black:s={width}x{height}:r={frame_rate}:d={total_seconds}", | |
| "-f", | |
| "lavfi", | |
| "-i", | |
| f"anullsrc=r=44100:cl=stereo:d={total_seconds}", | |
| ] | |
| input_indexes: list[tuple[int, int]] = [] | |
| next_input_index = 2 | |
| for segment in segments: | |
| video_input_index = next_input_index | |
| command.extend(["-i", str(segment["source"])]) | |
| next_input_index += 1 | |
| audio_source = segment.get("audio_source") | |
| if audio_source is not None and str(audio_source) != str(segment["source"]): | |
| audio_input_index = next_input_index | |
| command.extend(["-i", str(audio_source)]) | |
| next_input_index += 1 | |
| else: | |
| audio_input_index = video_input_index | |
| input_indexes.append((video_input_index, audio_input_index)) | |
| filters = ["[0:v]setpts=PTS-STARTPTS[basev]", "[1:a]asetpts=PTS-STARTPTS[basea]"] | |
| current_video = "basev" | |
| audio_labels = ["basea"] | |
| for index, segment in enumerate(segments): | |
| video_input_index, audio_input_index = input_indexes[index] | |
| start_frame = max(0, int(segment.get("start_frame", 0))) | |
| end_frame = min(total_length, max(start_frame, int(segment.get("end_frame", start_frame)))) | |
| start_seconds = start_frame / frame_rate | |
| source_start_frame = max(0, int(segment.get("source_start_frame", 0))) | |
| frame_count = end_frame - start_frame | |
| source_end_frame = source_start_frame + frame_count | |
| source_start_seconds = source_start_frame / frame_rate | |
| duration = frame_count / frame_rate | |
| if duration <= 0: | |
| continue | |
| clip_label = f"clipv{index}" | |
| output_label = f"timelinev{index}" | |
| # source_start_frame and frame_count are expressed in timeline frames, | |
| # not in the source file's native frame rate. Normalize the source to | |
| # the timeline rate before applying those frame boundaries. Trimming | |
| # first would turn 120 timeline frames into only four seconds of a | |
| # 30 fps source on a 24 fps timeline, after which tpad would visibly | |
| # freeze the last frame for the missing second. | |
| video_filters = [ | |
| "setpts=PTS-STARTPTS", | |
| f"fps=fps={frame_rate}:start_time=0", | |
| f"trim=start_frame={source_start_frame}:end_frame={source_end_frame}", | |
| "setpts=PTS-STARTPTS", | |
| ] | |
| if resize_filter is not None: | |
| video_filters.append(resize_filter) | |
| # Some containers report a duration-derived frame count that is one or | |
| # two frames longer than the frames FFmpeg can actually decode. Pad with | |
| # the final decoded frame before enforcing the exact timeline length so | |
| # the black base cannot leak through at a segment boundary. | |
| video_filters.extend([ | |
| f"tpad=stop_mode=clone:stop_duration={duration}", | |
| f"trim=end_frame={frame_count}", | |
| ]) | |
| # fps sets the time base to one frame, so use an integer offset to | |
| # avoid rounding a fractional-second start down to the previous frame. | |
| video_filters.append(f"setpts=PTS-STARTPTS+{start_frame}") | |
| filters.append(f"[{video_input_index}:v]{','.join(video_filters)}[{clip_label}]") | |
| # FFmpeg 4.4 evaluates enable on both overlay inputs: `n` can be the | |
| # clip-local frame count rather than the main timeline's frame count. | |
| # Both inputs have timeline timestamps, so derive the frame from `t`. | |
| # Rounding also protects fractional-second boundaries (e.g. 260 / 24). | |
| filters.append( | |
| f"[{current_video}][{clip_label}]overlay=eof_action=pass:" | |
| f"enable='between(round(t*{frame_rate}),{start_frame},{end_frame - 1})'[{output_label}]" | |
| ) | |
| current_video = output_label | |
| audio_source = str(segment.get("audio_source", segment["source"])) | |
| if ffprobe_info(audio_source).get("has_audio"): | |
| audio_label = f"clipa{index}" | |
| muted = segment.get("audio_muted") is True | |
| raw_volume_db = segment.get("audio_volume_db", 0.0) | |
| try: | |
| volume_db = float(raw_volume_db) | |
| except (TypeError, ValueError): | |
| volume_db = 0.0 | |
| if not math.isfinite(volume_db): | |
| volume_db = 0.0 | |
| volume_filter = "volume=0" if muted else f"volume={volume_db:g}dB" | |
| if segment.get("audio_locked") is True: | |
| timeline_sample_rate = 44100 | |
| source_start_sample = round( | |
| source_start_frame * timeline_sample_rate / frame_rate | |
| ) | |
| source_end_sample = round( | |
| source_end_frame * timeline_sample_rate / frame_rate | |
| ) | |
| timeline_start_sample = round( | |
| start_frame * timeline_sample_rate / frame_rate | |
| ) | |
| filters.append( | |
| f"[{audio_input_index}:a]aresample={timeline_sample_rate}:first_pts=0," | |
| f"atrim=start_sample={source_start_sample}:end_sample={source_end_sample}," | |
| f"asetpts=PTS-STARTPTS,{volume_filter}," | |
| f"adelay={timeline_start_sample}S:all=1[{audio_label}]" | |
| ) | |
| else: | |
| delay_ms = round(start_seconds * 1000) | |
| filters.append( | |
| f"[{audio_input_index}:a]atrim=start={source_start_seconds}:duration={duration},asetpts=PTS-STARTPTS," | |
| f"{volume_filter},adelay={delay_ms}:all=1[{audio_label}]" | |
| ) | |
| audio_labels.append(audio_label) | |
| if len(audio_labels) > 1: | |
| audio_inputs = "".join(f"[{label}]" for label in audio_labels) | |
| filters.append( | |
| f"{audio_inputs}amix=inputs={len(audio_labels)}:duration=longest:normalize=0[aout]" | |
| ) | |
| audio_output = "aout" | |
| else: | |
| audio_output = "basea" | |
| command.extend([ | |
| "-filter_complex", | |
| ";".join(filters), | |
| "-map", | |
| f"[{current_video}]", | |
| "-map", | |
| f"[{audio_output}]", | |
| "-t", | |
| str(total_seconds), | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "veryfast", | |
| "-crf", | |
| "18", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-c:a", | |
| "aac", | |
| "-movflags", | |
| "+faststart", | |
| output_path, | |
| ]) | |
| try: | |
| result = subprocess.run( | |
| command, capture_output=True, timeout=_FFMPEG_TIMEOUT_SECONDS, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise RuntimeError( | |
| f"FFmpeg video track merge timed out after {exc.timeout:g} seconds" | |
| ) from exc | |
| except OSError as exc: | |
| logger.warning("FFmpeg video track merge failed to start: %s", exc) | |
| record(f"FFmpeg failed to start ({exc})") | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| return None | |
| if result.returncode == 0: | |
| return output_path | |
| stderr_tail = result.stderr.decode(errors="replace")[-600:] | |
| logger.warning("FFmpeg video track merge failed: %s", stderr_tail) | |
| record( | |
| f"FFmpeg exited with code {result.returncode}: {stderr_tail.strip() or 'no stderr output'}" | |
| ) | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| return None | |
| def get_ffmpeg_path(name: str = "ffmpeg") -> str | None: | |
| """Find FFmpeg/ffprobe executable, with Windows-specific fallbacks.""" | |
| # Try standard which first (works on all platforms) | |
| ffmpeg = shutil.which(name) or shutil.which(f"{name}.exe") | |
| if ffmpeg: | |
| return ffmpeg | |
| # Windows fallback: common installation paths | |
| if os.name == "nt": | |
| for path in ( | |
| r"C:\ffmpeg\bin\{}.exe".format(name), | |
| os.path.expandvars(r"%LOCALAPPDATA%\Programs\{}\bin\{}.exe".format(name, name)), | |
| ): | |
| if os.path.isfile(path): | |
| return path | |
| return None | |
| def decode_multitrack_preview_frame( | |
| video_path: str | Path, | |
| frame: int, | |
| frame_rate: float, | |
| ) -> bytes: | |
| """Return the exact frame selected by the video-track FFmpeg fps filter.""" | |
| if frame < 0 or not math.isfinite(frame_rate) or frame_rate <= 0: | |
| raise ValueError("Invalid preview frame or frame rate") | |
| ffmpeg = get_ffmpeg_path() | |
| if ffmpeg is None: | |
| raise RuntimeError("FFmpeg is required for frame-accurate preview") | |
| # Accurate input seeking decodes from the preceding keyframe. Keep one second | |
| # of source frames before the requested output frame so the fps filter has | |
| # the same timestamp context as a full decode from frame zero. | |
| seek_time = max(0, math.floor(frame / frame_rate) - 1) | |
| seek_args = ["-ss", str(seek_time), "-copyts", "-start_at_zero"] if seek_time else [] | |
| timestamp_filter = "" if seek_time else "setpts=PTS-STARTPTS," | |
| result = subprocess.run( | |
| [ffmpeg, "-v", "error", *seek_args, "-i", str(video_path), | |
| "-vf", f"{timestamp_filter}fps=fps={frame_rate}:start_time=0," | |
| f"select=eq(n\\,{frame}),scale=w='min(960,iw)':h=-2", | |
| "-frames:v", "1", "-an", "-f", "image2pipe", "-vcodec", "mjpeg", "-"], | |
| capture_output=True, check=False, timeout=60, | |
| ) | |
| if result.returncode != 0 or not result.stdout: | |
| raise RuntimeError( | |
| f"Unable to decode preview frame: {result.stderr.decode(errors='replace')[-600:]}" | |
| ) | |
| return result.stdout | |
| def video_input_to_local_file( | |
| video: Any, | |
| suffix: str = ".mp4", | |
| save_kwargs: dict[str, Any] | None = None, | |
| ) -> tuple[str, list[str]]: | |
| """Return a local video file path, serializing non-file VIDEO inputs to temp.""" | |
| temp_files: list[str] = [] | |
| try: | |
| source = video.get_stream_source() | |
| except (AttributeError, NotImplementedError, RuntimeError, TypeError, ValueError): | |
| source = None | |
| if isinstance(source, str) and os.path.isfile(source): | |
| return source, temp_files | |
| if isinstance(source, io.BytesIO): | |
| source.seek(0) | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=suffix, | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| try: | |
| os.write(output_fd, source.read()) | |
| finally: | |
| os.close(output_fd) | |
| temp_files.append(output_path) | |
| return output_path, temp_files | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=suffix, | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| try: | |
| kwargs = save_kwargs or {} | |
| video.save_to(output_path, **kwargs) | |
| except Exception: | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise | |
| temp_files.append(output_path) | |
| return output_path, temp_files | |
| def _escape_subtitles_filter_path(path: str) -> str: | |
| return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", r"\'") | |
| def burn_subtitles_with_ffmpeg( | |
| video_path: str, | |
| subtitle_path: str, | |
| output_path: str, | |
| ) -> str: | |
| """Burn an SRT/ASS subtitle file into a video and return the output path.""" | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if ffmpeg is None: | |
| raise RuntimeError("FFmpeg is required to add subtitles to video.") | |
| if not os.path.isfile(video_path): | |
| raise FileNotFoundError(f"Video file not found: {video_path}") | |
| if not os.path.isfile(subtitle_path): | |
| raise FileNotFoundError(f"Subtitle file not found: {subtitle_path}") | |
| filter_value = f"subtitles='{_escape_subtitles_filter_path(subtitle_path)}'" | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-i", | |
| video_path, | |
| "-vf", | |
| filter_value, | |
| "-map", | |
| "0:v:0", | |
| "-map", | |
| "0:a?", | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "veryfast", | |
| "-crf", | |
| "18", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-c:a", | |
| "copy", | |
| "-movflags", | |
| "+faststart", | |
| output_path, | |
| ] | |
| result = subprocess.run(command, capture_output=True) | |
| if result.returncode == 0: | |
| return output_path | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise RuntimeError( | |
| "FFmpeg subtitle burn failed:\n" | |
| f"{result.stderr.decode(errors='replace')[-800:]}" | |
| ) | |
| class MergeSpec: | |
| width: int | |
| height: int | |
| fps: Fraction | |
| has_audio: bool | |
| sample_rate: int | None | |
| channels: int | None | |
| def normalize_video_images( | |
| images: torch.Tensor, divisible_by: int = 2 | |
| ) -> tuple[torch.Tensor, bool]: | |
| """Crop H/W dimensions to be divisible by `divisible_by` for video encoding. | |
| Returns (tensor, changed) where changed is True if the tensor was cropped. | |
| """ | |
| _batch, height, width, _channels = images.shape | |
| target_width = width - (width % divisible_by) | |
| target_height = height - (height % divisible_by) | |
| if target_width == width and target_height == height: | |
| return images, False | |
| cropped = images[:, :target_height, :target_width, :] | |
| return cropped.contiguous(), True | |
| def extract_merge_spec(video: Any) -> MergeSpec: | |
| """Extract a MergeSpec from a VideoInput object for compatibility checks.""" | |
| components = video.get_components() | |
| fps: Fraction = components.frame_rate | |
| width, height = video.get_dimensions() | |
| audio = components.audio | |
| if audio is None: | |
| has_audio = False | |
| sample_rate = None | |
| channels = None | |
| else: | |
| has_audio = True | |
| if isinstance(audio, dict): | |
| sample_rate = int(audio.get("sample_rate") or 0) | |
| waveform = audio.get("waveform") | |
| channels = int(waveform.shape[1]) if waveform is not None else None | |
| else: | |
| # Treat unknown audio types as present with unknown params | |
| sample_rate = None | |
| channels = None | |
| return MergeSpec( | |
| width=width, | |
| height=height, | |
| fps=fps, | |
| has_audio=has_audio, | |
| sample_rate=sample_rate, | |
| channels=channels, | |
| ) | |
| def validate_merge_compatibility(specs: list[MergeSpec]) -> None: | |
| """Raise ValueError if the given specs are not all merge-compatible.""" | |
| if not specs: | |
| raise ValueError("At least one video is required") | |
| baseline = specs[0] | |
| labels = ("width", "height", "fps", "has_audio", "sample_rate", "channels") | |
| for index, spec in enumerate(specs[1:], start=2): | |
| for label in labels: | |
| baseline_val = getattr(baseline, label) | |
| spec_val = getattr(spec, label) | |
| if label == "fps": | |
| # Decoders can report the same nominal rate with tiny rounding differences. | |
| matches = math.isclose(float(baseline_val), float(spec_val), rel_tol=0, abs_tol=1e-4) | |
| else: | |
| matches = baseline_val == spec_val | |
| if not matches: | |
| raise ValueError( | |
| f"Video {index} is incompatible: '{label}' mismatch " | |
| f"(expected {baseline_val!r}, got {spec_val!r})" | |
| ) | |
| def ffmpeg_concat( | |
| input_paths: list[str], | |
| output_path: str, | |
| progress_callback: "callable[[str], None] | None" = None, | |
| ) -> bool: | |
| """Concatenate video files using FFmpeg concat demuxer. | |
| Tries stream copy first (no re-encoding — extremely fast). | |
| Falls back to libx264/aac re-encoding if stream copy fails. | |
| Returns True on success, False if FFmpeg is not installed (caller should | |
| fall back to the tensor-based merge path). | |
| Raises RuntimeError if FFmpeg is available but the concat fails. | |
| """ | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg: | |
| return False | |
| # Build the concat list file | |
| list_fd, list_path = tempfile.mkstemp(suffix=".txt") | |
| try: | |
| with os.fdopen(list_fd, "w", encoding="utf-8") as f: | |
| for p in input_paths: | |
| # FFmpeg concat-list escaping: backslash-escape single quotes | |
| escaped = p.replace("\\", "\\\\").replace("'", "\\'") | |
| f.write(f"file '{escaped}'\n") | |
| base_cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", list_path] | |
| # --- attempt 1: stream copy (fastest) --- | |
| if progress_callback: | |
| progress_callback("FFmpeg concat (stream copy)…") | |
| result = subprocess.run( | |
| base_cmd + ["-c", "copy", output_path], | |
| capture_output=True, | |
| ) | |
| if result.returncode == 0: | |
| return True | |
| logger.warning( | |
| "[ffmpeg_concat] stream copy failed (rc=%d), retrying with re-encode. " | |
| "stderr: %s", | |
| result.returncode, | |
| result.stderr.decode(errors="replace")[-400:], | |
| ) | |
| # --- attempt 2: re-encode (compatible with mixed codecs) --- | |
| if progress_callback: | |
| progress_callback("FFmpeg concat (re-encoding)…") | |
| result2 = subprocess.run( | |
| base_cmd + [ | |
| "-c:v", "libx264", "-preset", "fast", | |
| "-c:a", "aac", | |
| output_path, | |
| ], | |
| capture_output=True, | |
| ) | |
| if result2.returncode != 0: | |
| raise RuntimeError( | |
| f"FFmpeg concat failed:\n{result2.stderr.decode(errors='replace')[-600:]}" | |
| ) | |
| return True | |
| finally: | |
| try: | |
| os.unlink(list_path) | |
| except OSError: | |
| pass | |
| def trim_video_with_ffmpeg( | |
| input_path: str, | |
| frame_count: int, | |
| progress_callback: "callable[[str], None] | None" = None, | |
| ) -> str | None: | |
| """Trim a video to ``frame_count`` frames based on its detected fps. | |
| Returns a temp output path when trimming succeeds. Returns ``None`` when | |
| trimming is disabled, FFmpeg is unavailable, or fps cannot be detected. | |
| """ | |
| if frame_count <= 0: | |
| return None | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg or not os.path.isfile(input_path): | |
| return None | |
| fps = ffprobe_info(input_path).get("fps") | |
| if not isinstance(fps, (int, float)) or fps <= 0: | |
| return None | |
| duration = frame_count / float(fps) | |
| suffix = _video_output_suffix(input_path) | |
| output_fd, output_path = tempfile.mkstemp( | |
| suffix=suffix, | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(output_fd) | |
| base_cmd = [ | |
| ffmpeg, | |
| "-y", | |
| "-t", | |
| f"{duration:.6f}", | |
| "-i", | |
| input_path, | |
| "-map", | |
| "0", | |
| ] | |
| if progress_callback: | |
| progress_callback(f"FFmpeg trim to {frame_count} frames ({duration:.3f}s)…") | |
| result = subprocess.run( | |
| base_cmd + ["-c", "copy", "-avoid_negative_ts", "make_zero", output_path], | |
| capture_output=True, | |
| ) | |
| if result.returncode == 0: | |
| return output_path | |
| logger.warning( | |
| "[trim_video_with_ffmpeg] stream copy failed (rc=%d), retrying with re-encode. " | |
| "stderr: %s", | |
| result.returncode, | |
| result.stderr.decode(errors="replace")[-400:], | |
| ) | |
| result2 = subprocess.run( | |
| base_cmd + [ | |
| "-c:v", "libx264", "-preset", "fast", | |
| "-c:a", "aac", | |
| output_path, | |
| ], | |
| capture_output=True, | |
| ) | |
| if result2.returncode == 0: | |
| return output_path | |
| try: | |
| os.unlink(output_path) | |
| except OSError: | |
| pass | |
| raise RuntimeError( | |
| f"FFmpeg trim failed:\n{result2.stderr.decode(errors='replace')[-600:]}" | |
| ) | |
| def _parse_rate(value: str | None) -> Fraction | None: | |
| if not value: | |
| return None | |
| if "/" in value: | |
| num, denom = value.split("/", 1) | |
| try: | |
| numerator = int(num) | |
| denominator = int(denom) | |
| except ValueError: | |
| return None | |
| if denominator > 0 and numerator > 0: | |
| return Fraction(numerator, denominator) | |
| return None | |
| try: | |
| parsed = float(value) | |
| except ValueError: | |
| return None | |
| if parsed <= 0: | |
| return None | |
| return Fraction(parsed).limit_denominator(100000) | |
| def _parse_positive_float(value: str | None) -> float | None: | |
| if value in (None, "", "N/A"): | |
| return None | |
| try: | |
| parsed = float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| if not math.isfinite(parsed) or parsed <= 0: | |
| return None | |
| return parsed | |
| def _parse_video_stream(stream: dict) -> "tuple[int | None, int | None, float | None, Fraction | None, int | None]": | |
| """Extract width, height, fps, and frame count from a video stream dict.""" | |
| width = int(stream["width"]) if stream.get("width") else None | |
| height = int(stream["height"]) if stream.get("height") else None | |
| fps_fraction = _parse_rate(stream.get("avg_frame_rate")) or _parse_rate(stream.get("r_frame_rate")) | |
| fps = float(fps_fraction) if fps_fraction is not None else None | |
| raw_frame_count = stream.get("nb_read_frames") or stream.get("nb_frames") | |
| frame_count = None | |
| if raw_frame_count not in (None, "N/A"): | |
| try: | |
| frame_count = int(raw_frame_count) | |
| except (TypeError, ValueError): | |
| frame_count = None | |
| return width, height, fps, fps_fraction, frame_count | |
| def ffprobe_info(path: str) -> dict[str, Any]: | |
| """Return basic media info (duration, has_video, has_audio, width, height, fps) via ffprobe.""" | |
| ffprobe = get_ffmpeg_path("ffprobe") | |
| if not ffprobe: | |
| return {} | |
| try: | |
| result = subprocess.run( | |
| [ | |
| ffprobe, "-v", "error", | |
| "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate,avg_frame_rate,nb_frames,nb_read_frames", | |
| "-of", "json", | |
| path, | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=30, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise RuntimeError(f"FFprobe timed out for {path} after 30 seconds") from exc | |
| if result.returncode != 0: | |
| return {} | |
| try: | |
| data = json.loads(result.stdout) | |
| streams = data.get("streams", []) | |
| codec_types = {s.get("codec_type") for s in streams} | |
| duration_str = data.get("format", {}).get("duration") | |
| width = height = fps = frame_count = None | |
| fps_fraction = None | |
| for stream in streams: | |
| if stream.get("codec_type") == "video": | |
| width, height, fps, fps_fraction, frame_count = _parse_video_stream(stream) | |
| break | |
| duration = _parse_positive_float(duration_str) | |
| if frame_count is None and duration is not None and fps: | |
| frame_count = max(1, round(duration * fps)) | |
| return { | |
| "duration": duration, | |
| "has_video": "video" in codec_types, | |
| "has_audio": "audio" in codec_types, | |
| "width": width, | |
| "height": height, | |
| "fps": fps, | |
| "fps_fraction": fps_fraction, | |
| "frame_count": frame_count, | |
| } | |
| except Exception: | |
| return {} | |
| def ffmpeg_supports_xfade() -> bool: | |
| """Return True if the installed FFmpeg build includes the xfade filter (requires 4.3+).""" | |
| for name in ("ffmpeg", "ffmpeg-full"): | |
| ffmpeg = get_ffmpeg_path(name) | |
| if not ffmpeg: | |
| continue | |
| result = subprocess.run( | |
| [ffmpeg, "-hide_banner", "-filters"], | |
| capture_output=True, | |
| text=True, | |
| ) | |
| if result.returncode == 0 and "xfade" in result.stdout: | |
| return True | |
| return False | |
| def tensor_crossfade_images( | |
| clips: "list[torch.Tensor]", | |
| fade_frames: int, | |
| ) -> "torch.Tensor": | |
| """Merge image tensors with a linear crossfade at each clip boundary. | |
| Each clip tensor is shaped [N_i, H, W, C]. The output has | |
| ``sum(N_i) - (len(clips)-1) * fade_frames`` frames. | |
| """ | |
| if fade_frames <= 0 or len(clips) < 2: | |
| return torch.cat(clips, dim=0) | |
| parts: "list[torch.Tensor]" = [] | |
| for i, clip in enumerate(clips): | |
| n = clip.shape[0] | |
| f = min(fade_frames, n // 2) | |
| body_start = f if i > 0 else 0 | |
| body_end = n - f if i < len(clips) - 1 else n | |
| if body_start < body_end: | |
| parts.append(clip[body_start:body_end]) | |
| if i < len(clips) - 1: | |
| nf = min(fade_frames, clips[i + 1].shape[0] // 2) | |
| af = min(f, nf) | |
| if af > 0: | |
| a = clip[-af:].float() | |
| b = clips[i + 1][:af].float() | |
| t = torch.linspace(0.0, 1.0, af, device=a.device).view(af, 1, 1, 1) | |
| parts.append((a * (1.0 - t) + b * t).to(clip.dtype)) | |
| return torch.cat(parts, dim=0) | |
| def tensor_crossfade_audio( | |
| waveforms: "list[torch.Tensor]", | |
| fade_samples: int, | |
| ) -> "torch.Tensor": | |
| """Merge audio waveform tensors with a linear crossfade at each clip boundary. | |
| Each waveform is shaped [batch, channels, samples]. The output has | |
| fewer samples than the raw concatenation by ``(len(waveforms)-1) * fade_samples``. | |
| """ | |
| if fade_samples <= 0 or len(waveforms) < 2: | |
| return torch.cat(waveforms, dim=2) | |
| parts: "list[torch.Tensor]" = [] | |
| for i, wave in enumerate(waveforms): | |
| s = wave.shape[2] | |
| f = min(fade_samples, s // 2) | |
| body_start = f if i > 0 else 0 | |
| body_end = s - f if i < len(waveforms) - 1 else s | |
| if body_start < body_end: | |
| parts.append(wave[:, :, body_start:body_end]) | |
| if i < len(waveforms) - 1: | |
| nf = min(fade_samples, waveforms[i + 1].shape[2] // 2) | |
| af = min(f, nf) | |
| if af > 0: | |
| a = wave[:, :, -af:].float() | |
| b = waveforms[i + 1][:, :, :af].float() | |
| t = torch.linspace(0.0, 1.0, af, device=a.device).view(1, 1, af) | |
| parts.append((a * (1.0 - t) + b * t).to(wave.dtype)) | |
| return torch.cat(parts, dim=2) | |
| def ffmpeg_concat_with_fade( | |
| input_paths: list[str], | |
| output_path: str, | |
| fade_duration: float = 0.5, | |
| progress_callback: "callable[[str], None] | None" = None, | |
| ) -> bool: | |
| """Concatenate video files with dissolve transitions, PRESERVING total duration and audio. | |
| Uses segment-based approach with alpha channel fade for proper dissolve effect: | |
| - First clip: trim end to exclude fade region | |
| - Subsequent clips: trim start to exclude fade region at beginning | |
| - Fade-out segment from clip N and fade-in segment from clip N+1 are blended | |
| - All segments are concatenated in order | |
| Returns True on success, False if FFmpeg is not installed. | |
| Raises RuntimeError if FFmpeg fails. | |
| """ | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg: | |
| return False | |
| n = len(input_paths) | |
| if n < 2: | |
| return False | |
| if progress_callback: | |
| progress_callback("Probing clip info...") | |
| infos: list[dict[str, Any]] = [] | |
| for path in input_paths: | |
| info = ffprobe_info(path) | |
| if not info.get("duration"): | |
| raise RuntimeError(f"Cannot probe duration for clip: {path!r}") | |
| infos.append(info) | |
| durations = [info["duration"] for info in infos] | |
| has_audio = any(info.get("has_audio", False) for info in infos) | |
| # Clamp fade_duration to half of the shortest clip duration | |
| min_duration = min(durations) | |
| if min_duration <= 0: | |
| raise RuntimeError("All video clips must have a duration greater than zero") | |
| max_fade = min_duration / 2.0 - 0.001 | |
| if fade_duration > max_fade: | |
| logger.warning( | |
| "[ffmpeg_concat_with_fade] fade_duration %.4f clamped to %.4f", | |
| fade_duration, max_fade, | |
| ) | |
| fade_duration = max(0.001, max_fade) | |
| half_fade = fade_duration / 2.0 | |
| total_duration = sum(durations) | |
| # Create temp directory for segments | |
| temp_dir = tempfile.mkdtemp(prefix="dissolve_") | |
| try: | |
| if progress_callback: | |
| progress_callback("Preparing video segments...") | |
| segment_files: list[str] = [] | |
| # Step 1: Process first clip - trim end to exclude fade region | |
| clip1_path = input_paths[0] | |
| clip1_duration = durations[0] | |
| base1_path = os.path.join(temp_dir, "base_1.mp4") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", "-i", clip1_path, | |
| "-vf", f"trim=0:{clip1_duration - half_fade},setpts=PTS-STARTPTS,fps=10", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-an", | |
| base1_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Failed to create base segment: {result.stderr.decode()[-300:]}") | |
| segment_files.append(base1_path) | |
| # Step 2: Process clips 2 to N | |
| for i in range(1, n): | |
| path = input_paths[i] | |
| clip_duration = durations[i] | |
| prev_path = input_paths[i - 1] | |
| # Create fadeout from previous clip (last half_fade seconds) | |
| fadeout_path = os.path.join(temp_dir, f"fadeout_{i-1}.mp4") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", "-i", prev_path, | |
| "-vf", f"trim={clip_duration - half_fade}:{clip_duration},setpts=PTS-STARTPTS,fps=10,format=yuva420p,fade=t=out:st=0:d={half_fade}:alpha=1", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-an", | |
| fadeout_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Failed to create fadeout segment: {result.stderr.decode()[-300:]}") | |
| # Create fadein for current clip (first half_fade seconds) | |
| fadein_path = os.path.join(temp_dir, f"fadein_{i}.mp4") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", "-i", path, | |
| "-vf", f"trim=0:{half_fade},setpts=PTS-STARTPTS,fps=10,format=yuva420p,fade=t=in:st=0:d={half_fade}:alpha=1", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-an", | |
| fadein_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Failed to create fadein segment: {result.stderr.decode()[-300:]}") | |
| # Blend fadeout and fadein | |
| blend_path = os.path.join(temp_dir, f"blend_{i-1}_{i}.mp4") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", "-i", fadeout_path, "-i", fadein_path, | |
| "-filter_complex", "[0:v][1:v]overlay=0:0[blend]", | |
| "-map", "[blend]", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-an", | |
| blend_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Failed to blend segments: {result.stderr.decode()[-300:]}") | |
| segment_files.append(blend_path) | |
| # Create base segment for current clip (from half_fade to end) | |
| base_path = os.path.join(temp_dir, f"base_{i}.mp4") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", "-i", path, | |
| "-vf", f"trim={half_fade}:{clip_duration},setpts=PTS-STARTPTS+{half_fade}/TB,fps=10", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-an", | |
| base_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"Failed to create base segment: {result.stderr.decode()[-300:]}") | |
| segment_files.append(base_path) | |
| if progress_callback: | |
| progress_callback("Finalizing video...") | |
| # Step 3: Concatenate all segments using concat demuxer | |
| concat_list_path = os.path.join(temp_dir, "concat.txt") | |
| with open(concat_list_path, "w", encoding="utf-8") as f: | |
| for seg in segment_files: | |
| escaped = seg.replace("\\", "\\\\").replace("'", "\\'") | |
| f.write(f"file '{escaped}'\n") | |
| result = subprocess.run([ | |
| ffmpeg, "-y", | |
| "-f", "concat", "-safe", "0", "-i", concat_list_path, | |
| "-i", input_paths[0], # For audio from first clip | |
| "-map", "0:v", "-map", "1:a?", | |
| "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", | |
| output_path, | |
| ], capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"FFmpeg concat failed:\n{result.stderr.decode()[-600:]}") | |
| if progress_callback: | |
| progress_callback("Done - total duration: " + str(total_duration) + "s (preserved)") | |
| return True | |
| finally: | |
| # Cleanup temp directory | |
| try: | |
| shutil.rmtree(temp_dir) | |
| except OSError: | |
| pass | |
| def ffmpeg_replace_audio( | |
| video_path: str, | |
| audio_path: str, | |
| output_path: str, | |
| ) -> bool: | |
| """Replace (or add) the audio track of a video file using FFmpeg stream copy. | |
| The video stream is always stream-copied (no re-encode). The audio is | |
| encoded to AAC. The output is trimmed to the shortest stream. | |
| Returns True on success, False if FFmpeg is not installed. | |
| Raises RuntimeError if FFmpeg is available but the operation fails. | |
| """ | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg: | |
| return False | |
| cmd = [ | |
| ffmpeg, "-y", | |
| "-i", video_path, | |
| "-i", audio_path, | |
| "-c:v", "copy", | |
| "-c:a", "aac", | |
| "-map", "0:v:0", | |
| "-map", "1:a:0", | |
| "-shortest", | |
| output_path, | |
| ] | |
| result = subprocess.run(cmd, capture_output=True) | |
| if result.returncode != 0: | |
| raise RuntimeError( | |
| f"FFmpeg replace-audio failed:\n{result.stderr.decode(errors='replace')[-600:]}" | |
| ) | |
| return True | |
| def _load_wav_audio(path: str) -> dict: | |
| try: | |
| import soundfile as sf # type: ignore[import] | |
| data, sample_rate = sf.read(path, dtype="float32", always_2d=True) | |
| waveform = torch.from_numpy(data.T).unsqueeze(0) | |
| return {"waveform": waveform, "sample_rate": int(sample_rate)} | |
| except Exception: | |
| try: | |
| import torchaudio # type: ignore[import] | |
| waveform, sample_rate = torchaudio.load(path) | |
| return {"waveform": waveform.unsqueeze(0), "sample_rate": int(sample_rate)} | |
| except Exception as exc: | |
| raise RuntimeError("Failed to load extracted audio with soundfile or torchaudio.") from exc | |
| def ffmpeg_extract_audio(video_path: str) -> dict | None: | |
| """Extract a video's audio track to a ComfyUI AUDIO dict using FFmpeg. | |
| Returns None when FFmpeg is unavailable, the source path is not file-backed, | |
| or FFmpeg reports that audio extraction failed. | |
| """ | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg: | |
| return None | |
| if not os.path.isfile(video_path): | |
| return None | |
| tmp_fd, audio_path = tempfile.mkstemp(suffix=".wav", dir=folder_paths.get_temp_directory()) | |
| os.close(tmp_fd) | |
| cmd = [ | |
| ffmpeg, | |
| "-y", | |
| "-nostdin", | |
| "-loglevel", "error", | |
| "-i", | |
| video_path, | |
| "-vn", | |
| "-acodec", | |
| "pcm_s16le", | |
| audio_path, | |
| ] | |
| try: | |
| result = subprocess.run( | |
| cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, | |
| check=False, timeout=_FFMPEG_TIMEOUT_SECONDS, | |
| ) | |
| if result.returncode != 0: | |
| logger.warning( | |
| "[ffmpeg_extract_audio] FFmpeg audio extraction failed: %s", | |
| result.stderr.decode("utf-8", errors="replace").strip()[-600:], | |
| ) | |
| return None | |
| return _load_wav_audio(audio_path) | |
| except subprocess.TimeoutExpired: | |
| # A stalled decoder must not fall through to a second full-frame decode. | |
| raise | |
| except Exception as exc: | |
| logger.warning("[ffmpeg_extract_audio] FFmpeg audio extraction error: %s", exc) | |
| return None | |
| finally: | |
| try: | |
| os.unlink(audio_path) | |
| except OSError: | |
| pass | |
| def extract_video_audio( | |
| video, | |
| cache: "dict | None" = None, | |
| ) -> "dict | None": | |
| """Extract a ComfyUI AUDIO dict from a VIDEO input. Prefers FFmpeg. | |
| PyAV's full-frame decode walks every frame of the source file — wasteful | |
| when only the audio stream is needed. FFmpeg demuxes the audio stream | |
| directly. This helper is the shared extraction path for any code that | |
| needs the audio track of a VIDEO input (e.g. audio-locked multitrack | |
| tracks). | |
| When ``cache`` is provided, the result is memoized for inputs that resolve | |
| to the same underlying source so repeated calls (one per timeline segment) | |
| share a single decode. The cache key is the source path when the input is | |
| file-backed, else ``id(video)``. | |
| Falls back to PyAV's ``video.get_components().audio`` when FFmpeg cannot | |
| be used (in-memory / URL / trimmed source, or when FFmpeg raised or | |
| returned no audio). A ``[Warning]`` is logged only when FFmpeg was | |
| actually attempted but failed — in-memory / URL sources silently fall | |
| back because FFmpeg was never in the picture. | |
| """ | |
| # Lazy import: ``utils/__init__.py`` loads ``.video`` (step 4) before | |
| # ``.multitrack`` (step 13), so a top-level ``from .multitrack import …`` | |
| # here would fire before ``_video_stream_source`` exists. | |
| from .multitrack import _video_stream_source | |
| source_path = _video_stream_source(video) | |
| cache_key = source_path if isinstance(source_path, str) else id(video) | |
| if isinstance(cache, dict) and cache_key in cache: | |
| return cache[cache_key] | |
| audio = _extract_video_audio_uncached(video, source_path) | |
| if isinstance(cache, dict): | |
| cache[cache_key] = audio | |
| return audio | |
| def _extract_video_audio_uncached(video, source_path) -> "dict | None": | |
| ffmpeg_attempted = False | |
| if isinstance(source_path, str) and os.path.isfile(source_path): | |
| if ffprobe_info(source_path).get("has_audio") is False: | |
| return None | |
| ffmpeg_attempted = True | |
| try: | |
| audio = ffmpeg_extract_audio(source_path) | |
| except subprocess.TimeoutExpired as exc: | |
| raise RuntimeError( | |
| f"FFmpeg audio extraction timed out for {source_path} " | |
| f"after {exc.timeout:g} seconds" | |
| ) from exc | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "[extract_video_audio] FFmpeg audio extraction raised: %s", | |
| exc, | |
| ) | |
| audio = None | |
| if isinstance(audio, dict): | |
| return audio | |
| try: | |
| components = video.get_components() | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "[extract_video_audio] PyAV get_components raised: %s", | |
| exc, | |
| ) | |
| return None | |
| audio = getattr(components, "audio", None) | |
| if ffmpeg_attempted and isinstance(audio, dict): | |
| logger.warning( | |
| "[extract_video_audio] FFmpeg returned no audio for %s; " | |
| "falling back to PyAV full-frame decode (slower).", | |
| source_path, | |
| ) | |
| return audio if isinstance(audio, dict) else None | |
| # --------------------------------------------------------------------------- | |
| # Media segment helpers (used by routes.py) | |
| # --------------------------------------------------------------------------- | |
| import tempfile as _tempfile | |
| import aiohttp as _aiohttp | |
| from urllib.parse import urlparse as _urlparse | |
| async def download_video_to_temp(url: str) -> Path: | |
| """Download a video URL to a temporary file and return its path.""" | |
| parsed = _urlparse(url) | |
| if parsed.scheme not in ("http", "https"): | |
| raise ValueError("video URL must use http or https") | |
| suffix = Path(parsed.path).suffix.lower() | |
| if suffix not in _VIDEO_EXTENSIONS: | |
| suffix = ".mp4" | |
| temp_file = _tempfile.NamedTemporaryFile( | |
| prefix="easy_media_omni_", suffix=suffix, delete=False | |
| ) | |
| temp_path = Path(temp_file.name) | |
| try: | |
| timeout = _aiohttp.ClientTimeout(total=300) | |
| async with _aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.get(url) as response: | |
| response.raise_for_status() | |
| while chunk := await response.content.read(1024 * 1024): | |
| temp_file.write(chunk) | |
| temp_file.close() | |
| return temp_path | |
| except Exception: | |
| temp_file.close() | |
| temp_path.unlink(missing_ok=True) | |
| raise | |
| async def download_audio_to_temp(url: str) -> Path: | |
| """Download an audio URL to a temporary file and return its path.""" | |
| parsed = _urlparse(url) | |
| if parsed.scheme not in ("http", "https"): | |
| raise ValueError("audio URL must use http or https") | |
| suffix = Path(parsed.path).suffix.lower() | |
| if suffix not in AUDIO_EXTENSIONS: | |
| suffix = ".wav" | |
| temp_file = _tempfile.NamedTemporaryFile( | |
| prefix="easy_media_asr_audio_", suffix=suffix, delete=False | |
| ) | |
| temp_path = Path(temp_file.name) | |
| try: | |
| timeout = _aiohttp.ClientTimeout(total=300) | |
| async with _aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.get(url) as response: | |
| response.raise_for_status() | |
| while chunk := await response.content.read(1024 * 1024): | |
| temp_file.write(chunk) | |
| temp_file.close() | |
| return temp_path | |
| except Exception: | |
| temp_file.close() | |
| temp_path.unlink(missing_ok=True) | |
| raise | |
| def resolve_segment_video_path(data: dict) -> Path: | |
| """Resolve a segment media descriptor to a local video file.""" | |
| source_type = data.get("source_type", "input") | |
| if source_type == "local": | |
| raw_path = data.get("local_path") or data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("local_path is required for local video segments") | |
| path = Path(raw_path).expanduser().resolve() | |
| elif source_type in ("input", "preset", "slot"): | |
| raw_path = data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("file_path is required for input video segments") | |
| path = (Path(folder_paths.get_input_directory()).resolve() / raw_path).resolve() | |
| path.relative_to(Path(folder_paths.get_input_directory()).resolve()) | |
| elif source_type == "output": | |
| raw_path = data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("file_path is required for output video segments") | |
| path = (Path(folder_paths.get_output_directory()).resolve() / raw_path).resolve() | |
| path.relative_to(Path(folder_paths.get_output_directory()).resolve()) | |
| else: | |
| raise ValueError(f"unsupported video source_type: {source_type}") | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"video file not found: {path}") | |
| if path.suffix.lower() not in _VIDEO_EXTENSIONS: | |
| raise ValueError(f"unsupported video extension: {path.suffix}") | |
| return path | |
| def resolve_segment_audio_path(data: dict) -> Path: | |
| """Resolve a segment media descriptor to a local audio file.""" | |
| source_type = data.get("source_type", "input") | |
| if source_type == "local": | |
| raw_path = data.get("local_path") or data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("local_path is required for local audio segments") | |
| path = Path(raw_path).expanduser().resolve() | |
| elif source_type in ("input", "preset", "slot"): | |
| raw_path = data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("file_path is required for input audio segments") | |
| path = (Path(folder_paths.get_input_directory()).resolve() / raw_path).resolve() | |
| path.relative_to(Path(folder_paths.get_input_directory()).resolve()) | |
| elif source_type == "output": | |
| raw_path = data.get("file_path") | |
| if not isinstance(raw_path, str) or not raw_path: | |
| raise ValueError("file_path is required for output audio segments") | |
| path = (Path(folder_paths.get_output_directory()).resolve() / raw_path).resolve() | |
| path.relative_to(Path(folder_paths.get_output_directory()).resolve()) | |
| else: | |
| raise ValueError(f"unsupported audio source_type: {source_type}") | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"audio file not found: {path}") | |
| if path.suffix.lower() not in AUDIO_EXTENSIONS: | |
| raise ValueError(f"unsupported audio extension: {path.suffix}") | |
| return path | |
| def extract_video_audio_to_temp( | |
| video_path: Path, | |
| start_time: float = 0.0, | |
| duration: float = 0.0, | |
| ) -> Path: | |
| """Extract the audio track from a video file to a temporary WAV file.""" | |
| info = ffprobe_info(str(video_path)) | |
| if info.get("has_audio") is False: | |
| raise ValueError("Video segment does not contain an audio track.") | |
| ffmpeg = get_ffmpeg_path("ffmpeg") | |
| if not ffmpeg: | |
| raise RuntimeError("FFmpeg is required to extract audio from video segments.") | |
| tmp_fd, audio_path = _tempfile.mkstemp( | |
| prefix="easy_media_asr_extracted_", | |
| suffix=".wav", | |
| dir=folder_paths.get_temp_directory(), | |
| ) | |
| os.close(tmp_fd) | |
| output = Path(audio_path) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| ] | |
| if math.isfinite(start_time) and start_time > 0: | |
| command.extend(["-ss", f"{start_time:g}"]) | |
| command.extend([ | |
| "-i", | |
| str(video_path), | |
| ]) | |
| if math.isfinite(duration) and duration > 0: | |
| command.extend(["-t", f"{duration:g}"]) | |
| command.extend([ | |
| "-vn", | |
| "-ac", | |
| "1", | |
| "-ar", | |
| "16000", | |
| "-acodec", | |
| "pcm_s16le", | |
| str(output), | |
| ]) | |
| try: | |
| result = subprocess.run( | |
| command, | |
| capture_output=True, | |
| check=False, | |
| ) | |
| if result.returncode != 0 or not output.is_file() or output.stat().st_size == 0: | |
| output.unlink(missing_ok=True) | |
| raise ValueError("Video segment does not contain an audio track.") | |
| return output | |
| except Exception: | |
| output.unlink(missing_ok=True) | |
| raise | |