Signed-off-by: Ian Eaves <ian.k.eaves@gmail.com> Signed-off-by: Samit <285365963@qq.com> Co-authored-by: Samit <285365963@qq.com> Co-authored-by: Gao Han <gaohan19@huawei.com>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Image/tensor format helpers.
|
|
|
|
The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@dougbtv).
|
|
Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed under the MIT License.
|
|
"""
|
|
|
|
import base64
|
|
import mimetypes
|
|
from fractions import Fraction
|
|
from io import BytesIO
|
|
|
|
import av
|
|
import numpy as np
|
|
import torch
|
|
from av.audio.frame import AudioFrame
|
|
from av.audio.resampler import AudioResampler
|
|
from av.video.frame import VideoFrame
|
|
from comfy_api.input import AudioInput, VideoInput
|
|
from comfy_api.latest import InputImpl, Types
|
|
from comfy_extras import nodes_audio
|
|
from PIL import Image
|
|
|
|
from .logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def base64_to_image_tensor(base64_str: str, mode: str = "RGB") -> torch.Tensor:
|
|
"""
|
|
Convert base64-encoded image to ComfyUI image tensor.
|
|
|
|
Args:
|
|
base64_str: Base64-encoded image string
|
|
mode: PIL image mode (default RGB for transparency support)
|
|
|
|
Returns:
|
|
torch.Tensor with shape (1, H, W, C) in float32 [0, 1] range
|
|
|
|
Raises:
|
|
ValueError: If base64 string is invalid or image cannot be decoded
|
|
"""
|
|
if base64_str.startswith("data:image"):
|
|
_, base64_str = base64_str.split(",", 1)
|
|
|
|
try:
|
|
# Decode base64 to bytes
|
|
image_bytes = base64.b64decode(base64_str)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid base64 string: {e}")
|
|
|
|
# Create BytesIO object for PIL
|
|
image_bytesio = BytesIO(image_bytes)
|
|
|
|
# Open with PIL and convert to desired mode
|
|
try:
|
|
pil_image = Image.open(image_bytesio)
|
|
pil_image = pil_image.convert(mode)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to open image: {e}")
|
|
|
|
image_array = np.asarray(pil_image).astype(np.float32) / 255.0
|
|
image_tensor = torch.from_numpy(image_array)
|
|
return image_tensor
|
|
|
|
|
|
def image_tensor_to_png_bytes(tensor: torch.Tensor, filename: str = "image.png") -> BytesIO:
|
|
"""
|
|
Convert ComfyUI image tensor to PNG BytesIO for multipart upload.
|
|
|
|
This function converts a ComfyUI IMAGE tensor to a PNG-encoded BytesIO object
|
|
suitable for multipart/form-data upload. The BytesIO object has its .name
|
|
attribute set, which is required by aiohttp for file uploads.
|
|
|
|
Args:
|
|
tensor: ComfyUI IMAGE tensor with shape (B, H, W, C), dtype float32, range [0, 1]
|
|
filename: Name attribute to set on BytesIO (default: "image.png")
|
|
|
|
Returns:
|
|
BytesIO object containing PNG-encoded image with .name attribute set
|
|
|
|
Raises:
|
|
ValueError: If tensor format is invalid (not 4D, wrong dtype, etc.)
|
|
"""
|
|
if tensor.ndim != 4:
|
|
raise ValueError(f"Expected 4D tensor with shape (B, H, W, C), got {tensor.ndim}D tensor")
|
|
|
|
image_tensor = tensor[0] # Shape: (H, W, C)
|
|
image_np = (image_tensor.cpu().numpy() * 255.0).astype(np.uint8)
|
|
pil_image = Image.fromarray(image_np)
|
|
|
|
# Save to BytesIO as image file
|
|
img_bytes = BytesIO()
|
|
# Set name attribute (required for multipart upload and mimetype detection)
|
|
img_bytes.name = filename
|
|
try:
|
|
pil_image.save(img_bytes)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to save image as file: {e}")
|
|
|
|
# Reset position to beginning
|
|
img_bytes.seek(0)
|
|
|
|
return img_bytes
|
|
|
|
|
|
def image_tensor_to_base64(tensor: torch.Tensor, filename: str = "image.png") -> str:
|
|
"""
|
|
Convert ComfyUI image tensor to base64-encoded image string.
|
|
|
|
Args:
|
|
tensor: ComfyUI IMAGE tensor with shape (B, H, W, C), dtype float32, range [0, 1]
|
|
filename: Name attribute to set on BytesIO (default: "image.png")
|
|
format: File format of the output image file buffer (default: "PNG")
|
|
|
|
Returns:
|
|
Base64-encoded image string
|
|
|
|
Raises:
|
|
ValueError: If tensor format is invalid (not 4D, wrong dtype, etc.)
|
|
"""
|
|
img_bytes = image_tensor_to_png_bytes(tensor, filename)
|
|
img_bytes.seek(0)
|
|
byte_data = img_bytes.read()
|
|
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
|
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
return f"data:{mime_type};base64,{base64_str}"
|
|
|
|
|
|
def video_to_bytes(video: VideoInput, filename: str = "video.mp4") -> BytesIO:
|
|
output_buffer = BytesIO()
|
|
output_buffer.name = filename
|
|
video.save_to(output_buffer)
|
|
output_buffer.seek(0)
|
|
return output_buffer
|
|
|
|
|
|
def video_to_base64(video: VideoInput, filename: str = "video.mp4") -> str:
|
|
video_buffer = video_to_bytes(video, filename)
|
|
video_buffer.seek(0)
|
|
byte_data = video_buffer.read()
|
|
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
|
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
return f"data:{mime_type};base64,{base64_str}"
|
|
|
|
|
|
def bytes_to_video(video_bytes: bytes) -> VideoInput:
|
|
video_buffer = BytesIO(video_bytes)
|
|
|
|
try:
|
|
with av.open(video_buffer, mode="r") as container:
|
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
|
if video_stream is None:
|
|
raise ValueError("No video stream found in decoded payload.")
|
|
|
|
frames = []
|
|
for frame in container.decode(video_stream):
|
|
if not isinstance(frame, VideoFrame):
|
|
continue
|
|
image = frame.to_ndarray(format="rgb24")
|
|
frames.append(torch.from_numpy(image).float() / 255.0)
|
|
|
|
if len(frames) == 0:
|
|
raise ValueError("No video frames found in decoded payload.")
|
|
|
|
images = torch.stack(frames, dim=0)
|
|
frame_rate = Fraction(video_stream.average_rate) if video_stream.average_rate else Fraction(1)
|
|
|
|
audio: AudioInput | None = None
|
|
if len(container.streams.audio):
|
|
audio_stream = container.streams.audio[-1]
|
|
audio_frames = []
|
|
resampler = AudioResampler(format="fltp")
|
|
for frame in container.decode(audio_stream):
|
|
if not isinstance(frame, AudioFrame):
|
|
continue
|
|
resampled = resampler.resample(frame)
|
|
if not isinstance(resampled, list):
|
|
resampled = [resampled]
|
|
for audio_frame in resampled:
|
|
if audio_frame is not None:
|
|
audio_frames.append(audio_frame.to_ndarray())
|
|
|
|
if len(audio_frames) > 0:
|
|
audio_data = np.concatenate(audio_frames, axis=1)
|
|
sample_rate = int(audio_stream.sample_rate) if audio_stream.sample_rate else 1
|
|
audio = {
|
|
"waveform": torch.from_numpy(audio_data).unsqueeze(0),
|
|
"sample_rate": sample_rate,
|
|
}
|
|
|
|
components = Types.VideoComponents(
|
|
images=images,
|
|
frame_rate=frame_rate,
|
|
audio=audio,
|
|
metadata=container.metadata if container.metadata else None,
|
|
)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to decode video: {e}")
|
|
|
|
return InputImpl.VideoFromComponents(components)
|
|
|
|
|
|
def base64_to_video(base64_str: str) -> VideoInput:
|
|
if base64_str.startswith("data:video"):
|
|
_, base64_str = base64_str.split(",", 1)
|
|
|
|
try:
|
|
video_bytes = base64.b64decode(base64_str)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid base64 string: {e}")
|
|
|
|
return bytes_to_video(video_bytes)
|
|
|
|
|
|
def audio_to_bytes(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> BytesIO:
|
|
waveform = audio["waveform"][0] # Shape: (C, T)
|
|
sample_rate = audio["sample_rate"]
|
|
format = filename.rsplit(".", maxsplit=1)[1]
|
|
layout = "mono" if waveform.shape[0] == 1 else "stereo"
|
|
|
|
output_buffer = BytesIO()
|
|
output_buffer.name = filename
|
|
output_container = av.open(output_buffer, mode="w", format=format)
|
|
if format == "opus":
|
|
out_stream = output_container.add_stream("libopus", rate=sample_rate, layout=layout)
|
|
if quality == "64k":
|
|
out_stream.bit_rate = 64000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "96k":
|
|
out_stream.bit_rate = 96000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "128k":
|
|
out_stream.bit_rate = 128000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "192k":
|
|
out_stream.bit_rate = 192000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "320k":
|
|
out_stream.bit_rate = 320000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif format == "mp3":
|
|
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate, layout=layout)
|
|
if quality == "V0":
|
|
out_stream.codec_context.qscale = 1 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "128k":
|
|
out_stream.bit_rate = 128000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
elif quality == "320k":
|
|
out_stream.bit_rate = 320000 # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
else: # format == "flac":
|
|
out_stream = output_container.add_stream("flac", rate=sample_rate, layout=layout)
|
|
|
|
frame = av.AudioFrame.from_ndarray(
|
|
waveform.movedim(0, 1).reshape(1, -1).float().numpy(),
|
|
format="flt",
|
|
layout=layout,
|
|
)
|
|
frame.sample_rate = sample_rate
|
|
frame.pts = 0
|
|
output_container.mux(out_stream.encode(frame)) # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
# Flush encoder
|
|
output_container.mux(out_stream.encode(None)) # type: ignore # copy from ComfyUI comfy_api/latest/_ui.py
|
|
output_container.close()
|
|
output_buffer.seek(0)
|
|
|
|
return output_buffer
|
|
|
|
|
|
def audio_to_base64(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> str:
|
|
audio_buffer = audio_to_bytes(audio, filename, quality)
|
|
audio_buffer.seek(0)
|
|
byte_data = audio_buffer.read()
|
|
base64_str = base64.b64encode(byte_data).decode("utf-8")
|
|
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
return f"data:{mime_type};base64,{base64_str}"
|
|
|
|
|
|
def bytes_to_audio(audio_bytes: bytes) -> AudioInput:
|
|
"""
|
|
Convert audio bytes to ComfyUI audio tensor.
|
|
|
|
Args:
|
|
audio_bytes: Audio file bytes
|
|
Returns:
|
|
torch.Tensor with shape (B, C, T) in float32 range [-1, 1]
|
|
"""
|
|
audio_buffer = BytesIO(audio_bytes)
|
|
waveform, sample_rate = nodes_audio.load(audio_buffer) # type: ignore # Although expect string argument, it calls av.open underneath, which supports BytesIO (file-like)
|
|
return {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
|
|
|
|
|
|
def base64_to_audio(base64_str: str) -> AudioInput:
|
|
"""
|
|
Convert base64-encoded audio to ComfyUI audio tensor.
|
|
|
|
Args:
|
|
base64_str: Base64-encoded audio string
|
|
Returns:
|
|
torch.Tensor with shape (B, C, T) in float32 range [-1, 1]
|
|
"""
|
|
if base64_str.startswith("data:audio"):
|
|
_, base64_str = base64_str.split(",", 1)
|
|
|
|
try:
|
|
# Decode base64 to bytes
|
|
audio_bytes = base64.b64decode(base64_str)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid base64 string: {e}")
|
|
|
|
return bytes_to_audio(audio_bytes)
|