Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8800f3366e | ||
|
|
9a3ced38af | ||
|
|
ef621b2b6e | ||
|
|
0a5288386f | ||
|
|
07b7f001ae | ||
|
|
d4d6dc7c76 | ||
|
|
d99af94221 | ||
|
|
2310e54dea | ||
|
|
2d4e98a936 | ||
|
|
efac5cfe40 | ||
|
|
bc601cb1b3 | ||
|
|
6f2218c995 | ||
|
|
f00930e4c6 | ||
|
|
59277e744c | ||
|
|
804ce36643 | ||
|
|
9a96c59f1c | ||
|
|
1d3e99fabe | ||
|
|
ba162c54e3 | ||
|
|
ca69dbd55e | ||
|
|
547fbde1dd | ||
|
|
30aac91b90 | ||
|
|
c2e8c155f4 | ||
|
|
8ecd3c4e20 | ||
|
|
8fabb2d56f | ||
|
|
7a2bbb0ed0 | ||
|
|
82df57cca6 | ||
|
|
e6fc0d0ac3 | ||
|
|
f0a75b74dd | ||
|
|
e5ad23f9c2 | ||
|
|
75cb843f56 | ||
|
|
2e5a806f5b | ||
|
|
0f2631d7b0 | ||
|
|
874e29dfad | ||
|
|
9401852f5d | ||
|
|
4b91fb9b1a | ||
|
|
244dbf79a0 | ||
|
|
b75f9601b8 | ||
|
|
b5b8093265 | ||
|
|
3404ada0cb | ||
|
|
a0e4dda6f2 | ||
|
|
993c8bf5b4 | ||
|
|
e227ab7bf4 | ||
|
|
5e3cccfc62 | ||
|
|
7e92119bef | ||
|
|
697f9d5d76 | ||
|
|
1c767061cf | ||
|
|
be44e90e4b | ||
|
|
5cb3dcfa9e | ||
|
|
eeb142eaa4 | ||
|
|
59cc2bcc8c | ||
|
|
a43a8336c0 | ||
|
|
68f889f144 | ||
|
|
ab4b4509a4 | ||
|
|
f36a14b33b | ||
|
|
1a4ff6e1e0 | ||
|
|
f148e2df10 | ||
|
|
ada0212d57 | ||
|
|
3efccfe642 | ||
|
|
485e9ba7ed | ||
|
|
cf2e19b673 |
@@ -101,3 +101,6 @@ cookiecutter-pypackage-env/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Build artifacts
|
||||
node.zip
|
||||
|
||||
+311
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user