60 Commits
Author SHA1 Message Date
Inoland 8800f3366e Update requirements.txt 2026-07-14 12:41:24 +04:00
Inoland 9a3ced38af update 2026-07-03 13:43:07 +04:00
Inoland ef621b2b6e Update s3_upload_audio_node.py 2026-07-03 13:18:21 +04:00
Inoland 0a5288386f Update image_helper.py 2026-05-02 11:22:35 +04:00
Inoland 07b7f001ae Update image_helper.py 2026-05-02 10:42:34 +04:00
Inoland d4d6dc7c76 Update file_helper.py 2026-05-02 10:42:32 +04:00
Inoland d99af94221 Update image_helper.py 2026-05-02 10:33:04 +04:00
Inoland 2310e54dea Update image_helper.py 2026-05-01 23:18:37 +04:00
Inoland 2d4e98a936 Update image_helper.py 2026-05-01 23:12:29 +04:00
InolandandClaude Opus 4.7 efac5cfe40 add list_helper with 6 list nodes
InoMakeAnyList, InoListLength, InoListGetItem, InoListGetFirst,
InoListGetLast, InoListAppend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:16:49 +04:00
Inoland bc601cb1b3 Update sync_assets.py 2026-05-01 19:18:10 +04:00
Inoland 6f2218c995 Update download_model_helper.py 2026-05-01 19:17:52 +04:00
Inoland f00930e4c6 added close to s3 helper 2026-05-01 19:17:42 +04:00
Inoland 59277e744c full re-work of s3 nodes 2026-05-01 17:59:15 +04:00
Inoland 804ce36643 Update requirements.txt 2026-05-01 17:38:24 +04:00
Inoland 9a96c59f1c Update openai_helper.py 2026-04-30 18:06:54 +04:00
Inoland 1d3e99fabe Update requirements.txt 2026-04-30 18:02:42 +04:00
Inoland ba162c54e3 Update openai_helper.py 2026-04-29 00:32:53 +04:00
Inoland ca69dbd55e Update openai_helper.py 2026-04-28 23:41:01 +04:00
Inoland 547fbde1dd Update openai_helper.py 2026-04-28 23:19:52 +04:00
Inoland 30aac91b90 Update extra_nodes.py 2026-04-15 14:52:55 +04:00
Inoland c2e8c155f4 debugged execute and ino relay 2026-04-15 12:51:56 +04:00
Inoland 8ecd3c4e20 Update json_helper.py 2026-04-15 12:10:52 +04:00
Inoland 8fabb2d56f removed invalidate cache and added async 2026-04-14 23:56:51 +04:00
Inoland 7a2bbb0ed0 removed invalidate cache 2026-04-14 23:56:01 +04:00
Inoland 82df57cca6 fix to invalidate cache 2026-04-14 23:14:12 +04:00
Inoland e6fc0d0ac3 Update requirements.txt 2026-04-14 19:11:04 +04:00
Inoland f0a75b74dd Update openai_helper.py 2026-04-14 17:23:38 +04:00
Inoland e5ad23f9c2 Update image_helper.py 2026-04-13 19:42:57 +04:00
Inoland 75cb843f56 added math for int and float 2026-04-13 19:30:08 +04:00
Inoland 2e5a806f5b Update sync_assets.py 2026-04-11 00:56:16 +04:00
Inoland 0f2631d7b0 Update sync_assets.py 2026-04-11 00:09:22 +04:00
Inoland 874e29dfad Update runpod_helper.py 2026-04-10 23:22:49 +04:00
Inoland 9401852f5d Update sync_assets.py 2026-04-10 23:07:58 +04:00
Inoland 4b91fb9b1a Update requirements.txt 2026-04-10 15:48:02 +04:00
Inoland 244dbf79a0 Update node_helper.py 2026-04-10 15:33:17 +04:00
Inoland b75f9601b8 added invalidate cache on fail 2026-04-10 15:27:07 +04:00
Inoland b5b8093265 Update runpod_helper.py 2026-04-10 15:08:38 +04:00
Inoland 3404ada0cb Update runpod_helper.py 2026-04-10 14:57:52 +04:00
Inoland a0e4dda6f2 Release v2.0.5 2026-04-08 23:02:50 +04:00
Inoland 993c8bf5b4 Update CLAUDE.md 2026-04-08 23:02:26 +04:00
Inoland e227ab7bf4 Update runpod_helper.py 2026-04-08 22:53:55 +04:00
Inoland 5e3cccfc62 Update requirements.txt 2026-04-08 22:53:54 +04:00
Inoland 7e92119bef Update sync_assets.py 2026-04-08 20:17:11 +04:00
Inoland 697f9d5d76 Update image_helper.py 2026-04-08 18:32:59 +04:00
Inoland 1c767061cf Update image_helper.py 2026-04-08 18:18:10 +04:00
Inoland be44e90e4b Update image_helper.py 2026-04-08 18:05:32 +04:00
Inoland 5cb3dcfa9e Update sync_assets.py 2026-04-08 16:19:18 +04:00
Inoland eeb142eaa4 Update cast_helper.py 2026-04-08 15:14:23 +04:00
Inoland 59cc2bcc8c Update image_helper.py 2026-04-06 23:29:00 +04:00
Inoland a43a8336c0 Update node_helper.py 2026-04-04 18:27:20 +04:00
Inoland 68f889f144 Create CHANGELOG.md 2026-04-04 18:15:25 +04:00
Inoland ab4b4509a4 Update __init__.py 2026-04-04 17:23:54 +04:00
Inoland f36a14b33b updated doc 2026-04-04 17:09:51 +04:00
Inoland 1a4ff6e1e0 Update .gitignore 2026-04-04 17:00:10 +04:00
Inoland f148e2df10 Update publish.py 2026-04-04 16:58:10 +04:00
Inoland ada0212d57 Release v2.0.4 2026-04-04 16:57:06 +04:00
Inoland 3efccfe642 Release v2.0.3 2026-04-04 16:56:23 +04:00
Inoland 485e9ba7ed Update publish.py 2026-04-04 16:52:05 +04:00
Inoland cf2e19b673 Release v2.0.2 2026-04-04 16:51:47 +04:00
40 changed files with 1767 additions and 385 deletions
+3
View File
@@ -101,3 +101,6 @@ cookiecutter-pypackage-env/
# Environment variables
.env
# Build artifacts
node.zip
+311
View File
@@ -0,0 +1,311 @@
# Changelog — ComfyUI-InoNodes
---
## 0.19.0
**Full migration to ComfyUI V3 schema across all nodes.**
- Migrated every node to V3 pattern (`io.ComfyNode` + `define_schema` + `execute` classmethod + `io.NodeOutput`) — 48 files rewritten (~4,600 insertions, ~4,700 deletions)
- Added `video_helper.py` with preview video node
- Added S3 download string and download video nodes
- Added `publish.py` for PyPI package publishing
- Added `CLAUDE.md` project documentation
- Unified image load/save nodes with shared `load_image` and `load_images_from_folder` in `node_helper.py`
- Added mask output to image loading nodes
- Expanded `string_helper.py` with additional string manipulation nodes
- Removed `prompt_helper.py` (random character prompt generator)
- Removed `util_helper.py`
- Major rewrites to `sampler_helper`, `download_model_helper`, `load_model_helper`, `image_helper`
## 0.18.0
**New Runpod integration, S3 sync/verify nodes, and broad improvements across all modules.**
- Added `runpod_helper.py` — Runpod serverless vLLM inference node
- Added `s3_sync_folder_node.py` — bidirectional S3 folder sync
- Added `s3_verify_file_node.py` — S3 file verification
- Added int and float compare nodes to `bool_helper.py` and `float_helper.py`
- Removed `init_helper.py` (functionality consolidated elsewhere)
- Major `image_helper.py` improvements (resize, crop, batch operations expanded)
- Improved all class helpers (`file_helper`, `http_helper`, `json_helper`, `openai_helper`)
- Expanded `lora_helper.py` with enhanced multi-LoRA loading
- Refactored `extra_nodes.py` (relay, switches, delay, logging nodes improved)
- Removed legacy test files and frontend JS extension
- Updated `sync_assets.py`
## 0.17.2
- Improved `sync_assets.py` with better error handling and configuration
## 0.17.1
- Added `sync_assets.py` — asset synchronization utility for managing model/data files (127 lines)
- Updated requirements
## 0.17.0
**Re-added S3 video upload and new LoRA data files.**
- Added `s3_upload_video_node.py` — S3 video upload node
- Created `lora_files.csv` for LoRA model download configurations
- Refactored `download_model_helper.py` (simplified download logic)
- Updated video model data files
## 0.16.4
- Added Civitai Klein NSFW model configuration
- Fixed S3 upload audio node (multiple iteration fixes for encoding/upload)
- Updated image, video, and clip model data files
## 0.16.3
- Updated image model and clip model data files with new entries
## 0.16.2
- Added Klein model configurations to image, clip, and VAE data files
## 0.16.1
- Added `DateTimeDuration` node to `time_helper.py`
- Updated image model data files
## 0.16.0
**New initialization system and major image helper expansion.**
- Added `init_helper.py` — initialization utilities for startup model/config management (135 lines)
- Added `util_helper.py` for shared utility functions
- Major `image_helper.py` expansion with new image processing nodes (+228 lines)
- Added new file operations to `file_helper.py`
- Expanded `string_helper.py` with additional string nodes
- Updated basic auth middleware
- Overhauled all model data files (clip, controlnet, image, vae, video)
## 0.15.1
- Started ComfyUI V3 schema migration (initial conversion of `bool_helper.py`)
- Added Mistral 3 FP8 model to clip data files
- Expanded `path_helper.py` with new path utility nodes
- Expanded `string_helper.py` with additional string operations
## 0.15.0
**New model loader system — split download and load into separate modules.**
- Split `model_helper.py` into `download_model_helper.py` and `load_model_helper.py`
- Added `load_model_helper.py` — 9 nodes for loading VAE, CLIP, ControlNet, and Diffusion models (466 lines)
- Added `controlnet_files.csv` for ControlNet model configurations
- Added Qwen model entries to data files
## 0.14.0
**New media helper and expanded model catalog.**
- Added `media_helper.py` — FFmpeg-based video conversion node
- Added Flux 2, Wan 2.2, and additional video models to download configurations
- Updated clip, vae, and video model data files
## 0.13.0
**CSV-based model configuration system.**
- Added CSV reader to `model_helper.py` for loading model download configs from CSV files
- Reorganized data files into `data/configs/` and `data/files/` subdirectories
- Updated model data files
## 0.12.3
- Split unified model data into per-type CSV files: `clip_files.csv`, `image_models_files.csv`, `video_models_files.csv`, `vae_files.csv`, plus config CSVs
- Removed old unified `models_config.csv` and `models_files.csv`
- Updated `node_helper.py` and `model_helper.py` to use new file structure
## 0.12.2
- Added `path_helper.py` — path utility nodes (file path manipulation)
- Expanded `file_helper.py` with additional file operations (+113 lines)
- Expanded `string_helper.py` with new string manipulation nodes
- Fixed S3 upload nodes (audio, image, string)
- Updated `model_helper.py` with improved download handling
## 0.12.1
- Fixed basic auth middleware edge cases
- Fixed S3 helper connection and debug logging
- Added debug logging to S3 download/upload nodes
## 0.12.0
**Massive feature expansion — cast nodes, time nodes, audio S3, model loader, and much more.**
- Added `cast_helper.py` — 6 type casting nodes (Any to String/Int/Model/Clip/Vae/ControlNet)
- Added `time_helper.py` — 4 DateTime formatting and duration nodes
- Added `s3_download_audio_node.py` and `s3_upload_audio_node.py` — S3 audio transfer
- Added `s3_get_download_url.py` — presigned URL generation for S3 objects
- Added HuggingFace repository download support to `model_helper.py`
- Added control net loader node
- Added model loader node from JSON/CSV config
- Added debug logging (`ino_print_log`) across all helper modules
- Added S3 environment variable support (`S3_ACCESS_KEY`, `S3_ACCESS_SECRET`, etc.)
- Added download config and LoRA config nodes
- Created `models_config.csv` and `models_files.csv` for CSV-based model management
- Added 32-bit support to random noise node
- Added multi-bool condition node
- Major expansion of `model_helper.py` (+681 lines) and `sampler_helper.py` refactor
- Improved `openai_helper.py` with OpenAI Responses API support
## 0.11.0
**Major project restructure — new directory layout, basic auth, and model/node helpers.**
- Restructured project into `class_helpers/`, `node_helpers/`, `workflow_helpers/` directories
- Moved `file_helper`, `http_helper`, `json_helper`, `openai_helper` to `class_helpers/`
- Moved `lora_helper`, `prompt_helper`, `sampler_helper` to `workflow_helpers/`
- Added `basic_auth.py` — HTTP Basic Auth middleware for ComfyUI endpoints
- Added `model_helper.py` — model download management from S3 and HuggingFace (265 lines)
- Added `bool_helper.py` — 4 boolean operation nodes
- Added `float_helper.py` — 2 float operation nodes
- Added `int_helper.py` — 4 integer operation nodes
- Added `image_helper.py` — image load, save, resize nodes (204 lines)
- Added `string_helper.py` — string manipulation nodes
- Added `web/js/inonodes.js` — frontend extension for dynamic input UI
- Created `data/models.json` for centralized model configuration (610 lines)
- Added environment variable support for all S3 nodes
## 0.10.0
**New OpenAI integration, S3 string upload, and image save node.**
- Added `openai_helper.py` — OpenAI API integration (responses + chat completions)
- Added `s3_upload_string_node.py` — upload text content to S3
- Added image save node to `extra_nodes.py`
- Removed `node_cloudreve.py` and `node_utils.py` permanently (code consolidated into helpers)
- Simplified `http_helper.py` to use shared helper utilities
- Updated S3 upload file/image nodes with better error handling
## 0.9.0
**New HTTP client, JSON nodes, and S3 folder operations.**
- Added `http_helper.py` — REST client node supporting GET/POST/PUT/DELETE/PATCH
- Added `json_helper.py` — JSON field manipulation and save nodes
- Added `s3_download_folder_node.py` and `s3_upload_folder_node.py` — S3 folder transfer
- Added S3 __init__.py with node class aggregation
- Major `__init__.py` restructure (cleaner node registration)
- Refactored `sampler_helper.py`
- Expanded `extra_nodes.py` with new utility nodes
- Updated README with comprehensive documentation
## 0.8.0
**S3 config refactor and new utility nodes.**
- Refactored S3 from client-based to `s3_helper.py` (S3Helper class with shared connection)
- Added `node_helper.py` — shared utilities (`resolve_comfy_path`, etc.)
- Added random noise node, random int in range node, datetime random int node
- Added int to string node
- Added JSON helper nodes (create, parse, save)
- Changed S3 config from environment variables to ComfyUI input nodes
- Removed `s3_upload_video_node.py` (temporarily, re-added later)
- Fixed int max limit in random int node
- Updated pyproject.toml and README
## 0.7.0
**New S3 cloud storage integration.**
- Added S3 helper module with `s3_client.py` for AWS S3 / compatible storage
- Added S3 upload nodes: file, video, image
- Added S3 download nodes: file, image
- Updated sampler helper with model config management
- Updated model configurations
## 0.6.0
**New extra nodes, LoRA helper, and prompt helper.**
- Added `extra_nodes.py` — relay, switches, delay, and logging utility nodes (150 lines)
- Added `lora_helper.py` — load and apply multiple LoRAs (99 lines)
- Added `prompt_helper.py` — random character prompt generator (416 lines)
- Removed `node_utils.py` (code split into new modules)
- Added lora applied bool output to sampler helper
- Added sampler helper node
## 0.5.0
**Major cleanup — removed legacy modules, added LoRA support to sampler.**
- Removed bedrive module entirely (upload helper, save file/image, get parent ID)
- Removed deprecated module (old video convert, legacy nodes)
- Added `loras.json` configuration for LoRA models
- Major `sampler_helper.py` rewrite with LoRA support (+545 lines changed)
- Added trigger words support, show LoRA and model config nodes
- Updated model configurations and paths
## 0.4.1
- Updated sampler helper with additional model configurations
## 0.4.0
**New sampler helper with model config system.**
- Added `sampler_helper.py` — complete sampler configuration node (369 lines)
- Created `models.json` configuration file for model presets (136 entries)
- Added bool switch node to `node_utils.py`
- Renamed `node_file_helper.py` to `file_helper.py`
## 0.3.0
**Project reorganization and file helper.**
- Reorganized project into subdirectories: `bedrive/`, `depricated/`, `utils/`
- Moved bedrive nodes into `bedrive/` subfolder
- Moved legacy nodes into `depricated/` subfolder
- Added `node_file_helper.py` — InoFileHelper nodes for file operations (273 lines)
- Re-added Cloudreve support with expanded upload/download functionality
- Added pyproject.toml and updated requirements.txt
## 0.2.0
**Cloudreve cleanup and new utility nodes.**
- Created `requirements.txt`
- Removed `helper_cloudreve.py` (functionality inlined)
- Added seed to batch type node
- Added sample every N node
- Added toggle case node
- Fixed calculate LoRA configuration
- Cleaned up Cloudreve node
## 0.1.1
- Bug fix for batch folder node
## 0.1.0
**Cloudreve integration and batch utilities.**
- Added `helper_cloudreve.py` — Cloudreve cloud storage helper
- Added `node_cloudreve.py` — Cloudreve upload/download nodes
- Added calculate LoRA config utility
- Added batch folder ID and get batch utilities to `node_utils.py`
## 0.0.3
- Major rewrite of character prompt generator with expanded options and improved generation logic (+310 lines, -78 lines)
## 0.0.2
- Added `RandomCharacterPrompt` node for generating random character descriptions (190 lines)
## 0.0.1
**Initial release.**
- Project initialization with basic ComfyUI custom node structure
- Added Bedrive integration nodes (save file, save image, get parent ID)
- Added FFmpeg video convert node
- Added file count node
- Added upload helper utility
+25 -34
View File
@@ -2,13 +2,14 @@
## Project Overview
Custom node package for ComfyUI (v1.3.2) providing 125+ nodes across 18 categories. Published by Inoland.
Custom node package for ComfyUI (v2.0.4) providing 131 nodes across 18 categories. Published by Inoland.
- **Package**: `comfyui_ino_nodes`
- **Python**: >= 3.10
- **Conda env**: `comfyui` — run Python commands with `conda run -n comfyui`
- **Entry point**: `__init__.py` (aggregates all nodes via `NODE_CLASS_MAPPINGS`)
- **Tests**: `cd src/comfyui_ino_nodes && pytest`
- **Dependencies**: inopyutils, inocloudreve, openai, aiohttp, numpy, Pillow, torch, huggingface_hub, hf_xet
- **Tests**: `conda run -n comfyui pytest` (from src/comfyui_ino_nodes)
- **Dependencies**: inopyutils, openai, aiohttp, numpy, Pillow, torch, huggingface_hub, hf_xet
## Architecture
@@ -17,56 +18,56 @@ All nodes use **ComfyUI V3 schema** (`io.ComfyNode` with `define_schema` + `exec
```
__init__.py # Root — registers all nodes + basic auth middleware + log_capture
src/comfyui_ino_nodes/
├── node_helper.py # Shared utilities (resolve_comfy_path, load_image, load_images_from_folder, LogCapture, etc.)
├── node_helper.py # Shared utilities (resolve_comfy_path, PARENT_FOLDER_OPTIONS, load_image, load_images_from_folder, LogCapture, etc.)
├── init_helper.py # Initialization utilities
├── basic_auth.py # HTTP Basic Auth middleware
├── basic_auth.py # HTTP Basic Auth middleware (1 node)
├── sync_assets.py # Asset synchronization
├── class_helpers/ # Feature modules
│ ├── file_helper.py # 13 nodes — InoFileHelper (zip, unzip, copy, move, hash, etc.)
│ ├── file_helper.py # 13 nodes — InoFileHelper (zip, unzip, copy, remove, count, validate, dedup, etc.)
│ ├── media_helper.py # 1 node — FFmpeg video conversion
│ ├── http_helper.py # 1 node — REST client (GET/POST/PUT/DELETE/PATCH)
│ ├── json_helper.py # 3 nodes — JSON field manipulation and save
│ ├── openai_helper.py # 2 nodes — OpenAI API (responses + chat completions)
│ └── runpod_helper.py # 1 node — Runpod serverless vLLM inference
│ └── runpod_helper.py # 1 node — Runpod serverless vLLM inference (with polling/retry)
├── node_helpers/ # Data type nodes
│ ├── bool_helper.py # 4 nodes — Boolean ops
│ ├── int_helper.py # 4 nodes — Integer ops
│ ├── float_helper.py # 2 nodes — Float ops
│ ├── string_helper.py # 13 nodes — String manipulation + save text
│ ├── image_helper.py # 10 nodes — Image load, save, resize, crop, batch, base64
│ ├── image_helper.py # 14 nodes — Image load, save, resize, crop, batch, base64, megapixel resolution
│ ├── video_helper.py # 1 node — Video preview
│ ├── time_helper.py # 4 nodes — DateTime formatting and duration
│ ├── path_helper.py # 2 nodes — Path utilities
│ └── cast_helper.py # 6 nodes — Type casting (Any → String/Int/Model/Clip/Vae/ControlNet)
│ └── cast_helper.py # 7 nodes — Type casting (Any → String/Int/Model/Clip/Vae/ControlNet/etc.)
├── s3_helper/ # S3 cloud storage (16 nodes)
│ ├── s3_helper.py # S3Helper class + InoS3Config node
│ ├── s3_download_*.py # Download: file, folder, image, audio, video, string
│ ├── s3_download_*.py # Download: file, folder, image, audio
│ ├── s3_upload_*.py # Upload: file, folder, image, audio, video, string
│ ├── s3_sync_folder_node.py # Bidirectional sync
│ ├── s3_verify_file_node.py # File verification
│ └── s3_get_download_url.py # Presigned URL generation
├── utils/extra_nodes.py # 8 nodes — Relay, switches, delay, logging, noise, length
├── utils/extra_nodes.py # 9 nodes — Relay, switches, delay, logging, noise, length
├── workflow_helpers/ # Complex workflow nodes
│ ├── download_model_helper.py # 13 nodes — Download from S3/HF/Civitai/HTTP
│ ├── load_model_helper.py # 9 nodes — Load VAE/CLIP/ControlNet/Diffusion/LoRA
│ ├── lora_helper.py # 1 node — Load multiple LoRAs
│ ├── prompt_helper.py # 1 node — Random character prompt generation
│ └── sampler_helper.py # 11 nodes — Model config, conditioning, sampler setup
│ └── sampler_helper.py # 10 nodes — Model config, conditioning, sampler setup
├── data/ # CSV/JSON configs for models, LoRAs, clips, VAEs
└── web/js/inonodes.js # Frontend extension
```
## Node Categories (18 categories, 125 nodes)
## Node Categories (18 categories, 131 nodes)
| Category | Count | File(s) |
|---|---|---|
| InoBoolHelper | 4 | bool_helper.py |
| InoCastHelper | 6 | cast_helper.py |
| InoExtraNodes | 8 | extra_nodes.py |
| InoCastHelper | 7 | cast_helper.py |
| InoExtraNodes | 9 | extra_nodes.py |
| InoFileHelper | 13 | file_helper.py |
| InoFloatHelper | 2 | float_helper.py |
| InoHttpHelper | 1 | http_helper.py |
| InoImageHelper | 10 | image_helper.py |
| InoImageHelper | 14 | image_helper.py |
| InoIntHelper | 4 | int_helper.py |
| InoJsonHelper | 3 | json_helper.py |
| InoMediaHelper | 1 | media_helper.py |
@@ -75,7 +76,7 @@ src/comfyui_ino_nodes/
| InoPathHelper | 2 | path_helper.py |
| InoRunpodHelper | 1 | runpod_helper.py |
| InoS3Helper | 16 | s3_helper/*.py |
| InoSamplerHelper | 11 | sampler_helper.py, extra_nodes.py (InoRandomNoise) |
| InoSamplerHelper | 10 | sampler_helper.py |
| InoStringHelper | 13 | string_helper.py |
| InoTimeHelper | 4 | time_helper.py |
| InoVideoHelper | 1 | video_helper.py |
@@ -120,24 +121,14 @@ Nodes that deal with local files use:
- **Inputs**: `parent_folder` (combo: input/output/temp), `folder` (string), `filename` (string, if needed)
- **Outputs**: `success` (bool), `message` (string), `rel_path` (string), `abs_path` (string)
### S3 Upload Media Nodes (image/audio/video/string)
### S3 Nodes Convention
- Save to temp directory under node-specific subfolder (e.g., `temp/s3_upload_image/`)
- Local filename always uses `InoUtilHelper.get_date_time_utc_base64()` for uniqueness
- `unique_file_name` (default True) controls S3 filename: unique name or user-provided `filename`
- `filename` input strips extension via `Path(filename).stem` — extension comes from the media type
- No `execute` input, no `delete_local`, no `parent_folder`/`folder` — temp auto-cleans
### S3 Download Media Nodes (image/audio/video/string)
- Save to temp directory under node-specific subfolder
- No `parent_folder`/`folder` inputs — no `rel_path`/`abs_path` outputs
- Outputs: `success`, `message`, + the media type
### S3 Download/Upload File/Folder Nodes
- Use `parent_folder`/`folder`/`filename` pattern for local paths
- Upload file/folder have `execute` trigger input and `delete_local` option
All S3 nodes use `parent_folder`/`folder` for local paths and output `success`, `message`, `rel_path`, `abs_path`.
- **Upload file/folder**: have `execute` trigger input and `delete_local` option
- **Upload media** (image/audio/video/string): save to local first, then upload to S3
- **Download media** (image/audio): download from S3 to local `parent_folder`/`folder`
- **Sync**: bidirectional sync between S3 and local folder
- **Verify**: compare local file against S3 (size, md5, sha256)
### Result Handling
+2 -3
View File
@@ -14,7 +14,7 @@ Every file-related node follows a unified pattern with `parent_folder` (input/ou
---
> **Important:** This package requires `inopyutils` and `inocloudreve` packages. All S3 operations require S3 credentials configured via environment variables or the S3 Config node. OpenAI and Runpod nodes require their respective API keys. Basic Auth middleware is optional and only activates when `COMFYUI_USERNAME` and `COMFYUI_PASSWORD` are set.
> **Important:** This package requires `inopyutils`. All S3 operations require S3 credentials configured via environment variables or the S3 Config node. OpenAI and Runpod nodes require their respective API keys. Basic Auth middleware is optional and only activates when `COMFYUI_USERNAME` and `COMFYUI_PASSWORD` are set.
---
@@ -322,7 +322,6 @@ Video preview.
| Package | Purpose |
|---|---|
| `inopyutils` | Core utility functions (file, S3, HTTP, JSON helpers) |
| `inocloudreve` | Cloudreve cloud storage integration |
| `openai` | OpenAI API client |
| `aiohttp` | Async HTTP client and server middleware |
| `numpy` | Array operations |
@@ -335,7 +334,7 @@ Video preview.
| | |
|---|---|
| **Version** | 2.0.1 |
| **Version** | 2.0.5 |
| **Python** | >= 3.10 |
| **ComfyUI** | v0.18.1+ |
| **Schema** | V3 (all nodes) |
+7 -7
View File
@@ -63,6 +63,10 @@ from .src.comfyui_ino_nodes.node_helpers.video_helper import (
LOCAL_NODE_CLASS as VIDEO_HELPER_CLASS,
LOCAL_NODE_NAME as VIDEO_HELPER_NAME
)
from .src.comfyui_ino_nodes.node_helpers.list_helper import (
LOCAL_NODE_CLASS as LIST_HELPER_CLASS,
LOCAL_NODE_NAME as LIST_HELPER_NAME
)
#--------------------------------- S3 helper ---------------------------------
from .src.comfyui_ino_nodes.s3_helper import (
@@ -89,10 +93,6 @@ from .src.comfyui_ino_nodes.workflow_helpers.load_model_helper import (
LOCAL_NODE_CLASS as LOAD_MODEL_HELPER_CLASS,
LOCAL_NODE_NAME as LOAD_MODEL_HELPER_NAME
)
from .src.comfyui_ino_nodes.workflow_helpers.prompt_helper import (
LOCAL_NODE_CLASS as PROMPT_HELPER_CLASS,
LOCAL_NODE_NAME as PROMPT_HELPER_NAME
)
from .src.comfyui_ino_nodes.workflow_helpers.sampler_helper import (
LOCAL_NODE_CLASS as SAMPLER_HELPER_CLASS,
LOCAL_NODE_NAME as SAMPLER_HELPER_NAME
@@ -115,6 +115,7 @@ _node_classes.update(TIME_HELPER_CLASS)
_node_classes.update(CAST_HELPER_CLASS)
_node_classes.update(CAST_PATH_CLASS)
_node_classes.update(VIDEO_HELPER_CLASS)
_node_classes.update(LIST_HELPER_CLASS)
_node_classes.update(S3_HELPER_CLASS)
@@ -123,7 +124,6 @@ _node_classes.update(EXTRA_CLASS)
_node_classes.update(LORA_HELPER_CLASS)
_node_classes.update(DOWNLOAD_MODEL_HELPER_CLASS)
_node_classes.update(LOAD_MODEL_HELPER_CLASS)
_node_classes.update(PROMPT_HELPER_CLASS)
_node_classes.update(SAMPLER_HELPER_CLASS)
_node_names = {}
@@ -143,6 +143,7 @@ _node_names.update(TIME_HELPER_NAME)
_node_names.update(CAST_HELPER_NAME)
_node_names.update(CAST_PATH_NAME)
_node_names.update(VIDEO_HELPER_NAME)
_node_names.update(LIST_HELPER_NAME)
_node_names.update(S3_HELPER_NAME)
@@ -151,7 +152,6 @@ _node_names.update(EXTRA_NAME)
_node_names.update(LORA_HELPER_NAME)
_node_names.update(DOWNLOAD_MODEL_HELPER_NAME)
_node_names.update(LOAD_MODEL_HELPER_NAME)
_node_names.update(PROMPT_HELPER_NAME)
_node_names.update(SAMPLER_HELPER_NAME)
NODE_CLASS_MAPPINGS = _node_classes
@@ -167,7 +167,7 @@ __all__ = [
__author__ = """InoNodes"""
__email__ = "contact@inoland.net"
__version__ = "2.0.1"
__version__ = "2.0.5"
from .src.comfyui_ino_nodes.node_helper import log_capture
log_capture.install()
+21 -11
View File
@@ -121,22 +121,27 @@ def git_push():
def publish_comfy_registry(token: str):
"""Publish to Comfy Registry using comfy-cli."""
run(
["comfy", "node", "publish"],
env={"COMFY_REGISTRY_TOKEN": token},
)
print("Published to Comfy Registry")
print("\n> comfy node publish --token ***")
result = subprocess.run(["comfy", "node", "publish", "--token", token])
if result.returncode != 0:
print(f"Comfy Registry publish exited with code {result.returncode}")
else:
print("Published to Comfy Registry")
def create_github_release(version: str):
"""Create a GitHub release using gh CLI (uses existing gh auth)."""
run(
print(f"\n> gh release create v{version}")
result = subprocess.run(
["gh", "release", "create", f"v{version}",
"--title", f"v{version}",
"--notes", f"Release v{version}",
"--latest"],
)
print(f"Created GitHub release v{version}")
if result.returncode != 0:
print(f"GitHub release creation exited with code {result.returncode}")
else:
print(f"Created GitHub release v{version}")
def main():
@@ -186,10 +191,15 @@ def main():
print("Skipping Comfy Registry (COMFY_REGISTRY_TOKEN not set)")
# Create GitHub release (uses existing gh auth)
try:
create_github_release(new_version)
except SystemExit:
print("Warning: GitHub release creation failed. Is 'gh' installed and authenticated?")
import shutil
if shutil.which("gh"):
try:
create_github_release(new_version)
except Exception as e:
print(f"Warning: GitHub release creation failed: {e}")
else:
print("Skipping GitHub release ('gh' CLI not installed)")
print(f" Create manually: gh release create v{new_version} --title 'v{new_version}' --latest")
print(f"\nDone! v{new_version} published.")
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-InoNodes"
description = "125+ production-ready V3 custom nodes for ComfyUI covering file ops, S3 cloud storage, image/video/audio processing, model management (HuggingFace/Civitai/S3), OpenAI & Runpod integration, and workflow utilities."
version = "2.0.1"
version = "2.0.5"
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -1,4 +1,4 @@
inopyutils==1.8.2
inopyutils==2.0.3
openai
huggingface_hub
hf_xet
@@ -1,3 +1,5 @@
import asyncio
import shutil
from pathlib import Path
from inopyutils import InoFileHelper
@@ -230,6 +232,67 @@ class InoCopyFiles(io.ComfyNode):
return io.NodeOutput(res["success"], res["msg"], rel_path, abs_path, res["logs"])
class InoCopyFile(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoCopyFile",
display_name="Ino Copy File",
category="InoFileHelper",
description="Copies a single file from one location to another. Preserves modification time and permissions. Leave to_filename empty to keep the source filename.",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.Combo.Input("from_parent_folder", options=PARENT_FOLDER_OPTIONS),
io.String.Input("from_folder", default=""),
io.String.Input("from_filename", default=""),
io.Combo.Input("to_parent_folder", options=PARENT_FOLDER_OPTIONS),
io.String.Input("to_folder", default=""),
io.String.Input("to_filename", default="", optional=True, tooltip="Leave empty to keep the source filename."),
io.Boolean.Input("overwrite", default=False, optional=True),
],
outputs=[
io.Boolean.Output(display_name="success"),
io.String.Output(display_name="message"),
io.String.Output(display_name="rel_path"),
io.String.Output(display_name="abs_path"),
],
)
@classmethod
async def execute(cls, enabled, from_parent_folder, from_folder, from_filename,
to_parent_folder, to_folder, to_filename="", overwrite=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "Node is disabled", "", "")
if not from_filename:
return io.NodeOutput(False, "from_filename is required", "", "")
# Default the destination filename to the source filename if not provided.
target_filename = to_filename or from_filename
_, from_abs = resolve_comfy_path(from_parent_folder, from_folder, from_filename)
rel_path, to_abs = resolve_comfy_path(to_parent_folder, to_folder, target_filename)
from_path = Path(from_abs)
to_path = Path(to_abs)
if not from_path.is_file():
return io.NodeOutput(False, f"Source file not found: {from_abs}", rel_path, to_abs)
if to_path.exists() and not overwrite:
return io.NodeOutput(False, f"Destination already exists (overwrite=False): {to_abs}", rel_path, to_abs)
try:
await asyncio.to_thread(to_path.parent.mkdir, parents=True, exist_ok=True)
# shutil.copy2 preserves mtime + permissions, like the move helper does.
await asyncio.to_thread(shutil.copy2, str(from_path), str(to_path))
except Exception as e:
return io.NodeOutput(False, f"Copy failed: {e}", rel_path, to_abs)
return io.NodeOutput(True, "copied", rel_path, to_abs)
class InoCountFiles(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -374,7 +437,7 @@ class InoGetLastFile(io.ComfyNode):
return io.NodeOutput(False, "Node is disabled", "", "")
_, abs_path = resolve_comfy_path(parent_folder, folder)
res = InoFileHelper.get_last_file(path=Path(abs_path))
res = await asyncio.to_thread(InoFileHelper.get_last_file, path=Path(abs_path))
if not res["success"]:
return io.NodeOutput(False, res["msg"], "", "")
return io.NodeOutput(True, res["msg"], res.get("file_name", ""), res.get("file_path", ""))
@@ -496,6 +559,7 @@ LOCAL_NODE_CLASS = {
"InoRemoveFile": InoRemoveFile,
"InoRemoveFolder": InoRemoveFolder,
"InoCopyFiles": InoCopyFiles,
"InoCopyFile": InoCopyFile,
"InoCountFiles": InoCountFiles,
"InoValidateMediaFiles": InoValidateMediaFiles,
"InoRemoveDuplicateFiles": InoRemoveDuplicateFiles,
@@ -511,6 +575,7 @@ LOCAL_NODE_NAME = {
"InoRemoveFile": "Ino Remove File",
"InoRemoveFolder": "Ino Remove Folder",
"InoCopyFiles": "Ino Copy Files",
"InoCopyFile": "Ino Copy File",
"InoCountFiles": "Ino Count Files",
"InoValidateMediaFiles": "Ino Validate Media Files",
"InoRemoveDuplicateFiles": "Ino Remove Duplicate Files",
@@ -24,7 +24,6 @@ class InoJsonSetField(io.ComfyNode):
io.Boolean.Output(display_name="success"),
io.String.Output(display_name="message"),
io.String.Output(display_name="json"),
io.String.Output(display_name="json_as_dict"),
],
)
@@ -34,20 +33,20 @@ class InoJsonSetField(io.ComfyNode):
json_object = InoJsonHelper.string_to_dict(base_json)
if not json_object["success"]:
ino_print_log("InoJsonSetField", json_object["msg"])
return io.NodeOutput(False, json_object["msg"], "", "")
return io.NodeOutput(False, json_object["msg"], "")
json_object = json_object["data"]
json_object[field_name] = field_value
json_string = InoJsonHelper.dict_to_string(json_object)
if not json_string["success"]:
ino_print_log("InoJsonSetField", json_string["msg"])
return io.NodeOutput(False, json_string["msg"], "", "")
return io.NodeOutput(False, json_string["msg"], "")
ino_print_log("InoJsonSetField", "Success")
return io.NodeOutput(True, "Success", json_string["data"], str(json_object))
return io.NodeOutput(True, "Success", json_string["data"])
except Exception as e:
ino_print_log("InoJsonSetField", "", str(e))
return io.NodeOutput(False, f"failed: {e}", "", "")
return io.NodeOutput(False, f"failed: {e}", "")
class InoJsonGetField(io.ComfyNode):
@@ -75,14 +74,14 @@ class InoJsonGetField(io.ComfyNode):
json_object = InoJsonHelper.string_to_dict(base_json)
if not json_object["success"]:
ino_print_log("InoJsonGetField", json_object["msg"])
return io.NodeOutput(False, json_object["msg"], "")
return io.NodeOutput(False, json_object["msg"], None)
json_object = json_object["data"]
ino_print_log("InoJsonGetField", "Success")
return io.NodeOutput(True, "Success", json_object[field_name])
except Exception as e:
ino_print_log("InoJsonGetField", "", str(e))
return io.NodeOutput(False, f"failed: {e}", "")
return io.NodeOutput(False, f"failed: {e}", None)
class InoSaveJson(io.ComfyNode):
@@ -95,7 +94,6 @@ class InoSaveJson(io.ComfyNode):
description="Saves a JSON string to a file in the specified folder.",
is_output_node=True,
inputs=[
io.AnyType.Input("execute"),
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("json_string", default="{}"),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
@@ -111,11 +109,9 @@ class InoSaveJson(io.ComfyNode):
)
@classmethod
async def execute(cls, execute, enabled, json_string, parent_folder, folder, filename) -> io.NodeOutput:
async def execute(cls, enabled, json_string, parent_folder, folder, filename) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "")
if not execute:
return io.NodeOutput(False, "execute is false", "", "")
return io.NodeOutput(True, "skipped: not enabled", "", "")
try:
rel_path, abs_path = resolve_comfy_path(parent_folder, folder, filename)
@@ -1,3 +1,4 @@
import asyncio
import os
from inopyutils import InoJsonHelper, InoOpenAIHelper
@@ -60,7 +61,7 @@ class InoOpenaiResponses(io.ComfyNode):
}
]
response = client.responses.create(model=model, input=response_input)
response = await asyncio.to_thread(client.responses.create, model=model, input=response_input)
error_message = response.error.message if response.error else "none"
response_output = InoJsonHelper.dict_to_string(response.output)["data"] if response.output else "empty"
@@ -72,6 +73,25 @@ class InoOpenaiResponses(io.ComfyNode):
return io.NodeOutput(False, "", "Openai response failed", str(e), "", "")
def _serialize_tool_calls(tool_calls) -> str:
"""Best-effort JSON serialization of OpenAI tool_calls (list of objects)."""
if not tool_calls:
return ""
try:
# OpenAI SDK objects expose model_dump(); fall back to dict() / str()
serialized = []
for tc in tool_calls:
if hasattr(tc, "model_dump"):
serialized.append(tc.model_dump())
elif hasattr(tc, "dict"):
serialized.append(tc.dict())
else:
serialized.append(str(tc))
return InoJsonHelper.dict_to_string(serialized)["data"]
except Exception:
return str(tool_calls)
class InoOpenaiChatCompletions(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -79,9 +99,10 @@ class InoOpenaiChatCompletions(io.ComfyNode):
node_id="InoOpenaiChatCompletions",
display_name="Ino Openai Chat Completions",
category="InoOpenaiHelper",
description="Sends a chat completion request to any OpenAI-compatible API with optional system prompt and image.",
description="Sends a chat completion request to any OpenAI-compatible API (OpenAI, vLLM, RunPod, Modal) with optional system prompt and image.",
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
#io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff, control_after_generate=True),
io.String.Input("user_prompt", default=""),
io.String.Input("openai_api_key", default="", optional=True),
io.String.Input("base_url", default="https://api.openai.com/v1", optional=True),
@@ -90,41 +111,66 @@ class InoOpenaiChatCompletions(io.ComfyNode):
io.String.Input("image_url", default="", optional=True),
io.Float.Input("temperature", default=0.7, min=0.0, max=2.0, step=0.1, optional=True),
io.Int.Input("max_tokens", default=1024, min=1, max=128000, optional=True),
io.Float.Input("top_p", default=1.0, min=0.0, max=1.0, step=0.05, optional=True),
io.Boolean.Input("enable_thinking", default=True, label_off="OFF", label_on="ON", optional=True, tooltip="Qwen3-style thinking phase toggle for vLLM. Ignored by OpenAI-only servers."),
],
outputs=[
io.Boolean.Output(display_name="success"),
io.String.Output(display_name="response"),
io.String.Output(display_name="finish_reason"),
io.String.Output(display_name="error"),
io.String.Output(display_name="reasoning"),
io.String.Output(display_name="tool_calls"),
io.Int.Output(display_name="prompt_tokens"),
io.Int.Output(display_name="completion_tokens"),
io.Int.Output(display_name="total_tokens"),
],
)
@classmethod
async def execute(cls, enabled, user_prompt, openai_api_key="", base_url="https://api.openai.com/v1",
model="gpt-5", system_prompt="", image_url="", temperature=0.7, max_tokens=1024) -> io.NodeOutput:
model="gpt-5", system_prompt="", image_url="", temperature=0.7, max_tokens=1024,
top_p=1.0, enable_thinking=True) -> io.NodeOutput:
if not enabled:
ino_print_log("InoOpenaiChatCompletions", "Node is disabled")
return io.NodeOutput(False, "", "", "not enabled")
return io.NodeOutput(False, "", "", "not enabled", "", "", 0, 0, 0)
try:
api_key = openai_api_key if openai_api_key else os.getenv('OPENAI_TOKEN', '')
base_url = base_url if base_url else os.getenv('OPENAI_URL', '')
model = model if model else os.getenv('OPENAI_MODEL', '')
image = image_url if image_url else None
result = await InoOpenAIHelper.chat_completions(
api_key=api_key, base_url=base_url, model=model,
user_prompt=user_prompt, system_prompt=system_prompt,
image=image, temperature=temperature, max_tokens=max_tokens,
top_p=top_p, enable_thinking=enable_thinking,
)
if result.get("success"):
return io.NodeOutput(True, result.get("response", ""), result.get("finish_reason", ""), "none")
usage = result.get("usage") or {}
reasoning = result.get("reasoning") or ""
tool_calls_str = _serialize_tool_calls(result.get("tool_calls"))
return io.NodeOutput(
True,
result.get("response", ""),
result.get("finish_reason") or "",
"none",
reasoning,
tool_calls_str,
int(usage.get("prompt_tokens") or 0),
int(usage.get("completion_tokens") or 0),
int(usage.get("total_tokens") or 0),
)
else:
error_msg = result.get("message", "unknown error")
# ino_err uses the key "msg", not "message"
error_msg = result.get("msg", "unknown error")
ino_print_log("InoOpenaiChatCompletions", "", error_msg)
return io.NodeOutput(False, "", "", error_msg)
return io.NodeOutput(False, "", "", error_msg, "", "", 0, 0, 0)
except Exception as e:
ino_print_log("InoOpenaiChatCompletions", "", e)
return io.NodeOutput(False, "", "", str(e))
return io.NodeOutput(False, "", "", str(e), "", "", 0, 0, 0)
LOCAL_NODE_CLASS = {
@@ -25,6 +25,10 @@ class InoVllmRunSync(io.ComfyNode):
io.String.Input("image_url", default="", optional=True),
io.Float.Input("temperature", default=0.7, min=0.0, max=2.0, step=0.1, optional=True),
io.Int.Input("max_tokens", default=1024, min=1, max=128000, optional=True),
io.Float.Input("timeout", default=600.0, min=10.0, max=600.0, step=10.0, optional=True),
io.Int.Input("max_polls", default=70, min=1, max=100, optional=True),
io.Float.Input("poll_delay", default=10.0, min=1.0, max=60.0, step=1.0, optional=True),
io.Int.Input("max_failed_retries", default=10, min=0, max=20, optional=True),
],
outputs=[
io.Boolean.Output(display_name="success"),
@@ -33,28 +37,36 @@ class InoVllmRunSync(io.ComfyNode):
io.Int.Output(display_name="delay_time"),
io.Int.Output(display_name="execution_time"),
io.String.Output(display_name="response"),
io.String.Output(display_name="reasoning"),
io.String.Output(display_name="finish_reason"),
],
)
@classmethod
async def execute(cls, enabled, url, api_key, model, user_prompt,
system_prompt="", image_url="", temperature=0.7, max_tokens=1024) -> io.NodeOutput:
system_prompt="", image_url="", temperature=0.7, max_tokens=1024,
timeout=300.0, max_polls=24, poll_delay=10.0, max_failed_retries=5) -> io.NodeOutput:
if not enabled:
ino_print_log("InoVllmRunSync", "Node is disabled")
return io.NodeOutput(False, "", "not enabled", 0, 0, "")
return io.NodeOutput(False, "", "not enabled", 0, 0, "", "", "")
try:
api_key = api_key if api_key else os.getenv('RUNPOD_API_KEY', '')
api_key = api_key if api_key else os.getenv('RUNPOD_LLM_API', '')
url = url if url else os.getenv('RUNPOD_LLM_URL', '')
model = model if model else os.getenv('RUNPOD_LLM_MODEL', '')
image = image_url if image_url else None
response = await InoRunpodHelper.serverless_vllm_runsync(
url=url, api_key=api_key, model=model,
user_prompt=user_prompt, system_prompt=system_prompt,
image=image, temperature=temperature, max_tokens=max_tokens,
timeout=timeout, max_polls=max_polls, poll_delay=poll_delay,
max_failed_retries=max_failed_retries,
)
if ino_is_err(response):
return io.NodeOutput(False, "", response.get("msg", "unknown error"), 0, 0, "")
return io.NodeOutput(False, "", response.get("msg", "unknown error"), 0, 0, "", "", "")
return io.NodeOutput(
True,
@@ -63,10 +75,12 @@ class InoVllmRunSync(io.ComfyNode):
response.get("delay_time", 0),
response.get("execution_time", 0),
response.get("response", ""),
response.get("reasoning", ""),
response.get("finish_reason", ""),
)
except Exception as e:
ino_print_log("InoVllmRunSync", "", e)
return io.NodeOutput(False, "", f"response failed: {e}", 0, 0, "")
return io.NodeOutput(False, "", f"response failed: {e}", 0, 0, "", "", "")
LOCAL_NODE_CLASS = {
+32 -20
View File
@@ -6,37 +6,48 @@ import collections
import threading
class LogCapture:
"""Captures terminal output (stdout) into a ring buffer."""
class _StreamInterceptor:
"""Wraps a stream (stdout/stderr) and copies output to a shared buffer."""
def __init__(self, max_lines=10000):
self._buffer = collections.deque(maxlen=max_lines)
self._lock = threading.Lock()
self._original_stdout = None
self._installed = False
def install(self):
if self._installed:
return
self._original_stdout = sys.stdout
sys.stdout = self
self._installed = True
def __init__(self, original, buffer, lock):
self._original = original
self._buffer = buffer
self._lock = lock
@property
def encoding(self):
return getattr(self._original_stdout, "encoding", "utf-8")
return getattr(self._original, "encoding", "utf-8")
def write(self, text):
if self._original_stdout:
self._original_stdout.write(text)
if self._original:
self._original.write(text)
if text and text != "\n":
with self._lock:
for line in text.rstrip("\n").split("\n"):
self._buffer.append(line)
def flush(self):
if self._original_stdout:
self._original_stdout.flush()
if self._original:
self._original.flush()
def __getattr__(self, name):
return getattr(self._original, name)
class LogCapture:
"""Captures terminal output (stdout + stderr) into a ring buffer."""
def __init__(self, max_lines=10000):
self._buffer = collections.deque(maxlen=max_lines)
self._lock = threading.Lock()
self._installed = False
def install(self):
if self._installed:
return
sys.stdout = _StreamInterceptor(sys.stdout, self._buffer, self._lock)
sys.stderr = _StreamInterceptor(sys.stderr, self._buffer, self._lock)
self._installed = True
def get_lines(self, count):
with self._lock:
@@ -104,10 +115,11 @@ MODEL_TYPES = (
UNET_WEIGHT_DTYPE= ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"]
CLIP_TYPE= ["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis"]
CLIP_TYPE= ["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit", "ideogram4", "boogu", "krea2"]
PARENT_FOLDER_OPTIONS = ["input", "output", "temp"]
def _load_csv_as_dict(is_config: bool, model_type: str) -> list:
base_dir = Path(__file__).resolve().parent.parent.parent
middle_dir = "configs" if is_config else "files"
@@ -128,6 +128,27 @@ class InoCastAnyToControlnet(io.ComfyNode):
return io.NodeOutput(input_any)
class InoCastStringToCombo(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoCastStringToCombo",
display_name="Ino Cast String To Combo",
category="InoCastHelper",
description="Converts a string to a combo-compatible output. Wire this into any combo input (e.g. parent_folder).",
inputs=[
io.String.Input("input_string", default=""),
],
outputs=[
io.AnyType.Output(display_name="combo"),
],
)
@classmethod
def execute(cls, input_string) -> io.NodeOutput:
return io.NodeOutput(input_string)
LOCAL_NODE_CLASS = {
"InoCastAnyToString": InoCastAnyToString,
"InoCastAnyToInt": InoCastAnyToInt,
@@ -135,6 +156,7 @@ LOCAL_NODE_CLASS = {
"InoCastAnyToClip": InoCastAnyToClip,
"InoCastAnyToVae": InoCastAnyToVae,
"InoCastAnyToControlnet": InoCastAnyToControlnet,
"InoCastStringToCombo": InoCastStringToCombo,
}
LOCAL_NODE_NAME = {
"InoCastAnyToString": "Ino Cast Any To String",
@@ -143,4 +165,5 @@ LOCAL_NODE_NAME = {
"InoCastAnyToClip": "Ino Cast Any To Clip",
"InoCastAnyToVae": "Ino Cast Any To Vae",
"InoCastAnyToControlnet": "Ino Cast Any To Controlnet",
"InoCastStringToCombo": "Ino Cast String To Combo",
}
@@ -60,11 +60,49 @@ class InoCompareFloat(io.ComfyNode):
}
return io.NodeOutput(ops[compare])
class InoMathFloat(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoMathFloat",
display_name="Ino Math Float",
category="InoFloatHelper",
description="Performs a math operation on two floats: add, subtract, multiply, divide, modulo, power.",
inputs=[
io.Float.Input("float_a", default=0.0),
io.Float.Input("float_b", default=0.0),
io.Combo.Input("operation", options=["add", "subtract", "multiply", "divide", "modulo", "power"]),
],
outputs=[
io.Float.Output(display_name="float"),
io.Int.Output(display_name="int"),
],
)
@classmethod
def execute(cls, float_a, float_b, operation) -> io.NodeOutput:
if operation == "add":
r = float_a + float_b
elif operation == "subtract":
r = float_a - float_b
elif operation == "multiply":
r = float_a * float_b
elif operation == "divide":
r = float_a / float_b if float_b != 0 else 0.0
elif operation == "modulo":
r = math.fmod(float_a, float_b) if float_b != 0 else 0.0
else:
r = float_a ** float_b
return io.NodeOutput(float(r), int(r))
LOCAL_NODE_CLASS = {
"InoFloatToInt": InoFloatToInt,
"InoCompareFloat": InoCompareFloat,
"InoMathFloat": InoMathFloat,
}
LOCAL_NODE_NAME = {
"InoFloatToInt": "Ino Float To Int",
"InoCompareFloat": "Ino Compare Float",
"InoMathFloat": "Ino Math Float",
}
@@ -1,3 +1,4 @@
import asyncio
import base64
import io as std_io
import torch
@@ -17,7 +18,7 @@ from comfy_api.latest import io
from inopyutils import InoJsonHelper, ino_is_err
from ..node_helper import PARENT_FOLDER_OPTIONS, resolve_comfy_path, load_images_from_folder
from ..node_helper import PARENT_FOLDER_OPTIONS, resolve_comfy_path, load_image, load_images_from_folder
MAX_RESOLUTION = nodes.MAX_RESOLUTION
@@ -79,7 +80,7 @@ class InoSaveImages(io.ComfyNode):
)
@classmethod
def execute(cls, images, parent_folder, folder, filename_prefix) -> io.NodeOutput:
async def execute(cls, images, parent_folder, folder, filename_prefix) -> io.NodeOutput:
time_now = datetime.now(timezone.utc).isoformat()
rel_path, abs_path = resolve_comfy_path(parent_folder, folder)
@@ -96,20 +97,23 @@ class InoSaveImages(io.ComfyNode):
from comfy.cli_args import args
from PIL.PngImagePlugin import PngInfo
results = []
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
img = PILImage.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
images_cpu = [img.cpu().numpy() for img in images]
include_metadata = not args.disable_metadata
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
full_path = os.path.join(full_output_folder, file)
img.save(full_path, pnginfo=metadata, compress_level=4)
results.append({"filename": file, "subfolder": subfolder, "type": parent_folder})
counter += 1
def _save_all():
saved = []
local_counter = counter
for batch_number, arr in enumerate(images_cpu):
pil_img = PILImage.fromarray(np.clip(255. * arr, 0, 255).astype(np.uint8))
metadata = PngInfo() if include_metadata else None
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{local_counter:05}_.png"
pil_img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=4)
saved.append({"filename": file, "subfolder": subfolder, "type": parent_folder})
local_counter += 1
return saved
results = await asyncio.to_thread(_save_all)
if len(results) == 0:
return io.NodeOutput(False, "No images saved", rel_path, abs_path, 0, time_now)
@@ -202,14 +206,12 @@ class InoImageResizeByLongerSideAndCropV2(io.ComfyNode):
resize_height = target_height
resize_width = round((target_height / source_height) * source_width)
resizer = ResizeAndPadImage()
resized_image = resizer.resize_and_pad(image, resize_width, resize_height, padding_color, interpolation)
resized_image = ResizeAndPadImage.execute(image, resize_width, resize_height, padding_color, interpolation)
if not crop:
return io.NodeOutput(resized_image[0])
return io.NodeOutput(resized_image.args[0])
cropper = ImageCrop()
canvas = resized_image[0]
canvas = resized_image.args[0]
canvas_h = int(canvas.shape[1])
canvas_w = int(canvas.shape[2])
@@ -238,9 +240,71 @@ class InoImageResizeByLongerSideAndCropV2(io.ComfyNode):
crop_x = clamp(crop_x, 0, max(0, canvas_w - 1))
crop_y = clamp(crop_y, 0, max(0, canvas_h - 1))
cropped_image = cropper.crop(canvas, int(target_width), int(target_height), int(crop_x), int(crop_y))
cropped_image = ImageCrop.execute(canvas, int(target_width), int(target_height), int(crop_x), int(crop_y))
return io.NodeOutput(cropped_image[0])
return io.NodeOutput(cropped_image.args[0])
class InoResizeCropImage(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoResizeCropImage",
display_name="Ino Resize Crop Image",
category="InoImageHelper",
description="Resizes an image to cover the target width/height, then crops to the exact size at the selected position.",
inputs=[
io.Image.Input("image"),
io.Int.Input("width", default=512, min=1, max=MAX_RESOLUTION, step=1),
io.Int.Input("height", default=512, min=1, max=MAX_RESOLUTION, step=1),
io.Combo.Input("crop_position", options=[
"top-left", "top-center", "top-right",
"center-left", "center", "center-right",
"bottom-left", "bottom-center", "bottom-right",
], default="center"),
io.Combo.Input("interpolation", options=["lanczos", "bicubic", "bilinear", "area", "nearest-exact"], default="lanczos"),
],
outputs=[
io.Image.Output(display_name="image"),
],
)
@classmethod
def execute(cls, image, width, height, crop_position, interpolation) -> io.NodeOutput:
from comfy.utils import common_upscale
target_w = int(width)
target_h = int(height)
src_h = int(image.shape[1])
src_w = int(image.shape[2])
scale = max(target_w / src_w, target_h / src_h)
resize_w = max(target_w, int(round(src_w * scale)))
resize_h = max(target_h, int(round(src_h * scale)))
img = image.movedim(-1, 1)
img = common_upscale(img, resize_w, resize_h, interpolation, "disabled")
img = img.movedim(1, -1)
canvas_h = int(img.shape[1])
canvas_w = int(img.shape[2])
if "left" in crop_position:
x = 0
elif "right" in crop_position:
x = max(0, canvas_w - target_w)
else:
x = max(0, (canvas_w - target_w) // 2)
if "top" in crop_position:
y = 0
elif "bottom" in crop_position:
y = max(0, canvas_h - target_h)
else:
y = max(0, (canvas_h - target_h) // 2)
cropped = img[:, y:y + target_h, x:x + target_w, :]
return io.NodeOutput(cropped)
class InoLoadImagesFromFolder(io.ComfyNode):
@@ -280,6 +344,69 @@ class InoLoadImagesFromFolder(io.ComfyNode):
return io.NodeOutput(True, f"Loaded {len(output_images)} images", rel_path, abs_path, output_images, output_masks, len(output_images))
class InoLoadImage(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoLoadImage",
display_name="Ino Load Image",
category="InoImageHelper",
description="Loads a single image from input/output/temp by parent_folder + folder + filename. Returns (success, message, rel_path, abs_path, image, mask).",
inputs=[
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
io.String.Input("folder", default=""),
io.String.Input("filename", default=""),
],
outputs=[
io.Boolean.Output(display_name="success"),
io.String.Output(display_name="message"),
io.String.Output(display_name="rel_path"),
io.String.Output(display_name="abs_path"),
io.Image.Output(display_name="image"),
io.Mask.Output(display_name="mask"),
],
)
# Extension fallback order when filename is provided without a suffix.
EXTENSION_FALLBACKS = (".jpg", ".png")
@classmethod
async def execute(cls, parent_folder, folder, filename) -> io.NodeOutput:
rel_path, abs_path = resolve_comfy_path(parent_folder, folder, filename)
def _empty():
from nodes import EmptyImage
empty_image = EmptyImage().generate(512, 512)[0]
empty_mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu").unsqueeze(0)
return empty_image, empty_mask
if not filename:
empty_image, empty_mask = _empty()
return io.NodeOutput(False, "filename is required", rel_path, abs_path, empty_image, empty_mask)
# If the user didn't include an extension, probe the fallback list
# (jpg first, then png) and use the first match found on disk.
if not Path(filename).suffix:
for ext in cls.EXTENSION_FALLBACKS:
candidate_rel, candidate_abs = resolve_comfy_path(parent_folder, folder, filename + ext)
if Path(candidate_abs).is_file():
rel_path, abs_path = candidate_rel, candidate_abs
break
if not Path(abs_path).is_file():
empty_image, empty_mask = _empty()
return io.NodeOutput(False, f"File not found: {abs_path}", rel_path, abs_path, empty_image, empty_mask)
try:
# load_image is synchronous (PIL + torch) — offload to a thread.
image, mask = await asyncio.to_thread(load_image, abs_path)
except Exception as e:
empty_image, empty_mask = _empty()
return io.NodeOutput(False, f"Failed to load image: {e}", rel_path, abs_path, empty_image, empty_mask)
return io.NodeOutput(True, "loaded", rel_path, abs_path, image, mask)
class InoImageListToBatch(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -563,10 +690,329 @@ class InoImagesToReferenceLatent(io.ComfyNode):
return io.NodeOutput(latents, pos_cond, neg_cond)
class InoIsImagePortrait(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoIsImagePortrait",
display_name="Ino Is Image Portrait",
category="InoImageHelper",
description="Returns True if the image height is greater than its width.",
inputs=[
io.Image.Input("image"),
],
outputs=[
io.Boolean.Output(display_name="is_portrait"),
],
)
@classmethod
def execute(cls, image) -> io.NodeOutput:
_, h, w, _ = image.shape
return io.NodeOutput(h > w)
class InoIsImageLandscape(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoIsImageLandscape",
display_name="Ino Is Image Landscape",
category="InoImageHelper",
description="Returns True if the image width is greater than its height.",
inputs=[
io.Image.Input("image"),
],
outputs=[
io.Boolean.Output(display_name="is_landscape"),
],
)
@classmethod
def execute(cls, image) -> io.NodeOutput:
_, h, w, _ = image.shape
return io.NodeOutput(w > h)
class InoIsImageSquare(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoIsImageSquare",
display_name="Ino Is Image Square",
category="InoImageHelper",
description="Returns True if the image width equals its height.",
inputs=[
io.Image.Input("image"),
],
outputs=[
io.Boolean.Output(display_name="is_square"),
],
)
@classmethod
def execute(cls, image) -> io.NodeOutput:
_, h, w, _ = image.shape
return io.NodeOutput(h == w)
import math
ASPECT_RATIO_OPTIONS = [
"1:1 Square",
"9:16 Vertical Video",
"16:9 Widescreen",
"4:5 Social Media",
"5:4 Classic Monitor",
"3:4 Tablet",
"4:3 Standard TV",
"2:3 Film Photo",
"3:2 DSLR Photo",
"5:7 Print",
"7:5 Print Wide",
"1:2 Tall Banner",
"2:1 Panoramic",
"6:13 iPhone Portrait",
"13:6 iPhone Landscape",
"9:20 Android Portrait",
"20:9 Android Landscape",
]
ASPECT_RATIO_MAP = {
"1:1 Square": (1, 1),
"9:16 Vertical Video": (9, 16),
"16:9 Widescreen": (16, 9),
"4:5 Social Media": (4, 5),
"5:4 Classic Monitor": (5, 4),
"3:4 Tablet": (3, 4),
"4:3 Standard TV": (4, 3),
"2:3 Film Photo": (2, 3),
"3:2 DSLR Photo": (3, 2),
"5:7 Print": (5, 7),
"7:5 Print Wide": (7, 5),
"1:2 Tall Banner": (1, 2),
"2:1 Panoramic": (2, 1),
"6:13 iPhone Portrait": (6, 13),
"13:6 iPhone Landscape": (13, 6),
"9:20 Android Portrait": (9, 20),
"20:9 Android Landscape": (20, 9),
}
class InoMegapixelResolution(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoMegapixelResolution",
display_name="Ino Megapixel Resolution",
category="InoImageHelper",
description="Calculate width and height from megapixels and aspect ratio.",
inputs=[
io.Float.Input("megapixels", default=1.0, min=0.01, max=100.0, step=0.01),
io.Combo.Input("aspect_ratio", options=ASPECT_RATIO_OPTIONS, default="1:1 Square"),
io.String.Input("custom_aspect_ratio", default=""),
io.Int.Input("divisible_by", default=16, min=1, max=512),
],
outputs=[
io.Int.Output(display_name="width"),
io.Int.Output(display_name="height"),
io.Int.Output(display_name="aspect_ratio_x"),
io.Int.Output(display_name="aspect_ratio_y"),
io.String.Output(display_name="aspect_ratio"),
],
)
@classmethod
def execute(cls, megapixels, aspect_ratio, custom_aspect_ratio, divisible_by) -> io.NodeOutput:
# Parse aspect ratio
custom = custom_aspect_ratio.strip()
if custom and ":" in custom:
parts = custom.split(":")
try:
ar_x = int(parts[0].strip())
ar_y = int(parts[1].strip())
if ar_x <= 0 or ar_y <= 0:
raise ValueError("Aspect ratio values must be positive")
except (ValueError, IndexError):
ar_x, ar_y = ASPECT_RATIO_MAP.get(aspect_ratio, (1, 1))
else:
ar_x, ar_y = ASPECT_RATIO_MAP.get(aspect_ratio, (1, 1))
total_pixels = megapixels * 1_000_000
# width / height = ar_x / ar_y
# width * height = total_pixels
# width = sqrt(total_pixels * ar_x / ar_y)
raw_width = math.sqrt(total_pixels * ar_x / ar_y)
raw_height = math.sqrt(total_pixels * ar_y / ar_x)
# Round to nearest divisible_by
width = max(divisible_by, round(raw_width / divisible_by) * divisible_by)
height = max(divisible_by, round(raw_height / divisible_by) * divisible_by)
ar_string = f"{ar_x}:{ar_y}"
return io.NodeOutput(width, height, ar_x, ar_y, ar_string)
# ---------------------------------------------------------------------------
# NSFW detection
# ---------------------------------------------------------------------------
# Module-level cache of (processor, model, device, id2label) bundles, keyed
# by "{model_id}|{cpu|gpu}". We cache outside the node class because V3
# ComfyUI locks the node class at execution time, which forbids
# `cls.<attr> = value`.
_NSFW_CLASSIFIERS: dict = {}
def _get_nsfw_classifier(model_id: str, use_gpu: bool):
"""Lazy-load and cache the processor + model.
We deliberately bypass `transformers.pipeline()` because it loads the
model with meta tensors (low_cpu_mem_usage=True default) and then calls
`model.to(device)` which fails with:
"Cannot copy out of meta tensor; no data! Please use
torch.nn.Module.to_empty() instead of torch.nn.Module.to()..."
on certain transformers + accelerate version combinations.
Loading explicitly with `low_cpu_mem_usage=False` keeps the weights on
real (non-meta) tensors so the subsequent .to(device) is a normal copy.
On first call for a given (model_id, device) pair the weights are
downloaded via the standard HuggingFace cache (~/.cache/huggingface/hub
or HF_HOME).
"""
cache_key = f"{model_id}|{'gpu' if use_gpu else 'cpu'}"
cached = _NSFW_CLASSIFIERS.get(cache_key)
if cached is not None:
return cached
from transformers import AutoImageProcessor, AutoModelForImageClassification
processor = AutoImageProcessor.from_pretrained(model_id)
model = AutoModelForImageClassification.from_pretrained(
model_id,
low_cpu_mem_usage=False, # avoid meta-tensor materialization path
)
device = torch.device("cuda" if (use_gpu and torch.cuda.is_available()) else "cpu")
model = model.to(device)
model.eval()
id2label = {int(k): str(v) for k, v in model.config.id2label.items()}
bundle = (processor, model, device, id2label)
_NSFW_CLASSIFIERS[cache_key] = bundle
return bundle
def _classify_and_maybe_blur(image_chw_tensor, classifier, threshold: float, blur_radius: int):
"""Synchronous helper: classify a single (H, W, C) torch tensor [0,1] and
blur it if NSFW score >= threshold and blur_radius > 0.
When blur_radius == 0 the image is returned unchanged even if flagged —
useful for detect-only workflows where the caller wants the raw image
plus the `is_nsfw` flag and decides downstream what to do.
Returns (out_arr_hwc_float, nsfw_score, sfw_score, is_nsfw).
"""
from PIL import Image as PILImage, ImageFilter
processor, model, device, id2label = classifier
arr_uint8 = (image_chw_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil = PILImage.fromarray(arr_uint8)
inputs = processor(images=pil, return_tensors="pt")
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
logits = model(**inputs).logits
probs = torch.softmax(logits, dim=-1)[0].detach().cpu().tolist()
scores: dict = {}
for idx, p in enumerate(probs):
label = id2label.get(idx, str(idx))
scores[label.upper()] = float(p)
nsfw_score = scores.get("NSFW", 0.0)
sfw_score = scores.get("SFW", 0.0)
is_nsfw = nsfw_score >= threshold
if is_nsfw and blur_radius > 0:
blurred = pil.filter(ImageFilter.GaussianBlur(radius=blur_radius))
out_arr = np.array(blurred, dtype=np.float32) / 255.0
# GaussianBlur preserves mode; if the source had no alpha, the output
# is HxWx3 — same shape as the original tensor.
else:
out_arr = image_chw_tensor.cpu().numpy().astype(np.float32)
return out_arr, nsfw_score, sfw_score, is_nsfw
class InoNsfwDetect(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoNsfwDetect",
display_name="Ino NSFW Detect",
category="InoImageHelper",
description=(
"Classifies the image with Marqo/nsfw-image-detection-384. "
"Returns the original image when SFW. When NSFW and blur_radius > 0, returns a Gaussian-blurred copy; "
"set blur_radius to 0 for detect-only mode (image unchanged, is_nsfw still reflects the verdict). "
"The model is downloaded once on first use and cached by the HuggingFace transformers cache."
),
inputs=[
io.Image.Input("image"),
io.Float.Input("threshold", default=0.5, min=0.0, max=1.0, step=0.05, tooltip="NSFW score above which the image is flagged."),
io.Int.Input("blur_radius", default=30, min=0, max=200, optional=True, tooltip="Gaussian blur radius applied to flagged images. Set to 0 to skip blurring even when the image is flagged (detect-only mode)."),
io.String.Input("model_id", default="Marqo/nsfw-image-detection-384", optional=True, tooltip="HuggingFace model id. Must be an image-classification model with NSFW/SFW labels."),
io.Boolean.Input("use_gpu", default=False, optional=True, label_off="CPU", label_on="GPU", tooltip="Run the classifier on CUDA when available. CPU is recommended to avoid evicting diffusion weights from VRAM."),
],
outputs=[
io.Image.Output(display_name="image"),
io.Boolean.Output(display_name="is_nsfw"),
io.Float.Output(display_name="nsfw_score"),
io.Float.Output(display_name="sfw_score"),
io.String.Output(display_name="message"),
],
)
@classmethod
async def execute(cls, image, threshold=0.5, blur_radius=30, model_id="Marqo/nsfw-image-detection-384", use_gpu=False) -> io.NodeOutput:
if image is None or image.shape[0] == 0:
return io.NodeOutput(image, False, 0.0, 0.0, "empty input")
try:
classifier = await asyncio.to_thread(_get_nsfw_classifier, model_id, use_gpu)
except Exception as e:
return io.NodeOutput(image, False, 0.0, 0.0, f"Failed to load classifier: {e}")
try:
out_tensors: list[torch.Tensor] = []
any_nsfw = False
max_nsfw = 0.0
max_sfw = 0.0
for i in range(image.shape[0]):
arr, nsfw, sfw, is_nsfw = await asyncio.to_thread(
_classify_and_maybe_blur, image[i], classifier, threshold, blur_radius
)
out_tensors.append(torch.from_numpy(arr).unsqueeze(0))
any_nsfw = any_nsfw or is_nsfw
if nsfw > max_nsfw:
max_nsfw = nsfw
if sfw > max_sfw:
max_sfw = sfw
out_image = torch.cat(out_tensors, dim=0)
return io.NodeOutput(out_image, any_nsfw, float(max_nsfw), float(max_sfw), "ok")
except Exception as e:
return io.NodeOutput(image, False, 0.0, 0.0, f"Classification failed: {e}")
LOCAL_NODE_CLASS = {
"InoSaveImages": InoSaveImages,
"InoImageResizeByLongerSideV1": InoImageResizeByLongerSideV1,
"InoImageResizeByLongerSideAndCropV2": InoImageResizeByLongerSideAndCropV2,
"InoResizeCropImage": InoResizeCropImage,
"InoLoadImage": InoLoadImage,
"InoLoadImagesFromFolder": InoLoadImagesFromFolder,
"InoOnImageListCompleted": InoOnImageListCompleted,
"InoCropImageByBox": InoCropImageByBox,
@@ -574,11 +1020,18 @@ LOCAL_NODE_CLASS = {
"InoImagesToReferenceLatent": InoImagesToReferenceLatent,
"InoImagesFromFolderToReferenceLatent": InoImagesFromFolderToReferenceLatent,
"InoImageListToBatch": InoImageListToBatch,
"InoIsImagePortrait": InoIsImagePortrait,
"InoIsImageLandscape": InoIsImageLandscape,
"InoIsImageSquare": InoIsImageSquare,
"InoMegapixelResolution": InoMegapixelResolution,
"InoNsfwDetect": InoNsfwDetect,
}
LOCAL_NODE_NAME = {
"InoSaveImages": "Ino Save Images",
"InoImageResizeByLongerSideV1": "Ino Image Resize By Longer Side V1",
"InoImageResizeByLongerSideAndCropV2": "Ino Image Resize By Longer Side And Crop V2",
"InoResizeCropImage": "Ino Resize Crop Image",
"InoLoadImage": "Ino Load Image",
"InoLoadImagesFromFolder": "Ino Load Images From Folder",
"InoOnImageListCompleted": "Ino On Image List Completed",
"InoCropImageByBox": "Ino Crop Image By Box",
@@ -586,4 +1039,9 @@ LOCAL_NODE_NAME = {
"InoImagesToReferenceLatent": "Ino Images To Reference Latent",
"InoImagesFromFolderToReferenceLatent": "Ino Images From Folder To Reference Latent",
"InoImageListToBatch": "Ino Image List To Batch",
"InoIsImagePortrait": "Ino Is Image Portrait",
"InoIsImageLandscape": "Ino Is Image Landscape",
"InoIsImageSquare": "Ino Is Image Square",
"InoMegapixelResolution": "Ino Megapixel Resolution",
"InoNsfwDetect": "Ino NSFW Detect",
}
@@ -104,15 +104,53 @@ class InoCompareInt(io.ComfyNode):
return io.NodeOutput(ops[compare])
class InoMathInt(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoMathInt",
display_name="Ino Math Int",
category="InoIntHelper",
description="Performs a math operation on two integers: add, subtract, multiply, divide, modulo, power.",
inputs=[
io.Int.Input("int_a", default=0),
io.Int.Input("int_b", default=0),
io.Combo.Input("operation", options=["add", "subtract", "multiply", "divide", "modulo", "power"]),
],
outputs=[
io.Int.Output(display_name="int"),
io.Float.Output(display_name="float"),
],
)
@classmethod
def execute(cls, int_a, int_b, operation) -> io.NodeOutput:
if operation == "add":
r = int_a + int_b
elif operation == "subtract":
r = int_a - int_b
elif operation == "multiply":
r = int_a * int_b
elif operation == "divide":
r = int_a / int_b if int_b != 0 else 0
elif operation == "modulo":
r = int_a % int_b if int_b != 0 else 0
else:
r = int_a ** int_b
return io.NodeOutput(int(r), float(r))
LOCAL_NODE_CLASS = {
"InoRandomIntInRange": InoRandomIntInRange,
"InoIntToString": InoIntToString,
"InoIntToFloat": InoIntToFloat,
"InoCompareInt": InoCompareInt,
"InoMathInt": InoMathInt,
}
LOCAL_NODE_NAME = {
"InoRandomIntInRange": "Ino Random Int In Range",
"InoIntToString": "Ino Int To String",
"InoIntToFloat": "Ino Int To Float",
"InoCompareInt": "Ino Compare Int",
"InoMathInt": "Ino Math Int",
}
@@ -0,0 +1,229 @@
"""List-construction and list-manipulation nodes.
All list-consuming nodes use schema-level `is_input_list=True` so the upstream
list reaches `execute` as a single Python list, instead of being expanded into
multiple invocations. With that flag, ComfyUI also wraps every scalar input in
a 1-element list, so we unwrap scalars below before using them.
"""
from comfy_api.latest import io
def _unwrap_scalar(v):
"""Unwrap a 1-element list back to its scalar (for is_input_list inputs)."""
if isinstance(v, list) and len(v) == 1:
return v[0]
return v
# ---------------------------------------------------------------------------
# Constructors
# ---------------------------------------------------------------------------
class InoMakeAnyList(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoMakeAnyList",
display_name="Ino Make Any List",
category="InoListHelper",
description="Collects up to 10 inputs of any type into a single list. Optionally drops slots that are None (unwired).",
inputs=[
io.AnyType.Input("any_1", optional=True),
io.AnyType.Input("any_2", optional=True),
io.AnyType.Input("any_3", optional=True),
io.AnyType.Input("any_4", optional=True),
io.AnyType.Input("any_5", optional=True),
io.AnyType.Input("any_6", optional=True),
io.AnyType.Input("any_7", optional=True),
io.AnyType.Input("any_8", optional=True),
io.AnyType.Input("any_9", optional=True),
io.AnyType.Input("any_10", optional=True),
io.Boolean.Input("skip_none", default=True, optional=True, label_off="keep all", label_on="skip None", tooltip="When ON, slots that are None (typically unwired inputs) are excluded. Other falsy values (0, False, '', []) are always kept."),
],
outputs=[
io.AnyType.Output(display_name="list", is_output_list=True),
io.Int.Output(display_name="count"),
],
)
@classmethod
def execute(
cls,
any_1=None, any_2=None, any_3=None, any_4=None, any_5=None,
any_6=None, any_7=None, any_8=None, any_9=None, any_10=None,
skip_none=True,
) -> io.NodeOutput:
items = [
any_1, any_2, any_3, any_4, any_5,
any_6, any_7, any_8, any_9, any_10,
]
if skip_none:
items = [v for v in items if v is not None]
return io.NodeOutput(items, len(items))
# ---------------------------------------------------------------------------
# Inspectors / accessors (consume a list)
# ---------------------------------------------------------------------------
class InoListLength(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoListLength",
display_name="Ino List Length",
category="InoListHelper",
description="Returns the number of items in a list.",
is_input_list=True,
inputs=[
io.AnyType.Input("items"),
],
outputs=[
io.Int.Output(display_name="length"),
],
)
@classmethod
def execute(cls, items) -> io.NodeOutput:
if items is None:
return io.NodeOutput(0)
return io.NodeOutput(len(items))
class InoListGetItem(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoListGetItem",
display_name="Ino List Get Item",
category="InoListHelper",
description="Gets the item at the specified index. Negative indices count from the end. `success` is False if the list is empty or the index is out of range.",
is_input_list=True,
inputs=[
io.AnyType.Input("items"),
io.Int.Input("index", default=0, min=-99999, max=99999),
],
outputs=[
io.AnyType.Output(display_name="item"),
io.Boolean.Output(display_name="success"),
io.Int.Output(display_name="length"),
],
)
@classmethod
def execute(cls, items, index) -> io.NodeOutput:
index = _unwrap_scalar(index)
if not items:
return io.NodeOutput(None, False, 0)
n = len(items)
if index < -n or index >= n:
return io.NodeOutput(None, False, n)
return io.NodeOutput(items[index], True, n)
class InoListGetFirst(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoListGetFirst",
display_name="Ino List Get First",
category="InoListHelper",
description="Returns the first item in a list. `success` is False if the list is empty.",
is_input_list=True,
inputs=[
io.AnyType.Input("items"),
],
outputs=[
io.AnyType.Output(display_name="item"),
io.Boolean.Output(display_name="success"),
io.Int.Output(display_name="length"),
],
)
@classmethod
def execute(cls, items) -> io.NodeOutput:
if not items:
return io.NodeOutput(None, False, 0)
return io.NodeOutput(items[0], True, len(items))
class InoListGetLast(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoListGetLast",
display_name="Ino List Get Last",
category="InoListHelper",
description="Returns the last item in a list. `success` is False if the list is empty.",
is_input_list=True,
inputs=[
io.AnyType.Input("items"),
],
outputs=[
io.AnyType.Output(display_name="item"),
io.Boolean.Output(display_name="success"),
io.Int.Output(display_name="length"),
],
)
@classmethod
def execute(cls, items) -> io.NodeOutput:
if not items:
return io.NodeOutput(None, False, 0)
return io.NodeOutput(items[-1], True, len(items))
# ---------------------------------------------------------------------------
# Mutations (return a new list)
# ---------------------------------------------------------------------------
class InoListAppend(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="InoListAppend",
display_name="Ino List Append",
category="InoListHelper",
description=(
"Appends one or more items to a list and returns the combined list. "
"If `item` is wired from a list-output node, all of its items are appended (extend behavior). "
"If wired from a regular node, the single value is appended."
),
is_input_list=True,
inputs=[
io.AnyType.Input("items"),
io.AnyType.Input("item"),
],
outputs=[
io.AnyType.Output(display_name="list", is_output_list=True),
io.Int.Output(display_name="count"),
],
)
@classmethod
def execute(cls, items, item) -> io.NodeOutput:
# With is_input_list=True, both arrive as lists. A scalar from a
# regular upstream comes as a 1-element list — concatenating either
# way is correct for both append-one and extend-many.
base = list(items) if items else []
addition = list(item) if item else []
result = base + addition
return io.NodeOutput(result, len(result))
LOCAL_NODE_CLASS = {
"InoMakeAnyList": InoMakeAnyList,
"InoListLength": InoListLength,
"InoListGetItem": InoListGetItem,
"InoListGetFirst": InoListGetFirst,
"InoListGetLast": InoListGetLast,
"InoListAppend": InoListAppend,
}
LOCAL_NODE_NAME = {
"InoMakeAnyList": "Ino Make Any List",
"InoListLength": "Ino List Length",
"InoListGetItem": "Ino List Get Item",
"InoListGetFirst": "Ino List Get First",
"InoListGetLast": "Ino List Get Last",
"InoListAppend": "Ino List Append",
}
@@ -1,3 +1,4 @@
import asyncio
import re
import hashlib
import base64
@@ -8,6 +9,12 @@ from comfy_api.latest import io
from ..node_helper import PARENT_FOLDER_OPTIONS, resolve_comfy_path
def _write_text_sync(abs_path, text):
Path(abs_path).parent.mkdir(parents=True, exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(text)
class InoStringToggleCase(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -168,15 +175,13 @@ class InoSaveText(io.ComfyNode):
)
@classmethod
def execute(cls, enabled, text, parent_folder, folder, filename) -> io.NodeOutput:
async def execute(cls, enabled, text, parent_folder, folder, filename) -> io.NodeOutput:
rel_path, abs_path = resolve_comfy_path(parent_folder, folder, filename)
if not enabled:
return io.NodeOutput(False, "not enabled", rel_path, abs_path)
try:
Path(abs_path).parent.mkdir(parents=True, exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(text)
await asyncio.to_thread(_write_text_sync, abs_path, text)
return io.NodeOutput(True, "saved", rel_path, abs_path)
except Exception as e:
return io.NodeOutput(False, str(e), rel_path, abs_path)
@@ -1,3 +1,4 @@
import asyncio
import os
import random
import string
@@ -27,7 +28,7 @@ class InoPreviewVideo(io.ComfyNode):
)
@classmethod
def execute(cls, enabled, video: Input.Video, format="mp4", codec="h264") -> io.NodeOutput:
async def execute(cls, enabled, video: Input.Video, format="mp4", codec="h264") -> io.NodeOutput:
if not enabled:
return io.NodeOutput(ui=ui.PreviewVideo([]).as_dict())
@@ -37,7 +38,8 @@ class InoPreviewVideo(io.ComfyNode):
)
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format)}"
video.save_to(
await asyncio.to_thread(
video.save_to,
os.path.join(full_output_folder, file),
format=Types.VideoContainer(format),
codec=Types.VideoCodec(codec),
@@ -49,18 +49,19 @@ class InoS3DownloadAudio(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], None)
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], None)
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], None)
from comfy_extras.nodes_audio import LoadAudio
from comfy_extras.nodes_audio import LoadAudio
annotated_name = f"s3_download_audio/{file_name} [temp]"
load_audio = LoadAudio.execute(audio=annotated_name)
annotated_name = f"s3_download_audio/{file_name} [temp]"
load_audio = LoadAudio.execute(audio=annotated_name)
if load_audio[0]:
return io.NodeOutput(True, "Success", load_audio[0])
if load_audio[0]:
return io.NodeOutput(True, "Success", load_audio[0])
return io.NodeOutput(False, "failed to load the audio", None)
return io.NodeOutput(False, "failed to load the audio", None)
@@ -1,5 +1,3 @@
from pathlib import Path
from inopyutils import ino_is_err
from comfy_api.latest import io
@@ -16,12 +14,14 @@ class InoS3DownloadFile(io.ComfyNode):
display_name="Ino S3 Download File",
category="InoS3Helper",
description="Downloads a file from S3 to a local folder.",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default="input/example.png"),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
io.String.Input("folder", default="s3download/"),
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
io.Boolean.Input("overwrite", default=False, optional=True, tooltip="If False (default), skip the download when the local file already matches the remote (sha256/md5/size cascade)."),
],
outputs=[
io.Boolean.Output(display_name="success"),
@@ -32,7 +32,7 @@ class InoS3DownloadFile(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, s3_key, parent_folder, folder, s3_config=None) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, parent_folder, folder, s3_config=None, overwrite=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "")
@@ -43,13 +43,12 @@ class InoS3DownloadFile(io.ComfyNode):
save_path = S3Helper.get_save_path(s3_key, folder)
rel_path, abs_path = resolve_comfy_path(parent_folder, str(save_path))
if not Path(abs_path).is_dir():
Path(abs_path).parent.mkdir(parents=True, exist_ok=True)
# download_file already creates the parent dir; no client-side mkdir needed.
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.download_file(s3_key=s3_key, local_file_path=abs_path)
return io.NodeOutput(s3_result["success"], s3_result["msg"], rel_path, abs_path)
s3_result = await s3_instance.download_file(s3_key=s3_key, local_file_path=abs_path, overwrite=overwrite)
return io.NodeOutput(s3_result["success"], s3_result["msg"], rel_path, abs_path)
@@ -16,6 +16,7 @@ class InoS3DownloadFolder(io.ComfyNode):
display_name="Ino S3 Download Folder",
category="InoS3Helper",
description="Downloads an entire folder from S3 with concurrent downloads.",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default="input/example.png"),
@@ -23,36 +24,59 @@ class InoS3DownloadFolder(io.ComfyNode):
io.String.Input("folder", default="input/"),
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
io.Int.Input("max_concurrent", default=8, min=1, max=16, optional=True),
io.Boolean.Input("verify", default=False, optional=True, tooltip="If True, verify folder sync after download (skipped if download had failures)."),
io.Boolean.Input("overwrite", default=False, optional=True, tooltip="If False (default), per file skip when the local file already matches S3."),
],
outputs=[
io.Boolean.Output(display_name="success"),
io.String.Output(display_name="message"),
io.String.Output(display_name="rel_path"),
io.String.Output(display_name="abs_path"),
io.Int.Output(display_name="total_files"),
io.Int.Output(display_name="downloaded_successfully"),
io.Int.Output(display_name="skipped_files"),
io.Int.Output(display_name="failed_downloads"),
io.String.Output(display_name="errors"),
],
)
@classmethod
async def execute(cls, enabled, s3_key, parent_folder, folder, s3_config=None, max_concurrent=5) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, parent_folder, folder, s3_config=None, max_concurrent=8, verify=False, overwrite=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "")
return io.NodeOutput(False, "not enabled", "", "", 0, 0, 0, 0, "")
validate_s3_key = S3Helper.validate_s3_key(s3_key)
if not validate_s3_key["success"]:
return io.NodeOutput(False, validate_s3_key["msg"], "", "")
return io.NodeOutput(False, validate_s3_key["msg"], "", "", 0, 0, 0, 0, "")
rel_path, abs_path = resolve_comfy_path(parent_folder, folder)
if Path(abs_path).is_file():
return io.NodeOutput(False, "Save path is a file", "", "")
return io.NodeOutput(False, "Save path is a file", rel_path, abs_path, 0, 0, 0, 0, "")
if not Path(abs_path).is_dir():
Path(abs_path).mkdir(parents=True, exist_ok=True)
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
return io.NodeOutput(False, s3_instance["msg"], rel_path, abs_path, 0, 0, 0, 0, "")
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.download_folder(s3_folder_key=s3_key, local_folder_path=abs_path, max_concurrent=max_concurrent)
return io.NodeOutput(s3_result["success"], s3_result["msg"], rel_path, abs_path)
s3_result = await s3_instance.download_folder(
s3_folder_key=s3_key,
local_folder_path=abs_path,
max_concurrent=max_concurrent,
verify=verify,
overwrite=overwrite,
)
return io.NodeOutput(
s3_result["success"],
s3_result["msg"],
rel_path,
abs_path,
int(s3_result.get("total_files", 0)),
int(s3_result.get("downloaded_successfully", 0)),
int(s3_result.get("skipped_files", 0)),
int(s3_result.get("failed_downloads", 0)),
str(s3_result.get("errors", "")),
)
@@ -1,3 +1,4 @@
import asyncio
import os
from pathlib import Path
@@ -10,6 +11,11 @@ from .s3_helper import S3Helper, S3_EMPTY_CONFIG_STRING
from ..node_helper import load_image
def _empty_image():
from nodes import EmptyImage
return EmptyImage().generate(512, 512)[0]
class InoS3DownloadImage(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -33,18 +39,16 @@ class InoS3DownloadImage(io.ComfyNode):
@classmethod
async def execute(cls, enabled, s3_key, s3_config=None) -> io.NodeOutput:
from nodes import EmptyImage
empty_image = EmptyImage().generate(512, 512)[0]
if not enabled:
return io.NodeOutput(False, "not enabled", empty_image, None)
return io.NodeOutput(False, "not enabled", _empty_image(), None)
validate_s3_key = S3Helper.validate_s3_key(s3_key)
if not validate_s3_key["success"]:
return io.NodeOutput(False, validate_s3_key["msg"], empty_image, None)
return io.NodeOutput(False, validate_s3_key["msg"], _empty_image(), None)
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_download_image")
os.makedirs(save_dir, exist_ok=True)
await asyncio.to_thread(os.makedirs, save_dir, exist_ok=True)
random_str = InoUtilHelper.get_date_time_utc_base64()
file_name = f'{random_str}{Path(s3_key).suffix}'
@@ -52,12 +56,13 @@ class InoS3DownloadImage(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], empty_image, None)
s3_instance = s3_instance["instance"]
return io.NodeOutput(False, s3_instance["msg"], _empty_image(), None)
async with s3_instance["instance"] as s3_instance:
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], empty_image, None)
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], _empty_image(), None)
output_image, output_mask = load_image(downloaded["local_file"])
return io.NodeOutput(True, downloaded["msg"], output_image, output_mask)
# load_image is synchronous (PIL + torch) — offload to a thread.
output_image, output_mask = await asyncio.to_thread(load_image, downloaded["local_file"])
return io.NodeOutput(True, downloaded["msg"], output_image, output_mask)
@@ -1,9 +1,5 @@
import os
from pathlib import Path
from inopyutils import ino_is_err
from inopyutils import ino_is_err, InoUtilHelper
import folder_paths
from comfy_api.latest import io
from .s3_helper import S3Helper, S3_EMPTY_CONFIG_STRING
@@ -16,11 +12,12 @@ class InoS3DownloadString(io.ComfyNode):
node_id="InoS3DownloadString",
display_name="Ino S3 Download String",
category="InoS3Helper",
description="Downloads a text file (txt/json/ini) from S3 and returns its content as a string.",
description="Downloads a text object from S3 directly into memory and returns its content as a string.",
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default="input/example.txt"),
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
io.String.Input("encoding", default="utf-8", optional=True),
],
outputs=[
io.Boolean.Output(display_name="success"),
@@ -30,7 +27,7 @@ class InoS3DownloadString(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, s3_key, s3_config=None) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, s3_config=None, encoding="utf-8") -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "")
@@ -38,28 +35,13 @@ class InoS3DownloadString(io.ComfyNode):
if not validate_s3_key["success"]:
return io.NodeOutput(False, validate_s3_key["msg"], "")
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_download_string")
os.makedirs(save_dir, exist_ok=True)
random_str = InoUtilHelper.get_date_time_utc_base64()
ext = Path(s3_key).suffix or ".txt"
local_file = f"{random_str}{ext}"
full_path = os.path.join(save_dir, local_file)
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], "")
result = await s3_instance.get_text(s3_key=s3_key, encoding=encoding)
if not result["success"]:
return io.NodeOutput(False, result["msg"], "")
try:
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return io.NodeOutput(False, f"Failed to read file: {e}", "")
return io.NodeOutput(True, "Success", content)
return io.NodeOutput(True, result.get("msg", "Success"), result.get("text", ""))
@@ -49,13 +49,13 @@ class InoS3DownloadVideo(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], None)
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], None)
downloaded = await s3_instance.download_file(s3_key=s3_key, local_file_path=full_path)
if not downloaded["success"]:
return io.NodeOutput(False, downloaded["msg"], None)
from comfy_api.latest import VideoFromFile
video = VideoFromFile(full_path)
from comfy_api.latest import VideoFromFile
video = VideoFromFile(full_path)
return io.NodeOutput(True, "Success", video)
return io.NodeOutput(True, "Success", video)
@@ -5,6 +5,10 @@ from comfy_api.latest import io
from .s3_helper import S3Helper, S3_EMPTY_CONFIG_STRING
# AWS SigV4 caps presigned URL lifetimes at 7 days.
_MAX_EXPIRES_IN = 7 * 24 * 3600 # 604800 seconds
class InoS3GetDownloadURL(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -16,9 +20,9 @@ class InoS3GetDownloadURL(io.ComfyNode):
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default="input/example.png"),
io.Int.Input("expires_in", default=3600, min=1, max=3600, step=1),
io.Int.Input("expires_in", default=3600, min=1, max=_MAX_EXPIRES_IN, step=1, tooltip="Link lifetime in seconds (max 7 days = 604800)."),
io.Boolean.Input("as_attachment", default=False),
io.String.Input("filename", default=""),
io.String.Input("filename", default="", tooltip="Optional download filename. Leave empty to default to the object's basename."),
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
],
outputs=[
@@ -30,7 +34,7 @@ class InoS3GetDownloadURL(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, s3_key, expires_in=3600, as_attachment=False, filename=None, s3_config=None) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, expires_in=3600, as_attachment=False, filename="", s3_config=None) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "")
@@ -41,7 +45,20 @@ class InoS3GetDownloadURL(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.get_download_link(s3_key=s3_key, expires_in=expires_in, as_attachment=as_attachment, filename=filename)
return io.NodeOutput(s3_result["success"], s3_result["msg"], s3_result["url"], s3_result["filename"])
# Helper expects None (not "") to fall back to the object's basename.
suggested_filename = filename or None
s3_result = await s3_instance.get_download_link(
s3_key=s3_key,
expires_in=expires_in,
as_attachment=as_attachment,
filename=suggested_filename,
)
return io.NodeOutput(
s3_result.get("success", False),
s3_result.get("msg", ""),
s3_result.get("url", ""),
s3_result.get("filename", ""),
)
+19 -7
View File
@@ -54,20 +54,32 @@ class S3Helper:
}
s3_config_dict = s3_config_dict["data"]
s3_config_dict["access_key_id"] = os.getenv('S3_ACCESS_KEY', s3_config_dict.get("access_key_id", ""))
s3_config_dict["access_key_secret"] = os.getenv('S3_ACCESS_SECRET', s3_config_dict.get("access_key_secret", ""))
s3_config_dict["endpoint_url"] = os.getenv('S3_ENDPOINT_URL', s3_config_dict.get("endpoint_url", ""))
s3_config_dict["region_name"] = os.getenv('S3_REGION_NAME', s3_config_dict.get("region_name", ""))
s3_config_dict["bucket_name"] = os.getenv('S3_BUCKET_NAME', s3_config_dict.get("bucket_name", ""))
# Overlay env vars on top of the JSON config. Only override when the
# env var is set to a non-empty value, so callers can leave fields
# empty in the JSON and have env vars populate them — without an
# accidental empty-string env var clobbering a real JSON value.
env_map = {
"access_key_id": "S3_ACCESS_KEY",
"access_key_secret": "S3_ACCESS_SECRET",
"endpoint_url": "S3_ENDPOINT_URL",
"region_name": "S3_REGION_NAME",
"bucket_name": "S3_BUCKET_NAME",
}
for field, env_name in env_map.items():
env_val = os.getenv(env_name)
if env_val:
s3_config_dict[field] = env_val
else:
s3_config_dict.setdefault(field, "")
if s3_config_dict.get("access_key_id", None) is None:
if not s3_config_dict.get("access_key_id"):
return {
"success": False,
"msg": "S3 configuration missing access_key_id",
"config": ""
}
if s3_config_dict.get("access_key_secret", None) is None:
if not s3_config_dict.get("access_key_secret"):
return {
"success": False,
"msg": "S3 configuration missing access_key_secret",
@@ -18,7 +18,6 @@ class InoS3SyncFolder(io.ComfyNode):
description="Syncs a local folder with S3 bidirectionally (upload or download).",
is_output_node=True,
inputs=[
io.AnyType.Input("execute"),
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default=""),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
@@ -26,6 +25,7 @@ class InoS3SyncFolder(io.ComfyNode):
io.Boolean.Input("sync_local", default=True, label_off="Upload (local->S3)", label_on="Download (S3->local)"),
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
io.Int.Input("concurrency", default=5, min=1, max=10, optional=True),
io.Boolean.Input("delete", default=False, optional=True, tooltip="If True, delete orphan files on the destination side (rsync-style). Defaults to False — set to True only when you intentionally want one-way mirroring."),
],
outputs=[
io.Boolean.Output(display_name="success"),
@@ -36,19 +36,20 @@ class InoS3SyncFolder(io.ComfyNode):
io.Int.Output(display_name="uploaded"),
io.Int.Output(display_name="skipped_unchanged"),
io.Int.Output(display_name="failed"),
io.Int.Output(display_name="removed_local"),
io.Int.Output(display_name="removed_remote"),
io.String.Output(display_name="errors"),
],
)
@classmethod
async def execute(cls, execute, enabled, s3_key, parent_folder, folder, sync_local, s3_config=None, concurrency=5) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, parent_folder, folder, sync_local, s3_config=None, concurrency=5, delete=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "", 0, 0, 0, 0)
if not execute:
return io.NodeOutput(False, "execute empty", "", "", 0, 0, 0, 0)
return io.NodeOutput(False, "not enabled", "", "", 0, 0, 0, 0, 0, 0, "")
validate_s3_key = S3Helper.validate_s3_key(s3_key)
if not validate_s3_key["success"]:
return io.NodeOutput(False, validate_s3_key["msg"], "", "", 0, 0, 0, 0)
return io.NodeOutput(False, validate_s3_key["msg"], "", "", 0, 0, 0, 0, 0, 0, "")
rel_path, abs_path = resolve_comfy_path(parent_folder, folder)
@@ -58,13 +59,27 @@ class InoS3SyncFolder(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "", "", 0, 0, 0, 0)
s3_instance = s3_instance["instance"]
return io.NodeOutput(False, s3_instance["msg"], rel_path, abs_path, 0, 0, 0, 0, 0, 0, "")
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.sync_folder(s3_key=s3_key, local_folder_path=abs_path, sync_local=sync_local, concurrency=concurrency)
s3_result = await s3_instance.sync_folder(
s3_key=s3_key,
local_folder_path=abs_path,
sync_local=sync_local,
concurrency=concurrency,
delete=delete,
)
return io.NodeOutput(
s3_result["success"], s3_result["msg"], rel_path, abs_path,
s3_result.get("downloaded", 0), s3_result.get("uploaded", 0),
s3_result.get("skipped_unchanged", 0), s3_result.get("failed", 0),
)
return io.NodeOutput(
s3_result["success"],
s3_result["msg"],
rel_path,
abs_path,
int(s3_result.get("downloaded", 0)),
int(s3_result.get("uploaded", 0)),
int(s3_result.get("skipped_unchanged", 0)),
int(s3_result.get("failed", 0)),
int(s3_result.get("removed_local", 0)),
int(s3_result.get("removed_remote", 0)),
str(s3_result.get("errors", "")),
)
@@ -1,3 +1,4 @@
import asyncio
import os
from pathlib import Path
@@ -16,7 +17,7 @@ class InoS3UploadAudio(io.ComfyNode):
node_id="InoS3UploadAudio",
display_name="Ino S3 Upload Audio",
category="InoS3Helper",
description="Saves audio as MP3 to temp then uploads it to S3.",
description="Saves audio as MP3 then uploads it to S3.",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
@@ -25,6 +26,8 @@ class InoS3UploadAudio(io.ComfyNode):
io.String.Input("s3_config", default=S3_EMPTY_CONFIG_STRING, optional=True, tooltip="you can leave it empty and pass it with env vars"),
io.Boolean.Input("unique_file_name", default=True, optional=True, label_off="Use filename", label_on="Unique name"),
io.String.Input("filename", default="", optional=True),
io.Boolean.Input("delete_local", default=True, optional=True, tooltip="Delete the locally saved MP3 after a successful S3 upload."),
io.Combo.Input("quality", options=["V0", "128k", "320k"], default="128k", optional=True, tooltip="MP3 encoding quality."),
],
outputs=[
io.Audio.Output(display_name="audio"),
@@ -36,7 +39,7 @@ class InoS3UploadAudio(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, audio, s3_path_key, filename, s3_config=None, unique_file_name=True) -> io.NodeOutput:
async def execute(cls, enabled, audio, s3_path_key, filename, s3_config=None, unique_file_name=True, delete_local=True, quality="128k") -> io.NodeOutput:
if not enabled:
return io.NodeOutput(audio, False, "", "", "")
@@ -44,37 +47,51 @@ class InoS3UploadAudio(io.ComfyNode):
if not validate_s3_key["success"]:
return io.NodeOutput(audio, False, validate_s3_key["msg"], "", "")
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_upload_audio")
os.makedirs(save_dir, exist_ok=True)
local_name = InoUtilHelper.get_date_time_utc_base64()
local_file = f"{local_name}.mp3"
full_path = os.path.join(save_dir, local_file)
from comfy_extras.nodes_audio import SaveAudioMP3
# Encode the MP3 with AudioSaveHelper instead of SaveAudioMP3.execute:
# output nodes read cls.hidden, which is only populated on their own
# execution clone, so invoking one from another node crashes (issue #3).
# Passing our own cls keeps the workflow metadata embedded in the file;
# it is skipped when execute() is called outside the executor, where
# cls.hidden is None.
from comfy_api.latest import ui
audio_saver = SaveAudioMP3()
save_audio = audio_saver.execute(audio=audio, filename_prefix=local_name, format="mp3", quality="128k")
metadata_cls = cls if getattr(cls, "hidden", None) is not None else None
try:
save_audio = ui.AudioSaveHelper.get_save_audio_ui(
audio, filename_prefix=local_name, cls=metadata_cls, format="mp3", quality=quality,
)
except Exception as e:
return io.NodeOutput(audio, False, f"Failed to save audio: {e}", "", "")
try:
saved_filename = save_audio.ui.as_dict()["audio"][0]["filename"]
output_path = folder_paths.get_output_directory()
full_path = str((Path(output_path) / saved_filename).resolve())
except:
return io.NodeOutput(audio, False, "Audio saved, but failed to get filename", "", "")
saved = save_audio.results[0]
saved_filename = str(Path(saved.subfolder) / saved.filename) if saved.subfolder else saved.filename
except (AttributeError, IndexError, TypeError) as e:
return io.NodeOutput(audio, False, f"Audio saved but failed to read result filename: {e}", "", "")
s3_name = local_name if unique_file_name else Path(filename).stem
output_path = folder_paths.get_output_directory()
full_path = str((Path(output_path) / saved_filename).resolve())
s3_name = local_name if unique_file_name else (Path(filename).stem or local_name)
s3_file = f"{s3_name}.mp3"
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(audio, False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
if not s3_result["success"]:
return io.NodeOutput(audio, False, s3_result["msg"], "", "")
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
if not s3_result["success"]:
return io.NodeOutput(audio, False, s3_result["msg"], "", "")
return io.NodeOutput(audio, True, "Success", s3_file, s3_full_key)
if delete_local:
try:
await asyncio.to_thread(os.remove, full_path)
except OSError:
# Non-fatal — upload succeeded.
pass
return io.NodeOutput(audio, True, s3_result.get("msg", "Success"), s3_file, s3_full_key)
@@ -1,3 +1,4 @@
import asyncio
import os
from pathlib import Path
@@ -19,7 +20,6 @@ class InoS3UploadFile(io.ComfyNode):
description="Uploads a local file to S3. If s3_path_key contains a filename, its stem is used with the local file's extension. Otherwise the local filename is used.",
is_output_node=True,
inputs=[
io.AnyType.Input("execute"),
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_path_key", default=""),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
@@ -36,11 +36,9 @@ class InoS3UploadFile(io.ComfyNode):
)
@classmethod
async def execute(cls, execute, enabled, s3_path_key, parent_folder, folder, filename, delete_local, s3_config=None) -> io.NodeOutput:
async def execute(cls, enabled, s3_path_key, parent_folder, folder, filename, delete_local, s3_config=None) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "")
if not execute:
return io.NodeOutput(False, "execute empty", "")
validate_s3_key = S3Helper.validate_s3_key(s3_path_key)
if not validate_s3_key["success"]:
@@ -65,10 +63,10 @@ class InoS3UploadFile(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=abs_path)
if s3_result["success"] and delete_local:
os.remove(abs_path)
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=abs_path)
if s3_result["success"] and delete_local:
await asyncio.to_thread(os.remove, abs_path)
return io.NodeOutput(s3_result["success"], s3_result["msg"], s3_full_key)
return io.NodeOutput(s3_result["success"], s3_result["msg"], s3_full_key)
@@ -1,3 +1,4 @@
import asyncio
import shutil
from pathlib import Path
@@ -19,7 +20,6 @@ class InoS3UploadFolder(io.ComfyNode):
description="Uploads an entire local folder to S3 with concurrent uploads and optional verification.",
is_output_node=True,
inputs=[
io.AnyType.Input("execute"),
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default=""),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
@@ -42,11 +42,9 @@ class InoS3UploadFolder(io.ComfyNode):
)
@classmethod
async def execute(cls, execute, enabled, s3_key, parent_folder, folder, delete_local, s3_config=None, max_concurrent=5, verify_with_s3=False) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, parent_folder, folder, delete_local, s3_config=None, max_concurrent=5, verify_with_s3=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "", "", "", 0, 0, 0, "")
if not execute:
return io.NodeOutput(False, "", "", "", 0, 0, 0, "")
validate_s3_key = S3Helper.validate_s3_key(s3_key)
if not validate_s3_key["success"]:
@@ -61,10 +59,10 @@ class InoS3UploadFolder(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], rel_path, abs_path, 0, 0, 0, "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.upload_folder(s3_folder_key=s3_key, local_folder_path=abs_path, max_concurrent=max_concurrent, verify=verify_with_s3)
if s3_result["success"] and delete_local:
shutil.rmtree(Path(abs_path))
s3_result = await s3_instance.upload_folder(s3_folder_key=s3_key, local_folder_path=abs_path, max_concurrent=max_concurrent, verify=verify_with_s3)
if s3_result["success"] and delete_local:
await asyncio.to_thread(shutil.rmtree, Path(abs_path))
return io.NodeOutput(s3_result["success"], s3_result["msg"], rel_path, abs_path, s3_result["total_files"], s3_result["uploaded_successfully"], s3_result["failed_uploads"], str(s3_result["errors"]))
return io.NodeOutput(s3_result["success"], s3_result["msg"], rel_path, abs_path, s3_result["total_files"], s3_result["uploaded_successfully"], s3_result["failed_uploads"], str(s3_result["errors"]))
@@ -1,3 +1,5 @@
import asyncio
import io as std_io
import os
from pathlib import Path
@@ -6,13 +8,25 @@ from PIL.PngImagePlugin import PngInfo
import numpy as np
from inopyutils import ino_is_err, InoUtilHelper
import folder_paths
from comfy.cli_args import args
from comfy_api.latest import io
from .s3_helper import S3Helper, S3_EMPTY_CONFIG_STRING
def _encode_png_bytes(image_tensor, compress_level: int, include_metadata: bool) -> bytes:
"""Encode a single-image torch tensor (B=1, H, W, C) as PNG bytes in-memory."""
arr = 255.0 * image_tensor[0].cpu().numpy()
img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
metadata = PngInfo() if include_metadata else None
buf = std_io.BytesIO()
try:
img.save(buf, format="PNG", pnginfo=metadata, compress_level=compress_level)
finally:
img.close()
return buf.getvalue()
class InoS3UploadImage(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -20,7 +34,7 @@ class InoS3UploadImage(io.ComfyNode):
node_id="InoS3UploadImage",
display_name="Ino S3 Upload Image",
category="InoS3Helper",
description="Saves a single image as PNG to temp then uploads it to S3. Only supports one image (batch size 1).",
description="Encodes a single image as PNG in memory and uploads it directly to S3. Only supports one image (batch size 1).",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
@@ -52,33 +66,33 @@ class InoS3UploadImage(io.ComfyNode):
if not validate_s3_key["success"]:
return io.NodeOutput(image, False, validate_s3_key["msg"], "", "")
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_upload_image")
os.makedirs(save_dir, exist_ok=True)
i = 255. * image[0].cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
local_name = InoUtilHelper.get_date_time_utc_base64()
local_file = f"{local_name}.png"
full_path = os.path.join(save_dir, local_file)
img.save(full_path, pnginfo=metadata, compress_level=compress_level)
img.close()
s3_name = local_name if unique_file_name else Path(filename).stem
s3_name = local_name if unique_file_name else (Path(filename).stem or local_name)
s3_file = f"{s3_name}.png"
# PIL encoding is CPU-bound — offload off the event loop.
try:
png_bytes = await asyncio.to_thread(
_encode_png_bytes,
image,
compress_level,
not args.disable_metadata,
)
except Exception as e:
return io.NodeOutput(image, False, f"Failed to encode image: {e}", "", "")
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(image, False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
if not s3_result["success"]:
return io.NodeOutput(image, False, s3_result["msg"], "", "")
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.put_bytes(
data=png_bytes,
s3_key=s3_full_key,
content_type="image/png",
)
if not s3_result["success"]:
return io.NodeOutput(image, False, s3_result["msg"], "", "")
return io.NodeOutput(image, True, "Success", s3_file, s3_full_key)
return io.NodeOutput(image, True, s3_result.get("msg", "Success"), s3_file, s3_full_key)
@@ -1,14 +1,19 @@
import os
from pathlib import Path
from inopyutils import InoJsonHelper, InoFileHelper, ino_is_err, InoUtilHelper
from inopyutils import ino_is_err, InoUtilHelper
import folder_paths
from comfy_api.latest import io
from .s3_helper import S3Helper, S3_EMPTY_CONFIG_STRING
_CONTENT_TYPE_MAP = {
"txt": "text/plain; charset=utf-8",
"json": "application/json; charset=utf-8",
"ini": "text/plain; charset=utf-8",
}
class InoS3UploadString(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -16,7 +21,7 @@ class InoS3UploadString(io.ComfyNode):
node_id="InoS3UploadString",
display_name="Ino S3 Upload String",
category="InoS3Helper",
description="Saves a string as a file (txt/json/ini) to temp then uploads it to S3.",
description="Uploads a string directly to S3 as a text object (no temp file).",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
@@ -45,33 +50,19 @@ class InoS3UploadString(io.ComfyNode):
if not validate_s3_key["success"]:
return io.NodeOutput(string, False, validate_s3_key["msg"], "", "")
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_upload_string")
os.makedirs(save_dir, exist_ok=True)
local_name = InoUtilHelper.get_date_time_utc_base64()
local_file = f"{local_name}.{save_as}"
full_path = os.path.join(save_dir, local_file)
if save_as == "json":
save_file = await InoJsonHelper.save_string_as_json_async(string, full_path)
else:
save_file = await InoFileHelper.save_string_as_file(string, full_path)
if not save_file["success"]:
return io.NodeOutput(string, False, save_file["msg"], "", "")
s3_name = local_name if unique_file_name else Path(filename).stem
s3_name = local_name if unique_file_name else (Path(filename).stem or local_name)
s3_file = f"{s3_name}.{save_as}"
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(string, False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
if not s3_result["success"]:
return io.NodeOutput(string, False, s3_result["msg"], "", "")
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
content_type = _CONTENT_TYPE_MAP.get(save_as, "text/plain; charset=utf-8")
s3_result = await s3_instance.put_text(text=string, s3_key=s3_full_key, content_type=content_type)
if not s3_result["success"]:
return io.NodeOutput(string, False, s3_result["msg"], "", "")
return io.NodeOutput(string, True, "Success", s3_file, s3_full_key)
return io.NodeOutput(string, True, s3_result.get("msg", "Success"), s3_file, s3_full_key)
@@ -1,3 +1,4 @@
import asyncio
import os
from pathlib import Path
@@ -27,6 +28,7 @@ class InoS3UploadVideo(io.ComfyNode):
io.Combo.Input("video_codec", options=["h264", "auto"], optional=True),
io.Boolean.Input("unique_file_name", default=True, optional=True, label_off="Use filename", label_on="Unique name"),
io.String.Input("filename", default="", optional=True),
io.Boolean.Input("delete_local", default=True, optional=True, tooltip="Delete the locally saved video after a successful S3 upload."),
],
outputs=[
io.Video.Output(display_name="video"),
@@ -38,7 +40,7 @@ class InoS3UploadVideo(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, video: Input.Video, s3_path_key, filename, s3_config=None, unique_file_name=True, video_format="mp4", video_codec="h264") -> io.NodeOutput:
async def execute(cls, enabled, video: Input.Video, s3_path_key, filename, s3_config=None, unique_file_name=True, video_format="mp4", video_codec="h264", delete_local=True) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(video, False, "", "", "")
@@ -48,24 +50,39 @@ class InoS3UploadVideo(io.ComfyNode):
temp_path = folder_paths.get_temp_directory()
save_dir = os.path.join(temp_path, "s3_upload_video")
os.makedirs(save_dir, exist_ok=True)
await asyncio.to_thread(os.makedirs, save_dir, exist_ok=True)
ext = Types.VideoContainer.get_extension(video_format)
local_name = InoUtilHelper.get_date_time_utc_base64()
local_file = f"{local_name}.{ext}"
full_path = os.path.join(save_dir, local_file)
video.save_to(full_path, format=Types.VideoContainer(video_format), codec=Types.VideoCodec(video_codec))
# video.save_to() runs ffmpeg synchronously — offload to a thread.
try:
await asyncio.to_thread(
video.save_to,
full_path,
format=Types.VideoContainer(video_format),
codec=Types.VideoCodec(video_codec),
)
except Exception as e:
return io.NodeOutput(video, False, f"Failed to save video: {e}", "", "")
s3_name = local_name if unique_file_name else Path(filename).stem
s3_name = local_name if unique_file_name else (Path(filename).stem or local_name)
s3_file = f"{s3_name}.{ext}"
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(video, False, s3_instance["msg"], "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
s3_full_key = f"{s3_path_key.rstrip('/')}/{s3_file}"
s3_result = await s3_instance.upload_file(s3_key=s3_full_key, local_file_path=full_path)
return io.NodeOutput(video, s3_result["success"], s3_result["msg"], s3_file, s3_full_key)
if s3_result["success"] and delete_local:
try:
await asyncio.to_thread(os.remove, full_path)
except OSError:
pass
return io.NodeOutput(video, s3_result["success"], s3_result["msg"], s3_file, s3_full_key)
@@ -17,7 +17,6 @@ class InoS3VerifyFile(io.ComfyNode):
category="InoS3Helper",
description="Verifies a local file exists in S3 and optionally checks integrity via hash.",
inputs=[
io.AnyType.Input("execute"),
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.String.Input("s3_key", default=""),
io.Combo.Input("parent_folder", options=PARENT_FOLDER_OPTIONS),
@@ -34,34 +33,48 @@ class InoS3VerifyFile(io.ComfyNode):
io.String.Output(display_name="abs_path"),
io.Boolean.Output(display_name="exists_remote"),
io.Boolean.Output(display_name="sizes_match"),
io.String.Output(display_name="verify_method"),
io.Int.Output(display_name="local_size"),
io.Int.Output(display_name="remote_size"),
io.String.Output(display_name="etag"),
io.Boolean.Output(display_name="md5_match"),
io.Boolean.Output(display_name="sha256_match"),
],
)
@classmethod
async def execute(cls, execute, enabled, s3_key, parent_folder, folder, filename, s3_config=None, use_md5=False, use_sha256=False) -> io.NodeOutput:
async def execute(cls, enabled, s3_key, parent_folder, folder, filename, s3_config=None, use_md5=False, use_sha256=False) -> io.NodeOutput:
if not enabled:
return io.NodeOutput(False, "not enabled", "", "", False, False)
if not execute:
return io.NodeOutput(False, "execute empty", "", "", False, False)
return io.NodeOutput(False, "not enabled", "", "", False, False, "", 0, 0, "", False, False)
validate_s3_key = S3Helper.validate_s3_key(s3_key)
if not validate_s3_key["success"]:
return io.NodeOutput(False, validate_s3_key["msg"], "", "", False, False)
return io.NodeOutput(False, validate_s3_key["msg"], "", "", False, False, "", 0, 0, "", False, False)
rel_path, abs_path = resolve_comfy_path(parent_folder, folder, filename)
validate_local_path = S3Helper.validate_local_path(Path(abs_path))
if not validate_local_path["success"]:
return io.NodeOutput(False, validate_local_path["msg"], rel_path, abs_path, False, False)
return io.NodeOutput(False, validate_local_path["msg"], rel_path, abs_path, False, False, "", 0, 0, "", False, False)
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], rel_path, abs_path, False, False)
s3_instance = s3_instance["instance"]
return io.NodeOutput(False, s3_instance["msg"], rel_path, abs_path, False, False, "", 0, 0, "", False, False)
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.verify_file(local_file_path=abs_path, s3_key=s3_key, use_md5=use_md5, use_sha256=use_sha256)
s3_result = await s3_instance.verify_file(local_file_path=abs_path, s3_key=s3_key, use_md5=use_md5, use_sha256=use_sha256)
return io.NodeOutput(
s3_result["success"], s3_result["msg"], rel_path, abs_path,
s3_result.get("exists_remote", False), s3_result.get("sizes_match", False),
)
return io.NodeOutput(
s3_result["success"],
s3_result["msg"],
rel_path,
abs_path,
bool(s3_result.get("exists_remote", False)),
bool(s3_result.get("sizes_match", False)),
str(s3_result.get("verify_method") or ""),
int(s3_result.get("local_size") or 0),
int(s3_result.get("remote_size") or 0),
str(s3_result.get("etag") or ""),
bool(s3_result.get("md5_match") or False),
bool(s3_result.get("sha256_match") or False),
)
+56 -13
View File
@@ -1,21 +1,45 @@
import os
import asyncio
from contextlib import suppress
from botocore.config import Config
from inopyutils import InoS3Helper, ino_is_err
def _env_bool(name: str, default: bool) -> bool:
val = os.getenv(name)
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")
def _env_int(name: str, default: int) -> int:
val = os.getenv(name)
if not val:
return default
try:
return int(val)
except ValueError:
return default
class FileSyncer:
def __init__(self):
self.global_comfy_models: dict[str, list[str]] = {
"flux-2-dev": [
"comfyui/models/diffusion_models/flux-2-dev",
"comfyui/models/text_encoders/Flux2",
"comfyui/models/text_encoders/flux-2-dev",
"comfyui/models/vae/Flux2"
],
"flux-2-klein-9b": [
"comfyui/models/diffusion_models/flux-2-klein-9b",
"comfyui/models/text_encoders/flux-2-klein",
"comfyui/models/vae/Flux2"
],
"flux-2-klein-9b-base": [
"comfyui/models/diffusion_models/flux-2-klein-9b-base",
"comfyui/models/text_encoders/Flux2",
"comfyui/models/text_encoders/flux-2-klein",
"comfyui/models/vae/Flux2"
],
"flux-2-loras": [
@@ -46,12 +70,6 @@ class FileSyncer:
"vibe-voice": [
"comfyui/models/vibevoice"
],
"qwen-llm-1": [
"comfyui/models/prompt_generator",
],
"qwen-llm-2": [
"comfyui/models/LLM/Qwen-VL",
]
}
self.s3_config = Config(
@@ -69,6 +87,13 @@ class FileSyncer:
self.s3_id: str = os.getenv("SYNC_S3_ID", "")
self.s3_secret: str = os.getenv("SYNC_S3_SECRET", "")
# Tunable knobs. delete defaults to False so we don't wipe local
# files that aren't on S3 (the helper's own default is True, which
# is unsafe for our use case where users often drop files manually
# into the model folders).
self.delete_orphans: bool = _env_bool("SYNC_DELETE", False)
self.concurrency: int = max(1, _env_int("SYNC_CONCURRENCY", 5))
missing = [name for name, val in [
("SYNC_S3_URL", self.s3_url), ("SYNC_S3_BUCKET", self.s3_bucket),
("SYNC_S3_ID", self.s3_id), ("SYNC_S3_SECRET", self.s3_secret),
@@ -87,6 +112,7 @@ class FileSyncer:
region_name=self.s3_region,
bucket_name=self.s3_bucket,
config=self.s3_config,
retries=10,
)
self.local_comfy_path = os.getenv("SYNC_COMFY_ROOT", "/app")
@@ -141,7 +167,8 @@ class FileSyncer:
syncfolder_res = await self.s3_client.sync_folder(
s3_key=folder,
local_folder_path=local_folder_path,
concurrency=10
concurrency=self.concurrency,
delete=self.delete_orphans,
)
if ino_is_err(syncfolder_res):
print(f"Syncing folder failed for {folder}: {syncfolder_res['msg']}")
@@ -152,13 +179,29 @@ class FileSyncer:
if not self.enabled:
print("[FileSyncer] Skipping sync — not configured.")
return
await asyncio.gather(*[self._sync_one(folder) for folder in self.sync_folders])
for folder in self.sync_folders:
await self._sync_one(folder)
async def close(self) -> None:
"""Tear down the underlying aioboto3 client. Safe to call multiple times."""
if self.s3_client is not None:
with suppress(Exception):
await self.s3_client.close()
self.s3_client = None
async def __aenter__(self) -> "FileSyncer":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.close()
async def main():
print("Sync assets starting...")
file_syncer = FileSyncer()
file_syncer.prepare_comfy_models()
await file_syncer.sync_files()
async with FileSyncer() as file_syncer:
file_syncer.prepare_comfy_models()
await file_syncer.sync_files()
if __name__ == "__main__":
asyncio.run(main())
+5 -6
View File
@@ -15,8 +15,8 @@ class InoRelay(io.ComfyNode):
category="InoExtraNodes",
description="Passes through two values of any type. Useful for controlling execution order.",
inputs=[
io.MatchType.Input("execute", template=execute_template, optional=True),
io.MatchType.Input("relay", template=relay_template, optional=True),
io.MatchType.Input("execute", template=execute_template),
io.MatchType.Input("relay", template=relay_template),
],
outputs=[
io.MatchType.Output(template=execute_template, display_name="execute"),
@@ -25,10 +25,8 @@ class InoRelay(io.ComfyNode):
)
@classmethod
def execute(cls, execute=None, relay=None) -> io.NodeOutput:
if execute is not None:
return io.NodeOutput(execute, relay)
return io.NodeOutput(execute, None)
def execute(cls, execute, relay) -> io.NodeOutput:
return io.NodeOutput(execute, relay)
class InoAnyEqual(io.ComfyNode):
@classmethod
@@ -243,6 +241,7 @@ class InoTerminalLog(io.ComfyNode):
display_name="Ino Terminal Log",
category="InoExtraNodes",
description="Captures and returns the last N lines from the terminal stdout.",
is_output_node=True,
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.Int.Input("lines", default=50, min=1, max=10000),
@@ -1,3 +1,4 @@
import asyncio
import os
import shutil
@@ -358,12 +359,12 @@ class InoS3DownloadModel(io.ComfyNode):
s3_instance = S3Helper.get_instance(s3_config)
if ino_is_err(s3_instance):
return io.NodeOutput(False, s3_instance["msg"], "", "", "")
s3_instance = s3_instance["instance"]
async with s3_instance["instance"] as s3_instance:
s3_result = await s3_instance.download_file(s3_key=s3_key, local_file_path=model_path)
s3_result = await s3_instance.download_file(s3_key=s3_key, local_file_path=model_path)
ino_print_log("InoS3DownloadModel", "file downloaded successfully")
return io.NodeOutput(s3_result["success"], s3_result["msg"], model_type, str(model_path), str(rel_path))
ino_print_log("InoS3DownloadModel", "file downloaded successfully")
return io.NodeOutput(s3_result["success"], s3_result["msg"], model_type, str(model_path), str(rel_path))
except Exception as e:
ino_print_log("InoS3DownloadModel", "", e)
return io.NodeOutput(False, f"Error: {e}", "", "", "")
@@ -441,9 +442,9 @@ class InoHuggingFaceDownloadModel(io.ComfyNode):
return io.NodeOutput(False, f"Error: {e}", "", "", "")
try:
result = hf_hub_download(repo_id, filename, **args)
result = await asyncio.to_thread(hf_hub_download, repo_id, filename, **args)
if ignore_repo_dir:
shutil.move(result, model_path)
await asyncio.to_thread(shutil.move, result, model_path)
result = f"{model_path}/{Path(result).name}"
rel_path = Path(result).relative_to(model_path_base)
ino_print_log("InoHuggingFaceDownloadModel", "file downloaded successfully")
@@ -518,7 +519,7 @@ class InoHuggingFaceDownloadRepo(io.ComfyNode):
return io.NodeOutput(False, f"Error: {e}", "", "", "")
try:
result = snapshot_download(repo_id, **args)
result = await asyncio.to_thread(snapshot_download, repo_id, **args)
rel_path = Path(result).relative_to(model_path_base)
ino_print_log("InoHuggingFaceDownloadRepo", "file downloaded successfully")
return io.NodeOutput(True, "Successful", model_type, str(result), str(rel_path))
@@ -421,7 +421,6 @@ class InoLoadSamplerModels(io.ComfyNode):
description="Downloads and loads all sampler models (UNET, CLIP, VAE) and up to 4 LoRAs from config.",
inputs=[
io.Boolean.Input("enabled", default=True, label_off="OFF", label_on="ON"),
io.AnyType.Input("execute"),
io.String.Input("model_config"),
io.String.Input("lora_1_config"),
io.String.Input("lora_2_config"),
@@ -442,7 +441,7 @@ class InoLoadSamplerModels(io.ComfyNode):
)
@classmethod
async def execute(cls, enabled, execute, model_config,
async def execute(cls, enabled, model_config,
lora_1_config, lora_2_config, lora_3_config, lora_4_config,
clip_device="default", use_dual_clip="unset") -> io.NodeOutput:
fail = lambda msg: io.NodeOutput(False, msg, None, None, None, None, None)
@@ -450,9 +449,6 @@ class InoLoadSamplerModels(io.ComfyNode):
if not enabled:
ino_print_log("InoLoadSamplerModels", "not enabled")
return fail("not enabled")
if not execute:
ino_print_log("InoLoadSamplerModels", "execute is False")
return fail("not enabled")
try:
load_json = InoJsonHelper.string_to_dict(model_config)
@@ -570,12 +566,12 @@ class InoGetSamplerConfig(io.ComfyNode):
from comfy_extras.nodes_custom_sampler import BasicGuider, CFGGuider, KSamplerSelect, BasicScheduler
if use_cfg_val:
get_guider = CFGGuider().get_guider(model=model, positive=positive, negative=negative, cfg=model_cfg.get("cfg", -1))
get_guider = CFGGuider.execute(model=model, positive=positive, negative=negative, cfg=model_cfg.get("cfg", -1))
else:
get_guider = BasicGuider().get_guider(model=model, conditioning=positive)
get_guider = BasicGuider.execute(model=model, conditioning=positive)
get_sampler = KSamplerSelect().get_sampler(sampler_name=model_cfg.get("sampler_name", "none"))
get_sigmas = BasicScheduler().get_sigmas(
get_sampler = KSamplerSelect.execute(sampler_name=model_cfg.get("sampler_name", "none"))
get_sigmas = BasicScheduler.execute(
model=model, scheduler=model_cfg.get("scheduler_name", "none"),
steps=model_cfg.get("steps", -1), denoise=model_cfg.get("denoise", -1),
)
@@ -586,7 +582,7 @@ class InoGetSamplerConfig(io.ComfyNode):
return io.NodeOutput(None, None, None, config, "")
ino_print_log("InoGetSamplerConfig", "success")
return io.NodeOutput(get_guider[0], get_sampler[0], get_sigmas[0], config, model_cfg_str["data"])
return io.NodeOutput(get_guider.args[0], get_sampler.args[0], get_sigmas.args[0], config, model_cfg_str["data"])
class InoGetConditioning(io.ComfyNode):
@@ -646,17 +642,17 @@ class InoGetConditioning(io.ComfyNode):
from comfy_extras.nodes_flux import CLIPTextEncodeFlux, FluxGuidance
if use_flux:
positive_condition = CLIPTextEncodeFlux().encode(clip=clip, clip_l=positive1, t5xxl=positive2, guidance=final_guidance)
positive_condition = CLIPTextEncodeFlux.execute(clip=clip, clip_l=positive1, t5xxl=positive2, guidance=final_guidance).args[0]
else:
positive_condition = CLIPTextEncode().encode(clip=clip, text=positive1)
positive_condition = CLIPTextEncode().encode(clip=clip, text=positive1)[0]
if not use_flux and use_flux_guid:
positive_condition = FluxGuidance().append(conditioning=positive_condition[0], guidance=final_guidance)
positive_condition = FluxGuidance.execute(conditioning=positive_condition, guidance=final_guidance).args[0]
if use_neg:
negative_condition = CLIPTextEncode().encode(clip=clip, text=negative)
negative_condition = CLIPTextEncode().encode(clip=clip, text=negative)[0]
else:
negative_condition = ConditioningZeroOut().zero_out(conditioning=positive_condition[0])
negative_condition = ConditioningZeroOut().zero_out(conditioning=positive_condition)[0]
model_cfg_str = InoJsonHelper.dict_to_string(model_cfg)
if not model_cfg_str["success"]:
@@ -664,7 +660,7 @@ class InoGetConditioning(io.ComfyNode):
return io.NodeOutput(None, None, config, "")
ino_print_log("InoGetConditioning", "success")
return io.NodeOutput(positive_condition[0], negative_condition[0], config, model_cfg_str["data"])
return io.NodeOutput(positive_condition, negative_condition, config, model_cfg_str["data"])
class InoGetModelDownloadConfig(io.ComfyNode):