Files

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",
}