Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e812b0c3 | ||
|
|
51777e3462 | ||
|
|
7806a92772 | ||
|
|
a42221565d | ||
|
|
75a45c6c34 | ||
|
|
414679c676 | ||
|
|
5af42118fe | ||
|
|
7bc6116a12 | ||
|
|
08e2d39d76 | ||
|
|
803fca81f7 | ||
|
|
b44a66bf03 | ||
|
|
35428a8287 | ||
|
|
41372155c1 | ||
|
|
04ac2b655a | ||
|
|
cc9056e11c | ||
|
|
19c693168b | ||
|
|
66d6318b9a | ||
|
|
a94814e632 | ||
|
|
c2edf563f9 | ||
|
|
ccb2b14dfa | ||
|
|
d2ae46d3c2 | ||
|
|
5f723ae82d | ||
|
|
feedc4a002 | ||
|
|
0d6ea9f00c | ||
|
|
55d83abed0 | ||
|
|
85e28d01d5 | ||
|
|
b091d3057b | ||
|
|
f731a55292 | ||
|
|
5e2aa12434 | ||
|
|
a76949e628 | ||
|
|
afc68ccded | ||
|
|
778ed5272f | ||
|
|
e5b165605a | ||
|
|
2387d0e0ad | ||
|
|
67c5159cfc | ||
|
|
dd9e13e148 | ||
|
|
9e5ccb8bb7 | ||
|
|
95aaea78c6 | ||
|
|
8f355e360c | ||
|
|
e8c5c449ac | ||
|
|
31ae8624d3 | ||
|
|
2c7edfc535 | ||
|
|
c2caab535a | ||
|
|
97cae869d4 | ||
|
|
3171063c50 |
@@ -188,4 +188,31 @@ Uploads video files to a HuggingFace dataset.
|
||||
| filename_prefix | STRING | Prefix for saved files |
|
||||
|
||||
**Notes**: Handles batch upload of multiple video files.
|
||||
</details>
|
||||
|
||||
## 📡 Core Nilor Services
|
||||
|
||||
<details>
|
||||
<summary><b>Worker Consumer Service</b></summary>
|
||||
|
||||
The `worker_consumer.py` script is a background service that runs on each ComfyUI worker. It is responsible for pulling jobs from the central ElasticMQ `jobs_to_process` queue and submitting them to its local ComfyUI instance for processing. This service is essential for the distributed architecture of the system.
|
||||
|
||||
**Key Responsibilities:**
|
||||
- Continuously polls the `jobs_to_process` queue for new jobs using long polling.
|
||||
- When a job is received, it extracts the workflow data and submits it to the local ComfyUI server.
|
||||
- Deletes the job message from the queue upon successful submission to prevent reprocessing.
|
||||
- If submission fails, the message remains on the queue to be picked up by another worker.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Environment Variables</b></summary>
|
||||
|
||||
The `nilor-nodes` require a `.env` file to be present in the `ComfyUI` directory to configure the connection to the core services (MinIO, ElasticMQ, and the Brain API). To set it up, create a file named `.env` in the root of your `ComfyUI` directory by copying the `.env.example` template.
|
||||
|
||||
**Instructions:**
|
||||
1. Create a new file named `.env` in the `ComfyUI` directory.
|
||||
2. Copy the contents of the `.env.example` file into your new `.env` file.
|
||||
3. Replace the placeholder values with your actual credentials and endpoint URLs for your local or production environment.
|
||||
|
||||
</details>
|
||||
@@ -1,286 +0,0 @@
|
||||
"""
|
||||
Brain API Client for ComfyUI Nodes
|
||||
|
||||
This client provides methods to interact with the Brain API storage endpoints,
|
||||
replacing the need for pre-signed URLs in the ComfyUI workflow.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dotenv_path = os.path.join(current_dir, ".env")
|
||||
load_dotenv(dotenv_path=dotenv_path)
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
|
||||
class BrainApiClient:
|
||||
"""
|
||||
Client for interacting with Brain API storage endpoints.
|
||||
|
||||
This client handles authentication and provides methods for uploading,
|
||||
downloading, and deleting files through the Brain API storage endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Brain API client with configuration from environment variables."""
|
||||
self.base_url = os.getenv("BRANDO_BRAIN_API_BASE_URL", "http://localhost:2024/api")
|
||||
self.api_key = os.getenv("BRANDO_API_KEY")
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
"BRANDO_API_KEY environment variable is required for Brain API authentication"
|
||||
)
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"User-Agent": "ComfyUI-NilorNodes/1.0"
|
||||
}
|
||||
|
||||
logging.info(f"Brain API Client initialized with base URL: {self.base_url}")
|
||||
|
||||
def upload_file_to_storage(self, file_path: str, filename: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file to Brain API storage and return storage metadata.
|
||||
|
||||
Args:
|
||||
file_path: Local path to the file to upload
|
||||
filename: Name to use for the uploaded file
|
||||
|
||||
Returns:
|
||||
Dict containing storage_id and filename
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If upload fails
|
||||
FileNotFoundError: If file_path doesn't exist
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
url = f"{self.base_url}/storage/upload"
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as file:
|
||||
files = {'file': (filename, file, 'application/octet-stream')}
|
||||
|
||||
logging.info(f"Uploading file '{filename}' to Brain API storage...")
|
||||
response = requests.post(
|
||||
url,
|
||||
files=files,
|
||||
headers=self.headers,
|
||||
timeout=300
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logging.info(f"Upload successful. Storage ID: {result.get('storage_id')}")
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Failed to upload file '{filename}': {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error uploading file '{filename}': {e}")
|
||||
raise
|
||||
|
||||
def upload_fileobj_to_storage(self, file_obj, filename: str, content_type: str = 'application/octet-stream') -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file-like object to Brain API storage and return storage metadata.
|
||||
|
||||
Args:
|
||||
file_obj: File-like object to upload
|
||||
filename: Name to use for the uploaded file
|
||||
content_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
Dict containing storage_id and filename
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If upload fails
|
||||
"""
|
||||
url = f"{self.base_url}/storage/upload"
|
||||
|
||||
try:
|
||||
files = {'file': (filename, file_obj, content_type)}
|
||||
|
||||
logging.info(f"Uploading file object '{filename}' to Brain API storage...")
|
||||
response = requests.post(
|
||||
url,
|
||||
files=files,
|
||||
headers=self.headers,
|
||||
timeout=300
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
logging.info(f"Upload successful. Storage ID: {result.get('storage_id')}")
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Failed to upload file object '{filename}': {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error uploading file object '{filename}': {e}")
|
||||
raise
|
||||
|
||||
def download_file_from_storage(self, storage_id: str, filename: str, dest_path: str) -> str:
|
||||
"""
|
||||
Download a file from Brain API storage to a local path.
|
||||
|
||||
Args:
|
||||
storage_id: Storage ID of the file to download
|
||||
filename: Name of the file to download
|
||||
dest_path: Local path where the file should be saved
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If download fails
|
||||
"""
|
||||
url = f"{self.base_url}/storage/{storage_id}"
|
||||
params = {'filename': filename}
|
||||
|
||||
try:
|
||||
logging.info(f"Downloading file '{filename}' (storage_id: {storage_id}) from Brain API storage...")
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
timeout=300,
|
||||
stream=True
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Ensure destination directory exists
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logging.info(f"Download successful. File saved to: {dest_path}")
|
||||
return dest_path
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Failed to download file '{filename}' (storage_id: {storage_id}): {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error downloading file '{filename}': {e}")
|
||||
raise
|
||||
|
||||
def get_file_from_storage(self, storage_id: str, filename: str) -> bytes:
|
||||
"""
|
||||
Get file content from Brain API storage as bytes.
|
||||
|
||||
Args:
|
||||
storage_id: Storage ID of the file to download
|
||||
filename: Name of the file to download
|
||||
|
||||
Returns:
|
||||
File content as bytes
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If download fails
|
||||
"""
|
||||
url = f"{self.base_url}/storage/{storage_id}"
|
||||
params = {'filename': filename}
|
||||
|
||||
try:
|
||||
logging.info(f"Getting file '{filename}' (storage_id: {storage_id}) from Brain API storage...")
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
timeout=300
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logging.info(f"File retrieval successful. Size: {len(response.content)} bytes")
|
||||
return response.content
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Failed to get file '{filename}' (storage_id: {storage_id}): {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error getting file '{filename}': {e}")
|
||||
raise
|
||||
|
||||
def delete_file_from_storage(self, storage_id: str, filename: str) -> None:
|
||||
"""
|
||||
Delete a file from Brain API storage.
|
||||
|
||||
Args:
|
||||
storage_id: Storage ID of the file to delete
|
||||
filename: Name of the file to delete
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If deletion fails
|
||||
"""
|
||||
url = f"{self.base_url}/storage/{storage_id}"
|
||||
params = {'filename': filename}
|
||||
|
||||
try:
|
||||
logging.info(f"Deleting file '{filename}' (storage_id: {storage_id}) from Brain API storage...")
|
||||
response = requests.delete(
|
||||
url,
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logging.info(f"File deletion successful")
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"Failed to delete file '{filename}' (storage_id: {storage_id}): {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error deleting file '{filename}': {e}")
|
||||
raise
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""
|
||||
Check if the Brain API is accessible and authentication is working.
|
||||
|
||||
Returns:
|
||||
True if API is accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Try to access a simple endpoint to verify connectivity
|
||||
url = f"{self.base_url}/health" # Assuming there's a health endpoint
|
||||
response = requests.get(url, headers=self.headers, timeout=10)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
# If health endpoint doesn't exist, try the storage upload endpoint
|
||||
# with a HEAD request to check authentication
|
||||
try:
|
||||
url = f"{self.base_url}/storage/upload"
|
||||
response = requests.head(url, headers=self.headers, timeout=10)
|
||||
return response.status_code in [200, 405] # 405 Method Not Allowed is OK for HEAD
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
# Global client instance
|
||||
_brain_api_client = None
|
||||
|
||||
def get_brain_api_client() -> BrainApiClient:
|
||||
"""
|
||||
Get or create the global Brain API client instance.
|
||||
|
||||
Returns:
|
||||
BrainApiClient instance
|
||||
"""
|
||||
global _brain_api_client
|
||||
if _brain_api_client is None:
|
||||
_brain_api_client = BrainApiClient()
|
||||
return _brain_api_client
|
||||
+82
-53
@@ -10,7 +10,6 @@ import boto3
|
||||
import os
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from .brain_api_client import get_brain_api_client
|
||||
|
||||
# --- Load Environment Variables ---
|
||||
# Get the directory of the current script
|
||||
@@ -50,13 +49,9 @@ class MediaStreamInput:
|
||||
{"default": "default_input", "multiline": False},
|
||||
),
|
||||
"format": (["image", "image_batch", "video"],),
|
||||
"storage_id": (
|
||||
"presigned_download_url": (
|
||||
"STRING",
|
||||
{"default": "<auto-filled by system>", "multiline": False},
|
||||
),
|
||||
"filename": (
|
||||
"STRING",
|
||||
{"default": "<auto-filled by system>", "multiline": False},
|
||||
{"multiline": True, "default": "<auto-filled by system>"},
|
||||
),
|
||||
},
|
||||
"hidden": {},
|
||||
@@ -69,23 +64,19 @@ class MediaStreamInput:
|
||||
|
||||
def download(
|
||||
self,
|
||||
storage_id: str,
|
||||
filename: str,
|
||||
presigned_download_url: str,
|
||||
format: str,
|
||||
input_name: str = "default_input",
|
||||
):
|
||||
logging.info(
|
||||
f"ℹ️\u2009 Nilor-Nodes: MediaStreamInput: Downloading file '{filename}' (storage_id: {storage_id}) for input '{input_name}' with format '{format}'"
|
||||
f"ℹ️\u2009 Nilor-Nodes: MediaStreamInput: Downloading from {presigned_download_url} for input '{input_name}' with format '{format}'"
|
||||
)
|
||||
try:
|
||||
# Get Brain API client
|
||||
brain_client = get_brain_api_client()
|
||||
|
||||
# Two-phase download for batches: manifest first, then assets
|
||||
if format == "image_batch":
|
||||
# Download manifest file first
|
||||
manifest_bytes = brain_client.get_file_from_storage(storage_id, filename)
|
||||
manifest = json.loads(manifest_bytes.decode('utf-8'))
|
||||
manifest_response = requests.get(presigned_download_url, timeout=60)
|
||||
manifest_response.raise_for_status()
|
||||
manifest = manifest_response.json()
|
||||
|
||||
logging.info(
|
||||
f"ℹ️\u2009 Nilor-Nodes: Processing manifest for '{manifest.get('input_name')}' with {len(manifest.get('files', []))} assets."
|
||||
@@ -96,19 +87,14 @@ class MediaStreamInput:
|
||||
manifest.get("files", []), key=lambda x: x.get("sequence", 0)
|
||||
)
|
||||
|
||||
# Download all assets using Brain API client
|
||||
# Download all assets in parallel
|
||||
asset_responses = []
|
||||
for file_info in sorted_files:
|
||||
try:
|
||||
# Each file_info should now contain storage_id and filename instead of presigned_url
|
||||
file_storage_id = file_info.get("storage_id")
|
||||
file_filename = file_info.get("filename")
|
||||
if not file_storage_id or not file_filename:
|
||||
raise ValueError(f"Missing storage_id or filename in manifest file info: {file_info}")
|
||||
|
||||
file_bytes = brain_client.get_file_from_storage(file_storage_id, file_filename)
|
||||
asset_responses.append(file_bytes)
|
||||
except Exception as e:
|
||||
resp = requests.get(file_info["presigned_url"], timeout=180)
|
||||
resp.raise_for_status()
|
||||
asset_responses.append(resp.content)
|
||||
except requests.RequestException as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes: Failed to download asset {file_info.get('filename')}: {e}"
|
||||
)
|
||||
@@ -117,7 +103,9 @@ class MediaStreamInput:
|
||||
return self._process_image_batch(asset_responses)
|
||||
|
||||
# --- Single-file download ---
|
||||
media_bytes = brain_client.get_file_from_storage(storage_id, filename)
|
||||
response = requests.get(presigned_download_url, timeout=180)
|
||||
response.raise_for_status()
|
||||
media_bytes = response.content
|
||||
|
||||
if format == "video":
|
||||
return self._process_video(media_bytes)
|
||||
@@ -129,9 +117,14 @@ class MediaStreamInput:
|
||||
f"[🛑] Nilor-Nodes (MediaStreamInput): Unsupported format '{format}' for single media download."
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Failed to download file: {e}"
|
||||
)
|
||||
return (None,)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Failed to download or process media: {e}"
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Failed to process media: {e}"
|
||||
)
|
||||
return (None,)
|
||||
|
||||
@@ -226,10 +219,18 @@ class MediaStreamOutput:
|
||||
"STRING",
|
||||
{"default": "<auto-filled by system>", "multiline": False},
|
||||
),
|
||||
"presigned_upload_url": (
|
||||
"STRING",
|
||||
{"multiline": True, "default": "<auto-filled by system>"},
|
||||
),
|
||||
"job_completions_queue_url": (
|
||||
"STRING",
|
||||
{"multiline": True, "default": "<auto-filled by system>"},
|
||||
),
|
||||
"output_object_keys": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "<auto-filled by system>"},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
@@ -250,7 +251,9 @@ class MediaStreamOutput:
|
||||
venue,
|
||||
canvas,
|
||||
scene,
|
||||
presigned_upload_url,
|
||||
job_completions_queue_url,
|
||||
output_object_keys,
|
||||
framerate,
|
||||
output_name: str = "default_output",
|
||||
prompt=None,
|
||||
@@ -261,28 +264,35 @@ class MediaStreamOutput:
|
||||
"[🛑] Nilor-Nodes (MediaStreamOutput): content_id is a required input for MediaStreamOutput."
|
||||
)
|
||||
|
||||
# No longer need to parse output_object_keys since we use storage_ids directly
|
||||
|
||||
# Upload the media using Brain API client
|
||||
brain_client = get_brain_api_client()
|
||||
storage_result = None
|
||||
|
||||
if format == "png":
|
||||
storage_result = self._upload_image(images[0], brain_client, output_name)
|
||||
elif format == "mp4":
|
||||
storage_result = self._upload_video(images, brain_client, framerate, output_name)
|
||||
|
||||
# Use the storage_id from the upload result for the SQS message
|
||||
if not storage_result or not storage_result.get('storage_id'):
|
||||
# The `output_object_keys` is received as a string representation of a dictionary.
|
||||
# We must parse it back into a dictionary.
|
||||
final_outputs_dict = {}
|
||||
try:
|
||||
# The string may use single quotes, so we replace them for valid JSON.
|
||||
final_outputs_dict = json.loads(output_object_keys.replace("'", '"'))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): FATAL -- Upload failed or no storage_id returned."
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): FATAL -- Could not parse output_object_keys from string: {output_object_keys}. Error: {e}"
|
||||
)
|
||||
final_outputs_dict = {} # Send empty dict on failure.
|
||||
|
||||
# The presigned_upload_url provided to this node is specific to its output_name.
|
||||
# We don't need to re-select it. We just need to perform the upload.
|
||||
if format == "png":
|
||||
self._upload_image(images[0], presigned_upload_url)
|
||||
elif format == "mp4":
|
||||
self._upload_video(images, presigned_upload_url, framerate)
|
||||
|
||||
# This node is responsible for a single output. We find its corresponding object key.
|
||||
output_key_for_this_node = final_outputs_dict.get(output_name)
|
||||
if not output_key_for_this_node:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): FATAL -- Could not find object key for output name '{output_name}' in output_object_keys."
|
||||
)
|
||||
# Send an empty dictionary to signal failure.
|
||||
final_outputs_for_sqs = {}
|
||||
else:
|
||||
# Use storage_id instead of object key
|
||||
storage_id = storage_result['storage_id']
|
||||
final_outputs_for_sqs = {output_name: storage_id}
|
||||
final_outputs_for_sqs = {output_name: output_key_for_this_node}
|
||||
|
||||
# After upload, send the filtered dictionary of outputs to the SQS queue.
|
||||
completion_message = {
|
||||
@@ -321,7 +331,7 @@ class MediaStreamOutput:
|
||||
|
||||
return {"ui": {"images": []}}
|
||||
|
||||
def _upload_image(self, image_tensor, brain_client, output_name):
|
||||
def _upload_image(self, image_tensor, url):
|
||||
logging.info(
|
||||
"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading as PNG image..."
|
||||
)
|
||||
@@ -332,10 +342,9 @@ class MediaStreamOutput:
|
||||
img_pil.save(buffer, format="PNG", compress_level=4)
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"{output_name}.png"
|
||||
return brain_client.upload_fileobj_to_storage(buffer, filename, "image/png")
|
||||
self._perform_upload(buffer, url, "image/png")
|
||||
|
||||
def _upload_video(self, image_batch_tensor, brain_client, framerate, output_name):
|
||||
def _upload_video(self, image_batch_tensor, url, framerate):
|
||||
logging.info(
|
||||
f"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading as MP4 video. Frame count: {len(image_batch_tensor)}"
|
||||
)
|
||||
@@ -349,9 +358,29 @@ class MediaStreamOutput:
|
||||
imageio.mimwrite(buffer, frames, format="mp4", fps=framerate, quality=8)
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"{output_name}.mp4"
|
||||
return brain_client.upload_fileobj_to_storage(buffer, filename, "video/mp4")
|
||||
self._perform_upload(buffer, url, "video/mp4")
|
||||
|
||||
def _perform_upload(self, buffer, url, content_type):
|
||||
try:
|
||||
logging.info(
|
||||
f"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading to {url} with Content-Type: {content_type}"
|
||||
)
|
||||
headers = {"Content-Type": content_type}
|
||||
response = requests.put(
|
||||
url, data=buffer.read(), headers=headers, timeout=300
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info("✅ Nilor-Nodes (MediaStreamOutput): Upload successful.")
|
||||
except requests.RequestException as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): Failed to upload media: {e}"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): Failed to process and upload media: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# --- Node Mappings ---
|
||||
@@ -361,6 +390,6 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MediaStreamInput": "👺 Media Stream Input (Storage)",
|
||||
"MediaStreamOutput": "👺 Media Stream Output (Storage)",
|
||||
"MediaStreamInput": "👺 Media Stream Input (URL)",
|
||||
"MediaStreamOutput": "👺 Media Stream Output (URL)",
|
||||
}
|
||||
|
||||
+438
-37
@@ -16,7 +16,11 @@ import torch
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
import cv2
|
||||
import warnings
|
||||
from .utils import pil2tensor, tensor2pil
|
||||
import logging
|
||||
from comfy.utils import common_upscale
|
||||
from comfy import model_management
|
||||
|
||||
BIGMIN = -(2**53 - 1)
|
||||
BIGMAX = 2**53 - 1
|
||||
@@ -166,7 +170,9 @@ class NilorRemapFloatList:
|
||||
):
|
||||
# Avoid division by zero
|
||||
if max_input - min_input == 0:
|
||||
raise ValueError("max_input and min_input cannot be the same value.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (RemapFloatList): max_input and min_input cannot be the same value."
|
||||
)
|
||||
|
||||
scale = (max_output - min_output) / (max_input - min_input)
|
||||
return ([min_output + (x - min_input) * scale for x in list_of_floats],)
|
||||
@@ -221,7 +227,9 @@ class NilorInverseMapFloatList:
|
||||
|
||||
def inverse_map_float_list(self, list_of_floats):
|
||||
if not list_of_floats:
|
||||
raise ValueError("The input list_of_floats cannot be empty.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (InverseMapFloatList): The input list_of_floats cannot be empty."
|
||||
)
|
||||
|
||||
min_input = min(list_of_floats)
|
||||
max_input = max(list_of_floats)
|
||||
@@ -317,7 +325,9 @@ class NilorCountImagesInDirectory:
|
||||
|
||||
def count_images_in_directory(self, directory):
|
||||
if not os.path.isdir(directory):
|
||||
raise FileNotFoundError(f"Directory '{directory} cannot be found.")
|
||||
raise FileNotFoundError(
|
||||
f"[🛑] Nilor-Nodes (NilorCountImagesInDirectory): Directory '{directory} cannot be found."
|
||||
)
|
||||
|
||||
list_dir = []
|
||||
list_dir = os.listdir(directory)
|
||||
@@ -365,7 +375,9 @@ class NilorSelectIndexFromList:
|
||||
|
||||
# Ensure the index is within bounds
|
||||
if index < 0 or index >= len(actual_list):
|
||||
raise ValueError("Index is outside the bounds of the array.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (SelectIndexFromList): Index is outside the bounds of the array."
|
||||
)
|
||||
|
||||
# Returns the value at the given index
|
||||
return (actual_list[index],)
|
||||
@@ -403,7 +415,9 @@ class NilorSaveEXRArbitrary:
|
||||
self, channels=None, filename_prefix="output", prompt=None, extra_pnginfo=None
|
||||
):
|
||||
|
||||
print("Running save_exr_arbitrary")
|
||||
logging.info(
|
||||
"ℹ️\u2009 Nilor-Nodes (SaveEXRArbitrary): Running save_exr_arbitrary"
|
||||
)
|
||||
# print(f"channels: {channels}")
|
||||
# print(f"filename_prefix: {filename_prefix}")
|
||||
|
||||
@@ -415,7 +429,9 @@ class NilorSaveEXRArbitrary:
|
||||
try:
|
||||
actual_channels[0]
|
||||
except TypeError:
|
||||
print("actual_channels is not subscriptable")
|
||||
logging.error(
|
||||
"🛑\u2009 Nilor-Nodes (SaveEXRArbitrary): actual_channels is not subscriptable"
|
||||
)
|
||||
return
|
||||
|
||||
# File path handling
|
||||
@@ -452,7 +468,9 @@ class NilorSaveEXRArbitrary:
|
||||
height, width = image_channels[0].shape[-2:]
|
||||
for tensor in image_channels:
|
||||
if tensor.shape[-2:] != (height, width):
|
||||
raise ValueError("All input tensors must have the same dimensions")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (SaveEXRArbitrary): All input tensors must have the same dimensions"
|
||||
)
|
||||
|
||||
# Channel naming
|
||||
default_names = ["R", "G", "B", "A"] + [
|
||||
@@ -502,9 +520,13 @@ class NilorSaveEXRArbitrary:
|
||||
exr_file.writePixels(channel_data)
|
||||
exr_file.close()
|
||||
|
||||
print(f"EXR file saved successfully to {writepath}")
|
||||
logging.info(
|
||||
f"✅ Nilor-Nodes (SaveEXRArbitrary): EXR file saved successfully to {writepath}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to write EXR file: {e}")
|
||||
logging.error(
|
||||
f"🛑\u2009 Nilor-Nodes (SaveEXRArbitrary): Failed to write EXR file: {e}"
|
||||
)
|
||||
|
||||
|
||||
class NilorSaveVideoToHFDataset:
|
||||
@@ -622,12 +644,14 @@ class NilorShuffleImageBatch:
|
||||
|
||||
def _check_image_dimensions(self, images):
|
||||
if images.shape[0] == 0:
|
||||
raise ValueError("Input images tensor is empty.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (ShuffleImageBatch): Input images tensor is empty."
|
||||
)
|
||||
|
||||
# All images in the batch should have the same dimensions
|
||||
if len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
f"[🛑] Nilor-Nodes (ShuffleImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
)
|
||||
|
||||
def shuffle_image_batch(self, images: torch.Tensor, seed):
|
||||
@@ -667,12 +691,14 @@ class NilorRepeatTrimImageBatch:
|
||||
|
||||
def _check_image_dimensions(self, images):
|
||||
if images.shape[0] == 0:
|
||||
raise ValueError("Input images tensor is empty.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (RepeatTrimImageBatch): Input images tensor is empty."
|
||||
)
|
||||
|
||||
# All images in the batch should have the same dimensions
|
||||
if len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
f"[🛑] Nilor-Nodes (RepeatTrimImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
)
|
||||
|
||||
def repeat_trim_image_batch(self, images: torch.Tensor, count):
|
||||
@@ -710,12 +736,14 @@ class NilorRepeatShuffleTrimImageBatch:
|
||||
|
||||
def _check_image_dimensions(self, images):
|
||||
if images.shape[0] == 0:
|
||||
raise ValueError("Input images tensor is empty.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (RepeatShuffleTrimImageBatch): Input images tensor is empty."
|
||||
)
|
||||
|
||||
# All images in the batch should have the same dimensions
|
||||
if len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
f"[🛑] Nilor-Nodes (RepeatShuffleTrimImageBatch): Expected 4D tensor (batch, channels, height, width), got shape {images.shape}"
|
||||
)
|
||||
|
||||
def repeat_shuffle_trim_image_batch(self, images: torch.Tensor, seed, count):
|
||||
@@ -781,12 +809,16 @@ class NilorOutputFilenameString:
|
||||
|
||||
if unique_id is not None and extra_pnginfo is not None:
|
||||
if not isinstance(extra_pnginfo, list):
|
||||
print("Error: extra_pnginfo is not a list")
|
||||
logging.error(
|
||||
"🛑\u2009 Nilor-Nodes (OutputFilenameString): extra_pnginfo is not a list"
|
||||
)
|
||||
elif (
|
||||
not isinstance(extra_pnginfo[0], dict)
|
||||
or "workflow" not in extra_pnginfo[0]
|
||||
):
|
||||
print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key")
|
||||
logging.error(
|
||||
"🛑\u2009 Nilor-Nodes (OutputFilenameString): extra_pnginfo[0] is not a dict or missing 'workflow' key"
|
||||
)
|
||||
else:
|
||||
workflow = extra_pnginfo[0]["workflow"]
|
||||
node = next(
|
||||
@@ -837,7 +869,159 @@ class NilorNFractionsOfInt:
|
||||
elif type == "start + end":
|
||||
return ([i * numerator // (denominator - 1) for i in range(denominator)],)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {type}")
|
||||
raise ValueError(
|
||||
f"[🛑] Nilor-Nodes (NilorNFractionsOfInt): Unknown type: {type}"
|
||||
)
|
||||
|
||||
|
||||
class NilorWanTileResolution:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_width": (
|
||||
"INT",
|
||||
{"default": 1920, "min": 16, "max": BIGMAX, "step": 1},
|
||||
),
|
||||
"input_height": (
|
||||
"INT",
|
||||
{"default": 1080, "min": 16, "max": BIGMAX, "step": 1},
|
||||
),
|
||||
"target_width": (
|
||||
"INT",
|
||||
{"default": 3840, "min": 16, "max": BIGMAX, "step": 1},
|
||||
),
|
||||
"target_height": (
|
||||
"INT",
|
||||
{"default": 2160, "min": 16, "max": BIGMAX, "step": 1},
|
||||
),
|
||||
"size_preference": (
|
||||
["largest", "smallest"],
|
||||
{"default": "largest"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("tile_width", "tile_height")
|
||||
|
||||
FUNCTION = "compute_tile_resolution"
|
||||
CATEGORY = category + subcategories["utilities"]
|
||||
|
||||
MIN_TILE_DIM = 384
|
||||
MAX_TILE_DIM = 1794
|
||||
MIN_TILE_AREA = 384 * 384
|
||||
MAX_TILE_AREA = 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def _clamp(value, minimum, maximum):
|
||||
return max(minimum, min(value, maximum))
|
||||
|
||||
def compute_tile_resolution(
|
||||
self,
|
||||
input_width,
|
||||
input_height,
|
||||
target_width,
|
||||
target_height,
|
||||
size_preference="largest",
|
||||
):
|
||||
"""
|
||||
Compute (Wt, Ht) tile size (multiples of 16) within
|
||||
[MIN_TILE_DIM, MAX_TILE_DIM] while keeping area between
|
||||
[MIN_TILE_AREA, MAX_TILE_AREA]. Emphasise aspect-ratio fidelity to
|
||||
Wa/Ha while staying within the allowed range.
|
||||
|
||||
Among options with comparable aspect error, prefer tiles that do
|
||||
not hit clamped bounds, then maximise area and width (or minimise both if
|
||||
size_preference == "smallest").
|
||||
|
||||
Assumes Wa, Ha are multiples of 16.
|
||||
"""
|
||||
|
||||
dims = {
|
||||
"input_width": input_width,
|
||||
"input_height": input_height,
|
||||
"target_width": target_width,
|
||||
"target_height": target_height,
|
||||
}
|
||||
|
||||
for name, value in dims.items():
|
||||
if value <= 0:
|
||||
raise ValueError(
|
||||
f"[🛑] Nilor-Nodes (NilorWanTileResolution): {name} must be a positive integer."
|
||||
)
|
||||
|
||||
if input_width % 16 != 0 or input_height % 16 != 0:
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (NilorWanTileResolution): input_width and input_height must be multiples of 16."
|
||||
)
|
||||
|
||||
if target_width < self.MIN_TILE_DIM or target_height < self.MIN_TILE_DIM:
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (NilorWanTileResolution): target_width and target_height must be at least the minimum tile size."
|
||||
)
|
||||
|
||||
min_blocks = self.MIN_TILE_DIM // 16
|
||||
max_blocks = self.MAX_TILE_DIM // 16
|
||||
|
||||
max_width_blocks = min(max_blocks, target_width // 16)
|
||||
max_height_blocks = min(max_blocks, target_height // 16)
|
||||
|
||||
if max_width_blocks < min_blocks or max_height_blocks < min_blocks:
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (NilorWanTileResolution): Target dimensions do not allow a tile within the supported range."
|
||||
)
|
||||
|
||||
aspect_ratio = input_width / input_height
|
||||
|
||||
best_score = None
|
||||
best_dimensions = None
|
||||
|
||||
for height_blocks in range(min_blocks, max_height_blocks + 1):
|
||||
width_blocks = round(aspect_ratio * height_blocks)
|
||||
width_blocks = self._clamp(width_blocks, min_blocks, max_width_blocks)
|
||||
|
||||
width_px = width_blocks * 16
|
||||
height_px = height_blocks * 16
|
||||
|
||||
area = width_px * height_px
|
||||
if area < self.MIN_TILE_AREA or area > self.MAX_TILE_AREA:
|
||||
# Skip tiles that are too small or too large
|
||||
continue
|
||||
aspect_error = abs((width_blocks / height_blocks) - aspect_ratio)
|
||||
|
||||
width_hits_bound = int(width_blocks in (min_blocks, max_width_blocks))
|
||||
height_hits_bound = int(height_blocks in (min_blocks, max_height_blocks))
|
||||
|
||||
# Penalise tiles that hit the clamped bounds
|
||||
bound_penalty = width_hits_bound + height_hits_bound
|
||||
|
||||
# Score tiles based on size preference
|
||||
if size_preference == "smallest":
|
||||
area_score = -area
|
||||
width_score = -width_px
|
||||
else:
|
||||
area_score = area
|
||||
width_score = width_px
|
||||
|
||||
# Combine scores
|
||||
candidate = (-aspect_error, -bound_penalty, area_score, width_score)
|
||||
|
||||
if best_score is None or candidate > best_score:
|
||||
# Update best score and dimensions if this candidate is better
|
||||
best_score = candidate
|
||||
best_dimensions = (width_px, height_px)
|
||||
|
||||
if best_dimensions is None:
|
||||
# If no suitable tile resolution was found, raise an error
|
||||
raise RuntimeError(
|
||||
"[🛑] Nilor-Nodes (NilorWanTileResolution): Failed to determine a suitable tile resolution."
|
||||
)
|
||||
|
||||
return best_dimensions
|
||||
|
||||
|
||||
class NilorCategorizeString:
|
||||
@@ -959,7 +1143,9 @@ class NilorRandomString:
|
||||
if item.strip()
|
||||
]
|
||||
if not options:
|
||||
raise ValueError("No valid choices provided.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (NilorRandomString): No valid choices provided."
|
||||
)
|
||||
|
||||
# Limit to the first 'max_options' entries if there are more options
|
||||
if len(options) > max_options:
|
||||
@@ -1001,7 +1187,9 @@ class NilorLoadImageByIndex:
|
||||
|
||||
def load_image_by_index(self, image_directory, seed, sort_mode, reverse_sort):
|
||||
if not os.path.exists(image_directory):
|
||||
raise FileNotFoundError(f"Image directory {image_directory} does not exist")
|
||||
raise FileNotFoundError(
|
||||
f"[🛑] Nilor-Nodes (NilorLoadImageByIndex): Image directory {image_directory} does not exist"
|
||||
)
|
||||
|
||||
# Get list of image files
|
||||
files = []
|
||||
@@ -1013,7 +1201,9 @@ class NilorLoadImageByIndex:
|
||||
files.append(file_path)
|
||||
|
||||
if not files:
|
||||
raise ValueError(f"No image files found in {image_directory}")
|
||||
raise ValueError(
|
||||
f"[🛑] Nilor-Nodes (NilorLoadImageByIndex): No image files found in {image_directory}"
|
||||
)
|
||||
|
||||
# Sort files based on selected mode
|
||||
if sort_mode == "filename":
|
||||
@@ -1063,7 +1253,9 @@ class NilorExtractFilenameFromPath:
|
||||
def extract_filename(self, filepath):
|
||||
# Ensure the input is a valid path
|
||||
if not filepath:
|
||||
raise ValueError("Filepath cannot be empty.")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (ExtractFilenameFromPath): Filepath cannot be empty."
|
||||
)
|
||||
|
||||
path = Path(filepath)
|
||||
|
||||
@@ -1098,14 +1290,18 @@ class NilorBlurAnalysis:
|
||||
"""
|
||||
# Ensure images is a 4D tensor.
|
||||
if images.dim() != 4:
|
||||
raise ValueError("Input images must be a 4D tensor (batch, channels/height, height/width, width/channels)")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (BlurAnalysis): Input images must be a 4D tensor (batch, channels/height, height/width, width/channels)"
|
||||
)
|
||||
|
||||
# Detect if using NCHW or NHWC.
|
||||
if images.shape[1] not in (1, 3):
|
||||
if images.shape[-1] in (1, 3):
|
||||
images = images.permute(0, 3, 1, 2)
|
||||
else:
|
||||
raise ValueError("Cannot determine image format (expected channel to be 1 or 3).")
|
||||
raise ValueError(
|
||||
"[🛑] Nilor-Nodes (BlurAnalysis): Cannot determine image format (expected channel to be 1 or 3)."
|
||||
)
|
||||
|
||||
output_images = []
|
||||
batch_size = images.shape[0]
|
||||
@@ -1116,9 +1312,7 @@ class NilorBlurAnalysis:
|
||||
|
||||
# Convert to grayscale.
|
||||
if img_np.shape[0] >= 3:
|
||||
gray = (0.299 * img_np[0] +
|
||||
0.587 * img_np[1] +
|
||||
0.114 * img_np[2])
|
||||
gray = 0.299 * img_np[0] + 0.587 * img_np[1] + 0.114 * img_np[2]
|
||||
else:
|
||||
gray = np.squeeze(img_np, axis=0) # shape: (H, W)
|
||||
|
||||
@@ -1146,7 +1340,9 @@ class NilorBlurAnalysis:
|
||||
# Convert the single channel output to a 3-channel image.
|
||||
# This ensures downstream nodes (like MaskFromRGBCMYBW) that index into channels work properly.
|
||||
if out_img.ndim == 2:
|
||||
out_img = np.stack([out_img, out_img, out_img], axis=-1) # shape becomes (H, W, 3)
|
||||
out_img = np.stack(
|
||||
[out_img, out_img, out_img], axis=-1
|
||||
) # shape becomes (H, W, 3)
|
||||
|
||||
# Convert from PIL image (or numpy array) to tensor.
|
||||
# pil2tensor should create a tensor in a format that downstream nodes expect.
|
||||
@@ -1157,6 +1353,7 @@ class NilorBlurAnalysis:
|
||||
# If each output has shape, say, (H, W, 3), stacking them gives a tensor of shape (B, H, W, 3).
|
||||
return (torch.cat(output_images, dim=0),)
|
||||
|
||||
|
||||
class NilorToSparseIndexMethod:
|
||||
def __init__(self):
|
||||
pass
|
||||
@@ -1179,10 +1376,210 @@ class NilorToSparseIndexMethod:
|
||||
|
||||
def convert_to_sparse_index_method(self, ints):
|
||||
indexes_str = ",".join(map(str, ints))
|
||||
|
||||
|
||||
return (indexes_str,)
|
||||
|
||||
|
||||
class NilorImageResizeV2:
|
||||
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"width": ("INT", {"default": 512, "min": 0, "max": BIGMAX, "step": 1}),
|
||||
"height": ("INT", {"default": 512, "min": 0, "max": BIGMAX, "step": 1}),
|
||||
"upscale_method": (s.upscale_methods,),
|
||||
"keep_proportion": (["stretch", "resize", "pad", "pad_edge", "pad_edge_pixel", "crop", "pillarbox_blur"], {"default": False}),
|
||||
"pad_color": ("STRING", {"default": "0, 0, 0"}),
|
||||
"crop_position": (["center", "top", "bottom", "left", "right"], {"default": "center"}),
|
||||
"divisible_by": ("INT", {"default": 2, "min": 0, "max": 512, "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("MASK",),
|
||||
"device": (["cpu", "gpu"],),
|
||||
"per_batch": ("INT", {"default": 16, "min": 0, "max": 4096, "step": 1, "tooltip": "Process images in sub-batches. 0 disables."}),
|
||||
},
|
||||
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT", "MASK")
|
||||
RETURN_NAMES = ("IMAGE", "width", "height", "mask")
|
||||
FUNCTION = "resize"
|
||||
CATEGORY = category + subcategories["utilities"]
|
||||
DESCRIPTION = """
|
||||
Resizes images with optional aspect preservation, padding/cropping, and sub-batching to lower peak memory.
|
||||
"""
|
||||
|
||||
def resize(self, image, width, height, keep_proportion, upscale_method, divisible_by, pad_color, crop_position, unique_id, device="cpu", mask=None, per_batch=16):
|
||||
B, H, W, C = image.shape
|
||||
|
||||
if device == "gpu":
|
||||
if upscale_method == "lanczos":
|
||||
raise Exception("Lanczos is not supported on the GPU")
|
||||
device = model_management.get_torch_device()
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
|
||||
if width == 0:
|
||||
width = W
|
||||
if height == 0:
|
||||
height = H
|
||||
|
||||
pillarbox_blur = keep_proportion == "pillarbox_blur"
|
||||
if keep_proportion == "resize" or keep_proportion.startswith("pad") or pillarbox_blur:
|
||||
if width == 0 and height != 0:
|
||||
ratio = height / H
|
||||
new_width = round(W * ratio)
|
||||
new_height = height
|
||||
elif height == 0 and width != 0:
|
||||
ratio = width / W
|
||||
new_width = width
|
||||
new_height = round(H * ratio)
|
||||
elif width != 0 and height != 0:
|
||||
ratio = min(width / W, height / H)
|
||||
new_width = round(W * ratio)
|
||||
new_height = round(H * ratio)
|
||||
else:
|
||||
new_width = width
|
||||
new_height = height
|
||||
|
||||
pad_left = pad_right = pad_top = pad_bottom = 0
|
||||
if keep_proportion.startswith("pad") or pillarbox_blur:
|
||||
if crop_position == "center":
|
||||
pad_left = (width - new_width) // 2
|
||||
pad_right = width - new_width - pad_left
|
||||
pad_top = (height - new_height) // 2
|
||||
pad_bottom = height - new_height - pad_top
|
||||
elif crop_position == "top":
|
||||
pad_left = (width - new_width) // 2
|
||||
pad_right = width - new_width - pad_left
|
||||
pad_top = 0
|
||||
pad_bottom = height - new_height
|
||||
elif crop_position == "bottom":
|
||||
pad_left = (width - new_width) // 2
|
||||
pad_right = width - new_width - pad_left
|
||||
pad_top = height - new_height
|
||||
pad_bottom = 0
|
||||
elif crop_position == "left":
|
||||
pad_left = 0
|
||||
pad_right = width - new_width
|
||||
pad_top = (height - new_height) // 2
|
||||
pad_bottom = height - new_height - pad_top
|
||||
elif crop_position == "right":
|
||||
pad_left = width - new_width
|
||||
pad_right = 0
|
||||
pad_top = (height - new_height) // 2
|
||||
pad_bottom = height - new_height - pad_top
|
||||
|
||||
width = new_width
|
||||
height = new_height
|
||||
|
||||
if divisible_by > 1:
|
||||
width = width - (width % divisible_by)
|
||||
height = height - (height % divisible_by)
|
||||
|
||||
if per_batch and B > per_batch:
|
||||
try:
|
||||
bytes_per_elem = image.element_size()
|
||||
est_total_bytes = B * height * width * C * bytes_per_elem
|
||||
est_mb = est_total_bytes / (1024 * 1024)
|
||||
print(f"[NilorImageResizeV2] estimated output ~{est_mb:.2f} MB; batching {per_batch}/{B}")
|
||||
except:
|
||||
pass
|
||||
|
||||
def _process_subbatch(in_image, in_mask):
|
||||
out_image = in_image if in_image.device == device else in_image.to(device)
|
||||
out_mask = None if in_mask is None else (in_mask if in_mask.device == device else in_mask.to(device))
|
||||
|
||||
if keep_proportion == "crop":
|
||||
old_height = out_image.shape[-3]
|
||||
old_width = out_image.shape[-2]
|
||||
old_aspect = old_width / old_height
|
||||
new_aspect = width / height
|
||||
if old_aspect > new_aspect:
|
||||
crop_w = round(old_height * new_aspect)
|
||||
crop_h = old_height
|
||||
else:
|
||||
crop_w = old_width
|
||||
crop_h = round(old_width / new_aspect)
|
||||
if crop_position == "center":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = (old_height - crop_h) // 2
|
||||
elif crop_position == "top":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = 0
|
||||
elif crop_position == "bottom":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = old_height - crop_h
|
||||
elif crop_position == "left":
|
||||
x = 0
|
||||
y = (old_height - crop_h) // 2
|
||||
elif crop_position == "right":
|
||||
x = old_width - crop_w
|
||||
y = (old_height - crop_h) // 2
|
||||
out_image = out_image.narrow(-2, x, crop_w).narrow(-3, y, crop_h)
|
||||
if out_mask is not None:
|
||||
out_mask = out_mask.narrow(-1, x, crop_w).narrow(-2, y, crop_h)
|
||||
|
||||
out_image = common_upscale(out_image.movedim(-1, 1), width, height, upscale_method, crop="disabled").movedim(1, -1)
|
||||
if out_mask is not None:
|
||||
if upscale_method == "lanczos":
|
||||
out_mask = common_upscale(out_mask.unsqueeze(1).repeat(1, 3, 1, 1), width, height, upscale_method, crop="disabled").movedim(1, -1)[:, :, :, 0]
|
||||
else:
|
||||
out_mask = common_upscale(out_mask.unsqueeze(1), width, height, upscale_method, crop="disabled").squeeze(1)
|
||||
|
||||
if (keep_proportion.startswith("pad") or pillarbox_blur) and (pad_left > 0 or pad_right > 0 or pad_top > 0 or pad_bottom > 0):
|
||||
padded_width = width + pad_left + pad_right
|
||||
padded_height = height + pad_top + pad_bottom
|
||||
if divisible_by > 1:
|
||||
width_remainder = padded_width % divisible_by
|
||||
height_remainder = padded_height % divisible_by
|
||||
if width_remainder > 0:
|
||||
extra_width = divisible_by - width_remainder
|
||||
pad_right += extra_width
|
||||
if height_remainder > 0:
|
||||
extra_height = divisible_by - height_remainder
|
||||
pad_bottom += extra_height
|
||||
|
||||
pad_mode = (
|
||||
"pillarbox_blur" if pillarbox_blur else
|
||||
"edge" if keep_proportion == "pad_edge" else
|
||||
"edge_pixel" if keep_proportion == "pad_edge_pixel" else
|
||||
"color"
|
||||
)
|
||||
out_image, out_mask = ImagePadKJ.pad(self, out_image, pad_left, pad_right, pad_top, pad_bottom, 0, pad_color, pad_mode, mask=out_mask)
|
||||
|
||||
return out_image, out_mask
|
||||
|
||||
if per_batch is None or per_batch == 0 or B <= per_batch:
|
||||
out_image, out_mask = _process_subbatch(image, mask)
|
||||
else:
|
||||
chunks = []
|
||||
mask_chunks = [] if mask is not None else None
|
||||
total_batches = (B + per_batch - 1) // per_batch
|
||||
current_batch = 0
|
||||
for start_idx in range(0, B, per_batch):
|
||||
current_batch += 1
|
||||
end_idx = min(start_idx + per_batch, B)
|
||||
sub_img = image[start_idx:end_idx]
|
||||
sub_mask = mask[start_idx:end_idx] if mask is not None else None
|
||||
sub_out_img, sub_out_mask = _process_subbatch(sub_img, sub_mask)
|
||||
chunks.append(sub_out_img.cpu())
|
||||
if mask is not None:
|
||||
mask_chunks.append(sub_out_mask.cpu() if sub_out_mask is not None else None)
|
||||
try:
|
||||
print(f"[NilorImageResizeV2] batch {current_batch}/{total_batches} · images {end_idx}/{B}")
|
||||
except:
|
||||
pass
|
||||
out_image = torch.cat(chunks, dim=0)
|
||||
if mask is not None and any(m is not None for m in mask_chunks):
|
||||
out_mask = torch.cat([m for m in mask_chunks if m is not None], dim=0)
|
||||
else:
|
||||
out_mask = None
|
||||
|
||||
return (out_image.cpu(), out_image.shape[2], out_image.shape[1], out_mask.cpu() if out_mask is not None else torch.zeros(64, 64, device=torch.device("cpu"), dtype=torch.float32))
|
||||
|
||||
# Mapping class names to objects for potential export
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Nilor Interpolated Float List": NilorInterpolatedFloatList,
|
||||
@@ -1204,19 +1601,21 @@ NODE_CLASS_MAPPINGS = {
|
||||
"Nilor n Fractions of Int": NilorNFractionsOfInt,
|
||||
"Nilor Categorize String": NilorCategorizeString,
|
||||
"Nilor Random String": NilorRandomString,
|
||||
"Nilor Wan Tile Resolution": NilorWanTileResolution,
|
||||
"Nilor Extract Filename from Path": NilorExtractFilenameFromPath,
|
||||
"Nilor Load Image By Index": NilorLoadImageByIndex,
|
||||
"Nilor Blur Analysis": NilorBlurAnalysis,
|
||||
"Nilor To Sparse Index Method": NilorToSparseIndexMethod,
|
||||
"Nilor Image Resize v2": NilorImageResizeV2,
|
||||
}
|
||||
|
||||
# Mapping nodes to human-readable names
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Nilor Interpolated Float List": "👺 Interpolated Float List",
|
||||
"Nilor One Minus Float List": "👺 One Minus Float List",
|
||||
"Nilor Remap Float List": "👺 Nilor Remap Float List",
|
||||
"Nilor Remap Float List Auto Input": "👺 Nilor Remap Float List Auto Input",
|
||||
"Nilor Inverse Map Float List": "👺 Nilor Inverse Map Float List",
|
||||
"Nilor Remap Float List": "👺 Remap Float List",
|
||||
"Nilor Remap Float List Auto Input": "👺 Remap Float List Auto Input",
|
||||
"Nilor Inverse Map Float List": "👺 Inverse Map Float List",
|
||||
"Nilor Int To List Of Bools": "👺 Int To List Of Bools",
|
||||
"Nilor List of Ints": "👺 List of Ints",
|
||||
"Nilor Count Images In Directory": "👺 Count Images In Directory",
|
||||
@@ -1224,15 +1623,17 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Nilor Save Video To HF Dataset": "👺 Save Video To HF Dataset",
|
||||
"Nilor Select Index From List": "👺 Select Index From List",
|
||||
"Nilor Save EXR Arbitrary": "👺 Save EXR Arbitrary",
|
||||
"Nilor Shuffle Image Batch": "👺 Nilor Shuffle Image Batch",
|
||||
"Nilor Repeat & Trim Image Batch": "👺 Nilor Repeat & Trim Image Batch",
|
||||
"Nilor Repeat, Shuffle, & Trim Image Batch": "👺 Nilor Repeat, Shuffle, & Trim Image Batch",
|
||||
"Nilor Output Filename String": "👺 Nilor Output Filename String",
|
||||
"Nilor n Fractions of Int": "👺 Nilor n Fractions of Int",
|
||||
"Nilor Shuffle Image Batch": "👺 Shuffle Image Batch",
|
||||
"Nilor Repeat & Trim Image Batch": "👺 Repeat & Trim Image Batch",
|
||||
"Nilor Repeat, Shuffle, & Trim Image Batch": "👺 Repeat, Shuffle, & Trim Image Batch",
|
||||
"Nilor Output Filename String": "👺 Output Filename String",
|
||||
"Nilor n Fractions of Int": "👺 n Fractions of Int",
|
||||
"Nilor Categorize String": "👺 Categorize String",
|
||||
"Nilor Random String": "👺 Random String",
|
||||
"Nilor Wan Tile Resolution": "👺 Wan Tile Resolution",
|
||||
"Nilor Extract Filename from Path": "👺 Extract Filename from Path",
|
||||
"Nilor Load Image By Index": "👺 Load Image By Index",
|
||||
"Nilor Blur Analysis": "👺 Blur Analysis",
|
||||
"Nilor To Sparse Index Method": "👺 To Sparse Index Method",
|
||||
"Nilor Image Resize v2": "👺 Resize Image v2",
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,2 +1,19 @@
|
||||
huggingface_hub
|
||||
openexr
|
||||
|
||||
aiobotocore==2.24.2
|
||||
aiofiles>=23.2.1
|
||||
aiohttp==3.12.14
|
||||
boto3==1.40.15
|
||||
fastapi==0.110.0
|
||||
huggingface_hub==0.33.4
|
||||
imageio==2.37.0
|
||||
imageio-ffmpeg==0.6.0
|
||||
numpy>=1.26.4
|
||||
opencv-python>=4.6.0.66
|
||||
openexr==3.3.4
|
||||
Pillow==10.4.0
|
||||
python-dotenv==1.0.1
|
||||
python-multipart==0.0.9
|
||||
requests==2.31.0
|
||||
uvicorn==0.27.1
|
||||
websockets==11.0.3
|
||||
--prefer-binary
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Brain API Client
|
||||
|
||||
This script tests the Brain API client functionality to ensure it can
|
||||
communicate with the Brain API storage endpoints correctly.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Add the current directory to the Python path
|
||||
current_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(current_dir))
|
||||
|
||||
from brain_api_client import get_brain_api_client
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
def test_brain_api_client():
|
||||
"""Test the Brain API client functionality."""
|
||||
print("🧪 Testing Brain API Client...")
|
||||
|
||||
try:
|
||||
# Initialize the client
|
||||
client = get_brain_api_client()
|
||||
print("✅ Brain API client initialized successfully")
|
||||
|
||||
# Test health check
|
||||
print("🔍 Testing health check...")
|
||||
is_healthy = client.health_check()
|
||||
if is_healthy:
|
||||
print("✅ Brain API is accessible")
|
||||
else:
|
||||
print("⚠️ Brain API health check failed - this might be expected if the API is not running")
|
||||
|
||||
# Test file upload
|
||||
print("📤 Testing file upload...")
|
||||
test_content = b"Hello, Brain API! This is a test file."
|
||||
test_filename = "test_file.txt"
|
||||
|
||||
# Create a temporary file
|
||||
with tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.txt') as temp_file:
|
||||
temp_file.write(test_content)
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Upload the file
|
||||
upload_result = client.upload_file_to_storage(temp_file_path, test_filename)
|
||||
print(f"✅ File uploaded successfully. Storage ID: {upload_result.get('storage_id')}")
|
||||
|
||||
storage_id = upload_result.get('storage_id')
|
||||
if storage_id:
|
||||
# Test file download
|
||||
print("📥 Testing file download...")
|
||||
downloaded_content = client.get_file_from_storage(storage_id, test_filename)
|
||||
|
||||
if downloaded_content == test_content:
|
||||
print("✅ File downloaded successfully and content matches")
|
||||
else:
|
||||
print("❌ Downloaded content does not match original")
|
||||
|
||||
# Test file deletion
|
||||
print("🗑️ Testing file deletion...")
|
||||
client.delete_file_from_storage(storage_id, test_filename)
|
||||
print("✅ File deleted successfully")
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
print("🎉 All tests passed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed: {e}")
|
||||
logging.exception("Test failed with exception:")
|
||||
return False
|
||||
|
||||
def test_fileobj_upload():
|
||||
"""Test uploading a file-like object."""
|
||||
print("\n🧪 Testing file object upload...")
|
||||
|
||||
try:
|
||||
client = get_brain_api_client()
|
||||
|
||||
# Create a file-like object
|
||||
import io
|
||||
test_content = b"Hello from file object!"
|
||||
file_obj = io.BytesIO(test_content)
|
||||
|
||||
# Upload the file object
|
||||
upload_result = client.upload_fileobj_to_storage(file_obj, "test_fileobj.txt", "text/plain")
|
||||
print(f"✅ File object uploaded successfully. Storage ID: {upload_result.get('storage_id')}")
|
||||
|
||||
storage_id = upload_result.get('storage_id')
|
||||
if storage_id:
|
||||
# Test download
|
||||
downloaded_content = client.get_file_from_storage(storage_id, "test_fileobj.txt")
|
||||
|
||||
if downloaded_content == test_content:
|
||||
print("✅ File object download successful and content matches")
|
||||
else:
|
||||
print("❌ Downloaded content does not match original")
|
||||
|
||||
# Clean up
|
||||
client.delete_file_from_storage(storage_id, "test_fileobj.txt")
|
||||
print("✅ File object deleted successfully")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ File object test failed: {e}")
|
||||
logging.exception("File object test failed with exception:")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 Starting Brain API Client Tests")
|
||||
print("=" * 50)
|
||||
|
||||
# Check environment variables
|
||||
api_key = os.getenv("BRANDO_API_KEY")
|
||||
base_url = os.getenv("BRANDO_BRAIN_API_BASE_URL", "http://localhost:2024/api")
|
||||
|
||||
print(f"API Key: {'✅ Set' if api_key else '❌ Not set'}")
|
||||
print(f"Base URL: {base_url}")
|
||||
print()
|
||||
|
||||
if not api_key:
|
||||
print("❌ BRANDO_API_KEY environment variable is not set!")
|
||||
print("Please set it in your .env file or environment.")
|
||||
sys.exit(1)
|
||||
|
||||
# Run tests
|
||||
success = True
|
||||
success &= test_brain_api_client()
|
||||
success &= test_fileobj_upload()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if success:
|
||||
print("🎉 All tests completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("❌ Some tests failed!")
|
||||
sys.exit(1)
|
||||
@@ -15,8 +15,9 @@ def numpy2pil(image: np.ndarray, mode=None):
|
||||
|
||||
|
||||
## Helper function equivalent to Mikey's pil2tensor
|
||||
#def pil2tensor(self, image):
|
||||
# return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
# def pil2tensor(self, image):
|
||||
# return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def pil2tensor(image: Image.Image):
|
||||
return torch.from_numpy(pil2numpy(image)).unsqueeze(0)
|
||||
|
||||
Reference in New Issue
Block a user