Files
WhatDreamsCost-Dumas/load_video_ui.py
WhatDreamsCost e4db1fc892 v1.3.3 Fixed errors due to audio encoding. Fixed minor widget issue.
- Fixed duration_seconds input
- Both duration settings are now shown by default
- Audio latent fix
2026-05-15 17:35:11 -05:00

412 lines
21 KiB
Python

import os
import torch
import numpy as np
import folder_paths
import av
from server import PromptServer
from aiohttp import web
import comfy.utils
# Custom API route to serve video files from anywhere on the user's system for the frontend preview
@PromptServer.instance.routes.get("/video_ui_custom_view")
async def custom_view(request):
file_path = request.query.get("filename", "")
if os.path.exists(file_path) and os.path.isfile(file_path):
return web.FileResponse(file_path)
return web.Response(status=404, text="File not found")
# Custom API route for Chunked Uploads to bypass the 413 Payload Too Large error
@PromptServer.instance.routes.post("/video_ui_upload_chunk")
async def upload_chunk(request):
post = await request.post()
file = post.get("file")
filename = post.get("filename")
chunk_index = int(post.get("chunk_index"))
total_chunks = int(post.get("total_chunks"))
upload_dir = folder_paths.get_input_directory()
file_path = os.path.join(upload_dir, filename)
# Append to file if it's not the first chunk, otherwise write new
mode = "ab" if chunk_index > 0 else "wb"
with open(file_path, mode) as f:
f.write(file.file.read())
if chunk_index == total_chunks - 1:
return web.json_response({"name": filename})
return web.json_response({"status": "ok"})
class LoadVideoUI:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video": ("STRING", {"default": ""}),
"start_time": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}),
"end_time": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}),
"duration": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}),
"start_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}),
"end_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}),
"duration_frames": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}),
"resize_method": (["maintain aspect ratio", "stretch to fit", "pad", "crop"], {"default": "maintain aspect ratio"}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 8, "tooltip": "Custom width. 0 means original width."}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 8, "tooltip": "Custom height. 0 means original height."}),
"frame_rate": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1, "tooltip": "Force the video to a specific frame rate for extraction."}),
"display_mode": (["seconds", "frames"], {"default": "seconds"}),
"crop_x": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}),
"crop_y": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}),
"crop_w": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}),
"crop_h": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}),
}
}
RETURN_TYPES = ("IMAGE", "AUDIO", "FLOAT", "INT")
RETURN_NAMES = ("images", "audio", "duration", "frame_count")
FUNCTION = "load_video"
CATEGORY = "WhatDreamsCost"
def load_video(self, video, frame_rate, display_mode, start_time, end_time, duration, start_frame, end_frame, duration_frames, custom_width=0, custom_height=0, resize_method="maintain aspect ratio", crop_x=0.0, crop_y=0.0, crop_w=1.0, crop_h=1.0, **kwargs):
if not video:
# Return blank defaults if no video is loaded
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
empty_audio = {"waveform": torch.zeros((1, 1, 44100)), "sample_rate": 44100}
return (empty_image, empty_audio, 0.0, 0)
# 1. Resolve path using ComfyUI standard paths or Absolute Path
video_path = video # Try exact/absolute path first
if not os.path.exists(video_path):
video_path_annotated = folder_paths.get_annotated_filepath(video)
if os.path.exists(video_path_annotated):
video_path = video_path_annotated
else:
video_path_input = os.path.join(folder_paths.get_input_directory(), video)
if os.path.exists(video_path_input):
video_path = video_path_input
else:
raise FileNotFoundError(f"Video file not found: {video}")
# Open container to read streams and metadata
container = av.open(video_path)
# Determine video stream and duration
video_stream = container.streams.video[0] if len(container.streams.video) > 0 else None
video_duration = 0
if video_stream and video_stream.duration and video_stream.time_base:
video_duration = float(video_stream.duration * video_stream.time_base)
orig_w = video_stream.codec_context.width if video_stream else 512
orig_h = video_stream.codec_context.height if video_stream else 512
# Determine correct colorspace and color range for PyAV conversion to prevent color shift
try:
from av.video.reformatter import Colorspace, ColorRange
# Improve fallback heuristic to check both dimensions (e.g. 720x1280 vertical video is HD)
fallback_cs = Colorspace.ITU709 if max(orig_w, orig_h) >= 720 else Colorspace.ITU601
fallback_cr = ColorRange.MPEG
dst_range = ColorRange.JPEG # RGB should always be full range
except ImportError:
fallback_cs = "itu709" if max(orig_w, orig_h) >= 720 else "itu601"
fallback_cr = "mpeg"
dst_range = "jpeg"
src_colorspace = fallback_cs
src_color_range = fallback_cr
if video_stream and video_stream.codec_context:
cc = video_stream.codec_context
c_space = getattr(cc, 'colorspace', getattr(cc, 'color_space', None))
if c_space and hasattr(c_space, 'name') and c_space.name != "UNSPECIFIED":
src_colorspace = c_space
elif c_space and isinstance(c_space, str) and "unspecified" not in c_space.lower():
src_colorspace = c_space
c_range = getattr(cc, 'color_range', None)
if c_range and hasattr(c_range, 'name') and c_range.name != "UNSPECIFIED":
src_color_range = c_range
elif c_range and isinstance(c_range, str) and "unspecified" not in c_range.lower():
src_color_range = c_range
target_w = custom_width if custom_width > 0 else orig_w
target_h = custom_height if custom_height > 0 else orig_h
target_w = target_w - (target_w % 2)
target_h = target_h - (target_h % 2)
# Calculate manual crop from interactive UI first
manual_crop_left = int(orig_w * crop_x)
manual_crop_top = int(orig_h * crop_y)
manual_crop_right = orig_w - int(orig_w * (crop_x + crop_w))
manual_crop_bottom = orig_h - int(orig_h * (crop_y + crop_h))
# Ensure we don't crop more than the image
manual_crop_left = max(0, min(manual_crop_left, orig_w - 1))
manual_crop_top = max(0, min(manual_crop_top, orig_h - 1))
manual_crop_right = max(0, min(manual_crop_right, orig_w - manual_crop_left - 1))
manual_crop_bottom = max(0, min(manual_crop_bottom, orig_h - manual_crop_top - 1))
# After manual crop, the new original dimensions are:
cropped_orig_w = orig_w - manual_crop_left - manual_crop_right
cropped_orig_h = orig_h - manual_crop_top - manual_crop_bottom
# If no custom width/height is provided, use the cropped original dimensions
if custom_width == 0:
target_w = cropped_orig_w
target_w = target_w - (target_w % 2)
if custom_height == 0:
target_h = cropped_orig_h
target_h = target_h - (target_h % 2)
scale_w, scale_h = target_w, target_h
pad_left = pad_right = pad_top = pad_bottom = 0
crop_left = crop_right = crop_top = crop_bottom = 0
if custom_width > 0 or custom_height > 0:
if resize_method == "maintain aspect ratio" or resize_method == "pad":
ratio = min(target_w / cropped_orig_w, target_h / cropped_orig_h)
scale_w = int(cropped_orig_w * ratio)
scale_h = int(cropped_orig_h * ratio)
scale_w = scale_w - (scale_w % 2)
scale_h = scale_h - (scale_h % 2)
if resize_method == "pad":
pad_x = target_w - scale_w
pad_y = target_h - scale_h
pad_left = pad_x // 2
pad_right = pad_x - pad_left
pad_top = pad_y // 2
pad_bottom = pad_y - pad_top
else:
target_w, target_h = scale_w, scale_h
elif resize_method == "crop":
ratio = max(target_w / cropped_orig_w, target_h / cropped_orig_h)
scale_w = int(cropped_orig_w * ratio)
scale_h = int(cropped_orig_h * ratio)
scale_w = scale_w - (scale_w % 2)
scale_h = scale_h - (scale_h % 2)
crop_x = scale_w - target_w
crop_y = scale_h - target_h
crop_left = crop_x // 2
crop_right = crop_x - crop_left
crop_top = crop_y // 2
crop_bottom = crop_y - crop_top
elif resize_method == "stretch to fit":
scale_w, scale_h = target_w, target_h
# Determine exact bounds based on frontend mode
if display_mode == "frames":
fr = float(frame_rate) if frame_rate > 0 else 24.0
actual_start_time = float(start_frame) / fr
actual_end_time = float(end_frame) / fr if (end_frame > 0 and end_frame > start_frame) else video_duration
else:
actual_start_time = start_time
actual_end_time = end_time if (end_time > 0 and end_time > start_time) else video_duration
if actual_end_time <= 0:
actual_end_time = float('inf') # Fallback if duration is unknown
# 2. Extract Video Frames (PyAV)
frames = []
image_tensor = None
frames_loaded = 0
if video_stream:
video_stream.thread_type = "AUTO" # Enable multithreaded decoding
# Efficiently seek backwards to the nearest keyframe
if video_stream.time_base:
seek_pts = int(actual_start_time / float(video_stream.time_base))
else:
seek_pts = int(actual_start_time * av.time_base)
container.seek(seek_pts, stream=video_stream, backward=True)
# Custom sampling to force specific framerate
frame_interval = 1.0 / float(frame_rate) if frame_rate > 0 else 1.0/24.0
expected_target_time = actual_start_time
# Pre-calculate expected frames
alloc_end_time = actual_end_time if actual_end_time != float('inf') else video_duration
expected_frames = 0
if alloc_end_time > 0:
duration_to_extract = alloc_end_time - actual_start_time
if duration_to_extract > 0:
expected_frames = int(np.ceil(duration_to_extract / frame_interval)) + 2
pbar = comfy.utils.ProgressBar(expected_frames) if expected_frames > 0 else None
for frame in container.decode(video_stream):
frame_time = frame.time
if frame_time is None:
frame_time = float(frame.pts * float(video_stream.time_base)) if frame.pts and video_stream.time_base else 0.0
if frame_time < actual_start_time:
continue
# Add a slight buffer (interval) to ensure we evaluate the boundary correctly
if frame_time > actual_end_time + frame_interval:
break
# Fix PyAV color shift by forcing proper colorspace and range conversion.
# Omit dst_colorspace so swscale defaults naturally for RGB output
# (passing it can cause the YUV matrix to be applied incorrectly).
try:
frame = frame.reformat(
format="rgb24",
src_colorspace=src_colorspace,
src_color_range=src_color_range,
dst_color_range=dst_range
)
frame_rgb = frame.to_ndarray(format='rgb24')
except Exception as e:
# Fallback: if explicit color reformat fails, use PyAV's default conversion
print(f"[LoadVideoUI] Color reformat failed, using default: {e}")
frame_rgb = frame.to_ndarray(format='rgb24')
# Apply interactive crop first
if manual_crop_left > 0 or manual_crop_top > 0 or manual_crop_right > 0 or manual_crop_bottom > 0:
frame_rgb = frame_rgb[manual_crop_top:orig_h-manual_crop_bottom, manual_crop_left:orig_w-manual_crop_right, :]
# Now resize to the scaled dimensions
if scale_w != cropped_orig_w or scale_h != cropped_orig_h:
import cv2
frame_rgb = cv2.resize(frame_rgb, (scale_w, scale_h), interpolation=cv2.INTER_AREA)
if crop_left > 0 or crop_top > 0 or crop_right > 0 or crop_bottom > 0:
frame_rgb = frame_rgb[crop_top:scale_h-crop_bottom, crop_left:scale_w-crop_right, :]
if pad_left > 0 or pad_top > 0 or pad_right > 0 or pad_bottom > 0:
frame_rgb = np.pad(frame_rgb, ((pad_top, pad_bottom), (pad_left, pad_right), (0, 0)), mode='constant', constant_values=0)
# Duplicate or skip frames perfectly based on timestamps to meet forced framerate.
# FIX: Use strictly less than (<) for actual_end_time to prevent the loop from fetching an extra +1 frame
# at the exact boundary of the duration slice!
while expected_target_time <= frame_time and expected_target_time < actual_end_time - 1e-5:
if image_tensor is None and expected_frames > 0:
# First frame: allocate the tensor
height, width = frame_rgb.shape[:2]
alloc_frames = expected_frames + 50 # Add generous buffer to prevent reallocation
try:
image_tensor = torch.zeros((alloc_frames, height, width, 3), dtype=torch.float32)
except Exception as e:
print(f"[LoadVideoUI] Pre-allocation failed, falling back to list: {e}")
expected_frames = 0 # Disable pre-allocation
if image_tensor is not None:
# Check bounds (just in case)
if frames_loaded >= image_tensor.shape[0]:
# Extend tensor if we underestimated
extension = torch.zeros((50, image_tensor.shape[1], image_tensor.shape[2], 3), dtype=torch.float32)
image_tensor = torch.cat((image_tensor, extension), dim=0)
# Insert frame with minimal memory copy directly to tensor
image_tensor[frames_loaded] = torch.from_numpy(frame_rgb).float().div_(255.0)
frames_loaded += 1
else:
# Fallback list append if pre-allocation failed
frames.append(frame_rgb)
if pbar:
pbar.update(1)
expected_target_time += frame_interval
# Convert frames to ComfyUI Image standard format [N, H, W, C], float32, range 0.0-1.0
if image_tensor is not None:
if frames_loaded > 0:
image_tensor = image_tensor[:frames_loaded]
else:
image_tensor = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
elif len(frames) > 0:
frames_np = np.array(frames, dtype=np.float32) / 255.0
image_tensor = torch.from_numpy(frames_np)
else:
# Fallback for an empty slice
image_tensor = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
# 3. Extract Audio (PyAV)
audio_dict = {"waveform": torch.zeros((1, 1, 44100)), "sample_rate": 44100} # Default empty audio
if len(container.streams.audio) > 0:
try:
audio_stream = container.streams.audio[0]
audio_stream.thread_type = "AUTO"
sample_rate = getattr(audio_stream, 'rate', 44100) or 44100
# We must seek again on the container specifically for the audio stream
if audio_stream.time_base:
seek_pts = int(actual_start_time / float(audio_stream.time_base))
else:
seek_pts = int(actual_start_time * av.time_base)
container.seek(seek_pts, stream=audio_stream, backward=True)
# Resample to standard float planar format (fltp)
resampler = av.AudioResampler(format='fltp')
audio_data = []
first_frame_time = None
for frame in container.decode(audio_stream):
frame_time = frame.time
if frame_time is None:
frame_time = float(frame.pts * float(audio_stream.time_base)) if frame.pts and audio_stream.time_base else 0.0
# Give a small 1-second buffer to ensure we catch end frames
if frame_time > actual_end_time + 1.0:
break
if first_frame_time is None:
first_frame_time = frame_time
resampled_frames = resampler.resample(frame)
for r_frame in resampled_frames:
audio_data.append(r_frame.to_ndarray())
if audio_data:
# Concatenate all frames horizontally along the sample axis
waveform_np = np.concatenate(audio_data, axis=1)
waveform = torch.from_numpy(waveform_np).float()
if first_frame_time is None:
first_frame_time = 0.0
# Calculate exact slice points to trim precisely
offset_sec = max(0.0, actual_start_time - first_frame_time)
start_sample = int(offset_sec * sample_rate)
duration_sec_audio = actual_end_time - actual_start_time
end_sample = start_sample + int(duration_sec_audio * sample_rate)
# Trim array bounds properly
if end_sample > start_sample:
waveform = waveform[:, start_sample:end_sample]
else:
waveform = waveform[:, start_sample:]
# Expand to ComfyUI Audio standard [batch_size, channels, samples]
waveform = waveform.unsqueeze(0)
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
except Exception as e:
# Catch gracefully without breaking the pipeline execution
print(f"[LoadVideoUI] Audio track extraction skipped or failed: {e}")
# Always close container to free up system memory lock
container.close()
# Output accurate final duration in seconds
final_duration_sec = float(max(0.0, actual_end_time - actual_start_time))
# Accurately output the true number of extracted frames
# (Using the shape of the array provides exact 1:1 parity with the timeline's math)
frame_count = image_tensor.shape[0] if (frames_loaded > 0 or len(frames) > 0) else 0
if frame_count == 0 and final_duration_sec > 0:
# Fallback estimation only if PyAV completely failed to decode a valid chunk
calc_fr = float(frame_rate) if frame_rate > 0 else 24.0
frame_count = int(np.floor(final_duration_sec * calc_fr))
return (image_tensor, audio_dict, final_duration_sec, frame_count)