Files

510 lines
17 KiB
Python

import logging
import os
import tempfile
import urllib.parse
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import scipy.io.wavfile as wavfile
import torch
from PIL import Image
class FileHandler(ABC):
"""Base class for file handling."""
@abstractmethod
def save(self, data: Any, path: str) -> str:
"""Save data to file."""
pass
@abstractmethod
def load(self, path: str) -> Any:
"""Load data from file."""
pass
class AudioFileHandler(FileHandler):
"""Handler for audio files."""
def __init__(self):
self.supported_formats = [".wav", ".mp3", ".flac", ".m4a"]
def save(
self,
audio_data: Union[Dict, torch.Tensor, np.ndarray, Tuple],
path: str,
sample_rate: Optional[int] = None,
) -> str:
"""Save audio data to file.
Args:
audio_data: Audio data in various formats
path: Output file path
sample_rate: Sample rate (required if not in audio_data)
Returns:
Path to saved file
"""
waveform, sr = self._extract_audio_data(audio_data, sample_rate)
# Ensure waveform is in correct shape
waveform = self._normalize_waveform(waveform)
# Always save as WAV format
ext = os.path.splitext(path)[1].lower()
if ext != ".wav":
path = path.rsplit(".", 1)[0] + ".wav"
logging.info(f"Audio will be saved as WAV format: {path}")
# Ensure waveform is in int16 format for WAV
if waveform.dtype != np.int16:
# Normalize to [-1, 1] range if not already
if waveform.dtype == np.float32 or waveform.dtype == np.float64:
# Clip to [-1, 1] to avoid overflow
waveform = np.clip(waveform, -1.0, 1.0)
waveform = (waveform * 32767).astype(np.int16)
else:
# Assume uint8 or other integer type
waveform = waveform.astype(np.int16)
wavfile.write(path, sr, waveform)
logging.info(f"Audio saved to {path}")
return path
def load(self, path: str) -> Tuple[np.ndarray, int]:
"""Load audio from file.
Returns:
Tuple of (waveform, sample_rate)
"""
sample_rate, waveform = wavfile.read(path)
return waveform, sample_rate
def _extract_audio_data(self, audio_data: Any, sample_rate: Optional[int] = None) -> Tuple[np.ndarray, int]:
"""Extract waveform and sample rate from various audio formats.
Handles three main sources:
1. ComfyUI LoadAudio output: {"waveform": tensor, "sample_rate": int}
2. Tuple format: (waveform, sample_rate)
3. Raw waveform with separate sample_rate
"""
if isinstance(audio_data, dict):
if "waveform" in audio_data and "sample_rate" in audio_data:
waveform = audio_data["waveform"]
sr = audio_data["sample_rate"]
# Handle ComfyUI LoadAudio format specifically
# ComfyUI returns waveform with shape [batch, channels, samples]
if isinstance(waveform, torch.Tensor):
if waveform.dim() == 3: # [batch, channels, samples]
waveform = waveform[0] # Take first batch
if waveform.dim() == 2 and waveform.shape[0] <= 2: # [channels, samples]
waveform = waveform.transpose(0, 1) # -> [samples, channels]
waveform = waveform.cpu().numpy()
else:
raise ValueError("Audio dict must contain 'waveform' and 'sample_rate'")
elif isinstance(audio_data, tuple) and len(audio_data) == 2:
waveform, sr = audio_data
if isinstance(waveform, torch.Tensor):
waveform = waveform.cpu().numpy()
elif sample_rate is not None:
waveform = audio_data
sr = sample_rate
if isinstance(waveform, torch.Tensor):
waveform = waveform.cpu().numpy()
else:
raise ValueError("Sample rate must be provided for raw audio data")
return waveform, sr
def _normalize_waveform(self, waveform: np.ndarray) -> np.ndarray:
"""Normalize waveform shape for saving.
Ensures waveform is in shape [samples, channels] or [samples] for mono.
"""
# Already converted to numpy in _extract_audio_data
# Handle different shapes
if waveform.ndim == 3: # Shouldn't happen, but handle it
waveform = waveform[0]
if waveform.ndim == 2:
# Check if it's [channels, samples] format (channels < samples typically)
if waveform.shape[0] <= 2 and waveform.shape[0] < waveform.shape[1]:
waveform = waveform.T # -> [samples, channels]
# If mono with extra dimension, squeeze it
if waveform.shape[1] == 1:
waveform = waveform.squeeze()
return waveform
def _save_with_wave(self, path: str, waveform: np.ndarray, sample_rate: int):
"""Save audio using wave module."""
import wave
with wave.open(path, "wb") as wav_file:
wav_file.setnchannels(1 if waveform.ndim == 1 else waveform.shape[-1])
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(sample_rate)
if waveform.dtype != np.int16:
waveform = (waveform * 32767).astype(np.int16)
wav_file.writeframes(waveform.tobytes())
def _load_with_wave(self, path: str) -> Tuple[np.ndarray, int]:
"""Load audio using wave module."""
import wave
with wave.open(path, "rb") as wav_file:
sample_rate = wav_file.getframerate()
frames = wav_file.readframes(wav_file.getnframes())
waveform = np.frombuffer(frames, dtype=np.int16)
if wav_file.getnchannels() > 1:
waveform = waveform.reshape(-1, wav_file.getnchannels())
return waveform, sample_rate
class ImageFileHandler(FileHandler):
"""Handler for image files."""
def __init__(self):
self.supported_formats = [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
def save(self, image_data: Union[torch.Tensor, np.ndarray, Image.Image], path: str) -> str:
"""Save image data to file.
Args:
image_data: Image data in various formats
path: Output file path
Returns:
Path to saved file
"""
if isinstance(image_data, torch.Tensor):
# Convert from tensor [H, W, C] or [C, H, W]
if image_data.dim() == 4: # [batch, ...]
image_data = image_data[0]
image_np = image_data.cpu().numpy()
# Handle channel ordering
if image_np.shape[0] in [1, 3, 4]: # [C, H, W]
image_np = np.transpose(image_np, (1, 2, 0))
# Convert to uint8
if image_np.dtype != np.uint8:
image_np = (image_np * 255).astype(np.uint8)
image = Image.fromarray(image_np.squeeze())
elif isinstance(image_data, np.ndarray):
if image_data.dtype != np.uint8:
image_data = (image_data * 255).astype(np.uint8)
image = Image.fromarray(image_data.squeeze())
elif isinstance(image_data, Image.Image):
image = image_data
else:
raise ValueError(f"Unsupported image format: {type(image_data)}")
image.save(path)
logging.info(f"Image saved to {path}")
return path
def load(self, path: str) -> Image.Image:
"""Load image from file."""
return Image.open(path)
class MaskFileHandler(ImageFileHandler):
"""Handler specifically for mask files."""
def save(self, mask_data: Union[torch.Tensor, np.ndarray], path: str) -> str:
"""Save mask data to file.
Args:
mask_data: Mask data (single channel)
path: Output file path
Returns:
Path to saved file
"""
if isinstance(mask_data, torch.Tensor):
if mask_data.dim() == 3: # [batch, H, W]
mask_data = mask_data[0]
mask_np = (mask_data.cpu().numpy() * 255).astype(np.uint8)
elif isinstance(mask_data, np.ndarray):
mask_np = (mask_data * 255).astype(np.uint8)
else:
mask_np = mask_data
mask_image = Image.fromarray(mask_np)
mask_image.save(path)
logging.info(f"Mask saved to {path}")
return path
class TempFileManager:
def __init__(self):
self.temp_files: List[str] = []
self.temp_dirs: List[str] = []
@contextmanager
def temp_file(self, suffix: str = "", prefix: str = "lightx2v_", delete: bool = True):
temp_file = tempfile.NamedTemporaryFile(suffix=suffix, prefix=prefix, delete=False)
temp_path = temp_file.name
temp_file.close()
self.temp_files.append(temp_path)
try:
yield temp_path
finally:
if delete:
self.cleanup_file(temp_path)
def create_temp_file(self, suffix: str = "", prefix: str = "lightx2v_") -> str:
with tempfile.NamedTemporaryFile(suffix=suffix, prefix=prefix, delete=False) as tmp:
temp_path = tmp.name
self.temp_files.append(temp_path)
return temp_path
def cleanup_file(self, path: str):
if path in self.temp_files:
self.temp_files.remove(path)
if os.path.exists(path):
try:
os.unlink(path)
logging.debug(f"Cleaned up temp file: {path}")
except Exception as e:
logging.warning(f"Failed to clean up {path}: {e}")
@contextmanager
def temp_dir(self, suffix: str = "", prefix: str = "lightx2v_", delete: bool = True):
temp_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix)
self.temp_dirs.append(temp_dir)
try:
yield temp_dir
finally:
if delete:
self.cleanup_dir(temp_dir)
def create_temp_dir(self, suffix: str = "", prefix: str = "lightx2v_") -> str:
temp_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix)
self.temp_dirs.append(temp_dir)
return temp_dir
def cleanup_dir(self, path: str):
if path in self.temp_dirs:
self.temp_dirs.remove(path)
if os.path.exists(path):
try:
import shutil
shutil.rmtree(path)
logging.debug(f"Cleaned up temp directory: {path}")
except Exception as e:
logging.warning(f"Failed to clean up directory {path}: {e}")
def cleanup_all(self):
for temp_file in self.temp_files[:]:
self.cleanup_file(temp_file)
self.temp_files.clear()
for temp_dir in self.temp_dirs[:]:
self.cleanup_dir(temp_dir)
self.temp_dirs.clear()
def __del__(self):
self.cleanup_all()
class HTTPFileDownloader:
"""Handler for downloading files from HTTP/HTTPS URLs."""
def __init__(self):
self.temp_manager = TempFileManager()
@staticmethod
def is_url(path: str) -> bool:
"""Check if the path is an HTTP/HTTPS URL.
Args:
path: Path to check
Returns:
True if path is HTTP/HTTPS URL, False otherwise
"""
if not path:
return False
parsed = urllib.parse.urlparse(path)
return parsed.scheme in ("http", "https")
def download_to_input(self, url: str, filename: Optional[str] = None) -> str:
"""Download file from URL to ComfyUI input directory.
Args:
url: URL to download from
filename: Target filename (optional, will be generated if not provided)
Returns:
Absolute path to downloaded file
Raises:
Exception: If download fails
"""
try:
import requests
except ImportError:
logging.error("requests module not available for HTTP download")
raise ImportError("requests module is required for HTTP download")
# Generate filename if not provided
if not filename:
# Extract filename from URL
parsed_url = urllib.parse.urlparse(url)
url_filename = os.path.basename(parsed_url.path)
if url_filename:
# Use URL filename but add a unique suffix to avoid conflicts
import uuid
name, ext = os.path.splitext(url_filename)
filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
else:
# Generate a completely new filename
import uuid
filename = f"downloaded_{uuid.uuid4().hex[:8]}"
# Get input directory
input_dir = ComfyUIFileResolver.get_input_directory()
full_path = os.path.join(input_dir, filename)
# Create directory if needed
os.makedirs(input_dir, exist_ok=True)
try:
logging.info(f"Downloading file from {url} to {full_path}")
# Download with streaming to handle large files
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
# Get total size for progress reporting
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
# Write to file
with open(full_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
# Log progress for large files
if total_size > 0 and total_size > 1024 * 1024: # > 1MB
progress = (downloaded_size / total_size) * 100
if downloaded_size % (1024 * 1024) == 0: # Log every 1MB
logging.debug(f"Download progress: {progress:.1f}%")
logging.info(f"Successfully downloaded file to {full_path}")
return full_path
except requests.exceptions.RequestException as e:
# Clean up partial file if download failed
if os.path.exists(full_path):
try:
os.unlink(full_path)
except Exception:
pass
logging.error(f"Failed to download file from {url}: {e}")
raise Exception(f"Failed to download file from {url}: {e}")
except Exception as e:
# Clean up partial file if download failed
if os.path.exists(full_path):
try:
os.unlink(full_path)
except Exception:
pass
logging.error(f"Error downloading file: {e}")
raise
def download_if_url(self, path: str, prefix: str = "downloaded") -> str:
"""Download file if path is URL, otherwise return path as-is.
Args:
path: Path or URL to process
prefix: Prefix for downloaded filename
Returns:
Absolute path to local file
"""
if self.is_url(path):
# Generate filename with prefix
import uuid
ext = os.path.splitext(urllib.parse.urlparse(path).path)[1] or ".bin"
filename = f"{prefix}_{uuid.uuid4().hex[:8]}{ext}"
return self.download_to_input(path, filename)
return path
class ComfyUIFileResolver:
"""Resolve file paths for ComfyUI input/output directories."""
@staticmethod
def get_input_directory() -> str:
"""Get ComfyUI input directory."""
try:
import folder_paths
return folder_paths.get_input_directory()
except ImportError:
# Fallback if not in ComfyUI environment
return "input"
@staticmethod
def resolve_input_path(filename: str) -> str:
"""Resolve a filename to full path in input directory."""
if os.path.isabs(filename):
return filename
input_dir = ComfyUIFileResolver.get_input_directory()
return os.path.join(input_dir, filename)
@staticmethod
def save_to_input(data: Any, filename: str, handler: FileHandler) -> str:
"""Save data to ComfyUI input directory.
Args:
data: Data to save
filename: Target filename
handler: File handler to use
Returns:
Full path to saved file
"""
input_dir = ComfyUIFileResolver.get_input_directory()
full_path = os.path.join(input_dir, filename)
# Create directory if needed
os.makedirs(input_dir, exist_ok=True)
return handler.save(data, full_path)