Initial ComfyUI Video Trimmer node
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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`
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user