Files
Fredthearboro 8a3bec8391 Vidia Open Studio Nodes: V3 ComfyUI custom node pack
VidiaVideoSaver: watermark + ffmpeg-mux + R2 upload + backend notify.
V3 API (io.ComfyNode, ComfyExtension, comfy_entrypoint).
not_idempotent=True forces re-execution (fixes upload/callback caching bug).
Remote log shipping opt-in via VIDIA_REMOTE_LOGGING env.
Env-only config (no hardcoded secrets).
2026-06-19 18:47:59 -05:00

486 lines
20 KiB
Python

"""
VidiaVideoSaver -- ComfyUI V3 custom node for Vidia Open Studio
(node pack: comfyui-vidia-open-studio-node).
Orchestrates video creation, audio processing, and cloud upload.
Requires a ComfyUI build that ships the V3 node API (comfy_api.latest).
Target: ComfyUI >= v0.24. The V1 NODE_CLASS_MAPPINGS registration was removed.
"""
import os
import json
import datetime
import torch
from PIL import Image, ExifTags
from PIL.PngImagePlugin import PngInfo
import folder_paths
from comfy_api.latest import io
from .config import HAS_BOTO3
from .utils import ffmpeg_path, tensor_to_bytes, VideoProcessingLogger
from .video_processing import (
get_video_formats,
apply_format_widgets,
ffmpeg_process,
resample_frames_ffmpeg,
process_watermark_frames,
process_corner_watermark,
VideoPathManager,
VideoProcessingState,
)
from .audio_processing import process_audio, mux_audio_into_video
from .upload import upload_to_cloudflare_s3
# Custom output type. The string is kept identical to the V1 node so existing
# workflows that wire VidiaVideoSaver as the [out] terminal still resolve.
VIDIA_FILENAMES = io.Custom("VIDIA_FILENAMES")
class VidiaVideoSaver(io.ComfyNode):
"""Saves generated frames to a video file with optional watermarks and audio,
uploads to Cloudflare R2, and notifies the Vidia backend.
Pipeline:
1. Preprocess and validate input frames
2. Generate metadata from prompt information
3. Save the first frame with metadata
4. Process frames (watermarks, dimension alignment)
5. Create the video file via ffmpeg using format JSON settings
6. Process and concatenate audio to match video segments
7. Upload to Cloudflare R2
8. Notify backend of completion
"""
@classmethod
def define_schema(cls):
ffmpeg_formats = get_video_formats()
# VHS-style format entries are either "video/<name>" or
# ["video/<name>", [widgets]]. V3 combos take flat option strings, so
# flatten to the name. The shipped h264-mp4 format declares no widgets;
# a widget-bearing format would need DynamicCombo to inject sub-inputs
# under V3 (no shipped format does today).
format_options = [f[0] if isinstance(f, list) else f for f in ffmpeg_formats]
return io.Schema(
node_id="VidiaVideoSaver",
display_name="Vidia Video Saver",
category="Vidia",
description=(
"Saves video frames to a file with optional watermarks and audio, "
"uploads to Cloudflare R2, and notifies the backend."
),
is_output_node=True,
# Side effects (R2 upload + backend callback) must run every time;
# never serve this node from cache.
not_idempotent=True,
inputs=[
io.Image.Input("images"),
io.Float.Input("frame_rate", default=8, min=1, step=1),
io.String.Input("filename_prefix", default="Vidia"),
io.Combo.Input("format", options=["image/gif", "image/webp"] + format_options),
io.Boolean.Input("save_output", default=True),
io.Audio.Input("audio", optional=True, lazy=True),
io.Image.Input("append_watermark", optional=True),
io.Audio.Input("watermark_audio", optional=True, lazy=True),
io.Image.Input("corner_watermark", optional=True),
io.Mask.Input("corner_watermark_mask", optional=True),
io.Float.Input("corner_opacity", default=0.4, min=0.0, max=1.0, step=0.1),
io.Float.Input("append_watermark_source_fps", default=0, min=0, max=120, step=1),
],
outputs=[
VIDIA_FILENAMES.Output("Filenames"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
)
@classmethod
def validate_inputs(cls, images, format, **kwargs):
if "/" not in format:
return "format must be of the form <type>/<ext> (e.g. video/h264-mp4)"
return True
@classmethod
def check_lazy_status(cls, images, audio=None, watermark_audio=None, **kwargs):
"""Tell the executor which lazy audio inputs are still needed."""
needed = []
if audio is None:
needed.append("audio")
if watermark_audio is None:
needed.append("watermark_audio")
return needed
# ------------------------------------------------------------------
# Main entry point
# ------------------------------------------------------------------
@classmethod
def execute(
cls,
images,
frame_rate,
filename_prefix,
format,
save_output,
audio=None,
append_watermark=None,
watermark_audio=None,
corner_watermark=None,
corner_watermark_mask=None,
corner_opacity=0.4,
append_watermark_source_fps=0,
**kwargs,
):
logger = VideoProcessingLogger("VidiaVideoSaver")
path_manager = VideoPathManager(
filename_prefix,
folder_paths.get_output_directory() if save_output else folder_paths.get_temp_directory(),
save_output,
)
state = VideoProcessingState()
logger.info(
f"Starting video processing with "
f"{len(images) if isinstance(images, list) else images.size(0)} frames"
)
images = cls._preprocess_inputs(images)
if images is None:
return io.NodeOutput((save_output, []), ui={"error": "No input frames"})
video_metadata = cls._prepare_metadata(cls.hidden.prompt, cls.hidden.extra_pnginfo)
cls._save_first_frame(images[0], video_metadata, path_manager, state, logger)
format_type, format_ext = format.split("/")
logger.info(f"Using format: {format_type}/{format_ext}")
if format_type == "image":
cls._process_image_format(images, format_ext, frame_rate, path_manager, state, logger)
else:
# Video format via ffmpeg
if ffmpeg_path is None:
raise RuntimeError(
"ffmpeg is required for video outputs and could not be found."
)
video_format = apply_format_widgets(format_ext, kwargs)
user_frames_count = len(images)
state.processed_frames = user_frames_count
processed_frames = cls._prepare_frames(
images, video_format, corner_watermark,
corner_watermark_mask, corner_opacity, append_watermark,
frame_rate, append_watermark_source_fps, logger, state,
)
watermark_frames_count = (
len(processed_frames) - user_frames_count if state.has_append_watermark else 0
)
logger.info(
f"Frame counts - User: {user_frames_count}, "
f"Append Watermark: {watermark_frames_count}, Total: {len(processed_frames)}"
)
success = cls._create_video_file(
processed_frames, video_format, video_metadata, frame_rate,
path_manager, state, logger,
)
if success:
audio_result = process_audio(
audio, watermark_audio, video_format, frame_rate,
user_frames_count=user_frames_count,
watermark_frames_count=watermark_frames_count,
logger=logger,
)
# audio_result is False/True, or (combined_audio_path, temp_files)
if isinstance(audio_result, tuple):
combined_audio_path, temp_files = audio_result
video_path = path_manager.get_output_files()[-1]
temp_video_path = path_manager.get_temp_video_path(
video_format.get("extension", "mp4")
)
mux_success = mux_audio_into_video(
video_path, combined_audio_path, temp_video_path, logger
)
if mux_success:
state.audio_processed = True
for tf in temp_files:
if os.path.exists(tf):
try:
os.unlink(tf)
except OSError:
pass
# Upload to Cloudflare
try:
if HAS_BOTO3:
upload_result = upload_to_cloudflare_s3(
format, path_manager.get_output_files()[-1],
filename_prefix, logger, state,
)
else:
logger.error("boto3 is not installed, cannot upload to S3.")
return io.NodeOutput(
(save_output, path_manager.get_output_files()),
ui={"error": "boto3 is not installed. Video saved locally."},
)
if upload_result.get("success", False):
return io.NodeOutput(
(save_output, path_manager.get_output_files()),
ui={"response": upload_result},
)
else:
error_msg = upload_result.get('error', 'Unknown error')
if isinstance(error_msg, list):
error_msg = ''.join(error_msg)
return io.NodeOutput(
(save_output, path_manager.get_output_files()),
ui={"error": f"Upload failed but video saved locally: {error_msg}"},
)
except Exception as e:
logger.error(f"Upload failed: {str(e)}")
return io.NodeOutput(
(save_output, path_manager.get_output_files()),
ui={"error": f"Upload failed but video saved locally: {str(e)}"},
)
return io.NodeOutput(
(save_output, path_manager.get_output_files()),
ui={"success": "Video processing completed"},
)
# ------------------------------------------------------------------
# Helpers (classmethods; V3 nodes hold no instance state)
# ------------------------------------------------------------------
@classmethod
def _preprocess_inputs(cls, images):
"""Validate and preprocess input images. Returns None if empty."""
if isinstance(images, dict):
images = images['samples']
if isinstance(images, torch.Tensor) and images.size(0) == 0:
return None
return images
@classmethod
def _prepare_metadata(cls, prompt, extra_pnginfo):
video_metadata = {}
if prompt is not None:
video_metadata["prompt"] = json.dumps(prompt)
if extra_pnginfo is not None:
for x in extra_pnginfo:
video_metadata[x] = extra_pnginfo[x]
video_metadata["CreationTime"] = datetime.datetime.now().isoformat(" ")[:19]
return video_metadata
@classmethod
def _save_first_frame(cls, first_frame, video_metadata, path_manager, state, logger):
metadata = PngInfo()
for key, value in video_metadata.items():
if isinstance(value, str):
metadata.add_text(key, value)
else:
metadata.add_text(key, json.dumps(value))
first_image_path = path_manager.get_first_frame_path()
logger.info(f"Saving first frame to {first_image_path}")
try:
Image.fromarray(tensor_to_bytes(first_frame)).save(
first_image_path, pnginfo=metadata, compress_level=4,
)
path_manager.add_output_file(first_image_path)
state.saved_first_frame = True
except Exception as e:
logger.error(f"Failed to save first frame: {str(e)}")
state.add_error(f"First frame save failed: {str(e)}")
@classmethod
def _process_image_format(cls, images, format_ext, frame_rate, path_manager, state, logger):
image_kwargs = {}
if format_ext == "gif":
image_kwargs['disposal'] = 2
if format_ext == "webp":
exif = Image.Exif()
exif[ExifTags.IFD.Exif] = {36867: datetime.datetime.now().isoformat(" ")[:19]}
image_kwargs['exif'] = exif
file_path = path_manager.get_animation_path(format_ext)
logger.info(f"Saving animation to {file_path}")
try:
frames = map(lambda x: Image.fromarray(tensor_to_bytes(x)), images)
next_frame = next(frames)
next_frame.save(
file_path, format=format_ext.upper(), save_all=True,
append_images=frames, duration=round(1000 / frame_rate),
loop=0, compress_level=4, **image_kwargs,
)
path_manager.add_output_file(file_path)
state.video_created = True
except Exception as e:
logger.error(f"Failed to create {format_ext} animation: {str(e)}")
state.add_error(f"Animation creation failed: {str(e)}")
@classmethod
def _prepare_frames(cls, images, video_format, corner_watermark=None,
corner_watermark_mask=None, corner_opacity=0.4,
append_watermark=None, frame_rate=None,
append_watermark_source_fps=0, logger=None, state=None):
"""Prepare frames: dimension alignment, watermarks, resampling."""
if isinstance(images, torch.Tensor):
images = list(images)
first_frame = images[0]
# Dimension alignment
dim_alignment = video_format.get("dim_alignment", 2)
if (first_frame.shape[1] % dim_alignment) or (first_frame.shape[0] % dim_alignment):
to_pad = (-first_frame.shape[1] % dim_alignment,
-first_frame.shape[0] % dim_alignment)
padding = (to_pad[0] // 2, to_pad[0] - to_pad[0] // 2,
to_pad[1] // 2, to_pad[1] - to_pad[1] // 2)
padfunc = torch.nn.ReplicationPad2d(padding)
def pad(image):
return padfunc(image.permute(2, 0, 1).to(dtype=torch.float32)).permute(1, 2, 0)
logger.warn("Frames not aligned to required dimensions; padding applied")
images = list(map(pad, images))
# Corner watermark (only on user frames)
if corner_watermark is not None and corner_watermark_mask is not None:
logger.info("Applying corner watermark")
if isinstance(corner_watermark, torch.Tensor) and corner_watermark.ndim == 3:
corner_watermark = [corner_watermark] * len(images)
if isinstance(corner_watermark_mask, torch.Tensor) and corner_watermark_mask.ndim == 2:
corner_watermark_mask = [corner_watermark_mask] * len(images)
processed_wm, processed_mask, (x_offset, y_offset) = process_corner_watermark(
corner_watermark, corner_watermark_mask, first_frame.shape, corner_opacity, logger=logger,
)
if processed_wm and processed_mask:
for i in range(len(images)):
try:
frame = images[i]
wm = processed_wm[i % len(processed_wm)]
mask = processed_mask[i % len(processed_mask)]
mask_3d = mask.unsqueeze(-1).repeat(1, 1, 3)
blended = frame.clone()
h, w = wm.shape[:2]
y_start, x_start = max(0, y_offset), max(0, x_offset)
y_end = min(frame.shape[0], y_offset + h)
x_end = min(frame.shape[1], x_offset + w)
h_slice, w_slice = y_end - y_start, x_end - x_start
if h_slice <= 0 or w_slice <= 0:
continue
wm_y_start = -y_offset if y_offset < 0 else 0
wm_x_start = -x_offset if x_offset < 0 else 0
frame_area = frame[y_start:y_end, x_start:x_end]
mask_area = mask_3d[wm_y_start:wm_y_start + h_slice, wm_x_start:wm_x_start + w_slice]
wm_area = wm[wm_y_start:wm_y_start + h_slice, wm_x_start:wm_x_start + w_slice] * corner_opacity
if frame_area.shape != mask_area.shape or frame_area.shape != wm_area.shape:
continue
blended[y_start:y_end, x_start:x_end] = frame_area * (1.0 - mask_area) + wm_area * mask_area
images[i] = blended
except Exception as e:
logger.error(f"Error applying watermark to frame {i}: {e}")
state.has_corner_watermark = True
# Append watermark
watermark_frames = []
if append_watermark is not None:
logger.info("Processing append watermark")
if isinstance(append_watermark, torch.Tensor) and append_watermark.ndim == 3:
append_watermark = [append_watermark] * len(images)
watermark_frames = process_watermark_frames(append_watermark, first_frame.shape)
if append_watermark_source_fps > 0 and frame_rate > 0 and abs(append_watermark_source_fps - frame_rate) > 0.01:
watermark_frames = resample_frames_ffmpeg(watermark_frames, append_watermark_source_fps, frame_rate, logger)
state.has_append_watermark = True
if watermark_frames:
images.extend(watermark_frames)
return images
@classmethod
def _create_video_file(cls, processed_frames, video_format, video_metadata, frame_rate,
path_manager, state, logger):
"""Create the video file with ffmpeg, using main_pass settings from the format JSON."""
format_ext = video_format.get("extension", "mp4")
file_path = path_manager.get_video_path(format_ext)
logger.info(f"Creating video at {file_path}")
try:
if isinstance(processed_frames, torch.Tensor):
processed_frames = list(processed_frames)
env = os.environ.copy()
# Build input args
pix_fmt_in = "rgb24" if processed_frames[0].shape[2] == 3 else "rgba"
args = [
ffmpeg_path,
"-y",
"-v", "error",
"-f", "rawvideo",
"-pix_fmt", pix_fmt_in,
"-s", f"{processed_frames[0].shape[1]}x{processed_frames[0].shape[0]}",
"-r", str(frame_rate),
"-i", "-",
]
# Apply main_pass settings from the format JSON
main_pass = video_format.get("main_pass", [])
if main_pass:
args.extend(main_pass)
logger.info(f"Applying format main_pass args: {main_pass}")
else:
args.extend(["-c:v", "libx264", "-preset", "medium", "-crf", "23", "-pix_fmt", "yuv420p"])
logger.warn("No main_pass in format JSON, using default h264 settings")
logger.info(f"ffmpeg command: {' '.join(args)} {file_path}")
proc = ffmpeg_process(args, video_format, video_metadata, file_path, env, logger)
next(proc)
for frame in processed_frames:
proc.send(tensor_to_bytes(frame).tobytes())
total_frames_output = proc.send(None)
logger.info(f"Finished encoding with {total_frames_output} frames")
if os.path.exists(file_path):
file_size = os.path.getsize(file_path)
if file_size < 1000:
logger.error(f"Video file is too small, likely corrupt: {file_path} ({file_size} bytes)")
state.add_error(f"Video file too small: {file_size} bytes")
return False
else:
logger.info(f"Created video: {file_path} ({file_size} bytes)")
path_manager.add_output_file(file_path)
state.video_created = True
return True
else:
logger.error(f"Video file not created: {file_path}")
state.add_error("Video file not created")
return False
except Exception as e:
logger.error(f"Failed to create video: {str(e)}")
state.add_error(f"Video creation failed: {str(e)}")
return False