mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-16 06:26:34 +08:00
feat: VIDEO_EDIT input type for video trim/crop rich widgets
This commit is contained in:
@@ -4,7 +4,7 @@ from fractions import Fraction
|
|||||||
from typing import Optional, Union, IO
|
from typing import Optional, Union, IO
|
||||||
import io
|
import io
|
||||||
import av
|
import av
|
||||||
from .._util import VideoContainer, VideoCodec, VideoComponents
|
from .._util import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
|
||||||
|
|
||||||
class VideoInput(ABC):
|
class VideoInput(ABC):
|
||||||
"""
|
"""
|
||||||
@@ -52,6 +52,45 @@ class VideoInput(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def as_cropped(
|
||||||
|
self,
|
||||||
|
x: int = 0,
|
||||||
|
y: int = 0,
|
||||||
|
width: int = 0,
|
||||||
|
height: int = 0,
|
||||||
|
) -> VideoInput:
|
||||||
|
"""
|
||||||
|
Create a new VideoInput spatially cropped to the given pixel rectangle.
|
||||||
|
|
||||||
|
The rectangle is clamped to the frame and even-aligned for encoder
|
||||||
|
compatibility. An empty or full-frame rectangle returns the input
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Default implementation materializes the video via get_components();
|
||||||
|
subclasses should override with lazier strategies when possible.
|
||||||
|
"""
|
||||||
|
components = self.get_components()
|
||||||
|
rect = normalize_crop_rect(
|
||||||
|
x, y, width, height, components.images.shape[2], components.images.shape[1]
|
||||||
|
)
|
||||||
|
if rect is None:
|
||||||
|
return self
|
||||||
|
from .._input_impl.video_types import VideoFromComponents
|
||||||
|
|
||||||
|
cx, cy, cw, ch = rect
|
||||||
|
return VideoFromComponents(
|
||||||
|
VideoComponents(
|
||||||
|
images=components.images[:, cy:cy + ch, cx:cx + cw, :],
|
||||||
|
audio=components.audio,
|
||||||
|
frame_rate=components.frame_rate,
|
||||||
|
metadata=components.metadata,
|
||||||
|
alpha=components.alpha[:, cy:cy + ch, cx:cx + cw]
|
||||||
|
if components.alpha is not None
|
||||||
|
else None,
|
||||||
|
),
|
||||||
|
bit_depth=self.get_bit_depth(),
|
||||||
|
)
|
||||||
|
|
||||||
def get_stream_source(self) -> Union[str, io.BytesIO]:
|
def get_stream_source(self) -> Union[str, io.BytesIO]:
|
||||||
"""
|
"""
|
||||||
Get a streamable source for the video. This allows processing without
|
Get a streamable source for the video. This allows processing without
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import numpy as np
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import torch
|
import torch
|
||||||
from .._util import VideoContainer, VideoCodec, VideoComponents
|
from .._util import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
@@ -111,12 +111,17 @@ def mp4_output_open_kwargs(path: str | io.BytesIO, format: VideoContainer, codec
|
|||||||
return open_kwargs
|
return open_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _rotation_quadrant(frame: av.VideoFrame) -> int:
|
||||||
|
return int(round(frame.rotation // 90)) % 4 if frame.rotation else 0
|
||||||
|
|
||||||
|
|
||||||
class VideoFromFile(VideoInput):
|
class VideoFromFile(VideoInput):
|
||||||
"""
|
"""
|
||||||
Class representing video input from a file.
|
Class representing video input from a file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, file: str | io.BytesIO, *, start_time: float=0, duration: float=0):
|
def __init__(self, file: str | io.BytesIO, *, start_time: float=0, duration: float=0,
|
||||||
|
crop: tuple[int, int, int, int] | None = None):
|
||||||
"""
|
"""
|
||||||
Initialize the VideoFromFile object based off of either a path on disk or a BytesIO object
|
Initialize the VideoFromFile object based off of either a path on disk or a BytesIO object
|
||||||
containing the file contents.
|
containing the file contents.
|
||||||
@@ -124,6 +129,7 @@ class VideoFromFile(VideoInput):
|
|||||||
self.__file = file
|
self.__file = file
|
||||||
self.__start_time = start_time
|
self.__start_time = start_time
|
||||||
self.__duration = duration
|
self.__duration = duration
|
||||||
|
self.__crop = crop
|
||||||
|
|
||||||
def get_stream_source(self) -> str | io.BytesIO:
|
def get_stream_source(self) -> str | io.BytesIO:
|
||||||
"""
|
"""
|
||||||
@@ -153,7 +159,31 @@ class VideoFromFile(VideoInput):
|
|||||||
for stream in container.streams:
|
for stream in container.streams:
|
||||||
if stream.type == 'video':
|
if stream.type == 'video':
|
||||||
assert isinstance(stream, av.VideoStream)
|
assert isinstance(stream, av.VideoStream)
|
||||||
return stream.width, stream.height
|
if self.__crop is None:
|
||||||
|
return stream.width, stream.height
|
||||||
|
|
||||||
|
display_width, display_height = self._get_display_dimensions()
|
||||||
|
rect = normalize_crop_rect(*self.__crop, display_width, display_height)
|
||||||
|
if rect is not None:
|
||||||
|
return rect[2], rect[3]
|
||||||
|
return display_width, display_height
|
||||||
|
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||||
|
|
||||||
|
def _get_display_dimensions(self) -> tuple[int, int]:
|
||||||
|
if isinstance(self.__file, io.BytesIO):
|
||||||
|
self.__file.seek(0)
|
||||||
|
with av.open(self.__file, mode='r') as container:
|
||||||
|
for stream in container.streams:
|
||||||
|
if stream.type == 'video':
|
||||||
|
assert isinstance(stream, av.VideoStream)
|
||||||
|
width, height = stream.width, stream.height
|
||||||
|
try:
|
||||||
|
frame = next(container.decode(stream), None)
|
||||||
|
except av.error.FFmpegError:
|
||||||
|
frame = None
|
||||||
|
if frame is not None and _rotation_quadrant(frame) % 2:
|
||||||
|
width, height = height, width
|
||||||
|
return width, height
|
||||||
raise ValueError(f"No video stream found in file '{self.__file}'")
|
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||||
|
|
||||||
def get_bit_depth(self) -> int:
|
def get_bit_depth(self) -> int:
|
||||||
@@ -323,6 +353,8 @@ class VideoFromFile(VideoInput):
|
|||||||
streams = [video_stream]
|
streams = [video_stream]
|
||||||
has_first_audio_frame = False
|
has_first_audio_frame = False
|
||||||
checked_alpha = False
|
checked_alpha = False
|
||||||
|
crop_rect = None
|
||||||
|
crop_resolved = False
|
||||||
|
|
||||||
# Default to False so we decode until EOF if duration is 0
|
# Default to False so we decode until EOF if duration is 0
|
||||||
video_done = False
|
video_done = False
|
||||||
@@ -393,9 +425,16 @@ class VideoFromFile(VideoInput):
|
|||||||
img = np.ascontiguousarray(align_graph[2].pull().to_ndarray(format=image_format)[:frame.height, :frame.width])
|
img = np.ascontiguousarray(align_graph[2].pull().to_ndarray(format=image_format)[:frame.height, :frame.width])
|
||||||
else:
|
else:
|
||||||
img = frame.to_ndarray(format=image_format)
|
img = frame.to_ndarray(format=image_format)
|
||||||
if frame.rotation != 0:
|
rotation_quadrant = _rotation_quadrant(frame)
|
||||||
k = int(round(frame.rotation // 90))
|
if rotation_quadrant:
|
||||||
img = np.rot90(img, k=k, axes=(0, 1)).copy()
|
img = np.rot90(img, k=rotation_quadrant, axes=(0, 1)).copy()
|
||||||
|
if self.__crop is not None:
|
||||||
|
if not crop_resolved:
|
||||||
|
crop_rect = normalize_crop_rect(*self.__crop, img.shape[1], img.shape[0])
|
||||||
|
crop_resolved = True
|
||||||
|
if crop_rect is not None:
|
||||||
|
cx, cy, cw, ch = crop_rect
|
||||||
|
img = np.ascontiguousarray(img[cy:cy + ch, cx:cx + cw])
|
||||||
if alphas is None:
|
if alphas is None:
|
||||||
frames.append(torch.from_numpy(img))
|
frames.append(torch.from_numpy(img))
|
||||||
else:
|
else:
|
||||||
@@ -477,6 +516,8 @@ class VideoFromFile(VideoInput):
|
|||||||
reuse_streams = False
|
reuse_streams = False
|
||||||
if self.__start_time or self.__duration:
|
if self.__start_time or self.__duration:
|
||||||
reuse_streams = False
|
reuse_streams = False
|
||||||
|
if self.__crop is not None:
|
||||||
|
reuse_streams = False
|
||||||
|
|
||||||
if not reuse_streams:
|
if not reuse_streams:
|
||||||
if bit_depth is None:
|
if bit_depth is None:
|
||||||
@@ -556,6 +597,12 @@ class VideoFromFile(VideoInput):
|
|||||||
if duration:
|
if duration:
|
||||||
duration_cap = math.ceil(duration * sample_rate)
|
duration_cap = math.ceil(duration * sample_rate)
|
||||||
|
|
||||||
|
import comfy.utils
|
||||||
|
raw_duration = self._get_raw_duration()
|
||||||
|
window_seconds = duration if duration else max(raw_duration - start_time, 0.0)
|
||||||
|
progress_total = max(1, int(round(window_seconds * float(rate))))
|
||||||
|
pbar = comfy.utils.ProgressBar(progress_total)
|
||||||
|
|
||||||
streams = [video_stream] if audio_stream is None else [video_stream, audio_stream]
|
streams = [video_stream] if audio_stream is None else [video_stream, audio_stream]
|
||||||
pts_step = max(1, int(round((1 / rate) / video_stream.time_base)))
|
pts_step = max(1, int(round((1 / rate) / video_stream.time_base)))
|
||||||
video_done = False
|
video_done = False
|
||||||
@@ -568,6 +615,8 @@ class VideoFromFile(VideoInput):
|
|||||||
source_size = None
|
source_size = None
|
||||||
rotation_k = 0
|
rotation_k = 0
|
||||||
rotation_filter = None
|
rotation_filter = None
|
||||||
|
crop_rect = None
|
||||||
|
crop_filter = None
|
||||||
audio_started = False
|
audio_started = False
|
||||||
samples_written = 0
|
samples_written = 0
|
||||||
pending_audio = []
|
pending_audio = []
|
||||||
@@ -641,11 +690,15 @@ class VideoFromFile(VideoInput):
|
|||||||
if end_pts is not None and frame.pts is not None:
|
if end_pts is not None and frame.pts is not None:
|
||||||
frame_duration = min(frame_duration, end_pts - frame.pts)
|
frame_duration = min(frame_duration, end_pts - frame.pts)
|
||||||
if output is None:
|
if output is None:
|
||||||
rotation_k = int(round(frame.rotation // 90)) % 4 if frame.rotation else 0
|
rotation_k = _rotation_quadrant(frame)
|
||||||
if rotation_k % 2:
|
if rotation_k % 2:
|
||||||
out_width, out_height = frame.height, frame.width
|
out_width, out_height = frame.height, frame.width
|
||||||
else:
|
else:
|
||||||
out_width, out_height = frame.width, frame.height
|
out_width, out_height = frame.width, frame.height
|
||||||
|
if self.__crop is not None:
|
||||||
|
crop_rect = normalize_crop_rect(*self.__crop, out_width, out_height)
|
||||||
|
if crop_rect is not None:
|
||||||
|
out_width, out_height = crop_rect[2], crop_rect[3]
|
||||||
if out_width % 2 or out_height % 2:
|
if out_width % 2 or out_height % 2:
|
||||||
raise ValueError(f"H.264 output requires even dimensions, got {out_width}x{out_height}")
|
raise ValueError(f"H.264 output requires even dimensions, got {out_width}x{out_height}")
|
||||||
source_size = (frame.width, frame.height)
|
source_size = (frame.width, frame.height)
|
||||||
@@ -684,9 +737,22 @@ class VideoFromFile(VideoInput):
|
|||||||
g_sink = g.add("buffersink")
|
g_sink = g.add("buffersink")
|
||||||
tail.link_to(g_sink)
|
tail.link_to(g_sink)
|
||||||
g.configure()
|
g.configure()
|
||||||
rotation_filter = (g_src, g_sink)
|
rotation_filter = (g, g_src, g_sink)
|
||||||
rotation_filter[0].push(frame)
|
rotation_filter[1].push(frame)
|
||||||
frame = rotation_filter[1].pull()
|
frame = rotation_filter[2].pull()
|
||||||
|
if crop_rect is not None:
|
||||||
|
if crop_filter is None:
|
||||||
|
g = av.filter.Graph()
|
||||||
|
g_src = g.add_buffer(width=frame.width, height=frame.height,
|
||||||
|
format=frame.format.name, time_base=video_stream.time_base)
|
||||||
|
g_crop = g.add("crop", f"{crop_rect[2]}:{crop_rect[3]}:{crop_rect[0]}:{crop_rect[1]}")
|
||||||
|
g_sink = g.add("buffersink")
|
||||||
|
g_src.link_to(g_crop)
|
||||||
|
g_crop.link_to(g_sink)
|
||||||
|
g.configure()
|
||||||
|
crop_filter = (g, g_src, g_sink)
|
||||||
|
crop_filter[1].push(frame)
|
||||||
|
frame = crop_filter[2].pull()
|
||||||
if frame.color_range == ColorRange.JPEG:
|
if frame.color_range == ColorRange.JPEG:
|
||||||
# compress full-range sources (yuvj/MJPEG) to limited range
|
# compress full-range sources (yuvj/MJPEG) to limited range
|
||||||
frame = frame.reformat(format=pix_fmt, src_color_range="JPEG", dst_color_range="MPEG")
|
frame = frame.reformat(format=pix_fmt, src_color_range="JPEG", dst_color_range="MPEG")
|
||||||
@@ -725,6 +791,7 @@ class VideoFromFile(VideoInput):
|
|||||||
out_packet.duration = video_frame_durations.pop(out_packet.pts, 0)
|
out_packet.duration = video_frame_durations.pop(out_packet.pts, 0)
|
||||||
output.mux(out_packet)
|
output.mux(out_packet)
|
||||||
drain_audio()
|
drain_audio()
|
||||||
|
pbar.update(1)
|
||||||
|
|
||||||
elif packet.stream == audio_stream and not audio_done:
|
elif packet.stream == audio_stream and not audio_done:
|
||||||
for resampled in itertools.chain.from_iterable(map(resampler.resample, packet.decode())):
|
for resampled in itertools.chain.from_iterable(map(resampler.resample, packet.decode())):
|
||||||
@@ -794,11 +861,42 @@ class VideoFromFile(VideoInput):
|
|||||||
self.get_stream_source(),
|
self.get_stream_source(),
|
||||||
start_time=start_time + self.__start_time,
|
start_time=start_time + self.__start_time,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
|
crop=self.__crop,
|
||||||
)
|
)
|
||||||
if trimmed.get_duration() < duration and strict_duration:
|
if trimmed.get_duration() < duration and strict_duration:
|
||||||
return None
|
return None
|
||||||
return trimmed
|
return trimmed
|
||||||
|
|
||||||
|
def as_cropped(
|
||||||
|
self, x: int = 0, y: int = 0, width: int = 0, height: int = 0
|
||||||
|
) -> VideoInput:
|
||||||
|
if int(width) <= 0 or int(height) <= 0:
|
||||||
|
return self
|
||||||
|
|
||||||
|
display_width, display_height = self._get_display_dimensions()
|
||||||
|
outer = (
|
||||||
|
normalize_crop_rect(*self.__crop, display_width, display_height)
|
||||||
|
if self.__crop is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if outer is None:
|
||||||
|
rect = normalize_crop_rect(x, y, width, height, display_width, display_height)
|
||||||
|
else:
|
||||||
|
inner = normalize_crop_rect(x, y, width, height, outer[2], outer[3])
|
||||||
|
rect = (
|
||||||
|
(outer[0] + inner[0], outer[1] + inner[1], inner[2], inner[3])
|
||||||
|
if inner is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if rect is None:
|
||||||
|
return self
|
||||||
|
return VideoFromFile(
|
||||||
|
self.get_stream_source(),
|
||||||
|
start_time=self.__start_time,
|
||||||
|
duration=self.__duration,
|
||||||
|
crop=rect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VideoFromComponents(VideoInput):
|
class VideoFromComponents(VideoInput):
|
||||||
"""
|
"""
|
||||||
@@ -815,6 +913,8 @@ class VideoFromComponents(VideoInput):
|
|||||||
images=self.__components.images,
|
images=self.__components.images,
|
||||||
audio=self.__components.audio,
|
audio=self.__components.audio,
|
||||||
frame_rate=self.__components.frame_rate,
|
frame_rate=self.__components.frame_rate,
|
||||||
|
metadata=self.__components.metadata,
|
||||||
|
alpha=self.__components.alpha,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_bit_depth(self) -> int:
|
def get_bit_depth(self) -> int:
|
||||||
|
|||||||
@@ -1361,6 +1361,41 @@ class BoundingBoxes(ComfyTypeIO):
|
|||||||
self.default = []
|
self.default = []
|
||||||
|
|
||||||
|
|
||||||
|
@comfytype(io_type="VIDEO_EDIT")
|
||||||
|
class VideoEdit(ComfyTypeIO):
|
||||||
|
class VideoTrimSection(TypedDict):
|
||||||
|
start_time: float
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
class VideoCropSection(TypedDict):
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
class VideoEditDict(TypedDict, total=False):
|
||||||
|
trim: 'VideoEdit.VideoTrimSection'
|
||||||
|
crop: 'VideoEdit.VideoCropSection'
|
||||||
|
Type = VideoEditDict
|
||||||
|
|
||||||
|
class Input(WidgetInput):
|
||||||
|
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||||
|
socketless: bool=True, default: dict=None, features: list[str]=None, advanced: bool=None):
|
||||||
|
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
|
||||||
|
self.features = features if features is not None else ["trim", "crop"]
|
||||||
|
if default is None:
|
||||||
|
self.default = {}
|
||||||
|
if "trim" in self.features:
|
||||||
|
self.default["trim"] = {"start_time": 0.0, "duration": 0.0}
|
||||||
|
if "crop" in self.features:
|
||||||
|
self.default["crop"] = {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||||
|
|
||||||
|
def as_dict(self):
|
||||||
|
return super().as_dict() | prune_dict({
|
||||||
|
"features": self.features,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@comfytype(io_type="HISTOGRAM")
|
@comfytype(io_type="HISTOGRAM")
|
||||||
class Histogram(ComfyTypeIO):
|
class Histogram(ComfyTypeIO):
|
||||||
"""A histogram represented as a list of bin counts."""
|
"""A histogram represented as a list of bin counts."""
|
||||||
@@ -2436,5 +2471,6 @@ __all__ = [
|
|||||||
"Curve",
|
"Curve",
|
||||||
"Histogram",
|
"Histogram",
|
||||||
"Range",
|
"Range",
|
||||||
|
"VideoEdit",
|
||||||
"NodeReplace",
|
"NodeReplace",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .video_types import VideoContainer, VideoCodec, VideoComponents
|
from .video_types import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
|
||||||
from .geometry_types import VOXEL, MESH, SPLAT, File3D
|
from .geometry_types import VOXEL, MESH, SPLAT, File3D
|
||||||
from .image_types import SVG
|
from .image_types import SVG
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ __all__ = [
|
|||||||
"VideoContainer",
|
"VideoContainer",
|
||||||
"VideoCodec",
|
"VideoCodec",
|
||||||
"VideoComponents",
|
"VideoComponents",
|
||||||
|
"normalize_crop_rect",
|
||||||
"VOXEL",
|
"VOXEL",
|
||||||
"MESH",
|
"MESH",
|
||||||
"SPLAT",
|
"SPLAT",
|
||||||
|
|||||||
@@ -48,3 +48,23 @@ class VideoComponents:
|
|||||||
audio: Optional[AudioInput] = None
|
audio: Optional[AudioInput] = None
|
||||||
metadata: Optional[dict] = None
|
metadata: Optional[dict] = None
|
||||||
alpha: Optional[MaskInput] = None
|
alpha: Optional[MaskInput] = None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_crop_rect(
|
||||||
|
x: int, y: int, width: int, height: int, source_width: int, source_height: int
|
||||||
|
) -> Optional[tuple[int, int, int, int]]:
|
||||||
|
width = int(width)
|
||||||
|
height = int(height)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return None
|
||||||
|
x = max(0, min(int(x), source_width - 1))
|
||||||
|
y = max(0, min(int(y), source_height - 1))
|
||||||
|
width = min(width, source_width - x)
|
||||||
|
height = min(height, source_height - y)
|
||||||
|
if x == 0 and y == 0 and width == source_width and height == source_height:
|
||||||
|
return None
|
||||||
|
width -= width % 2
|
||||||
|
height -= height % 2
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return None
|
||||||
|
return x, y, width, height
|
||||||
|
|||||||
@@ -202,8 +202,14 @@ class LoadVideo(io.ComfyNode):
|
|||||||
display_name="Load Video",
|
display_name="Load Video",
|
||||||
category="video",
|
category="video",
|
||||||
essentials_category="Basics",
|
essentials_category="Basics",
|
||||||
|
has_intermediate_output=True,
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
|
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
|
||||||
|
io.VideoEdit.Input(
|
||||||
|
"edit",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Trim (seconds) and crop (pixels) applied on load. Zero values leave the video unchanged.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
io.Video.Output(),
|
io.Video.Output(),
|
||||||
@@ -211,12 +217,17 @@ class LoadVideo(io.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, file) -> io.NodeOutput:
|
def execute(cls, file, edit=None) -> io.NodeOutput:
|
||||||
video_path = folder_paths.get_annotated_filepath(file)
|
video_path = folder_paths.get_annotated_filepath(file)
|
||||||
return io.NodeOutput(InputImpl.VideoFromFile(video_path))
|
source = InputImpl.VideoFromFile(video_path)
|
||||||
|
video = apply_video_trim(source, (edit or {}).get("trim"))
|
||||||
|
video = apply_video_crop(video, (edit or {}).get("crop"))
|
||||||
|
if video is source:
|
||||||
|
return io.NodeOutput(video, ui=preview_input_video(file))
|
||||||
|
return io.NodeOutput(video, ui=save_video_preview(video))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fingerprint_inputs(s, file):
|
def fingerprint_inputs(s, file, edit=None):
|
||||||
video_path = folder_paths.get_annotated_filepath(file)
|
video_path = folder_paths.get_annotated_filepath(file)
|
||||||
mod_time = os.path.getmtime(video_path)
|
mod_time = os.path.getmtime(video_path)
|
||||||
# Instead of hashing the file, we can just use the modification time to avoid
|
# Instead of hashing the file, we can just use the modification time to avoid
|
||||||
@@ -224,12 +235,60 @@ class LoadVideo(io.ComfyNode):
|
|||||||
return mod_time
|
return mod_time
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_inputs(s, file):
|
def validate_inputs(s, file, edit=None):
|
||||||
if not folder_paths.exists_annotated_filepath(file):
|
if not folder_paths.exists_annotated_filepath(file):
|
||||||
return "Invalid video file: {}".format(file)
|
return "Invalid video file: {}".format(file)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def preview_input_video(file: str) -> ui.PreviewVideo:
|
||||||
|
name, _ = folder_paths.annotated_filepath(file)
|
||||||
|
subfolder, _, filename = name.replace("\\", "/").rpartition("/")
|
||||||
|
return ui.PreviewVideo([ui.SavedResult(filename, subfolder, io.FolderType.input)])
|
||||||
|
|
||||||
|
|
||||||
|
def save_video_preview(video: Input.Video) -> ui.PreviewVideo:
|
||||||
|
width, height = video.get_dimensions()
|
||||||
|
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||||
|
"ComfyUI_temp_video", folder_paths.get_temp_directory(), width, height
|
||||||
|
)
|
||||||
|
preview_format = Types.VideoContainer.MP4
|
||||||
|
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(preview_format)}"
|
||||||
|
video.save_to(
|
||||||
|
os.path.join(full_output_folder, file),
|
||||||
|
format=preview_format,
|
||||||
|
codec="auto",
|
||||||
|
)
|
||||||
|
return ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.temp)])
|
||||||
|
|
||||||
|
|
||||||
|
def apply_video_trim(video: Input.Video, trim, strict_duration: bool = False) -> Input.Video:
|
||||||
|
trim = trim or {}
|
||||||
|
start_time = float(trim.get("start_time", 0.0))
|
||||||
|
duration = float(trim.get("duration", 0.0))
|
||||||
|
if duration < 0:
|
||||||
|
raise ValueError(f"Trim duration must be >= 0, got {duration}")
|
||||||
|
if start_time == 0.0 and duration == 0.0:
|
||||||
|
return video
|
||||||
|
|
||||||
|
trimmed = video.as_trimmed(start_time, duration, strict_duration=strict_duration)
|
||||||
|
if trimmed is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Failed to trim video:\nSource duration: {video.get_duration()}\nStart time: {start_time}\nTarget duration: {duration}"
|
||||||
|
)
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def apply_video_crop(video: Input.Video, crop) -> Input.Video:
|
||||||
|
crop = crop or {}
|
||||||
|
return video.as_cropped(
|
||||||
|
int(crop.get("x", 0)),
|
||||||
|
int(crop.get("y", 0)),
|
||||||
|
int(crop.get("width", 0)),
|
||||||
|
int(crop.get("height", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VideoSlice(io.ComfyNode):
|
class VideoSlice(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
@@ -277,6 +336,72 @@ class VideoSlice(io.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTrim(io.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return io.Schema(
|
||||||
|
node_id="VideoTrim",
|
||||||
|
display_name="Trim Video",
|
||||||
|
search_aliases=["trim video duration", "skip first frames", "cut video", "start time"],
|
||||||
|
category="video",
|
||||||
|
is_experimental=True,
|
||||||
|
essentials_category="Video Tools",
|
||||||
|
has_intermediate_output=True,
|
||||||
|
inputs=[
|
||||||
|
io.Video.Input("video"),
|
||||||
|
io.VideoEdit.Input(
|
||||||
|
"trim",
|
||||||
|
features=["trim"],
|
||||||
|
tooltip="Trim window in seconds. Duration 0 keeps the video until the end.",
|
||||||
|
),
|
||||||
|
io.Boolean.Input(
|
||||||
|
"strict_duration",
|
||||||
|
default=False,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="If True, when the specified duration is not possible, an error will be raised.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
io.Video.Output(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, video: io.Video.Type, trim: io.VideoEdit.Type, strict_duration: bool) -> io.NodeOutput:
|
||||||
|
trimmed = apply_video_trim(video, (trim or {}).get("trim"), strict_duration=strict_duration)
|
||||||
|
return io.NodeOutput(trimmed, ui=save_video_preview(trimmed))
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCrop(io.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return io.Schema(
|
||||||
|
node_id="VideoCrop",
|
||||||
|
display_name="Crop Video",
|
||||||
|
search_aliases=["crop video", "cut region", "spatial crop"],
|
||||||
|
category="video",
|
||||||
|
is_experimental=True,
|
||||||
|
essentials_category="Video Tools",
|
||||||
|
has_intermediate_output=True,
|
||||||
|
inputs=[
|
||||||
|
io.Video.Input("video"),
|
||||||
|
io.VideoEdit.Input(
|
||||||
|
"crop",
|
||||||
|
features=["crop"],
|
||||||
|
tooltip="Crop region in pixels. Zero width/height keeps the full frame.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
io.Video.Output(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, video: io.Video.Type, crop: io.VideoEdit.Type) -> io.NodeOutput:
|
||||||
|
cropped = apply_video_crop(video, (crop or {}).get("crop"))
|
||||||
|
return io.NodeOutput(cropped, ui=save_video_preview(cropped))
|
||||||
|
|
||||||
|
|
||||||
class VideoExtension(ComfyExtension):
|
class VideoExtension(ComfyExtension):
|
||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||||
@@ -287,6 +412,8 @@ class VideoExtension(ComfyExtension):
|
|||||||
GetVideoComponents,
|
GetVideoComponents,
|
||||||
LoadVideo,
|
LoadVideo,
|
||||||
VideoSlice,
|
VideoSlice,
|
||||||
|
VideoTrim,
|
||||||
|
VideoCrop,
|
||||||
]
|
]
|
||||||
|
|
||||||
async def comfy_entrypoint() -> VideoExtension:
|
async def comfy_entrypoint() -> VideoExtension:
|
||||||
|
|||||||
137
server.py
137
server.py
@@ -31,6 +31,7 @@ from io import BytesIO
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import av
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -212,6 +213,52 @@ def create_block_external_middleware():
|
|||||||
return block_external_middleware
|
return block_external_middleware
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_view_media_path(request, user_manager):
|
||||||
|
if "filename" not in request.rel_url.query:
|
||||||
|
return web.Response(status=400)
|
||||||
|
filename = request.rel_url.query["filename"]
|
||||||
|
|
||||||
|
# The frontend's LoadImage combo widget uses asset_hash values
|
||||||
|
# (e.g. "blake3:...") as widget values. When litegraph renders the
|
||||||
|
# node preview, it constructs /view?filename=<asset_hash>, so this
|
||||||
|
# endpoint must resolve blake3 hashes to their on-disk file paths.
|
||||||
|
if filename.startswith("blake3:"):
|
||||||
|
owner_id = user_manager.get_request_user_id(request)
|
||||||
|
result = resolve_hash_to_path(filename, owner_id=owner_id)
|
||||||
|
if result is None:
|
||||||
|
return web.Response(status=404)
|
||||||
|
return result.abs_path, result.download_name, result.content_type
|
||||||
|
|
||||||
|
filename, output_dir = folder_paths.annotated_filepath(filename)
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
# validation for security: prevent accessing arbitrary path
|
||||||
|
if filename[0] == '/' or '..' in filename:
|
||||||
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
if output_dir is None:
|
||||||
|
type = request.rel_url.query.get("type", "output")
|
||||||
|
output_dir = folder_paths.get_directory_by_type(type)
|
||||||
|
|
||||||
|
if output_dir is None:
|
||||||
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
if "subfolder" in request.rel_url.query:
|
||||||
|
subfolder = request.rel_url.query["subfolder"]
|
||||||
|
if os.path.isabs(subfolder) or os.path.splitdrive(subfolder)[0]:
|
||||||
|
return web.Response(status=403)
|
||||||
|
base_dir = os.path.abspath(output_dir)
|
||||||
|
full_output_dir = os.path.join(base_dir, subfolder)
|
||||||
|
if os.path.commonpath((os.path.abspath(full_output_dir), base_dir)) != base_dir:
|
||||||
|
return web.Response(status=403)
|
||||||
|
output_dir = full_output_dir
|
||||||
|
|
||||||
|
filename = os.path.basename(filename)
|
||||||
|
return os.path.join(output_dir, filename), filename, None
|
||||||
|
|
||||||
|
|
||||||
class PromptServer():
|
class PromptServer():
|
||||||
def __init__(self, loop):
|
def __init__(self, loop):
|
||||||
PromptServer.instance = self
|
PromptServer.instance = self
|
||||||
@@ -516,44 +563,10 @@ class PromptServer():
|
|||||||
@routes.get("/view")
|
@routes.get("/view")
|
||||||
async def view_image(request):
|
async def view_image(request):
|
||||||
if "filename" in request.rel_url.query:
|
if "filename" in request.rel_url.query:
|
||||||
filename = request.rel_url.query["filename"]
|
resolved = resolve_view_media_path(request, self.user_manager)
|
||||||
|
if isinstance(resolved, web.Response):
|
||||||
# The frontend's LoadImage combo widget uses asset_hash values
|
return resolved
|
||||||
# (e.g. "blake3:...") as widget values. When litegraph renders the
|
file, filename, resolved_content_type = resolved
|
||||||
# node preview, it constructs /view?filename=<asset_hash>, so this
|
|
||||||
# endpoint must resolve blake3 hashes to their on-disk file paths.
|
|
||||||
if filename.startswith("blake3:"):
|
|
||||||
owner_id = self.user_manager.get_request_user_id(request)
|
|
||||||
result = resolve_hash_to_path(filename, owner_id=owner_id)
|
|
||||||
if result is None:
|
|
||||||
return web.Response(status=404)
|
|
||||||
file, filename, resolved_content_type = result.abs_path, result.download_name, result.content_type
|
|
||||||
else:
|
|
||||||
resolved_content_type = None
|
|
||||||
filename, output_dir = folder_paths.annotated_filepath(filename)
|
|
||||||
|
|
||||||
if not filename:
|
|
||||||
return web.Response(status=400)
|
|
||||||
|
|
||||||
# validation for security: prevent accessing arbitrary path
|
|
||||||
if filename[0] == '/' or '..' in filename:
|
|
||||||
return web.Response(status=400)
|
|
||||||
|
|
||||||
if output_dir is None:
|
|
||||||
type = request.rel_url.query.get("type", "output")
|
|
||||||
output_dir = folder_paths.get_directory_by_type(type)
|
|
||||||
|
|
||||||
if output_dir is None:
|
|
||||||
return web.Response(status=400)
|
|
||||||
|
|
||||||
if "subfolder" in request.rel_url.query:
|
|
||||||
full_output_dir = os.path.join(output_dir, request.rel_url.query["subfolder"])
|
|
||||||
if os.path.commonpath((os.path.abspath(full_output_dir), output_dir)) != output_dir:
|
|
||||||
return web.Response(status=403)
|
|
||||||
output_dir = full_output_dir
|
|
||||||
|
|
||||||
filename = os.path.basename(filename)
|
|
||||||
file = os.path.join(output_dir, filename)
|
|
||||||
|
|
||||||
if os.path.isfile(file):
|
if os.path.isfile(file):
|
||||||
if 'preview' in request.rel_url.query:
|
if 'preview' in request.rel_url.query:
|
||||||
@@ -650,6 +663,54 @@ class PromptServer():
|
|||||||
|
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
@routes.get("/video_metadata")
|
||||||
|
async def get_video_metadata(request):
|
||||||
|
resolved = resolve_view_media_path(request, self.user_manager)
|
||||||
|
if isinstance(resolved, web.Response):
|
||||||
|
return resolved
|
||||||
|
file = resolved[0]
|
||||||
|
|
||||||
|
if not os.path.isfile(file):
|
||||||
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
def probe_video_metadata():
|
||||||
|
with av.open(file) as container:
|
||||||
|
stream = next((s for s in container.streams if s.type == "video"), None)
|
||||||
|
if stream is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fps = float(stream.average_rate) if stream.average_rate else None
|
||||||
|
duration = None
|
||||||
|
if stream.duration is not None and stream.time_base is not None:
|
||||||
|
duration = float(stream.duration * stream.time_base)
|
||||||
|
elif container.duration is not None:
|
||||||
|
duration = float(container.duration * av.time_base)
|
||||||
|
frame_count = stream.frames or None
|
||||||
|
if frame_count is None and duration is not None and fps is not None:
|
||||||
|
frame_count = round(duration * fps)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fps": fps,
|
||||||
|
"duration": duration,
|
||||||
|
"frame_count": frame_count,
|
||||||
|
"width": stream.codec_context.width,
|
||||||
|
"height": stream.codec_context.height,
|
||||||
|
"size": os.path.getsize(file),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = await asyncio.to_thread(probe_video_metadata)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return web.Response(status=404)
|
||||||
|
except PermissionError:
|
||||||
|
return web.Response(status=403)
|
||||||
|
except av.error.FFmpegError:
|
||||||
|
return web.Response(status=415)
|
||||||
|
if metadata is None:
|
||||||
|
return web.Response(status=415)
|
||||||
|
|
||||||
|
return web.json_response(metadata)
|
||||||
|
|
||||||
@routes.get("/view_metadata/{folder_name}")
|
@routes.get("/view_metadata/{folder_name}")
|
||||||
async def view_metadata(request):
|
async def view_metadata(request):
|
||||||
folder_name = request.match_info.get("folder_name", None)
|
folder_name = request.match_info.get("folder_name", None)
|
||||||
|
|||||||
Reference in New Issue
Block a user