298 lines
9.9 KiB
Python
298 lines
9.9 KiB
Python
"""
|
|
ComfyUI video trimming node.
|
|
|
|
Usage example inside ComfyUI:
|
|
Load Video -> Video Trimmer -> Save Video
|
|
|
|
The node accepts a native ``VIDEO`` object, trims it by time or frame range,
|
|
and returns another native ``VIDEO`` object that remains compatible with
|
|
downstream video nodes such as Save Video.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fractions import Fraction
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
|
|
try:
|
|
from comfy_api.latest import InputImpl, Types
|
|
except ImportError as exc: # pragma: no cover - import error depends on host ComfyUI version
|
|
raise ImportError(
|
|
"ComfyUI's VIDEO API is unavailable. This node requires a ComfyUI build "
|
|
"that exposes `comfy_api.latest` and native VIDEO components."
|
|
) from exc
|
|
|
|
|
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
|
"""Convert an arbitrary numeric value to float with a safe fallback."""
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _copy_audio_segment(
|
|
audio: Optional[Dict[str, Any]],
|
|
start_frame: int,
|
|
end_frame: int,
|
|
fps: float,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Trim audio so it stays synchronized with the selected video frames.
|
|
|
|
Args:
|
|
audio: ComfyUI audio payload or ``None``.
|
|
start_frame: First included video frame index.
|
|
end_frame: Exclusive ending video frame index.
|
|
fps: Video frame rate.
|
|
|
|
Returns:
|
|
A trimmed audio payload compatible with ComfyUI, or ``None`` if there is
|
|
no usable audio track.
|
|
"""
|
|
if not audio or fps <= 0:
|
|
return None
|
|
|
|
waveform = audio.get("waveform")
|
|
sample_rate = int(audio.get("sample_rate", 44100))
|
|
|
|
if waveform is None or sample_rate <= 0:
|
|
return None
|
|
|
|
start_sample = int((start_frame / fps) * sample_rate)
|
|
end_sample = int((end_frame / fps) * sample_rate)
|
|
|
|
total_samples = waveform.shape[-1]
|
|
start_sample = max(0, min(start_sample, total_samples))
|
|
end_sample = max(start_sample, min(end_sample, total_samples))
|
|
|
|
return {
|
|
"waveform": waveform[..., start_sample:end_sample],
|
|
"sample_rate": sample_rate,
|
|
}
|
|
|
|
|
|
class VideoTrimmer:
|
|
"""
|
|
Trim a native ComfyUI VIDEO input by timestamp or by frame range.
|
|
|
|
The implementation works directly on ComfyUI's in-memory video components,
|
|
which keeps the node easy to integrate with existing Load Video / Save Video
|
|
workflows and avoids unnecessary file-system round-trips.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Tuple[str, Dict[str, Any]]]]:
|
|
"""
|
|
Describe the node inputs shown in the ComfyUI interface.
|
|
|
|
``trim_mode`` lets users choose an intuitive way to define the cut:
|
|
either seconds or frame indices. Numeric controls are left available in
|
|
the UI so users can quickly switch modes without reconnecting anything.
|
|
"""
|
|
return {
|
|
"required": {
|
|
"video": ("VIDEO",),
|
|
"trim_mode": (
|
|
["time", "frames"],
|
|
{
|
|
"default": "time",
|
|
"tooltip": "Choose whether the trim range is defined in seconds or frame numbers.",
|
|
},
|
|
),
|
|
"start_time": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.0,
|
|
"min": 0.0,
|
|
"max": 86400.0,
|
|
"step": 0.01,
|
|
"display": "number",
|
|
"tooltip": "Trim start in seconds when trim_mode is set to time.",
|
|
},
|
|
),
|
|
"end_time": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.0,
|
|
"min": 0.0,
|
|
"max": 86400.0,
|
|
"step": 0.01,
|
|
"display": "number",
|
|
"tooltip": "Trim end in seconds when trim_mode is set to time. Use 0 to keep everything to the end.",
|
|
},
|
|
),
|
|
"start_frame": (
|
|
"INT",
|
|
{
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 100000000,
|
|
"step": 1,
|
|
"display": "number",
|
|
"tooltip": "Trim start frame when trim_mode is set to frames.",
|
|
},
|
|
),
|
|
"end_frame": (
|
|
"INT",
|
|
{
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 100000000,
|
|
"step": 1,
|
|
"display": "number",
|
|
"tooltip": "Trim end frame when trim_mode is set to frames. Use 0 to keep everything to the end.",
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("VIDEO", "FLOAT", "INT", "INT", "INT")
|
|
RETURN_NAMES = (
|
|
"trimmed_video",
|
|
"duration_seconds",
|
|
"frame_count",
|
|
"resolved_start_frame",
|
|
"resolved_end_frame",
|
|
)
|
|
FUNCTION = "trim_video"
|
|
CATEGORY = "Video Editing"
|
|
|
|
def trim_video(
|
|
self,
|
|
video: Any,
|
|
trim_mode: str,
|
|
start_time: float,
|
|
end_time: float,
|
|
start_frame: int,
|
|
end_frame: int,
|
|
) -> Tuple[Any, float, int, int, int]:
|
|
"""
|
|
Trim an incoming VIDEO object and return a new VIDEO object.
|
|
|
|
Args:
|
|
video: Native ComfyUI VIDEO input from a node such as Load Video.
|
|
trim_mode: Either ``"time"`` or ``"frames"``.
|
|
start_time: Start timestamp in seconds.
|
|
end_time: End timestamp in seconds, or 0 to use the video end.
|
|
start_frame: Start frame index.
|
|
end_frame: Exclusive end frame index, or 0 to use the video end.
|
|
|
|
Returns:
|
|
A tuple containing the trimmed video, resulting duration, resulting
|
|
frame count, and the resolved start/end frame indices.
|
|
|
|
Raises:
|
|
ValueError: If the incoming video object is invalid or the range is invalid.
|
|
"""
|
|
components = video.get_components()
|
|
frames = getattr(components, "images", None)
|
|
audio = getattr(components, "audio", None)
|
|
metadata = getattr(components, "metadata", None)
|
|
fps = _safe_float(getattr(components, "frame_rate", 0.0))
|
|
|
|
if frames is None:
|
|
raise ValueError("The input VIDEO object does not contain image frames.")
|
|
|
|
if getattr(frames, "ndim", 0) < 4:
|
|
raise ValueError(
|
|
"The input VIDEO frames are malformed. Expected a tensor shaped like [frames, height, width, channels]."
|
|
)
|
|
|
|
total_frames = int(frames.shape[0])
|
|
if total_frames <= 0:
|
|
raise ValueError("The input VIDEO object is empty and cannot be trimmed.")
|
|
|
|
if fps <= 0:
|
|
raise ValueError("The input VIDEO object does not expose a valid frame rate.")
|
|
|
|
resolved_start, resolved_end = self._resolve_frame_range(
|
|
trim_mode=trim_mode,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
start_frame=start_frame,
|
|
end_frame=end_frame,
|
|
fps=fps,
|
|
total_frames=total_frames,
|
|
)
|
|
|
|
trimmed_frames = frames[resolved_start:resolved_end]
|
|
trimmed_audio = _copy_audio_segment(audio, resolved_start, resolved_end, fps)
|
|
|
|
trimmed_video = InputImpl.VideoFromComponents(
|
|
Types.VideoComponents(
|
|
images=trimmed_frames,
|
|
audio=trimmed_audio,
|
|
frame_rate=Fraction(str(fps)),
|
|
metadata=metadata,
|
|
)
|
|
)
|
|
|
|
output_frame_count = int(trimmed_frames.shape[0])
|
|
output_duration = output_frame_count / fps
|
|
|
|
return (
|
|
trimmed_video,
|
|
output_duration,
|
|
output_frame_count,
|
|
resolved_start,
|
|
resolved_end,
|
|
)
|
|
|
|
def _resolve_frame_range(
|
|
self,
|
|
trim_mode: str,
|
|
start_time: float,
|
|
end_time: float,
|
|
start_frame: int,
|
|
end_frame: int,
|
|
fps: float,
|
|
total_frames: int,
|
|
) -> Tuple[int, int]:
|
|
"""
|
|
Convert UI inputs into a validated frame slice.
|
|
|
|
``end_*`` values are treated as exclusive bounds, which lines up with
|
|
Python slicing and makes downstream calculations straightforward.
|
|
"""
|
|
if trim_mode == "frames":
|
|
resolved_start = int(start_frame)
|
|
resolved_end = int(end_frame) if end_frame > 0 else total_frames
|
|
elif trim_mode == "time":
|
|
resolved_start = int(_safe_float(start_time) * fps)
|
|
resolved_end = int(_safe_float(end_time) * fps) if end_time > 0 else total_frames
|
|
else:
|
|
raise ValueError(f"Unsupported trim_mode '{trim_mode}'. Expected 'time' or 'frames'.")
|
|
|
|
if resolved_start < 0:
|
|
raise ValueError("Start position must be greater than or equal to 0.")
|
|
|
|
if resolved_end < 0:
|
|
raise ValueError("End position must be greater than or equal to 0.")
|
|
|
|
resolved_start = min(resolved_start, total_frames - 1)
|
|
resolved_end = min(resolved_end, total_frames)
|
|
resolved_end = max(resolved_end, resolved_start + 1)
|
|
|
|
if resolved_start >= total_frames:
|
|
raise ValueError(
|
|
f"Start position {resolved_start} is outside the video. Total frames: {total_frames}."
|
|
)
|
|
|
|
if resolved_start >= resolved_end:
|
|
raise ValueError(
|
|
f"Invalid trim range: start frame {resolved_start} must be smaller than end frame {resolved_end}."
|
|
)
|
|
|
|
return resolved_start, resolved_end
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"VideoTrimmer": VideoTrimmer,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"VideoTrimmer": "Video Trimmer",
|
|
}
|