# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Shared utilities for FlashVSR inference scripts. This module provides common functions for: - IO operations (video/image loading, saving) - Data processing (tensor conversion, prompt embedding) - Tag generation from checkpoint paths - Distributed processing utilities - S3 upload utilities (optional) """ import io import os import pickle import re from configparser import ConfigParser from enum import Enum from pathlib import Path from typing import List, Optional, Tuple, Union import imageio.v3 as iio import numpy as np import torch as th from einops import rearrange from PIL import Image # ============================================================================= # S3 Upload Configuration # ============================================================================= # AWS Profile name for S3 access S3_PROFILE_NAME = "pdx-yiflu" # Default S3 bucket name S3_BUCKET_NAME = "pid" # S3 root prefix (folder structure: ///*.mp4) S3_ROOT_PREFIX = "streamlit_assets" # Default group name S3_DEFAULT_GROUP_NAME = "pid_inference" class InputType(Enum): """Enum for input types""" VIDEO_FILE = "video_file" VIDEO_FOLDER = "video_folder" IMAGE_FOLDER = "image_folder" # ============================================================================= # IO Related Functions # ============================================================================= def is_video(path: str) -> bool: """Check if path is a video file""" return os.path.isfile(path) and path.lower().endswith((".mp4", ".mov", ".avi", ".mkv")) def is_image(path: str) -> bool: """Check if path is an image file""" return os.path.isfile(path) and path.lower().endswith((".png", ".jpg", ".jpeg")) def natural_key(name: str): """Natural sort key for filenames (e.g., img_1.png, img_2.png, ..., img_10.png)""" return [int(t) if t.isdigit() else t.lower() for t in re.split(r"([0-9]+)", os.path.basename(name))] def list_images_natural(folder: str) -> List[str]: """List images in folder with natural sorting""" exts = (".png", ".jpg", ".jpeg", ".PNG", ".JPG", ".JPEG") fs = [os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(exts)] fs.sort(key=natural_key) return fs def list_videos_in_directory(directory: str) -> List[str]: """List all video files in a directory (excluding files with 'hq' in name)""" if not os.path.isdir(directory): raise ValueError(f"Not a directory: {directory}") video_files = [] for filename in sorted(os.listdir(directory)): filepath = os.path.join(directory, filename) if is_video(filepath) and "hq" not in filepath.lower(): video_files.append(filepath) return video_files def list_files_in_directory( directory: str, rank: int = 0, world_size: int = 1, include_images: bool = False, ) -> List[str]: """ List all input files in a directory, with optional data parallel sharding. Args: directory: Path to directory containing videos/images rank: Current process rank (0-indexed) world_size: Total number of processes include_images: If True, also look for image sequences in subdirectories Returns: List of file/folder paths assigned to this rank """ if not os.path.isdir(directory): raise ValueError(f"Not a directory: {directory}") files = [] for entry in sorted(os.listdir(directory)): filepath = os.path.join(directory, entry) # Check if it's a video file if is_video(filepath) and "hq" not in filepath.lower(): files.append(filepath) # Check if it's an image sequence folder elif include_images and os.path.isdir(filepath): images = list_images_natural(filepath) if images: files.append(filepath) # Data parallel sharding: each rank processes a subset if world_size > 1: total_files = len(files) files = files[rank::world_size] # Interleaved sharding print(f"[Rank {rank}/{world_size}] Assigned {len(files)}/{total_files} files") return files def detect_input_type(path: str) -> InputType: """ Detect input type from path. Args: path: Input path (file or directory) Returns: InputType enum value """ if is_video(path): return InputType.VIDEO_FILE if os.path.isdir(path): # Check if it contains videos videos = list_videos_in_directory(path) if os.path.isdir(path) else [] if videos: return InputType.VIDEO_FOLDER # Check if it contains images (image sequence) images = list_images_natural(path) if images: return InputType.IMAGE_FOLDER raise ValueError(f"Directory {path} contains neither videos nor images") raise ValueError(f"Unsupported input path: {path}") def tensor2video(frames: th.Tensor) -> List[Image.Image]: """ Convert tensor (C, T, H, W) in [-1, 1] range to list of PIL images. Args: frames: Tensor of shape (C, T, H, W) in range [-1, 1] Returns: List of PIL Image objects """ frames = rearrange(frames, "C T H W -> T H W C") frames = ((frames.float() + 1) * 127.5).clip(0, 255).cpu().numpy().astype(np.uint8) return [Image.fromarray(frame) for frame in frames] def save_video( frames: List[Image.Image], save_path: str, fps: int, quality: int = 6, ) -> None: """ Save frames as video file. Args: frames: List of PIL Image objects save_path: Output path for video file fps: Frame rate quality: Video quality (1-10) """ os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True) video_array = np.stack([np.array(f) for f in frames], axis=0) iio.imwrite(save_path, video_array, fps=fps, quality=quality) # ============================================================================= # Data Processing Functions # ============================================================================= def load_prompt_embedding( prompt_path: str, dtype=th.bfloat16, device: str = "cuda", ) -> th.Tensor: """ Load prompt embedding from file (.pkl or .pth). Args: prompt_path: Path to embedding file dtype: Target dtype device: Target device Returns: Embedding tensor of shape (1, seq_len, dim) """ if prompt_path.endswith(".pkl"): with open(prompt_path, "rb") as f: data = pickle.load(f) if isinstance(data, dict) and "embedding" in data: embedding = data["embedding"] else: embedding = data else: embedding = th.load(prompt_path, map_location=device) if isinstance(embedding, np.ndarray): embedding = th.from_numpy(embedding) embedding = embedding.to(dtype=dtype, device=device) # Ensure proper shape (1, seq_len, dim) if embedding.dim() == 2: embedding = embedding.unsqueeze(0) return embedding def pil_to_tensor(img: Image.Image, dtype=th.bfloat16, device: str = "cuda") -> th.Tensor: """ Convert PIL image to tensor in [-1, 1] range. Args: img: PIL Image object dtype: Target dtype device: Target device Returns: Tensor of shape (C, H, W) in range [-1, 1] """ t = th.from_numpy(np.asarray(img, np.uint8)).to(device=device, dtype=th.float32) t = t.permute(2, 0, 1) / 255.0 * 2.0 - 1.0 return t.to(dtype) # ============================================================================= # Fix batch PNG encoding/decoding helpers # ============================================================================= # fix_batch .pt files store images as PNG-encoded bytes to save disk space (~10x # smaller than raw float32 tensors). These helpers encode tensors to PNG bytes # for saving, and decode PNG bytes back to tensors for inference/training. def encode_tensor_as_png(tensor: th.Tensor) -> bytes: """Encode [C, H, W] or [1, C, H, W] tensor in [-1, 1] to PNG bytes. Returns raw PNG bytes that can be stored in a .pt file. """ if tensor.ndim == 4: tensor = tensor[0] # [1, C, H, W] -> [C, H, W] arr = ((tensor.float().clamp(-1, 1) + 1.0) * 127.5).permute(1, 2, 0).cpu().numpy().astype(np.uint8) pil_img = Image.fromarray(arr) buf = io.BytesIO() pil_img.save(buf, format="PNG") return buf.getvalue() def decode_image_bytes_to_tensor(png_bytes: bytes, device: str = "cpu") -> th.Tensor: """Decode image bytes (PNG/JPG/etc) to [1, C, H, W] float32 tensor in [-1, 1]. Inverse of encode_tensor_as_png(). Also works with JPEG and other PIL-supported formats. """ pil_img = Image.open(io.BytesIO(png_bytes)).convert("RGB") arr = np.array(pil_img, dtype=np.uint8) t = th.from_numpy(arr).float().permute(2, 0, 1) / 127.5 - 1.0 # [C, H, W] in [-1, 1] return t.unsqueeze(0).to(device) # [1, C, H, W] def load_fix_batch(pt_path: str, device: str = "cpu") -> dict: """Load a fix_batch .pt file, auto-detecting PNG-encoded vs raw tensor format. Handles both new format ("HQ_video_or_image") and legacy format ("image"). Always returns both "HQ_video_or_image" and "image" pointing to the same tensor so callers can use either key. Returns dict with tensors in [-1, 1] float32: "HQ_video_or_image" / "image": [1, 3, H, W] or None "LQ_video_or_image": [1, 3, H_lq, W_lq] "LQ_latent": [1, C, H, W] (optional, pre-computed VAE latent — skips VAE encode) "caption": list[str] """ data = th.load(pt_path, map_location="cpu", weights_only=False) for key in ["HQ_video_or_image", "LQ_video_or_image"]: if key not in data: continue val = data[key] if isinstance(val, bytes): # Image bytes (PNG/JPG) -> decode to tensor data[key] = decode_image_bytes_to_tensor(val, device=device) elif isinstance(val, th.Tensor): # Raw tensor (legacy format) if val.dtype == th.uint8: data[key] = val.float() / 127.5 - 1.0 data[key] = data[key].to(device) # Move LQ_latent to target device if present if "LQ_latent" in data and isinstance(data["LQ_latent"], th.Tensor): data["LQ_latent"] = data["LQ_latent"].to(device).unsqueeze(0) # Provide "image" alias for model compatibility (input_data_key="image") if "HQ_video_or_image" in data: data["image"] = data["HQ_video_or_image"] return data def largest_8n_minus_3(n: int) -> int: """ Find largest number of form 8k-3 that is <= n. This is used to satisfy the constraint: num_frames = 8k - 3 for some integer k >= 1. The sequence is: 5, 13, 21, 29, 37, 45, 53, 61, 69, 77, 85, 93, ... """ if n < 5: return 0 # 8k - 3 <= n => k <= (n + 3) / 8 k = (n + 3) // 8 return 8 * k - 3 def video_file_to_tensor( video_path: str, num_frames: int, dtype=th.bfloat16, device: str = "cuda", ) -> Tuple[th.Tensor, int]: """ Load video file and convert to tensor. Args: video_path: Path to video file num_frames: Maximum number of frames to load dtype: Target dtype device: Target device Returns: Tuple of: - Tensor of shape (1, C, T, H, W) in range [-1, 1] - FPS of the video """ import decord decord.bridge.set_bridge("torch") video_reader = decord.VideoReader(video_path) total_frames = len(video_reader) num_frames = min(num_frames, total_frames) # Adjust to satisfy num_frames = 8n - 3 num_frames = largest_8n_minus_3(num_frames) if num_frames == 0: raise RuntimeError(f"Not enough frames in {video_path}, need at least 5 frames") print(f"Loading {num_frames} frames from {video_path}") frames = video_reader.get_batch(range(num_frames)) # [T, H, W, C] frames = frames.permute(3, 0, 1, 2).unsqueeze(0) # [1, C, T, H, W] frames = frames.to(device=device) / 127.5 - 1.0 video_tensor = frames.to(dtype=dtype) # Extract FPS fps = 24 try: fps_val = video_reader.get_avg_fps() fps = int(round(fps_val)) if isinstance(fps_val, (int, float)) and fps_val > 0 else 24 except Exception: pass return video_tensor, fps def image_folder_to_tensor( folder: str, num_frames: int, scale: int = 4, dtype=th.bfloat16, device: str = "cuda", ) -> Tuple[th.Tensor, int, int]: """ Load image sequence from folder and convert to tensor. Args: folder: Path to folder containing images num_frames: Maximum number of frames to load scale: Upscaling factor for target resolution dtype: Target dtype device: Target device Returns: Tuple of: - Tensor of shape (1, C, T, tH, tW) in range [-1, 1], upscaled to target resolution - Target height - Target width """ image_paths = list_images_natural(folder) if not image_paths: raise FileNotFoundError(f"No images found in {folder}") # Get original dimensions with Image.open(image_paths[0]) as img: w0, h0 = img.size total_images = len(image_paths) num_frames = min(num_frames, total_images) # Adjust to satisfy num_frames = 8n - 3 num_frames = largest_8n_minus_3(num_frames) if num_frames == 0: raise RuntimeError(f"Not enough images in {folder}, need at least 5 images") image_paths = image_paths[:num_frames] print(f"[{os.path.basename(folder)}] Loading {num_frames} images, original size: {w0}x{h0}") # Compute target dimensions (must be multiple of 128) tW = max(128, ((w0 * scale) // 128) * 128) tH = max(128, ((h0 * scale) // 128) * 128) print(f"[{os.path.basename(folder)}] Target size: {tW}x{tH}") frames = [] for p in image_paths: with Image.open(p).convert("RGB") as img: # Resize to target dimensions img_resized = img.resize((tW, tH), Image.BICUBIC) frames.append(pil_to_tensor(img_resized, dtype, device)) video_tensor = th.stack(frames, 0).permute(1, 0, 2, 3).unsqueeze(0) # (1, C, T, H, W) return video_tensor, tH, tW # ============================================================================= # Tag Generation Functions # ============================================================================= def generate_tag_from_checkpoint( checkpoint_path: str, extra_params: Optional[dict] = None, load_ema: bool = False, ) -> str: """ Generate tag from checkpoint path and parameters. Examples: checkpoint_path = ".../flashvsr_0119_stage2_xxx_cp1/checkpoints/iter_000009790" -> base_tag = "flashvsr_0119_stage2_xxx_cp1_iter_000009790" Args: checkpoint_path: Path to model checkpoint extra_params: Dictionary of extra parameters to append to tag load_ema: Whether EMA weights are loaded Returns: Generated tag string """ # Normalize path path = checkpoint_path.rstrip("/") # Extract iter name (last component) iter_name = os.path.basename(path) # Extract experiment name (parent of checkpoints dir) parent = os.path.dirname(path) if os.path.basename(parent) == "checkpoints": experiment_name = os.path.basename(os.path.dirname(parent)) else: # Fallback: use parent directory name experiment_name = os.path.basename(parent) # Build base tag tag = f"{experiment_name}_{iter_name}" # Append extra parameters if extra_params: for key, value in extra_params.items(): if value is not None: tag += f"_{key}{value}" # Append EMA/reg suffix tag += "_ema" if load_ema else "_reg" return tag # ============================================================================= # Distributed Processing Functions # ============================================================================= def get_rank_and_world_size() -> Tuple[int, int]: """ Get rank and world_size from environment (set by torchrun). Returns: Tuple of (rank, world_size) """ rank = int(os.environ.get("LOCAL_RANK", 0)) world_size = int(os.environ.get("WORLD_SIZE", 1)) return rank, world_size def init_data_parallel() -> Tuple[int, int]: """ Initialize data parallel environment. Returns: Tuple of (rank, world_size) """ rank, world_size = get_rank_and_world_size() if world_size > 1: th.cuda.set_device(rank) print(f"[Rank {rank}/{world_size}] Using GPU {rank}") return rank, world_size def create_data_batch( lq_video: th.Tensor, vsr_embedding: th.Tensor, scale: int = 4, dtype=th.bfloat16, device: str = "cuda", ) -> dict: """ Create a data batch dictionary for inference. Args: lq_video: Low-quality video tensor (1, C, T, H, W) vsr_embedding: VSR prompt embedding tensor scale: Upscaling factor dtype: Target dtype device: Target device Returns: Data batch dictionary """ T, H, W = lq_video.shape[2:] data_batch = { "dataset_name": "video_data", "LQ_video_or_image": lq_video, "video": th.zeros((1, 3, T, H * scale, W * scale), dtype=dtype, device=device), "t5_text_embeddings": th.randn(1, 512, 4096, dtype=dtype, device=device), "vsr_predefined_embedding": vsr_embedding, "fps": th.tensor([24], dtype=th.int64, device=device), "padding_mask": th.zeros(1, 1, T, H * scale, W * scale, dtype=dtype, device=device), "LQ_video_or_image_vae_latent": th.zeros(1, device=device), "LQ_video_or_image_upscaled_vae_latent": th.zeros(1, device=device), "LQ_video_or_image_vae_latent_upscaled": th.zeros(1, device=device), "is_preprocessed": True, } return data_batch def generate_output_path( input_path: str, output_dir: str, tag: str, suffix: str = ".mp4", ) -> str: """ Generate output path for a single input file/folder. Args: input_path: Input file or folder path output_dir: Base output directory tag: Tag string for subdirectory suffix: Output file suffix Returns: Full output path """ # Get input name without extension basename = os.path.basename(input_path.rstrip("/")) name = os.path.splitext(basename)[0] if os.path.isfile(input_path) else basename # Create output directory with tag tagged_output_dir = os.path.join(output_dir, tag) os.makedirs(tagged_output_dir, exist_ok=True) return os.path.join(tagged_output_dir, f"{name}{suffix}") # ============================================================================= # S3 Upload Functions # ============================================================================= def _parse_aws_credentials( cred_path_or_profile: Union[str, Path, None] = None, ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]: """ Parse AWS credentials from file, AWS profile, or environment variables. Args: cred_path_or_profile: Path to credentials file, AWS profile name, or None Returns: Tuple of (endpoint_url, access_key, secret_key, region) """ if cred_path_or_profile: cred_path = Path(cred_path_or_profile) # Check if it's a file path if cred_path.exists() and cred_path.is_file(): import json credentials = json.load(open(cred_path)) endpoint = credentials.get("endpoint_url") access_key = credentials.get("aws_access_key_id") secret_key = credentials.get("aws_secret_access_key") region = credentials.get("region_name", None) else: # Treat as AWS profile name profile = str(cred_path_or_profile) credentials_file = Path.home() / ".aws" / "credentials" config_file = Path.home() / ".aws" / "config" # Parse credentials file credentials_parser = ConfigParser() if credentials_file.exists(): credentials_parser.read(credentials_file) else: raise FileNotFoundError(f"AWS credentials file not found: {credentials_file}") # Parse config file config_parser = ConfigParser() if config_file.exists(): config_parser.read(config_file) # Get credentials from credentials file if profile in credentials_parser: access_key = credentials_parser[profile].get("aws_access_key_id") secret_key = credentials_parser[profile].get("aws_secret_access_key") region = credentials_parser[profile].get("region") endpoint = credentials_parser[profile].get("endpoint_url") else: access_key = None secret_key = None region = None endpoint = None # If not found in credentials file, try config file if not region or not endpoint: config_section = f"profile {profile}" if profile != "default" else profile if config_section in config_parser: if not region: region = config_parser[config_section].get("region") if not endpoint: endpoint = config_parser[config_section].get("endpoint_url") else: # Load from environment variables endpoint = os.getenv("AWS_ENDPOINT_URL") access_key = os.getenv("AWS_ACCESS_KEY_ID") secret_key = os.getenv("AWS_SECRET_ACCESS_KEY") region = os.getenv("AWS_REGION") # If no endpoint specified, use AWS S3 default endpoint if not endpoint: endpoint = "https://s3.amazonaws.com" return endpoint, access_key, secret_key, region def get_s3_client(profile_name: Optional[str] = None): """ Get a boto3 S3 client. Args: profile_name: AWS profile name (defaults to S3_PROFILE_NAME) Returns: boto3 S3 client """ try: import boto3 except ImportError: raise ImportError("boto3 is required for S3 upload. Install with: pip install boto3") if profile_name is None: profile_name = S3_PROFILE_NAME endpoint, access_key, secret_key, region = _parse_aws_credentials(profile_name) kwargs = {"endpoint_url": endpoint} if access_key: kwargs["aws_access_key_id"] = access_key if secret_key: kwargs["aws_secret_access_key"] = secret_key if region: kwargs["region_name"] = region return boto3.client("s3", **kwargs) def upload_file_to_s3( local_path: str, s3_key: str, bucket_name: Optional[str] = None, s3_client=None, ) -> bool: """ Upload a single file to S3. Args: local_path: Path to local file s3_key: S3 key (path in bucket) bucket_name: S3 bucket name (defaults to S3_BUCKET_NAME) s3_client: boto3 S3 client (will create one if not provided) Returns: True if upload succeeded, False otherwise """ if bucket_name is None: bucket_name = S3_BUCKET_NAME if s3_client is None: s3_client = get_s3_client() try: s3_client.upload_file(local_path, bucket_name, s3_key) return True except Exception as e: print(f"Failed to upload {local_path} to s3://{bucket_name}/{s3_key}: {e}") return False def download_file_from_s3( s3_key: str, local_path: str, bucket_name: Optional[str] = None, s3_client=None, ) -> bool: """ Download a single file from S3. Args: s3_key: S3 key (path in bucket) local_path: Path to save the downloaded file bucket_name: S3 bucket name (defaults to S3_BUCKET_NAME) s3_client: boto3 S3 client (will create one if not provided) Returns: True if download succeeded, False otherwise """ if bucket_name is None: bucket_name = S3_BUCKET_NAME if s3_client is None: s3_client = get_s3_client() try: os.makedirs(os.path.dirname(local_path), exist_ok=True) s3_client.download_file(bucket_name, s3_key, local_path) return True except Exception as e: print(f"Failed to download s3://{bucket_name}/{s3_key} to {local_path}: {e}") return False def upload_video_to_s3( local_path: str, group_name: str, experiment_name: str, bucket_name: Optional[str] = None, s3_client=None, ) -> bool: """ Upload a video file to S3 with standard path structure. The file will be uploaded to: s3:////// Args: local_path: Path to local video file group_name: Group name (e.g., "large_motion_lq") experiment_name: Experiment name (typically the tag) bucket_name: S3 bucket name (defaults to S3_BUCKET_NAME) s3_client: boto3 S3 client (will create one if not provided) Returns: True if upload succeeded, False otherwise """ filename = os.path.basename(local_path) s3_key = f"{S3_ROOT_PREFIX}/{group_name}/{experiment_name}/{filename}" success = upload_file_to_s3(local_path, s3_key, bucket_name, s3_client) if success: print(f"Uploaded to s3://{bucket_name or S3_BUCKET_NAME}/{s3_key}") return success def upload_directory_to_s3( local_dir: str, group_name: str, experiment_name: str, bucket_name: Optional[str] = None, file_extensions: Tuple[str, ...] = (".mp4", ".png", ".jpg", ".jpeg"), ) -> Tuple[int, int]: """ Upload all matching files in a directory to S3. Args: local_dir: Path to local directory group_name: Group name experiment_name: Experiment name (typically the tag) bucket_name: S3 bucket name (defaults to S3_BUCKET_NAME) file_extensions: Tuple of file extensions to upload Returns: Tuple of (success_count, total_count) """ if bucket_name is None: bucket_name = S3_BUCKET_NAME s3_client = get_s3_client() success_count = 0 total_count = 0 for filename in os.listdir(local_dir): if filename.lower().endswith(file_extensions): total_count += 1 local_path = os.path.join(local_dir, filename) if upload_video_to_s3(local_path, group_name, experiment_name, bucket_name, s3_client): success_count += 1 return success_count, total_count def upload_directory_to_s3_parallel( local_dir: str, group_name: str, experiment_name: str, bucket_name: Optional[str] = None, file_extensions: Tuple[str, ...] = (".mp4", ".png", ".jpg", ".jpeg"), max_workers: int = 16, ) -> Tuple[int, int]: """ Upload all matching files in a directory to S3 using parallel threads. Args: local_dir: Path to local directory group_name: Group name experiment_name: Experiment name (typically the tag) bucket_name: S3 bucket name (defaults to S3_BUCKET_NAME) file_extensions: Tuple of file extensions to upload max_workers: Maximum number of parallel upload threads Returns: Tuple of (success_count, total_count) """ from concurrent.futures import ThreadPoolExecutor, as_completed if bucket_name is None: bucket_name = S3_BUCKET_NAME # Collect files to upload files_to_upload = [ os.path.join(local_dir, filename) for filename in os.listdir(local_dir) if filename.lower().endswith(file_extensions) ] total_count = len(files_to_upload) if total_count == 0: return 0, 0 def upload_single_file(local_path): """Worker function for uploading a single file.""" s3_client = get_s3_client() return upload_video_to_s3(local_path, group_name, experiment_name, bucket_name, s3_client) success_count = 0 with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(upload_single_file, f): f for f in files_to_upload} for future in as_completed(futures): if future.result(): success_count += 1 return success_count, total_count def maybe_upload_video( local_path: str, tag: str, upload: bool, group_name: Optional[str] = None, ) -> bool: """ Optionally upload a single video to S3 immediately after generation. Args: local_path: Path to the local video file tag: Experiment tag (used as experiment_name in S3) upload: Whether to upload group_name: S3 group name (defaults to S3_DEFAULT_GROUP_NAME) Returns: True if upload succeeded or was skipped, False if upload failed """ if not upload: return True if group_name is None: group_name = S3_DEFAULT_GROUP_NAME if not os.path.isfile(local_path): print(f"Video file not found: {local_path}") return False try: s3_client = get_s3_client() success = upload_video_to_s3(local_path, group_name, tag, s3_client=s3_client) return success except Exception as e: print(f"Upload failed for {local_path}: {e}") return False