From 3d07355187b335455b22b843fd4641e335cd92c9 Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 2 Apr 2026 19:03:32 +0200 Subject: [PATCH] Initial ComfyUI Video Trimmer node --- .gitignore | 2 + README.md | 138 ++++++++++++++++++++++++ __init__.py | 7 ++ nodes.py | 297 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 nodes.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..f39f891 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# ComfyUI Video Trimmer + +`ComfyUI Video Trimmer` is a lightweight custom node that trims a native ComfyUI `VIDEO` object by either timestamps or frame numbers. + +It is designed to fit directly into standard video workflows: + +`Load Video -> Video Trimmer -> Save Video` + +## Features + +- Accepts native `VIDEO` input from ComfyUI video loader nodes +- Outputs native `VIDEO` for downstream save/encode nodes +- Supports trimming by time in seconds or by frame range +- Preserves audio alignment when an audio track is present +- Returns useful metadata about the trimmed clip + +## Installation + +1. Place this folder inside your ComfyUI `custom_nodes` directory: + +```text +ComfyUI/custom_nodes/ComfyUI_VideoCutter_CodexAPP +``` + +2. Restart ComfyUI. + +3. Search for the node named `Video Trimmer` in the `Video Editing` category. + +## Node Overview + +### Node Name + +`Video Trimmer` + +### Category + +`Video Editing` + +### Inputs + +- `video` + Native ComfyUI `VIDEO` input. +- `trim_mode` + Choose `time` or `frames`. +- `start_time` + Trim start time in seconds when `trim_mode = time`. +- `end_time` + Trim end time in seconds when `trim_mode = time`. + Use `0` to trim from `start_time` to the end of the video. +- `start_frame` + Trim start frame when `trim_mode = frames`. +- `end_frame` + Trim end frame when `trim_mode = frames`. + Use `0` to trim from `start_frame` to the end of the video. + +### Outputs + +- `trimmed_video` + Native ComfyUI `VIDEO` output. +- `duration_seconds` + Duration of the trimmed result. +- `frame_count` + Number of frames in the trimmed result. +- `resolved_start_frame` + Actual start frame used after validation. +- `resolved_end_frame` + Actual end frame used after validation. + +## How To Use + +### Trim By Time + +1. Add `Load Video`. +2. Add `Video Trimmer`. +3. Connect the `VIDEO` output from `Load Video` to `Video Trimmer`. +4. Set `trim_mode` to `time`. +5. Enter `start_time` and `end_time`. +6. Connect `trimmed_video` to your preferred `Save Video` node. + +Example: + +- `start_time = 2.50` +- `end_time = 7.75` + +This keeps the section from 2.50 seconds up to 7.75 seconds. + +### Trim By Frame + +1. Set `trim_mode` to `frames`. +2. Enter `start_frame` and `end_frame`. +3. Use `0` for `end_frame` to keep everything from the start frame to the video end. + +Example: + +- `start_frame = 48` +- `end_frame = 192` + +This keeps frames 48 through 191. + +## Example Workflow + +```text +Load Video + -> Video Trimmer + -> Save Video +``` + +Recommended setup: + +- Use `Load Video` to import a source clip. +- Use `Video Trimmer` to isolate the section you want. +- Use `Save Video` to encode/export the trimmed result. + +## Notes + +- The node trims in memory from the decoded `VIDEO` object supplied by upstream nodes. +- Because ComfyUI video workflows often operate on decoded frames, this node focuses on workflow compatibility and frame accuracy. +- Audio is trimmed to stay synchronized with the selected frame range when audio data is available on the incoming `VIDEO`. + +## Error Handling + +The node raises clear errors for: + +- empty videos +- invalid frame rate metadata +- malformed frame tensors +- start or end positions outside valid bounds +- invalid trim ranges where start is not before end + +## Developer Notes + +Implementation lives in: + +- `/Users/benjaminbertram/ComfyUI/custom_nodes/ComfyUI_VideoCutter_CodexAPP/nodes.py` + +Registration entry point: + +- `/Users/benjaminbertram/ComfyUI/custom_nodes/ComfyUI_VideoCutter_CodexAPP/__init__.py` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..31c39a2 --- /dev/null +++ b/__init__.py @@ -0,0 +1,7 @@ +""" +ComfyUI Video Cutter custom node package. +""" + +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..63b25b1 --- /dev/null +++ b/nodes.py @@ -0,0 +1,297 @@ +""" +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", +}