mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-03-05 17:27:42 +08:00
fix: resolve linting issues in SaveVideo codec options
- Add missing 'logging' import used in speed preset error handling - Apply ruff format to all PR-changed files Amp-Thread-ID: https://ampcode.com/threads/T-019ca1cb-0150-7549-8b1b-6713060d3408
This commit is contained in:
parent
13a4008735
commit
f2952d4634
@ -7,7 +7,14 @@ from comfy_api.internal.singleton import ProxiedSingleton
|
|||||||
from comfy_api.internal.async_to_sync import create_sync_class
|
from comfy_api.internal.async_to_sync import create_sync_class
|
||||||
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
|
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
|
||||||
from ._input_impl import VideoFromFile, VideoFromComponents
|
from ._input_impl import VideoFromFile, VideoFromComponents
|
||||||
from ._util import VideoCodec, VideoContainer, VideoComponents, VideoSpeedPreset, MESH, VOXEL
|
from ._util import (
|
||||||
|
VideoCodec,
|
||||||
|
VideoContainer,
|
||||||
|
VideoComponents,
|
||||||
|
VideoSpeedPreset,
|
||||||
|
MESH,
|
||||||
|
VOXEL,
|
||||||
|
)
|
||||||
from . import _io_public as io
|
from . import _io_public as io
|
||||||
from . import _ui_public as ui
|
from . import _ui_public as ui
|
||||||
from comfy_execution.utils import get_executing_context
|
from comfy_execution.utils import get_executing_context
|
||||||
@ -45,7 +52,9 @@ class ComfyAPI_latest(ComfyAPIBase):
|
|||||||
raise ValueError("node_id must be provided if not in executing context")
|
raise ValueError("node_id must be provided if not in executing context")
|
||||||
|
|
||||||
# Convert preview_image to PreviewImageTuple if needed
|
# Convert preview_image to PreviewImageTuple if needed
|
||||||
to_display: PreviewImageTuple | Image.Image | ImageInput | None = preview_image
|
to_display: PreviewImageTuple | Image.Image | ImageInput | None = (
|
||||||
|
preview_image
|
||||||
|
)
|
||||||
if to_display is not None:
|
if to_display is not None:
|
||||||
# First convert to PIL Image if needed
|
# First convert to PIL Image if needed
|
||||||
if isinstance(to_display, ImageInput):
|
if isinstance(to_display, ImageInput):
|
||||||
@ -75,6 +84,7 @@ class ComfyAPI_latest(ComfyAPIBase):
|
|||||||
|
|
||||||
execution: Execution
|
execution: Execution
|
||||||
|
|
||||||
|
|
||||||
class ComfyExtension(ABC):
|
class ComfyExtension(ABC):
|
||||||
async def on_load(self) -> None:
|
async def on_load(self) -> None:
|
||||||
"""
|
"""
|
||||||
@ -88,6 +98,7 @@ class ComfyExtension(ABC):
|
|||||||
Returns a list of nodes that this extension provides.
|
Returns a list of nodes that this extension provides.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Input:
|
class Input:
|
||||||
Image = ImageInput
|
Image = ImageInput
|
||||||
Audio = AudioInput
|
Audio = AudioInput
|
||||||
@ -95,10 +106,12 @@ class Input:
|
|||||||
Latent = LatentInput
|
Latent = LatentInput
|
||||||
Video = VideoInput
|
Video = VideoInput
|
||||||
|
|
||||||
|
|
||||||
class InputImpl:
|
class InputImpl:
|
||||||
VideoFromFile = VideoFromFile
|
VideoFromFile = VideoFromFile
|
||||||
VideoFromComponents = VideoFromComponents
|
VideoFromComponents = VideoFromComponents
|
||||||
|
|
||||||
|
|
||||||
class Types:
|
class Types:
|
||||||
VideoCodec = VideoCodec
|
VideoCodec = VideoCodec
|
||||||
VideoContainer = VideoContainer
|
VideoContainer = VideoContainer
|
||||||
@ -107,6 +120,7 @@ class Types:
|
|||||||
MESH = MESH
|
MESH = MESH
|
||||||
VOXEL = VOXEL
|
VOXEL = VOXEL
|
||||||
|
|
||||||
|
|
||||||
ComfyAPI = ComfyAPI_latest
|
ComfyAPI = ComfyAPI_latest
|
||||||
|
|
||||||
# Create a synchronous version of the API
|
# Create a synchronous version of the API
|
||||||
|
|||||||
@ -10,7 +10,13 @@ import json
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import math
|
import math
|
||||||
import torch
|
import torch
|
||||||
from .._util import VideoContainer, VideoCodec, VideoComponents, VideoSpeedPreset, quality_to_crf
|
from .._util import (
|
||||||
|
VideoContainer,
|
||||||
|
VideoCodec,
|
||||||
|
VideoComponents,
|
||||||
|
VideoSpeedPreset,
|
||||||
|
quality_to_crf,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def container_to_output_format(container_format: str | None) -> str | None:
|
def container_to_output_format(container_format: str | None) -> str | None:
|
||||||
@ -82,9 +88,9 @@ class VideoFromFile(VideoInput):
|
|||||||
"""
|
"""
|
||||||
if isinstance(self.__file, io.BytesIO):
|
if isinstance(self.__file, io.BytesIO):
|
||||||
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
||||||
with av.open(self.__file, mode='r') as container:
|
with av.open(self.__file, mode="r") as container:
|
||||||
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
|
return stream.width, stream.height
|
||||||
raise ValueError(f"No video stream found in file '{self.__file}'")
|
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||||
@ -138,7 +144,9 @@ class VideoFromFile(VideoInput):
|
|||||||
# 2. Try to estimate from duration and average_rate using only metadata
|
# 2. Try to estimate from duration and average_rate using only metadata
|
||||||
if container.duration is not None and video_stream.average_rate:
|
if container.duration is not None and video_stream.average_rate:
|
||||||
duration_seconds = float(container.duration / av.time_base)
|
duration_seconds = float(container.duration / av.time_base)
|
||||||
estimated_frames = int(round(duration_seconds * float(video_stream.average_rate)))
|
estimated_frames = int(
|
||||||
|
round(duration_seconds * float(video_stream.average_rate))
|
||||||
|
)
|
||||||
if estimated_frames > 0:
|
if estimated_frames > 0:
|
||||||
return estimated_frames
|
return estimated_frames
|
||||||
|
|
||||||
@ -148,7 +156,9 @@ class VideoFromFile(VideoInput):
|
|||||||
and video_stream.average_rate
|
and video_stream.average_rate
|
||||||
):
|
):
|
||||||
duration_seconds = float(video_stream.duration * video_stream.time_base)
|
duration_seconds = float(video_stream.duration * video_stream.time_base)
|
||||||
estimated_frames = int(round(duration_seconds * float(video_stream.average_rate)))
|
estimated_frames = int(
|
||||||
|
round(duration_seconds * float(video_stream.average_rate))
|
||||||
|
)
|
||||||
if estimated_frames > 0:
|
if estimated_frames > 0:
|
||||||
return estimated_frames
|
return estimated_frames
|
||||||
|
|
||||||
@ -160,7 +170,9 @@ class VideoFromFile(VideoInput):
|
|||||||
frame_count += 1
|
frame_count += 1
|
||||||
|
|
||||||
if frame_count == 0:
|
if frame_count == 0:
|
||||||
raise ValueError(f"Could not determine frame count for file '{self.__file}'")
|
raise ValueError(
|
||||||
|
f"Could not determine frame count for file '{self.__file}'"
|
||||||
|
)
|
||||||
return frame_count
|
return frame_count
|
||||||
|
|
||||||
def get_frame_rate(self) -> Fraction:
|
def get_frame_rate(self) -> Fraction:
|
||||||
@ -181,7 +193,9 @@ class VideoFromFile(VideoInput):
|
|||||||
if video_stream.frames and container.duration:
|
if video_stream.frames and container.duration:
|
||||||
duration_seconds = float(container.duration / av.time_base)
|
duration_seconds = float(container.duration / av.time_base)
|
||||||
if duration_seconds > 0:
|
if duration_seconds > 0:
|
||||||
return Fraction(video_stream.frames / duration_seconds).limit_denominator()
|
return Fraction(
|
||||||
|
video_stream.frames / duration_seconds
|
||||||
|
).limit_denominator()
|
||||||
|
|
||||||
# Last resort: match get_components_internal default
|
# Last resort: match get_components_internal default
|
||||||
return Fraction(1)
|
return Fraction(1)
|
||||||
@ -195,53 +209,69 @@ class VideoFromFile(VideoInput):
|
|||||||
"""
|
"""
|
||||||
if isinstance(self.__file, io.BytesIO):
|
if isinstance(self.__file, io.BytesIO):
|
||||||
self.__file.seek(0)
|
self.__file.seek(0)
|
||||||
with av.open(self.__file, mode='r') as container:
|
with av.open(self.__file, mode="r") as container:
|
||||||
return container.format.name
|
return container.format.name
|
||||||
|
|
||||||
def get_components_internal(self, container: InputContainer) -> VideoComponents:
|
def get_components_internal(self, container: InputContainer) -> VideoComponents:
|
||||||
# Get video frames
|
# Get video frames
|
||||||
frames = []
|
frames = []
|
||||||
for frame in container.decode(video=0):
|
for frame in container.decode(video=0):
|
||||||
img = frame.to_ndarray(format='rgb24') # shape: (H, W, 3)
|
img = frame.to_ndarray(format="rgb24") # shape: (H, W, 3)
|
||||||
img = torch.from_numpy(img) / 255.0 # shape: (H, W, 3)
|
img = torch.from_numpy(img) / 255.0 # shape: (H, W, 3)
|
||||||
frames.append(img)
|
frames.append(img)
|
||||||
|
|
||||||
images = torch.stack(frames) if len(frames) > 0 else torch.zeros(0, 3, 0, 0)
|
images = torch.stack(frames) if len(frames) > 0 else torch.zeros(0, 3, 0, 0)
|
||||||
|
|
||||||
# Get frame rate
|
# Get frame rate
|
||||||
video_stream = next(s for s in container.streams if s.type == 'video')
|
video_stream = next(s for s in container.streams if s.type == "video")
|
||||||
frame_rate = Fraction(video_stream.average_rate) if video_stream and video_stream.average_rate else Fraction(1)
|
frame_rate = (
|
||||||
|
Fraction(video_stream.average_rate)
|
||||||
|
if video_stream and video_stream.average_rate
|
||||||
|
else Fraction(1)
|
||||||
|
)
|
||||||
|
|
||||||
# Get audio if available
|
# Get audio if available
|
||||||
audio = None
|
audio = None
|
||||||
try:
|
try:
|
||||||
container.seek(0) # Reset the container to the beginning
|
container.seek(0) # Reset the container to the beginning
|
||||||
for stream in container.streams:
|
for stream in container.streams:
|
||||||
if stream.type != 'audio':
|
if stream.type != "audio":
|
||||||
continue
|
continue
|
||||||
assert isinstance(stream, av.AudioStream)
|
assert isinstance(stream, av.AudioStream)
|
||||||
audio_frames = []
|
audio_frames = []
|
||||||
for packet in container.demux(stream):
|
for packet in container.demux(stream):
|
||||||
for frame in packet.decode():
|
for frame in packet.decode():
|
||||||
assert isinstance(frame, av.AudioFrame)
|
assert isinstance(frame, av.AudioFrame)
|
||||||
audio_frames.append(frame.to_ndarray()) # shape: (channels, samples)
|
audio_frames.append(
|
||||||
|
frame.to_ndarray()
|
||||||
|
) # shape: (channels, samples)
|
||||||
if len(audio_frames) > 0:
|
if len(audio_frames) > 0:
|
||||||
audio_data = np.concatenate(audio_frames, axis=1) # shape: (channels, total_samples)
|
audio_data = np.concatenate(
|
||||||
audio_tensor = torch.from_numpy(audio_data).unsqueeze(0) # shape: (1, channels, total_samples)
|
audio_frames, axis=1
|
||||||
audio = AudioInput({
|
) # shape: (channels, total_samples)
|
||||||
"waveform": audio_tensor,
|
audio_tensor = torch.from_numpy(audio_data).unsqueeze(
|
||||||
"sample_rate": int(stream.sample_rate) if stream.sample_rate else 1,
|
0
|
||||||
})
|
) # shape: (1, channels, total_samples)
|
||||||
|
audio = AudioInput(
|
||||||
|
{
|
||||||
|
"waveform": audio_tensor,
|
||||||
|
"sample_rate": int(stream.sample_rate)
|
||||||
|
if stream.sample_rate
|
||||||
|
else 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
pass # No audio stream
|
pass # No audio stream
|
||||||
|
|
||||||
metadata = container.metadata
|
metadata = container.metadata
|
||||||
return VideoComponents(images=images, audio=audio, frame_rate=frame_rate, metadata=metadata)
|
return VideoComponents(
|
||||||
|
images=images, audio=audio, frame_rate=frame_rate, metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
def get_components(self) -> VideoComponents:
|
def get_components(self) -> VideoComponents:
|
||||||
if isinstance(self.__file, io.BytesIO):
|
if isinstance(self.__file, io.BytesIO):
|
||||||
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
self.__file.seek(0) # Reset the BytesIO object to the beginning
|
||||||
with av.open(self.__file, mode='r') as container:
|
with av.open(self.__file, mode="r") as container:
|
||||||
return self.get_components_internal(container)
|
return self.get_components_internal(container)
|
||||||
raise ValueError(f"No video stream found in file '{self.__file}'")
|
raise ValueError(f"No video stream found in file '{self.__file}'")
|
||||||
|
|
||||||
@ -260,13 +290,23 @@ class VideoFromFile(VideoInput):
|
|||||||
):
|
):
|
||||||
if isinstance(self.__file, io.BytesIO):
|
if isinstance(self.__file, io.BytesIO):
|
||||||
self.__file.seek(0)
|
self.__file.seek(0)
|
||||||
with av.open(self.__file, mode='r') as container:
|
with av.open(self.__file, mode="r") as container:
|
||||||
container_format = container.format.name
|
container_format = container.format.name
|
||||||
video_encoding = container.streams.video[0].codec.name if len(container.streams.video) > 0 else None
|
video_encoding = (
|
||||||
|
container.streams.video[0].codec.name
|
||||||
|
if len(container.streams.video) > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
reuse_streams = True
|
reuse_streams = True
|
||||||
if format != VideoContainer.AUTO and format not in container_format.split(","):
|
if format != VideoContainer.AUTO and format not in container_format.split(
|
||||||
|
","
|
||||||
|
):
|
||||||
reuse_streams = False
|
reuse_streams = False
|
||||||
if codec != VideoCodec.AUTO and codec != video_encoding and video_encoding is not None:
|
if (
|
||||||
|
codec != VideoCodec.AUTO
|
||||||
|
and codec != video_encoding
|
||||||
|
and video_encoding is not None
|
||||||
|
):
|
||||||
reuse_streams = False
|
reuse_streams = False
|
||||||
if quality is not None or speed is not None:
|
if quality is not None or speed is not None:
|
||||||
reuse_streams = False
|
reuse_streams = False
|
||||||
@ -309,8 +349,12 @@ class VideoFromFile(VideoInput):
|
|||||||
# Add streams to the new container
|
# Add streams to the new container
|
||||||
stream_map = {}
|
stream_map = {}
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)):
|
if isinstance(
|
||||||
out_stream = output_container.add_stream_from_template(template=stream, opaque=True)
|
stream, (av.VideoStream, av.AudioStream, SubtitleStream)
|
||||||
|
):
|
||||||
|
out_stream = output_container.add_stream_from_template(
|
||||||
|
template=stream, opaque=True
|
||||||
|
)
|
||||||
stream_map[stream] = out_stream
|
stream_map[stream] = out_stream
|
||||||
|
|
||||||
# Write packets to the new container
|
# Write packets to the new container
|
||||||
@ -338,7 +382,7 @@ class VideoFromComponents(VideoInput):
|
|||||||
return VideoComponents(
|
return VideoComponents(
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_to(
|
def save_to(
|
||||||
@ -399,7 +443,9 @@ class VideoFromComponents(VideoInput):
|
|||||||
if resolved_format == VideoContainer.MP4:
|
if resolved_format == VideoContainer.MP4:
|
||||||
container_options["movflags"] = "use_metadata_tags"
|
container_options["movflags"] = "use_metadata_tags"
|
||||||
|
|
||||||
with av.open(path, mode='w', options=container_options, **extra_kwargs) as output:
|
with av.open(
|
||||||
|
path, mode="w", options=container_options, **extra_kwargs
|
||||||
|
) as output:
|
||||||
if metadata is not None:
|
if metadata is not None:
|
||||||
for key, value in metadata.items():
|
for key, value in metadata.items():
|
||||||
output.metadata[key] = json.dumps(value)
|
output.metadata[key] = json.dumps(value)
|
||||||
@ -409,48 +455,50 @@ class VideoFromComponents(VideoInput):
|
|||||||
video_stream.width = self.__components.images.shape[2]
|
video_stream.width = self.__components.images.shape[2]
|
||||||
video_stream.height = self.__components.images.shape[1]
|
video_stream.height = self.__components.images.shape[1]
|
||||||
|
|
||||||
video_stream.pix_fmt = 'yuv420p'
|
video_stream.pix_fmt = "yuv420p"
|
||||||
if resolved_codec == VideoCodec.VP9:
|
if resolved_codec == VideoCodec.VP9:
|
||||||
video_stream.bit_rate = 0
|
video_stream.bit_rate = 0
|
||||||
|
|
||||||
if quality is not None:
|
if quality is not None:
|
||||||
crf = quality_to_crf(quality, ffmpeg_codec)
|
crf = quality_to_crf(quality, ffmpeg_codec)
|
||||||
video_stream.options['crf'] = str(crf)
|
video_stream.options["crf"] = str(crf)
|
||||||
|
|
||||||
if speed is not None and speed != VideoSpeedPreset.AUTO:
|
if speed is not None and speed != VideoSpeedPreset.AUTO:
|
||||||
if isinstance(speed, str):
|
if isinstance(speed, str):
|
||||||
speed = VideoSpeedPreset(speed)
|
speed = VideoSpeedPreset(speed)
|
||||||
preset = speed.to_ffmpeg_preset(ffmpeg_codec)
|
preset = speed.to_ffmpeg_preset(ffmpeg_codec)
|
||||||
if resolved_codec == VideoCodec.VP9:
|
if resolved_codec == VideoCodec.VP9:
|
||||||
video_stream.options['cpu-used'] = preset
|
video_stream.options["cpu-used"] = preset
|
||||||
else:
|
else:
|
||||||
video_stream.options['preset'] = preset
|
video_stream.options["preset"] = preset
|
||||||
|
|
||||||
# H.264-specific options
|
# H.264-specific options
|
||||||
if resolved_codec == VideoCodec.H264:
|
if resolved_codec == VideoCodec.H264:
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
video_stream.options['profile'] = profile
|
video_stream.options["profile"] = profile
|
||||||
if tune is not None:
|
if tune is not None:
|
||||||
video_stream.options['tune'] = tune
|
video_stream.options["tune"] = tune
|
||||||
|
|
||||||
# VP9-specific options
|
# VP9-specific options
|
||||||
if resolved_codec == VideoCodec.VP9:
|
if resolved_codec == VideoCodec.VP9:
|
||||||
if row_mt:
|
if row_mt:
|
||||||
video_stream.options['row-mt'] = '1'
|
video_stream.options["row-mt"] = "1"
|
||||||
if tile_columns is not None:
|
if tile_columns is not None:
|
||||||
video_stream.options['tile-columns'] = str(tile_columns)
|
video_stream.options["tile-columns"] = str(tile_columns)
|
||||||
|
|
||||||
audio_sample_rate = 1
|
audio_sample_rate = 1
|
||||||
audio_stream: Optional[av.AudioStream] = None
|
audio_stream: Optional[av.AudioStream] = None
|
||||||
if self.__components.audio:
|
if self.__components.audio:
|
||||||
audio_sample_rate = int(self.__components.audio['sample_rate'])
|
audio_sample_rate = int(self.__components.audio["sample_rate"])
|
||||||
audio_codec = 'libopus' if resolved_format == VideoContainer.WEBM else 'aac'
|
audio_codec = (
|
||||||
|
"libopus" if resolved_format == VideoContainer.WEBM else "aac"
|
||||||
|
)
|
||||||
audio_stream = output.add_stream(audio_codec, rate=audio_sample_rate)
|
audio_stream = output.add_stream(audio_codec, rate=audio_sample_rate)
|
||||||
|
|
||||||
for i, frame in enumerate(self.__components.images):
|
for i, frame in enumerate(self.__components.images):
|
||||||
img = (frame * 255).clamp(0, 255).byte().cpu().numpy()
|
img = (frame * 255).clamp(0, 255).byte().cpu().numpy()
|
||||||
video_frame = av.VideoFrame.from_ndarray(img, format='rgb24')
|
video_frame = av.VideoFrame.from_ndarray(img, format="rgb24")
|
||||||
video_frame = video_frame.reformat(format='yuv420p')
|
video_frame = video_frame.reformat(format="yuv420p")
|
||||||
packet = video_stream.encode(video_frame)
|
packet = video_stream.encode(video_frame)
|
||||||
output.mux(packet)
|
output.mux(packet)
|
||||||
|
|
||||||
@ -458,12 +506,19 @@ class VideoFromComponents(VideoInput):
|
|||||||
output.mux(packet)
|
output.mux(packet)
|
||||||
|
|
||||||
if audio_stream and self.__components.audio:
|
if audio_stream and self.__components.audio:
|
||||||
waveform = self.__components.audio['waveform']
|
waveform = self.__components.audio["waveform"]
|
||||||
waveform = waveform[:, :, :math.ceil((audio_sample_rate / frame_rate) * self.__components.images.shape[0])]
|
waveform = waveform[
|
||||||
|
:,
|
||||||
|
:,
|
||||||
|
: math.ceil(
|
||||||
|
(audio_sample_rate / frame_rate)
|
||||||
|
* self.__components.images.shape[0]
|
||||||
|
),
|
||||||
|
]
|
||||||
audio_frame = av.AudioFrame.from_ndarray(
|
audio_frame = av.AudioFrame.from_ndarray(
|
||||||
waveform.movedim(2, 1).reshape(1, -1).float().numpy(),
|
waveform.movedim(2, 1).reshape(1, -1).float().numpy(),
|
||||||
format='flt',
|
format="flt",
|
||||||
layout='mono' if waveform.shape[1] == 1 else 'stereo'
|
layout="mono" if waveform.shape[1] == 1 else "stereo",
|
||||||
)
|
)
|
||||||
audio_frame.sample_rate = audio_sample_rate
|
audio_frame.sample_rate = audio_sample_rate
|
||||||
audio_frame.pts = 0
|
audio_frame.pts = 0
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
from .video_types import VideoContainer, VideoCodec, VideoComponents, VideoSpeedPreset, quality_to_crf
|
from .video_types import (
|
||||||
|
VideoContainer,
|
||||||
|
VideoCodec,
|
||||||
|
VideoComponents,
|
||||||
|
VideoSpeedPreset,
|
||||||
|
quality_to_crf,
|
||||||
|
)
|
||||||
from .geometry_types import VOXEL, MESH
|
from .geometry_types import VOXEL, MESH
|
||||||
from .image_types import SVG
|
from .image_types import SVG
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from fractions import Fraction
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from .._input import ImageInput, AudioInput
|
from .._input import ImageInput, AudioInput
|
||||||
|
|
||||||
|
|
||||||
class VideoCodec(str, Enum):
|
class VideoCodec(str, Enum):
|
||||||
AUTO = "auto"
|
AUTO = "auto"
|
||||||
H264 = "h264"
|
H264 = "h264"
|
||||||
@ -46,6 +47,7 @@ class VideoContainer(str, Enum):
|
|||||||
|
|
||||||
class VideoSpeedPreset(str, Enum):
|
class VideoSpeedPreset(str, Enum):
|
||||||
"""Encoding speed presets - slower = better compression at same quality."""
|
"""Encoding speed presets - slower = better compression at same quality."""
|
||||||
|
|
||||||
AUTO = "auto"
|
AUTO = "auto"
|
||||||
FASTEST = "Fastest"
|
FASTEST = "Fastest"
|
||||||
FAST = "Fast"
|
FAST = "Fast"
|
||||||
@ -104,6 +106,7 @@ def quality_to_crf(quality: int, codec: str = "h264") -> int:
|
|||||||
# Default fallback
|
# Default fallback
|
||||||
return 23
|
return 23
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoComponents:
|
class VideoComponents:
|
||||||
"""
|
"""
|
||||||
@ -114,5 +117,3 @@ class VideoComponents:
|
|||||||
frame_rate: Fraction
|
frame_rate: Fraction
|
||||||
audio: Optional[AudioInput] = None
|
audio: Optional[AudioInput] = None
|
||||||
metadata: Optional[dict] = None
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import av
|
|
||||||
import torch
|
|
||||||
import folder_paths
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import av
|
||||||
|
import folder_paths
|
||||||
|
import torch
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from comfy_api.latest import ComfyExtension, io, ui, Input, InputImpl, Types
|
from comfy_api.latest import ComfyExtension, io, ui, Input, InputImpl, Types
|
||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
|
|
||||||
|
|
||||||
class SaveWEBM(io.ComfyNode):
|
class SaveWEBM(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
@ -23,7 +26,14 @@ class SaveWEBM(io.ComfyNode):
|
|||||||
io.String.Input("filename_prefix", default="ComfyUI"),
|
io.String.Input("filename_prefix", default="ComfyUI"),
|
||||||
io.Combo.Input("codec", options=["vp9", "av1"]),
|
io.Combo.Input("codec", options=["vp9", "av1"]),
|
||||||
io.Float.Input("fps", default=24.0, min=0.01, max=1000.0, step=0.01),
|
io.Float.Input("fps", default=24.0, min=0.01, max=1000.0, step=0.01),
|
||||||
io.Float.Input("crf", default=32.0, min=0, max=63.0, step=1, tooltip="Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize."),
|
io.Float.Input(
|
||||||
|
"crf",
|
||||||
|
default=32.0,
|
||||||
|
min=0,
|
||||||
|
max=63.0,
|
||||||
|
step=1,
|
||||||
|
tooltip="Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||||
is_output_node=True,
|
is_output_node=True,
|
||||||
@ -31,8 +41,13 @@ class SaveWEBM(io.ComfyNode):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, images, codec, fps, filename_prefix, crf) -> io.NodeOutput:
|
def execute(cls, images, codec, fps, filename_prefix, crf) -> io.NodeOutput:
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||||
filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0]
|
folder_paths.get_save_image_path(
|
||||||
|
filename_prefix,
|
||||||
|
folder_paths.get_output_directory(),
|
||||||
|
images[0].shape[1],
|
||||||
|
images[0].shape[0],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
file = f"{filename}_{counter:05}_.webm"
|
file = f"{filename}_{counter:05}_.webm"
|
||||||
@ -46,23 +61,33 @@ class SaveWEBM(io.ComfyNode):
|
|||||||
container.metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
|
container.metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
|
||||||
|
|
||||||
codec_map = {"vp9": "libvpx-vp9", "av1": "libsvtav1"}
|
codec_map = {"vp9": "libvpx-vp9", "av1": "libsvtav1"}
|
||||||
stream = container.add_stream(codec_map[codec], rate=Fraction(round(fps * 1000), 1000))
|
stream = container.add_stream(
|
||||||
|
codec_map[codec], rate=Fraction(round(fps * 1000), 1000)
|
||||||
|
)
|
||||||
stream.width = images.shape[-2]
|
stream.width = images.shape[-2]
|
||||||
stream.height = images.shape[-3]
|
stream.height = images.shape[-3]
|
||||||
stream.pix_fmt = "yuv420p10le" if codec == "av1" else "yuv420p"
|
stream.pix_fmt = "yuv420p10le" if codec == "av1" else "yuv420p"
|
||||||
stream.bit_rate = 0
|
stream.bit_rate = 0
|
||||||
stream.options = {'crf': str(crf)}
|
stream.options = {"crf": str(crf)}
|
||||||
if codec == "av1":
|
if codec == "av1":
|
||||||
stream.options["preset"] = "6"
|
stream.options["preset"] = "6"
|
||||||
|
|
||||||
for frame in images:
|
for frame in images:
|
||||||
frame = av.VideoFrame.from_ndarray(torch.clamp(frame[..., :3] * 255, min=0, max=255).to(device=torch.device("cpu"), dtype=torch.uint8).numpy(), format="rgb24")
|
frame = av.VideoFrame.from_ndarray(
|
||||||
|
torch.clamp(frame[..., :3] * 255, min=0, max=255)
|
||||||
|
.to(device=torch.device("cpu"), dtype=torch.uint8)
|
||||||
|
.numpy(),
|
||||||
|
format="rgb24",
|
||||||
|
)
|
||||||
for packet in stream.encode(frame):
|
for packet in stream.encode(frame):
|
||||||
container.mux(packet)
|
container.mux(packet)
|
||||||
container.mux(stream.encode())
|
container.mux(stream.encode())
|
||||||
container.close()
|
container.close()
|
||||||
|
|
||||||
return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
|
return io.NodeOutput(
|
||||||
|
ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SaveVideo(io.ComfyNode):
|
class SaveVideo(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -76,7 +101,7 @@ class SaveVideo(io.ComfyNode):
|
|||||||
step=1,
|
step=1,
|
||||||
display_name="Quality",
|
display_name="Quality",
|
||||||
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
||||||
"Internally maps to CRF: 100→CRF 12, 50→CRF 23, 0→CRF 40.",
|
"Internally maps to CRF: 100→CRF 12, 50→CRF 23, 0→CRF 40.",
|
||||||
)
|
)
|
||||||
h264_speed = io.Combo.Input(
|
h264_speed = io.Combo.Input(
|
||||||
"speed",
|
"speed",
|
||||||
@ -84,7 +109,7 @@ class SaveVideo(io.ComfyNode):
|
|||||||
default="auto",
|
default="auto",
|
||||||
display_name="Encoding Speed",
|
display_name="Encoding Speed",
|
||||||
tooltip="Encoding speed preset. Slower = better compression at same quality. "
|
tooltip="Encoding speed preset. Slower = better compression at same quality. "
|
||||||
"Maps to FFmpeg presets: Fastest=ultrafast, Balanced=medium, Best=veryslow.",
|
"Maps to FFmpeg presets: Fastest=ultrafast, Balanced=medium, Best=veryslow.",
|
||||||
)
|
)
|
||||||
h264_profile = io.Combo.Input(
|
h264_profile = io.Combo.Input(
|
||||||
"profile",
|
"profile",
|
||||||
@ -92,16 +117,24 @@ class SaveVideo(io.ComfyNode):
|
|||||||
default="auto",
|
default="auto",
|
||||||
display_name="Profile",
|
display_name="Profile",
|
||||||
tooltip="H.264 profile. 'baseline' for max compatibility (older devices), "
|
tooltip="H.264 profile. 'baseline' for max compatibility (older devices), "
|
||||||
"'main' for standard use, 'high' for best quality/compression.",
|
"'main' for standard use, 'high' for best quality/compression.",
|
||||||
advanced=True,
|
advanced=True,
|
||||||
)
|
)
|
||||||
h264_tune = io.Combo.Input(
|
h264_tune = io.Combo.Input(
|
||||||
"tune",
|
"tune",
|
||||||
options=["auto", "film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"],
|
options=[
|
||||||
|
"auto",
|
||||||
|
"film",
|
||||||
|
"animation",
|
||||||
|
"grain",
|
||||||
|
"stillimage",
|
||||||
|
"fastdecode",
|
||||||
|
"zerolatency",
|
||||||
|
],
|
||||||
default="auto",
|
default="auto",
|
||||||
display_name="Tune",
|
display_name="Tune",
|
||||||
tooltip="Optimize encoding for specific content types. "
|
tooltip="Optimize encoding for specific content types. "
|
||||||
"'film' for live action, 'animation' for cartoons/anime, 'grain' to preserve film grain.",
|
"'film' for live action, 'animation' for cartoons/anime, 'grain' to preserve film grain.",
|
||||||
advanced=True,
|
advanced=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -114,7 +147,7 @@ class SaveVideo(io.ComfyNode):
|
|||||||
step=1,
|
step=1,
|
||||||
display_name="Quality",
|
display_name="Quality",
|
||||||
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
tooltip="Output quality (0-100). Higher = better quality, larger files. "
|
||||||
"Internally maps to CRF: 100→CRF 15, 50→CRF 33, 0→CRF 50.",
|
"Internally maps to CRF: 100→CRF 15, 50→CRF 33, 0→CRF 50.",
|
||||||
)
|
)
|
||||||
vp9_speed = io.Combo.Input(
|
vp9_speed = io.Combo.Input(
|
||||||
"speed",
|
"speed",
|
||||||
@ -122,7 +155,7 @@ class SaveVideo(io.ComfyNode):
|
|||||||
default="auto",
|
default="auto",
|
||||||
display_name="Encoding Speed",
|
display_name="Encoding Speed",
|
||||||
tooltip="Encoding speed. Slower = better compression. "
|
tooltip="Encoding speed. Slower = better compression. "
|
||||||
"Maps to VP9 cpu-used: Fastest=0, Balanced=2, Best=4.",
|
"Maps to VP9 cpu-used: Fastest=0, Balanced=2, Best=4.",
|
||||||
)
|
)
|
||||||
vp9_row_mt = io.Boolean.Input(
|
vp9_row_mt = io.Boolean.Input(
|
||||||
"row_mt",
|
"row_mt",
|
||||||
@ -137,7 +170,7 @@ class SaveVideo(io.ComfyNode):
|
|||||||
default="auto",
|
default="auto",
|
||||||
display_name="Tile Columns",
|
display_name="Tile Columns",
|
||||||
tooltip="Number of tile columns (as power of 2). More tiles = faster encoding "
|
tooltip="Number of tile columns (as power of 2). More tiles = faster encoding "
|
||||||
"but slightly worse compression. 'auto' picks based on resolution.",
|
"but slightly worse compression. 'auto' picks based on resolution.",
|
||||||
advanced=True,
|
advanced=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -146,28 +179,39 @@ class SaveVideo(io.ComfyNode):
|
|||||||
display_name="Save Video",
|
display_name="Save Video",
|
||||||
category="image/video",
|
category="image/video",
|
||||||
description="Saves video to the output directory. "
|
description="Saves video to the output directory. "
|
||||||
"When format/codec/quality differ from source, the video is re-encoded.",
|
"When format/codec/quality differ from source, the video is re-encoded.",
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Video.Input("video", tooltip="The video to save."),
|
io.Video.Input("video", tooltip="The video to save."),
|
||||||
io.String.Input(
|
io.String.Input(
|
||||||
"filename_prefix",
|
"filename_prefix",
|
||||||
default="video/ComfyUI",
|
default="video/ComfyUI",
|
||||||
tooltip="The prefix for the file to save. "
|
tooltip="The prefix for the file to save. "
|
||||||
"Supports formatting like %date:yyyy-MM-dd%.",
|
"Supports formatting like %date:yyyy-MM-dd%.",
|
||||||
|
),
|
||||||
|
io.DynamicCombo.Input(
|
||||||
|
"codec",
|
||||||
|
options=[
|
||||||
|
io.DynamicCombo.Option("auto", []),
|
||||||
|
io.DynamicCombo.Option(
|
||||||
|
"h264", [h264_quality, h264_speed, h264_profile, h264_tune]
|
||||||
|
),
|
||||||
|
io.DynamicCombo.Option(
|
||||||
|
"vp9",
|
||||||
|
[vp9_quality, vp9_speed, vp9_row_mt, vp9_tile_columns],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tooltip="Video codec. 'auto' preserves source when possible. "
|
||||||
|
"h264 outputs MP4, vp9 outputs WebM.",
|
||||||
),
|
),
|
||||||
io.DynamicCombo.Input("codec", options=[
|
|
||||||
io.DynamicCombo.Option("auto", []),
|
|
||||||
io.DynamicCombo.Option("h264", [h264_quality, h264_speed, h264_profile, h264_tune]),
|
|
||||||
io.DynamicCombo.Option("vp9", [vp9_quality, vp9_speed, vp9_row_mt, vp9_tile_columns]),
|
|
||||||
], tooltip="Video codec. 'auto' preserves source when possible. "
|
|
||||||
"h264 outputs MP4, vp9 outputs WebM."),
|
|
||||||
],
|
],
|
||||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||||
is_output_node=True,
|
is_output_node=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, video: Input.Video, filename_prefix: str, codec: dict) -> io.NodeOutput:
|
def execute(
|
||||||
|
cls, video: Input.Video, filename_prefix: str, codec: dict
|
||||||
|
) -> io.NodeOutput:
|
||||||
selected_codec = codec.get("codec", "auto")
|
selected_codec = codec.get("codec", "auto")
|
||||||
quality = codec.get("quality")
|
quality = codec.get("quality")
|
||||||
speed_str = codec.get("speed", "auto")
|
speed_str = codec.get("speed", "auto")
|
||||||
@ -201,11 +245,10 @@ class SaveVideo(io.ComfyNode):
|
|||||||
logging.warning(f"Invalid speed preset '{speed_str}', using default")
|
logging.warning(f"Invalid speed preset '{speed_str}', using default")
|
||||||
|
|
||||||
width, height = video.get_dimensions()
|
width, height = video.get_dimensions()
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||||
filename_prefix,
|
folder_paths.get_save_image_path(
|
||||||
folder_paths.get_output_directory(),
|
filename_prefix, folder_paths.get_output_directory(), width, height
|
||||||
width,
|
)
|
||||||
height
|
|
||||||
)
|
)
|
||||||
|
|
||||||
saved_metadata = None
|
saved_metadata = None
|
||||||
@ -233,7 +276,9 @@ class SaveVideo(io.ComfyNode):
|
|||||||
tile_columns=int(tile_columns) if tile_columns != "auto" else None,
|
tile_columns=int(tile_columns) if tile_columns != "auto" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return io.NodeOutput(ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
|
return io.NodeOutput(
|
||||||
|
ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CreateVideo(io.ComfyNode):
|
class CreateVideo(io.ComfyNode):
|
||||||
@ -247,7 +292,9 @@ class CreateVideo(io.ComfyNode):
|
|||||||
inputs=[
|
inputs=[
|
||||||
io.Image.Input("images", tooltip="The images to create a video from."),
|
io.Image.Input("images", tooltip="The images to create a video from."),
|
||||||
io.Float.Input("fps", default=30.0, min=1.0, max=120.0, step=1.0),
|
io.Float.Input("fps", default=30.0, min=1.0, max=120.0, step=1.0),
|
||||||
io.Audio.Input("audio", optional=True, tooltip="The audio to add to the video."),
|
io.Audio.Input(
|
||||||
|
"audio", optional=True, tooltip="The audio to add to the video."
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
io.Video.Output(),
|
io.Video.Output(),
|
||||||
@ -255,11 +302,18 @@ class CreateVideo(io.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None) -> io.NodeOutput:
|
def execute(
|
||||||
|
cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None
|
||||||
|
) -> io.NodeOutput:
|
||||||
return io.NodeOutput(
|
return io.NodeOutput(
|
||||||
InputImpl.VideoFromComponents(Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)))
|
InputImpl.VideoFromComponents(
|
||||||
|
Types.VideoComponents(
|
||||||
|
images=images, audio=audio, frame_rate=Fraction(fps)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GetVideoComponents(io.ComfyNode):
|
class GetVideoComponents(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
@ -269,7 +323,9 @@ class GetVideoComponents(io.ComfyNode):
|
|||||||
category="image/video",
|
category="image/video",
|
||||||
description="Extracts all components from a video: frames, audio, and framerate.",
|
description="Extracts all components from a video: frames, audio, and framerate.",
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Video.Input("video", tooltip="The video to extract components from."),
|
io.Video.Input(
|
||||||
|
"video", tooltip="The video to extract components from."
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
io.Image.Output(display_name="images"),
|
io.Image.Output(display_name="images"),
|
||||||
@ -281,21 +337,29 @@ class GetVideoComponents(io.ComfyNode):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, video: Input.Video) -> io.NodeOutput:
|
def execute(cls, video: Input.Video) -> io.NodeOutput:
|
||||||
components = video.get_components()
|
components = video.get_components()
|
||||||
return io.NodeOutput(components.images, components.audio, float(components.frame_rate))
|
return io.NodeOutput(
|
||||||
|
components.images, components.audio, float(components.frame_rate)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoadVideo(io.ComfyNode):
|
class LoadVideo(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
input_dir = folder_paths.get_input_directory()
|
input_dir = folder_paths.get_input_directory()
|
||||||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
files = [
|
||||||
|
f
|
||||||
|
for f in os.listdir(input_dir)
|
||||||
|
if os.path.isfile(os.path.join(input_dir, f))
|
||||||
|
]
|
||||||
files = folder_paths.filter_files_content_types(files, ["video"])
|
files = folder_paths.filter_files_content_types(files, ["video"])
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="LoadVideo",
|
node_id="LoadVideo",
|
||||||
display_name="Load Video",
|
display_name="Load Video",
|
||||||
category="image/video",
|
category="image/video",
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Combo.Input("file", options=sorted(files), upload=io.UploadType.video),
|
io.Combo.Input(
|
||||||
|
"file", options=sorted(files), upload=io.UploadType.video
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
io.Video.Output(),
|
io.Video.Output(),
|
||||||
@ -334,5 +398,6 @@ class VideoExtension(ComfyExtension):
|
|||||||
LoadVideo,
|
LoadVideo,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def comfy_entrypoint() -> VideoExtension:
|
async def comfy_entrypoint() -> VideoExtension:
|
||||||
return VideoExtension()
|
return VideoExtension()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user