11 Commits
Author SHA1 Message Date
draconicdragon 2f934d3d9e chore: remove gen video from image node 2025-12-14 03:55:14 +01:00
draconicdragon 7e1391535c refactor: more work on video node
not finished at all but will generate video now, and outputs newer "VIDEO" datatype output
ProgressBar should show more correct based on polling response
adjusted polling interval to be a bit slower
add (temporary) ability to bypass api api call for video and load one from testing_video folder instead for testing video output usage downstream, if any
the debug env var will trigger any api generated video to be saved to the same testing_video folder instaed of just being saved as temp file
2025-12-13 06:30:28 +01:00
draconicdragon c54bdbbb97 feat: more (debug) logging for venice_client.py
and move setting some env vars to globals
2025-12-13 06:26:42 +01:00
draconicdragon e4caea1af6 fix: .gitignore entry 2025-12-13 06:23:48 +01:00
draconicdragon 19c9a3f212 chore: update .gitignore 2025-12-13 06:23:12 +01:00
draconicdragon 2ef0200c4c chore: increase default cache time to live to 15 minutes 2025-12-13 06:23:04 +01:00
draconicdragon b9b43e7ef5 chore: add README 2025-12-13 06:19:40 +01:00
draconicdragon b5bc49753e chore: add TODO.md 2025-12-13 06:18:00 +01:00
draconicdragon 67d4747e9c feat: add basic primitive video node, untested but might work 2025-12-12 08:15:17 +01:00
draconicdragon 583b7afa78 refactor: comment out VENICE_CLIENT_DRY_RUN 2025-12-12 05:50:13 +01:00
draconicdragon e6266cc049 refactor with new API client and config management
my shitty description:
refactor a bunch of stuff
better model loading, removed the stupid js workaround to populating the COMBO fields
removed model loading from js and model fetching from routes/pyserver
unified model/character/styles lists internally as "catalog"

changed textbox in settings to by type: password

replaced primitive requests with client session for better/cleaner/modular request managing, added some better error logging and responses
added dry run env var VENICE_CLIENT_DRY_RUN can be set to 'true' or '1' (it just returns dummy response and doesnt hit the veniceai api)

make __init__.py module/node loading better by loading any .py file with NODE_*_MAPPING in nodes folder instead of hardcoding names

remove old redundant files

AI summarized description with some editing:
- Added `venice_client.py` for handling API requests to VeniceAI.
- Introduced `venice_config.py` for managing API key configuration.
- Created `venice_catalog.py` to manage and cache models, styles, and characters.
- Removed outdated key management logic from `pyserver/get_key_from_jssetting.py`.
- Consolidated character and model update logic into the new catalog management system.
- Move utility functions to `nodes/utils.py` containing validation stuff mostly.
- Removed legacy update scripts for characters, models, and styles.
- Added error handling and logging for API interactions.
- remove obsolete workflow JSON file.
2025-12-12 04:02:43 +01:00
35 changed files with 1259 additions and 1473 deletions
+1
View File
@@ -3,6 +3,7 @@ test.py
js/test.js
veniceai_config.json
data/*
testing_video/*
# Ignore Mac system files
.DS_Store
+3 -109
View File
@@ -1,110 +1,4 @@
# ComfyUI Venice.AI API Custom Nodes
### Hi, hello
An unofficial custom node implementation for ComfyUI that integrates with venice.ai's Generative AI services such as Image, Text and TTS Generation models (as well as Upscale and Enhance). This project is adapted from [ComfyUI-FLUX-TOGETHER-API](https://github.com/BZcreativ/ComfyUI-FLUX-TOGETHER-API) to work with the venice.ai API.
**Note**: TTS is in Beta per Venice (as of 15th June 2025). The node for it "Generate Speech (Venice)" is set to BETA/Experimental too. To be able to find it with node search you have to enable "Show experimental nodes in search" setting in ComfyUI.
Disclaimer: I originally made this on a whim because someone wanted something similar to Together.AI custom nodes but have them use venice instead. I'm also not affiliated with Venice.AI. Idk is this good enough for a disclaimer or something
## Nodes: (Text gen node is missing but will be updated soon)
![all nodes showcased](./gh_assets/nodes_showcase.png)
Image and Text models (as of 15th June 2025 | for Speech gen only tts-kokoro is available)
For updated text generation experience please use `Generate Text Advanced BETA (Venice)` node and additionally `Textgen Parameters (Venice) for extra venice.ai specific parameters to pass onto the generation process.
![Image gen models](./gh_assets/image_gen_models_15june.png) ![Text gen models](./gh_assets/text_gen_models15june.png)
~~# todo: inpainting~~ Deprecated by venice, new thing is coming for it at some point
todo: actual log, maybe separate logging file for less clutter from comfyui stuff, maybe, maybe... eeeee
todo: add settings to set default model by user
todo: less convoluted approach to downloading and loading model/character/styles lists
~~todo: LLM characters list~~ done, but its in beta so subject to big changes, use with `Textgen Parameters (Venice)` node
todo: use variants api (currently in beta) | idk what happened to this, probably gone
Todo: chat history/memory/context for LLM | got a vague idea but thats pretty much it
todo
- Some Validation on queue for API limits like prompt max length or width/height
- these are different for different models so this isnt planned to be implemented unless the api exposes those limits somehow
- i'd have an error thrown before it gets sent to api about some value being too high or too low (though api should send error back for now either way)
- maybe the api already exposes those limits, the steps limit is afaik at least, need to look into it
### Below ReadMe text is only slightly altered from original Flux Together API readme, it was not really reworked or anything so its likely not correct or up to date
### Installation - these instructions are a mess
0. Before proceeding, check if you can find these nodes through ComfyUI-Manager interface rather than following the instructions below.
<details><summary>Expand me to see harder instructions</summary>
1. Clone this repository into your ComfyUI custom_nodes directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/DraconicDragon/ComfyUI-Venice-API.git
```
2. Install the required dependencies: (this might be done by comfyui automatically on restart already?)
```bash
pip install -r requirements.txt
```
OR From the Comfyui Folder (this one is usually preferred if you have portable edition)
```bash
./python_embeded\python.exe -m pip install -r ComfyUI\custom_nodes\ComfyUI-Venice-API\requirements.txt
```
</details>
### Configuration
1. Get your API key from [venice.ai](https://venice.ai)
2. Add your API key in ComfyUI settings > VeniceAI
### Parameters for Generate Image (Venice) node
| Parameter | Type | Range | Default | Description |
|-----------------|---------|------------|---------|-----------------------------------------|
| prompt | string | 1-1500 | "A flying cat made of lettuce" | Main generation prompt |
| negative_prompt | string | 0-1500 | "" | Elements to avoid |
| width | integer | 0-1280? | 1024 | Image width |
| height | integer | 0-1280? | 1024 | Image height |
| batch_size | integer | 1-4 | 1 | Number of Images to gen in a single run |
| steps | integer | 1-30 or 50 | 20 | Number of generation steps |
| cfg/guidance | float | 0-20.0 | 3.0 | Guidance scale |
| style_preset | string | N/A | none | The Style preset to apply |
| hide_watermark | boolean | N/A | true | Whether to hide watermark (NSFW = false)|
| safe_mode | boolean | N/A | false | Whether to blur NSFW images |
| seed | integer | -999999999 to 999999999 | -1 | Generation seed |
### License
MIT License - see [LICENSE](LICENSE) file for details.
### Credits
- This project is adapted from [ComfyUI-FLUX-TOGETHER-API](https://github.com/BZcreativ/ComfyUI-FLUX-TOGETHER-API)
- venice.ai for providing the generative AI services and API
- [ComfyUI-FLUX-TOGETHER-API](https://github.com/BZcreativ/ComfyUI-FLUX-TOGETHER-API) for their work
- ComfyUI team for the amazing framework
### Author
Created by [BZcreativ](https://github.com/BZcreativ)
venice.ai rewrite by [DraconicDragon](https://github.com/DraconicDragon)
### Contributing
Contributions are welcome! Feel free to submit a Pull Request.
### Example
todo
For detailed usage instructions, see [USAGE.md](USAGE.md) (not reworked)
WIP dev branch changing things woohee
see [TODO](TODO.md) for some sparse todo stuff
+7
View File
@@ -0,0 +1,7 @@
# TODO
- [ ] Support quote video gen button label for price estimates
- [ ] Convert to node schema v3
- [ ] Use DynamicCombo for all applicable nodes
- [ ] Combine DynamicCombo with other limits such as prompt length
- [ ] Check for TODOs in code files
-128
View File
@@ -1,128 +0,0 @@
# dont follow stuff thats in here i dont think its correct
# ComfyUI venice.ai API Node Usage Guide
### Disclaimer: I just made this on a whim because someone wanted something similar to Together.AI custom nodes but use venice instead and I don't have access to any API keys for any of the mentioned services.
## Setup
1. Ensure you have a venice.ai account and API key
2. Configure your API key in `config.ini`
3. Install all required dependencies (should be done automatically by comfy?)
## Node Configuration
### Input Parameters
#### Required Parameters:
- **Prompt** (String)
- Your main generation prompt
- Be specific and detailed for best results if using Flux
- **Negative Prompt** (String)
- Elements you want to avoid in the generation
- Leave empty if not needed | Will be ignored if flux-dev or flux-dev-uncensored is selected as model
- **Steps** (Integer)
- Range: 1-30
- Default: 20
- **Width** (Integer)
- Range: 512-2048
- Default: 1024
- Must be a multiple of 32
- Common values: 512, 768, 1024 (1MP), 1440 (Flux | 2MP)
- **Height** (Integer)
- Range: 512-2048
- Default: 1024
- Must be a multiple of 32
- Common values: 512, 768, 1024 (1MP), 1440 (Flux | 2MP)
- **Seed** (Integer)
- Range: 0 to max 64-bit integer
- Default: -1 | Random
- Reuse a seed with same prompt to reproduce an image
- **CFG (Guidance Scale)** (Float)
- Range: 0.1-15.0
- Default: 3.5
- Recommended range: 5.0-10.0 (SDXL) | ~3.5 (Flux)
### Output
The node outputs a single image tensor compatible with other ComfyUI nodes.
## Best Practices
1. **Prompt Engineering**
- Be specific and detailed in your prompts
- Use descriptive adjectives
- Include style references when needed
2. **Performance**
- Start with lower step counts (20-30) for testing
- Increase steps for final generations
- Use reasonable image dimensions (1024x1024 is standard)
3. **Error Handling/Troubleshooting**
- Check console for error messages
- Verify API key is correctly configured
- Ensure parameters are within valid ranges
## Common Workflows
### Basic Image Generation
1. Add Together API Node to workspace
2. Connect to a Load Image node
3. Configure prompt and basic parameters
4. Execute workflow
### Advanced Usage
1. Combine with other ComfyUI nodes
2. Use seed control for consistent results
3. Experiment with guidance scale for style control
## Troubleshooting
### Common Issues
1. **API Key Errors**
- Verify key in config.ini
- Check API key validity
- Ensure proper formatting
2. **Generation Errors**
- Verify parameter ranges
- Check prompt length
- Monitor API rate limits
3. **Image Quality Issues**
- Adjust step count
- Modify guidance scale
- Refine prompt
## Examples
### Basic Prompt Example
```
A beautiful landscape with mountains and lakes, cinematic lighting, high detail
```
### Advanced Prompt Example
```
A stunning mountain landscape at sunset, volumetric lighting,
golden hour, ultra detailed, professional photography,
8k resolution, artistic composition
```
### Negative Prompt Example
```
blur, haze, low quality, distortion, bad composition,
oversaturated, unrealistic lighting
```
## Support
For issues and feature requests, please use the GitHub issue tracker.
+23 -21
View File
@@ -1,39 +1,41 @@
import importlib
import logging
import os
from pathlib import Path
from .pyserver import (
get_key_from_jssetting, # noqa: F401
update_characters, # noqa: F401
update_models, # noqa: F401
update_styles, # noqa: F401
from .pyserver import routes # noqa: F401
NODE_PACKAGE = Path(__file__).with_name("nodes")
node_list = sorted(
module.stem for module in NODE_PACKAGE.glob("*.py") if module.is_file() and not module.name.startswith("_")
)
node_list = [
# "things_n_stuff_node",
"gen_image_node",
# "gen_image_inpaint_node",
"gen_text_node",
"gen_text_advanced_node",
"gen_text_venice_params_node",
"i2i_enhance_upscale_node",
"gen_speech_node",
"util_nodes",
]
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_MODULE_PREFIX = f"{__name__}.nodes"
for module_name in node_list:
try:
imported_module = importlib.import_module(f".nodes.{module_name}", __name__)
NODE_CLASS_MAPPINGS.update(imported_module.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(imported_module.NODE_DISPLAY_NAME_MAPPINGS)
imported_module = importlib.import_module(f"{NODE_MODULE_PREFIX}.{module_name}")
except ImportError as e:
logging.warning(f"Could not import module '{module_name}': {e}")
continue
has_classes = hasattr(imported_module, "NODE_CLASS_MAPPINGS")
has_display = hasattr(imported_module, "NODE_DISPLAY_NAME_MAPPINGS")
if has_classes and has_display:
NODE_CLASS_MAPPINGS.update(imported_module.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(imported_module.NODE_DISPLAY_NAME_MAPPINGS)
elif has_classes or has_display:
logging.warning(
"Module '%s' defines '%s' but is missing '%s'; module skipped",
module_name,
"NODE_CLASS_MAPPINGS" if has_classes else "NODE_DISPLAY_NAME_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS" if has_classes else "NODE_CLASS_MAPPINGS",
)
# modules without either mapping are ignored silently
WEB_DIRECTORY = os.path.join(os.path.dirname(__file__), "js")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+12 -2
View File
@@ -1,3 +1,5 @@
import os
API_ENDPOINTS = {
"list_models": "/models", # response type is list of strings
"list_styles": "/image/styles", #
@@ -6,9 +8,17 @@ API_ENDPOINTS = {
"upscale_image": "/image/upscale", # NOTE: apparently doesnt even work yet? idk; response type is image/png file, content type is multipart/form-data
"text_generate": "/chat/completions", # has much info, text response is in choices: content, can have multiple choices apparently but dosnt seem to be utilized
"speech_generate": "/audio/speech", # type: file (audio/aac; audio/mpeg; audio/wav.. etc)
"video_queue": "/video/queue", #
"video_quote": "/video/quote", # price estimate, takes same payload as video_queue
"video_retrieve": "/video/retrieve", # get video file by job id
"list_api_keys": "/api_keys",
}
VENICEAI_BASE_URL = "https://api.venice.ai/api/v1"
# unused right now
headers = {"User-Agent": "ComfyUI-Venice-API/1.0 (by draconicdragon on github)"}
# request hygiene
USER_AGENT = "ComfyUI-Venice-API/1.0 (by draconicdragon on github)"
os.environ["VENICE_CLIENT_DRY_RUN"] = "0"
os.environ["VENICE_CLIENT_DEBUG"] = "1"
-125
View File
@@ -1,125 +0,0 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
// Helper function to fetch data and assign to widget
async function fetchAndAssignWidget(widget, url, dataKey, logMsg, errorMsg) {
if (!widget) return;
try {
console.log(`(VeniceAI.NodeSpawn) ${logMsg}`);
const response = await api.fetchApi(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
const rawText = await response.text();
let data;
try {
data = JSON.parse(rawText);
} catch (jsonError) {
throw new Error(`Failed to parse JSON: ${jsonError.message}. Raw response: ${rawText}`);
}
widget.options.values = data[dataKey];
if (widget.onChange) {
widget.onChange();
}
this.setDirtyCanvas(true);
} catch (error) {
console.error(`(VeniceAI.NodeSpawn) ${errorMsg}:`, error);
alert(`(VeniceAI.NodeSpawn) ${errorMsg}:\n${error}`);
}
}
app.registerExtension({
name: "VeniceAI.NodeSpawn",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "GenerateImage_VENICE" || nodeData.name === "InpaintImage_VENICE") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = async function () {
if (originalOnNodeCreated) {
originalOnNodeCreated.apply(this);
}
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "model"),
"/veniceai/get_models_list",
"image_models",
"Trying to fetch image models...",
"Failed to fetch image models"
);
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "style_preset"),
"/veniceai/get_styles_list",
"data",
"Trying to fetch styles...",
"Failed to fetch styles"
);
};
}
if (nodeData.name === "GenerateText_VENICE" || nodeData.name === "GenerateTextAdvanced_VENICE") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = async function () {
if (originalOnNodeCreated) {
originalOnNodeCreated.apply(this);
}
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "model"),
"/veniceai/get_models_list",
"text_models",
"Trying to fetch text models...",
"Failed to fetch text models"
);
};
}
if (nodeData.name === "GenerateTextVeniceParameters_VENICE") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = async function () {
if (originalOnNodeCreated) {
originalOnNodeCreated.apply(this);
}
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "character_slug"),
"/veniceai/get_characters_list",
"characters",
"Trying to fetch character slugs...",
"Failed to fetch character slugs"
);
};
}
if (nodeData.name === "GenerateSpeech_VENICE") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = async function () {
if (originalOnNodeCreated) {
originalOnNodeCreated.apply(this);
}
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "model"),
"/veniceai/get_models_list",
"tts_models",
"Trying to fetch tts models...",
"Failed to fetch tts models"
);
await fetchAndAssignWidget.call(
this,
this.widgets.find(w => w.name === "voice"),
"/veniceai/get_models_list",
"tts_voices",
"Trying to fetch tts voices...",
"Failed to fetch tts voices"
);
}
}
}
});
+5 -2
View File
@@ -5,11 +5,15 @@ app.registerExtension({
name: "VeniceAI.Settings",
settings: [
{
category: ["VeniceAI", "API Key", "VeniceAI API Key"],
id: "VeniceAI.apikey",
name: "VeniceAI API Key",
type: "text",
defaultValue: "your_venice_api_key_here",
tooltip: "Enter your VeniceAI API Bearer Token Key here",
tooltip: "Enter your VeniceAI API Key/Bearer Token here",
attrs: {
type: "password",
},
onChange: async (newVal) => {
api.fetchApi("/veniceai/save_apikey", {
method: "POST",
@@ -19,4 +23,3 @@ app.registerExtension({
},
],
});
+5 -43
View File
@@ -5,51 +5,13 @@ app.registerExtension({
name: "VeniceAI.Startup",
async setup() {
// Load saved value from server on startup
console.log("(VeniceAI.Startup) Fetching VeniceAI API key from config file...");
const api_key_response = await api.fetchApi("/veniceai/get_apikey");
const savedKey = await api_key_response.json();
// update the settings UI
app.extensionManager.setting.set("VeniceAI.apikey", savedKey.apikey);
try {
// update the model list
console.log("(VeniceAI.Startup) Updating model list...");
//alert("fetching model list")
const response = await api.fetchApi("/veniceai/update_models_list");
const data = await response.json();
//alert(`response status ${JSON.stringify(data)}`);
if (data.error) {
alert(`${data.message}`);
console.log(`(VeniceAI.Startup) ${data.message}`);
}
else{
// update the style list if not model list error
console.log("(VeniceAI.Startup) Updating styles list...");
//alert("fetching styles list")
const response_s = await api.fetchApi("/veniceai/update_styles_list");
//alert(`response status ${await response_s.text()}`);
const data_s = await response_s.json();
if (data_s.error) {
alert(`${data_s.message}`);
console.log(`(VeniceAI.Startup) ${data_s.message}`);
}
// update the characters list
console.log("(VeniceAI.Startup) Updating characters list...");
const response_c = await api.fetchApi("/veniceai/update_characters_list");
const data_c = await response_c.json();
if (data_c.error) {
alert(`${data_c.message}`);
console.log(`(VeniceAI.Startup) ${data_c.message}`);
}
}
console.log("(VeniceAI.Startup) Fetching VeniceAI API key from config file...");
const api_key_response = await api.fetchApi("/veniceai/get_apikey");
const savedKey = await api_key_response.json();
app.extensionManager.setting.set("VeniceAI.apikey", savedKey.apikey);
} catch (error) {
// Handle any unexpected errors
alert(`(VeniceAI.Startup) Unexpected Error: ${error.message}`);
console.error("(VeniceAI.Startup) Unexpected Error:", error);
console.error("(VeniceAI.Startup) Failed to load Venice API key", error);
}
},
});
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
from typing import Iterable, Sequence, Tuple
from ..venice_catalog import get_characters, get_models, get_styles
log = logging.getLogger(__name__)
def _safe_values(
loader,
key: str,
fallback: Sequence[str] = ("This list is unavailable; check logs for details?",),
) -> Tuple[str, ...]:
"""
Safely retrieves a sequence of string values from a loader payload keyed by `key`.
Attempts to call the provided `loader` callable to obtain a payload, logging failures
and falling back to the provided default values. Extracts and normalizes the value
associated with `key`, ensuring it is returned as a tuple of strings. If the extracted
value is missing, empty, or otherwise falsy, the fallback values are returned instead.
"""
try:
payload = loader()
except Exception as exc:
log.debug("Failed to load %s catalog: %s", key, exc)
return tuple(fallback)
raw = payload.get(key)
if raw is None:
return tuple(fallback)
if isinstance(raw, Iterable) and not isinstance(raw, (str, bytes)):
values = tuple(str(item) for item in raw if item)
else:
values = tuple(str(raw)) if raw else ()
return values or tuple(fallback)
def image_model_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "image_models")
def text2video_model_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "text2video_models")
def image2video_model_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "image2video_models")
def text_model_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "text_models")
def tts_model_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "tts_models")
def tts_voice_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_models(), "tts_voices")
def style_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_styles(), "data")
def character_choices() -> Tuple[str, ...]:
return _safe_values(lambda: get_characters(), "characters")
+4 -6
View File
@@ -3,11 +3,12 @@ import io
import logging
import numpy as np
import torch # type: ignore
import torchvision.transforms as transforms # type: ignore
import torch # type: ignore
import torchvision.transforms as transforms # type: ignore
from PIL import Image
class GenerateImageBase:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
@@ -34,12 +35,9 @@ class GenerateImageBase:
except Exception as e:
raise Exception(f"Error processing image result: {str(e)}") from e
# unused
def create_blank_image(self):
blank_img = Image.new("RGB", (64, 64), color="black")
img_array = np.array(blank_img).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(img_array)[None,]
return (img_tensor,)
def check_multiple_of_32(self, width, height):
if width % 32 != 0 or height % 32 != 0:
raise ValueError(f"Width {width} and height {height} must be multiples of 32.")
View File
+70 -30
View File
@@ -1,33 +1,45 @@
import logging
import os
import re
import requests
import torch # type: ignore
import torch
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
from ..globals import API_ENDPOINTS
from ..nodes.catalog_utils import image_model_choices, style_choices
from ..nodes.gen_image_base import GenerateImageBase
from ..nodes.utils import ensure_multiple_of, ensure_prompt_length
from ..venice_client import client
class GenerateImage(GenerateImageBase):
@classmethod
def INPUT_TYPES(cls):
model_choices = image_model_choices()
style_preset_options = style_choices()
return {
"required": {
"model": (
"COMBO",
model_choices,
{
"default": "flux-dev",
"tooltip": "Model to use for image generation, if this just says flux-dev or C O M B O then something failed oopsie.",
"default": model_choices[0],
"tooltip": "Model to use for image generation",
},
),
"prompt": (
"STRING",
{
"default": "A flying cat made of lettuce",
"multiline": True,
"tooltip": "The text prompt to guide the image generation",
"placeholder": "Positive Prompt. Example: A flying cat made of lettuce",
},
),
"prompt": ("STRING", {"default": "A flying cat made of lettuce", "multiline": True}),
"neg_prompt": (
"STRING",
{
"placeholder": "Negative Prompt. (Ignored for Flux based models.)\nBad composition, rating_explicit, Text, signature, lowres, faded image, out of focus, cropped, out of frame, vacant scene, bad quality, worst quality,",
"placeholder": "Negative Prompt. (Ignored for Flux based models.)\n Example: bad composition, rating_explicit, bad quality,",
"multiline": True,
"tooltip": "Negative prompt. This is ignored when using flux-dev or flux-dev-uncensored",
"tooltip": "Negative prompt. This is ignored when using flux-dev or flux-dev-uncensored or similar models that do not support CFG (Classifier-Free-Guidance)",
},
),
"width": (
@@ -50,7 +62,15 @@ class GenerateImage(GenerateImageBase):
"tooltip": "Must be a multiple of 32. Maximum allowed by venice.ai at time of writing is 1280",
},
),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4}),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 4,
"tooltip": "Number of images to generate in a single batch. IMPORTANT: Doesn't do actual batches like ComfyUI would, just sends batches amount of different requests to Venice.",
},
),
"steps": (
"INT",
{
@@ -66,9 +86,23 @@ class GenerateImage(GenerateImageBase):
),
},
),
"guidance": ("FLOAT", {"default": 3.0, "min": 0.0, "max": 20.0, "step": 0.05}),
"guidance": (
"FLOAT",
{
"default": 3.0,
"min": 0.0,
"max": 20.0,
"step": 0.05,
"tooltip": "CFG scale parameter or 'Guidance' for Flux",
},
),
# "lora_strength": ("INT", {"default": 50, "min": 0, "max": 100}), # check docs idk how to work this yet
"style_preset": ("COMBO", {"default": "none"}),
"style_preset": (
style_preset_options,
{
"default": style_preset_options[0],
},
),
"hide_watermark": (
"BOOLEAN",
{
@@ -90,6 +124,26 @@ class GenerateImage(GenerateImageBase):
}, # 0xffffffffffffffff is 64 bit integer limit, current hex is 999999999, venice max
}
# todo: add variants for batch size
# todo: implement lora and lora_strength
# todo: see if aspect_ratio needs to be added (some models incl nano banana pro use this)
# https://docs.venice.ai/api-reference/endpoint/image/generate
# todo: see if resolution needs to be added (some models incl nano banana pro use this)
# todo: add enable_web_search, mention it charges extra credits
# todo: change up default limits, prompt length max is 7500 now
@classmethod
def VALIDATE_INPUTS(cls, input_types, **kwargs):
width = input_types.get("width", kwargs.get("width"))
height = input_types.get("height", kwargs.get("height"))
prompt = input_types.get("prompt", kwargs.get("prompt"))
neg_prompt = input_types.get("neg_prompt", kwargs.get("neg_prompt"))
ensure_multiple_of(width, height)
ensure_prompt_length(prompt, 1500, "Prompt")
ensure_prompt_length(neg_prompt, 1500, "Negative Prompt", allow_empty=True)
return True
def generate(
self,
model,
@@ -107,10 +161,6 @@ class GenerateImage(GenerateImageBase):
# format,
seed=-1,
):
if prompt == "" or len(prompt) > 1500:
raise ValueError("VeniceAI Generate Image Node: Prompt cannot be empty or above 1500 characters")
if len(neg_prompt) > 1500:
raise ValueError("VeniceAI Generate Image Node: Negative prompt cannot be above 1500 characters")
if re.match(r"^flux.*", model):
logging.info(f"VeniceAPI INFO: Ignoring negative prompt for {model}.")
neg_prompt = ""
@@ -118,11 +168,6 @@ class GenerateImage(GenerateImageBase):
images_tensor = () # empty tuple for tensors
try:
self.check_multiple_of_32(width, height) # todo: make this be validate node instead
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
url = VENICEAI_BASE_URL + API_ENDPOINTS["image_generate"]
payload = {
"model": model,
"prompt": prompt,
@@ -138,20 +183,15 @@ class GenerateImage(GenerateImageBase):
"hide_watermark": hide_watermark,
"safe_mode": safe_mode,
"format": "png", # hardcoded because, change to format var and uncomment related stuff above if want dynamic
"embed_exif_metadata": True, # this might not work and be overriden by comfyui on image save
}
if style_preset == "none":
del payload["style_preset"]
for i in range(batch_size):
payload["seed"] = seed + i
response = requests.request("POST", url, json=payload, headers=headers)
if response.status_code != 200:
raise requests.exceptions.HTTPError(
f"HTTP error: {response.status_code}, Response: {response.text}"
)
images_tensor += self.process_result(response.json())
response_json = client.post_json(API_ENDPOINTS["image_generate"], payload)
images_tensor += self.process_result(response_json)
merged = torch.cat(images_tensor, dim=0)
return (merged,)
+21 -14
View File
@@ -2,21 +2,27 @@ import os
import tempfile
import requests
import torch # type: ignore
import torchaudio # type: ignore
import torch
import torchaudio
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
from ..globals import API_ENDPOINTS
from ..nodes.catalog_utils import tts_model_choices, tts_voice_choices
from ..nodes.utils import ensure_prompt_length
from ..venice_client import client
class GenerateSpeech:
@classmethod
def INPUT_TYPES(cls):
model_options = tts_model_choices()
voice_options = tts_voice_choices()
return {
"required": {
"model": (
"COMBO",
model_options,
{
"default": "tts-kokoro",
"default": model_options[0],
},
),
"input": (
@@ -73,9 +79,9 @@ class GenerateSpeech:
# },
# ),
"voice": (
"COMBO",
voice_options,
{
"default": "af_sky - tts-kokoro",
"default": voice_options[0],
},
),
}
@@ -89,10 +95,9 @@ class GenerateSpeech:
EXPERIMENTAL = True
def gen_speech(self, model, input, response_format, speed, voice):
if len(input) > 4096 or len(input) == 0:
raise ValueError("Generate Speech (Venice) Input exceeds the max length of 4096 characters or is empty.")
ensure_prompt_length(input, 4096, label="Speech input")
url = VENICEAI_BASE_URL + API_ENDPOINTS["speech_generate"]
url = API_ENDPOINTS["speech_generate"]
# remove everything from voice string after and including the hyphen " - blabla"
voice = voice.split(" - ")[0] if " - " in voice else voice
@@ -107,12 +112,14 @@ class GenerateSpeech:
"streaming": False,
}
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
# Send request
try:
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
response = client.request(
"POST",
url,
json=payload,
headers={"Content-Type": "application/json"},
)
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Generate Speech (Venice) API request failed: {str(e)}")
+18 -85
View File
@@ -1,20 +1,22 @@
import base64
import io
import os
import numpy as np
import requests
from PIL import Image
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
from ..globals import API_ENDPOINTS
from ..nodes.catalog_utils import text_model_choices
from ..nodes.utils import encode_tensor_for_vision, ensure_prompt_length
from ..venice_client import client
class GenerateTextAdvanced:
@classmethod
def INPUT_TYPES(cls):
model_options = text_model_choices()
return {
"required": {
"model": ("COMBO", {"default": "llama-3.1-405b", "tooltip": ("The model to use for text generation.")}),
"model": (
model_options,
{
"default": model_options[0],
"tooltip": ("The model to use for text generation."),
},
),
"prompt": (
"STRING",
{
@@ -228,80 +230,17 @@ class GenerateTextAdvanced:
# endregion
):
url = VENICEAI_BASE_URL + API_ENDPOINTS["text_generate"]
ensure_prompt_length(prompt, 1500, label="Prompt")
user_content = []
venice_parameters = kwargs.get("venice_parameters", None)
image_for_vision = kwargs.get("image_for_vision", None)
venice_parameters = kwargs.get("venice_parameters")
image_for_vision = kwargs.get("image_for_vision")
if image_for_vision is not None and enable_vision:
# Convert tensor to PIL Image
image_tensor = image_for_vision[0] # shape: (H, W, 3)
image_np = image_tensor.cpu().numpy() # Still in (H, W, 3)
image_np = (image_np * 255).astype(np.uint8) # Scale from [0, 1] to [0, 255] if needed
pil_image = Image.fromarray(image_np)
# Resize image to meet constraints
original_width, original_height = pil_image.size
aspect_ratio = original_width / original_height
# Determine target dimensions
if original_width > original_height:
target_width = 1024
target_height = int(target_width / aspect_ratio)
if target_height < 256:
target_height = 256
target_width = int(target_height * aspect_ratio)
else:
target_height = 1024
target_width = int(target_height * aspect_ratio)
if target_width < 256:
target_width = 256
target_height = int(target_width / aspect_ratio)
# Round dimensions to multiples of 14
def round_down_to_multiple(value, multiple):
return (value // multiple) * multiple
target_width = round_down_to_multiple(target_width, 14)
target_height = round_down_to_multiple(target_height, 14)
# Ensure minimum dimension is 256 after rounding
if min(target_width, target_height) < 256:
if target_width < target_height:
target_width = ((256 + 13) // 14) * 14
target_height = round_down_to_multiple(int(target_width / aspect_ratio), 14)
else:
target_height = ((256 + 13) // 14) * 14
target_width = round_down_to_multiple(int(target_height * aspect_ratio), 14)
pil_image = pil_image.resize((target_width, target_height), Image.LANCZOS) # type: ignore
# Convert to base64 and check size
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
# Resize further if base64 exceeds 4.5MB
while len(img_base64) > 4500000:
scaling_factor = (4500000 / len(img_base64)) ** 0.5
new_width = int(target_width * scaling_factor)
new_height = int(target_height * scaling_factor)
new_width = max(round_down_to_multiple(new_width, 14), 256)
new_height = max(round_down_to_multiple(new_height, 14), 256)
pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS) # type: ignore
target_width, target_height = new_width, new_height
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
encoded_image = encode_tensor_for_vision(image_for_vision[0])
user_content.extend(
[
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_base64}"}},
{"type": "image_url", "image_url": {"url": encoded_image}},
]
)
else:
@@ -330,13 +269,7 @@ class GenerateTextAdvanced:
if venice_parameters is not None:
payload["venice_parameters"] = venice_parameters
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
if response.status_code != 200:
raise requests.exceptions.HTTPError(f"HTTP error: {response.status_code}, Response: {response.text}")
json_response = response.json()
json_response = client.post_json(API_ENDPOINTS["text_generate"], payload)
try:
content = json_response["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
+13 -85
View File
@@ -1,23 +1,20 @@
import base64
import io
import os
import numpy as np
import requests
from PIL import Image
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
from ..globals import API_ENDPOINTS
from ..nodes.catalog_utils import text_model_choices
from ..nodes.utils import encode_tensor_for_vision, ensure_prompt_length
from ..venice_client import client
class GenerateText:
@classmethod
def INPUT_TYPES(cls):
model_choices = text_model_choices()
return {
"required": {
"model": (
"COMBO",
model_choices,
{
"default": "llama-3.3-70b",
"default": model_choices[0],
},
),
"system_prompt": ("STRING", {"default": "", "multiline": True}),
@@ -50,79 +47,16 @@ class GenerateText:
enable_vision,
**kwargs,
):
url = VENICEAI_BASE_URL + API_ENDPOINTS["text_generate"]
ensure_prompt_length(prompt, 1500, label="Prompt")
user_content = []
image_for_vision = kwargs.get("image_for_vision", None)
image_for_vision = kwargs.get("image_for_vision")
if image_for_vision is not None and enable_vision:
# Convert tensor to PIL Image
image_tensor = image_for_vision[0] # shape: (H, W, 3)
image_np = image_tensor.cpu().numpy() # Still in (H, W, 3)
image_np = (image_np * 255).astype(np.uint8) # Scale from [0, 1] to [0, 255] if needed
pil_image = Image.fromarray(image_np)
# Resize image to meet constraints
original_width, original_height = pil_image.size
aspect_ratio = original_width / original_height
# Determine target dimensions
if original_width > original_height:
target_width = 1024
target_height = int(target_width / aspect_ratio)
if target_height < 256:
target_height = 256
target_width = int(target_height * aspect_ratio)
else:
target_height = 1024
target_width = int(target_height * aspect_ratio)
if target_width < 256:
target_width = 256
target_height = int(target_width / aspect_ratio)
# Round dimensions to multiples of 14
def round_down_to_multiple(value, multiple):
return (value // multiple) * multiple
target_width = round_down_to_multiple(target_width, 14)
target_height = round_down_to_multiple(target_height, 14)
# Ensure minimum dimension is 256 after rounding
if min(target_width, target_height) < 256:
if target_width < target_height:
target_width = ((256 + 13) // 14) * 14
target_height = round_down_to_multiple(int(target_width / aspect_ratio), 14)
else:
target_height = ((256 + 13) // 14) * 14
target_width = round_down_to_multiple(int(target_height * aspect_ratio), 14)
pil_image = pil_image.resize((target_width, target_height), Image.LANCZOS) # type: ignore
# Convert to base64 and check size
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
# Resize further if base64 exceeds 4.5MB
while len(img_base64) > 4500000:
scaling_factor = (4500000 / len(img_base64)) ** 0.5
new_width = int(target_width * scaling_factor)
new_height = int(target_height * scaling_factor)
new_width = max(round_down_to_multiple(new_width, 14), 256)
new_height = max(round_down_to_multiple(new_height, 14), 256)
pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS) # type: ignore
target_width, target_height = new_width, new_height
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
encoded_image = encode_tensor_for_vision(image_for_vision[0])
user_content.extend(
[
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_base64}"}},
{"type": "image_url", "image_url": {"url": encoded_image}},
]
)
else:
@@ -140,13 +74,7 @@ class GenerateText:
"top_p": top_p,
}
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
if response.status_code != 200:
raise requests.exceptions.HTTPError(f"HTTP error: {response.status_code}, Response: {response.text}")
json_response = response.json()
json_response = client.post_json(API_ENDPOINTS["text_generate"], payload)
content = json_response["choices"][0]["message"]["content"]
# print(content)
return (content,)
+7 -2
View File
@@ -1,12 +1,17 @@
from ..nodes.catalog_utils import character_choices
class GenerateTextVeniceParameters:
@classmethod
def INPUT_TYPES(cls):
character_options = character_choices()
return {
"required": {
"character_slug": (
"COMBO",
character_options,
{
"default": "strawberry-the-cat",
"default": character_options[0],
"tooltip": ("The character slug of a public Venice character."),
},
),
+151
View File
@@ -0,0 +1,151 @@
from comfy_api.latest import InputImpl, io
from ..nodes.catalog_utils import image2video_model_choices, text2video_model_choices
from ..nodes.utils import ensure_prompt_length
from ..nodes.video_utils import (
get_testing_video_path,
list_testing_videos,
poll_video_until_ready,
queue_video_job,
)
class GenerateVideoFromText:
@classmethod
def INPUT_TYPES(cls):
i2v_models = image2video_model_choices()
t2v_models = text2video_model_choices()
model_choices = i2v_models + t2v_models
# todo: make model choices show model name instead of id for readability and prepend i2v/t2v
video_choices = list_testing_videos()
existing_default = video_choices[0] if video_choices else "none_available"
choices_for_combo = video_choices or ["none_available"]
return {
"required": {
"model": (
model_choices,
{
# "default": model_choices[0],
"default": "longcat-distilled-text-to-video",
"tooltip": "Model to use for text-to-video generation",
},
),
"prompt": (
"STRING",
{
"default": "A cat made of lettuce flying through space",
"placeholder": "Positive Prompt. Example: A cat made of lettuce flying through space",
"tooltip": "Text prompt to generate the video from",
"multiline": True,
},
),
"negative_prompt": (
"STRING",
{
"default": "low resolution, error, worst quality, low quality, defects",
"placeholder": "Negative Prompt",
"multiline": True,
"tooltip": "Negative prompt to avoid elements in the video",
},
),
"duration": (
["4s", "5s", "6s", "8s", "10s", "12s", "14s", "15s", "16s", "18s", "20s"],
{
"default": "5s",
"tooltip": "Duration of the generated video",
},
),
"aspect_ratio": (
["16:9", "9:16", "1:1"],
{
"default": "16:9",
"tooltip": "Aspect ratio for the video",
},
),
"resolution": (
["1080p", "720p", "480p"],
{
"default": "720p",
"tooltip": "Resolution of the generated video",
},
),
"audio": (
"BOOLEAN",
{
"default": True,
"tooltip": "Generate audio if the model supports it",
},
),
"use_existing_video": (
"BOOLEAN",
{
"default": True, # NOTE: IMPORTANT DEFAULT TO TRUE FOR TESTING PURPOSES THE WHOLE TIME DO NOT REMOVE UNTIL DEPLOYMENT
"tooltip": "Use a cached video from testing_video instead of calling the Venice API",
},
),
"existing_video": (
choices_for_combo,
{
"default": existing_default,
"tooltip": "Select the cached video file that should be emitted when bypassing the API",
},
),
}
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("video",)
FUNCTION = "execute"
CATEGORY = "venice.ai"
def execute(
self,
model,
prompt,
negative_prompt,
duration,
aspect_ratio,
resolution,
audio,
use_existing_video,
existing_video,
):
ensure_prompt_length(prompt, 2500, label="Prompt")
ensure_prompt_length(negative_prompt, 2500, label="Negative Prompt", allow_empty=True)
payload = {
"model": model,
"prompt": prompt,
"negative_prompt": negative_prompt,
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
# "audio": audio, # todo: this will error with bad request if model without audio support is used, fix with node schema v3 rewrite
}
if use_existing_video:
if not existing_video:
raise ValueError("No cached video selected")
cached_files = list_testing_videos()
if existing_video not in cached_files:
raise ValueError("Selected cached video does not exist anymore")
video_path = get_testing_video_path(existing_video)
if not video_path.exists():
raise ValueError("Cached video file disappeared")
return io.NodeOutput(InputImpl.VideoFromFile(video_path))
model_id, queue_id = queue_video_job(payload)
video_path, _ = poll_video_until_ready(model=model_id, queue_id=queue_id)
return io.NodeOutput(InputImpl.VideoFromFile(video_path))
NODE_CLASS_MAPPINGS = {
"TextToVideo_VENICE": GenerateVideoFromText,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TextToVideo_VENICE": "Generate Video from Text (Venice)",
}
+12 -10
View File
@@ -1,13 +1,14 @@
import base64
import io
import logging
import os
import requests
from PIL import Image
from torchvision.transforms import ToPILImage, ToTensor # type: ignore
from torchvision.transforms import ToPILImage, ToTensor # type: ignore
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
from ..globals import API_ENDPOINTS
from ..nodes.utils import ensure_prompt_length
from ..venice_client import client
class I2IEnhanceUpscale:
@@ -86,10 +87,9 @@ class I2IEnhanceUpscale:
CATEGORY = "venice.ai"
def i2i_enhance_upscale(self, image, scale, enhance, enhance_creativity, enhance_prompt, replication):
url = VENICEAI_BASE_URL + API_ENDPOINTS["upscale_image"]
response = None
if len(enhance_prompt) > 1500:
raise ValueError("Upscale Image (Venice) enhance_prompt cannot be above 1500 characters")
ensure_prompt_length(enhance_prompt, 1500, label="Enhance prompt", allow_empty=True)
if scale == 1:
raise ValueError("Upscale Image (Venice) 'enhance' must be set to 'True' if scale is 1.")
if scale == 4:
@@ -134,12 +134,14 @@ class I2IEnhanceUpscale:
"replication": replication,
}
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
# Send request
try:
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
response = client.request(
"POST",
API_ENDPOINTS["upscale_image"],
json=payload,
headers={"Content-Type": "application/json"},
)
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Upscale Image (Venice) API request failed: {str(e)}")
-185
View File
@@ -1,185 +0,0 @@
# Todo: chat history/memory/context for LLM
# todo: inpainting
# todo: LLM characters
# todo: use variants api (currently in beta)
# import base64
# import io
# import os
# import numpy as np
# import requests
# import torch
# from PIL import Image
# from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
# from ..nodes.gen_image_base import GenerateImageBase
# todo: https://docs.comfy.org/custom-nodes/backend/more_on_inputs#dynamically-created-inputs
# for model list update?
# region inpaint
# class InpaintImage(GenerateImageBase):
# @classmethod
# def INPUT_TYPES(cls):
# return {
# "required": {
# "image": ("IMAGE",),
# "model": (
# "COMBO",
# {"default": "flux-dev"},
# ),
# "prompt": ("STRING", {"default": "A flying cat made of lettuce", "multiline": True}),
# "neg_prompt": (
# "STRING",
# {
# "placeholder": "Bad composition, rating_explicit, Text, signature, lowres, lowres, low details, faded image, out of focus, cropped, clipped, cut-off, out of frame, deserted scene, empty scene, vacant scene, desolate scene, sparse décor, bad quality, worst quality,",
# "multiline": True,
# "tooltip": "Negative prompt. This is ignored when using flux-dev or flux-dev-uncensored",
# },
# ),
# "width": (
# "INT",
# {
# "default": 1024,
# "min": 256,
# "max": 2048, # limit is 1280 but i dont want to restrict this in case of future updates, https://docs.venice.ai/api-reference/endpoint/image/generate#body-height
# "step": 32,
# "tooltip": "Must be a multiple of 32. Maximum allowed by venice.ai is 1280",
# },
# ),
# "height": (
# "INT",
# {
# "default": 1024,
# "min": 256,
# "max": 2048,
# "step": 32,
# "tooltip": "Must be a multiple of 32. Maximum allowed by venice.ai is 1280",
# },
# ),
# "batch_size": ("INT", {"default": 1, "min:": 1, "max": 4}),
# "steps": ("INT", {"default": 20, "min": 1, "max": 30}),
# "guidance": ("FLOAT", {"default": 3.0, "min": 0.1, "max": 15.0}),
# "style_preset": ("COMBO", {"default": "none"}),
# "hide_watermark": ("BOOLEAN", {"default": True}),
# "inpaint_strength": ("INT", {"default": 50, "min": 0, "max": 100}),
# },
# "optional": {"seed": ("INT", {"default": -1})},
# }
# def generate_image(
# self,
# image,
# model,
# prompt,
# neg_prompt,
# width,
# height,
# batch_size,
# steps,
# guidance,
# style_preset,
# hide_watermark,
# inpaint_strength,
# seed=-1,
# ):
# images_tensor = () # empty tuple for tensors
# if model in ["flux-dev", "flux-dev-uncensored"]:
# print(f"VeniceAPI INFO: Ignoring negative prompt for {model}.")
# neg_prompt = ""
# try:
# self.check_multiple_of_32(width, height) # todo: make this be validate node instead
# # Convert input image tensor to base64
# if image is None or image.size(0) == 0:
# raise ValueError("Input image is required for inpainting")
# # Process first image in the batch
# img_tensor = image[0].cpu() # Convert to CPU tensor
# np_image = img_tensor.numpy()
# np_image = (np_image * 255).astype(np.uint8) # Convert to 0-255 range
# # Create PIL Image and convert to base64
# pil_image = Image.fromarray(np_image, "RGB")
# buffered = io.BytesIO()
# pil_image.save(buffered, format="PNG")
# img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
# source_image_base64 = f"data:image/png;base64,{img_base64}"
# headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}", "Content-Type": "application/json"}
# url = VENICEAI_BASE_URL + API_ENDPOINTS["image_generate"]
# payload = {
# "model": model,
# "prompt": prompt,
# "negative_prompt": neg_prompt,
# "style_preset": style_preset,
# "height": height,
# "width": width,
# "steps": steps,
# "cfg_scale": guidance,
# "seed": seed,
# "return_binary": False,
# "hide_watermark": hide_watermark,
# "format": "png",
# "inpaint": {
# "strength": inpaint_strength,
# "source_image_base64": source_image_base64,
# "mask": {
# "image_prompt": "Generate a high-resolution image...",
# "object_target": "rabbit's face",
# "inferred_object": "rabbit's face wearing round silver spectacles",
# },
# },
# }
# if style_preset == "none":
# del payload["style_preset"]
# for i in range(batch_size):
# payload["seed"] = seed + i
# response = requests.request("POST", url, json=payload, headers=headers)
# if response.status_code != 200:
# raise requests.exceptions.HTTPError(
# f"HTTP error: {response.status_code}, Response: {response.text}"
# )
# images_tensor += self.process_result(response.json())
# merged = torch.cat(images_tensor, dim=0)
# return (merged,)
# except Exception as e:
# raise Exception(f"Error processing image result: {str(e)}") from e
# endregion
# region text gen
# endregion
# region upscale img
# endregion
# NODE_CLASS_MAPPINGS = {
# "InpaintImage_VENICE": InpaintImage,
# }
# NODE_DISPLAY_NAME_MAPPINGS = {
# "InpaintImage_VENICE": "Inpaint Image (Venice)",
# }
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import base64
import io
import numpy as np
import torch
from PIL import Image
from requests import RequestException
from ..globals import API_ENDPOINTS
from ..venice_client import VeniceAPIError, client
from ..venice_config import config as venice_config
def _round_down(value: int, multiple: int) -> int:
return (value // multiple) * multiple
def ensure_prompt_length(text: str, maximum: int, label: str, allow_empty: bool = False) -> None:
if not text:
if allow_empty:
return
raise ValueError(f"{label} is required and cannot be empty")
if len(text) > maximum:
raise ValueError(f"{label} exceeds the maximum length of {maximum} characters")
def encode_tensor_for_vision(image_tensor: torch.Tensor, *, max_encoded_bytes: int = 4_500_000) -> str:
"""
Encode a vision tensor into a Base64-encoded PNG string suitable for transmission.
Parameters
----------
image_tensor : torch.Tensor
A height×width×(3 or 4) tensor representing an image in the range [0, 1].
max_encoded_bytes : int, optional
Maximum allowed length of the Base64 payload. Defaults to 4,500,000 bytes.
Returns
-------
str
A data URI containing the PNG image encoded in Base64.
Raises
------
ValueError
If the tensor does not have 3 dimensions or the last dimension is not 3 or 4.
"""
tensor = image_tensor.detach().cpu()
if tensor.ndim != 3 or tensor.shape[-1] not in {3, 4}:
raise ValueError("Vision images must be height×width×(3 or 4 channels)")
if tensor.shape[-1] > 3:
tensor = tensor[:, :, :3]
array = (tensor.numpy() * 255).clip(0, 255).astype(np.uint8)
pil_image = Image.fromarray(array, "RGB")
original_width, original_height = pil_image.size
aspect_ratio = original_width / original_height
if original_width > original_height:
target_width = 1024
target_height = int(target_width / aspect_ratio)
if target_height < 256:
target_height = 256
target_width = int(target_height * aspect_ratio)
else:
target_height = 1024
target_width = int(target_height * aspect_ratio)
if target_width < 256:
target_width = 256
target_height = int(target_width / aspect_ratio)
target_width = _round_down(target_width, 14)
target_height = _round_down(target_height, 14)
if min(target_width, target_height) < 256:
if target_width < target_height:
target_width = _round_down(256 + 13, 14)
target_height = _round_down(int(target_width / aspect_ratio), 14)
else:
target_height = _round_down(256 + 13, 14)
target_width = _round_down(int(target_height * aspect_ratio), 14)
pil_image = pil_image.resize((target_width, target_height), Image.LANCZOS)
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
while len(img_base64) > max_encoded_bytes:
scaling_factor = (max_encoded_bytes / len(img_base64)) ** 0.5
new_width = max(_round_down(int(target_width * scaling_factor), 14), 256)
new_height = max(_round_down(int(target_height * scaling_factor), 14), 256)
pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS)
target_width, target_height = new_width, new_height
buffered = io.BytesIO()
pil_image.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
return f"data:image/png;base64,{img_base64}"
def ensure_multiple_of(width: int, height: int, *, multiple: int = 32) -> None:
bad_dimensions = []
if width % multiple != 0:
bad_dimensions.append(f"width ({width})")
if height % multiple != 0:
bad_dimensions.append(f"height ({height})")
if bad_dimensions:
dimensions = " and ".join(bad_dimensions)
raise ValueError(f"{dimensions} must be multiples of {multiple}")
# unused right now, might be useful, or not
def ensure_api_key_valid() -> None:
key = venice_config.apikey.strip()
if not key:
raise ValueError("VeniceAI API key is missing; set it in the VeniceAI settings first.")
try:
client.get_json(API_ENDPOINTS["list_api_keys"])
except VeniceAPIError as exc:
raise ValueError("Unable to validate the VeniceAI API key.", exc) from exc
except RequestException as exc:
raise ValueError("Unable to reach VeniceAI while validating the API key.", exc) from exc
return None
+164
View File
@@ -0,0 +1,164 @@
import logging
import os
import tempfile
import time
from pathlib import Path
from typing import List, Optional, Tuple
from comfy.utils import ProgressBar # type: ignore
from ..globals import API_ENDPOINTS
from ..venice_client import VeniceAPIError, client
LOG = logging.getLogger(__name__)
# Defaults
POLL_INTERVAL_SECONDS = 5
MAX_POLLS = 100 # MAX_POLLS * POLL_INTERVAL_SECONDS = x minutes total wait time
PROGRESS_BAR_TOTAL = 100
PROJECT_ROOT = Path(__file__).resolve().parents[1]
VIDEO_OUTPUT_DIR = PROJECT_ROOT / "testing_video"
VIDEO_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
DEBUG_SAVE_API_VIDEOS = os.environ.get("VENICE_CLIENT_DEBUG", "").lower() in {"1", "true"}
def queue_video_job(payload: dict) -> Tuple[str, str]:
"""
Enqueue a Venice video generation job and return (model, queue_id).
Raises VeniceAPIError on failure.
"""
resp = client.post_json(API_ENDPOINTS["video_queue"], payload)
model = resp.get("model") or payload.get("model") or ""
queue_id = resp.get("queue_id")
if not queue_id:
raise VeniceAPIError(f"Video queue response missing queue_id: {resp}")
LOG.info("Venice video queued: model=%s queue_id=%s", model, queue_id)
return model, queue_id
def _guess_suffix(content_type: str) -> str:
if "mp4" in content_type:
return ".mp4"
if "webm" in content_type:
return ".webm"
if "quicktime" in content_type or "mov" in content_type:
return ".mov"
return ".bin"
def _build_video_path(queue_id: str, suffix: str) -> Path:
clean_id = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in queue_id)
timestamp = int(time.time())
filename = f"{clean_id or 'queue'}_{timestamp}{suffix}"
return VIDEO_OUTPUT_DIR / filename
def poll_video_until_ready(
*,
model: str,
queue_id: str,
progress_bar: Optional[ProgressBar] = None,
delete_on_completion: bool = True,
max_polls: int = MAX_POLLS,
poll_interval: float = POLL_INTERVAL_SECONDS,
) -> Tuple[str, str]:
"""
Poll Venice /video/retrieve until the video is ready.
Returns (video_filepath, queue_id). Raises VeniceAPIError on failure/timeout.
"""
pbar = progress_bar or ProgressBar(PROGRESS_BAR_TOTAL)
last_progress = 0
for attempt in range(max_polls):
time.sleep(poll_interval)
retrieve_payload = {
"model": model,
"queue_id": queue_id,
"delete_media_on_completion": delete_on_completion,
}
resp = client.request(
"POST",
API_ENDPOINTS["video_retrieve"],
json=retrieve_payload,
headers={
"Content-Type": "application/json",
"Accept": "*/*", # allow binary video or JSON status
},
)
ctype = (resp.headers or {}).get("Content-Type", "").lower()
is_status_json = ctype.startswith("application/json") or ctype.startswith("text/")
if is_status_json:
data = resp.json()
status = (data.get("status") or "UNKNOWN").upper()
exec_dur = float(data.get("execution_duration") or 0)
avg_exec = float(data.get("average_execution_time") or 0)
LOG.debug(
"Venice video status: status=%s queue_id=%s exec_ms=%s avg_ms=%s attempt=%s/%s",
status,
queue_id,
exec_dur,
avg_exec,
attempt + 1,
max_polls,
)
if avg_exec and avg_exec > 0:
progress_value = min(int(exec_dur / avg_exec * PROGRESS_BAR_TOTAL), PROGRESS_BAR_TOTAL)
else:
progress_value = min(last_progress + 1, PROGRESS_BAR_TOTAL)
delta = progress_value - last_progress
if delta > 0:
pbar.update(delta)
last_progress = progress_value
if status not in {"PROCESSING", "QUEUED"}:
raise VeniceAPIError(f"Video job reported unexpected status '{status}' for queue_id {queue_id}")
continue
# Got binary content (video)
suffix = _guess_suffix(ctype)
if DEBUG_SAVE_API_VIDEOS:
video_path = _build_video_path(queue_id, suffix)
with open(video_path, "wb") as fp:
fp.write(resp.content)
else:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
tmp_file.write(resp.content)
video_path = Path(tmp_file.name)
LOG.debug(
"Venice video ready: queue_id=%s saved_to=%s content_type=%s",
queue_id,
video_path,
ctype,
)
if last_progress < PROGRESS_BAR_TOTAL:
pbar.update(PROGRESS_BAR_TOTAL - last_progress)
return str(video_path), queue_id
raise VeniceAPIError(
f"Timed out waiting for Venice video. queue_id={queue_id} after {max_polls * poll_interval:.0f}s"
)
def list_testing_videos() -> List[str]:
"""Return cached video filenames that can be selected inside a node."""
if not VIDEO_OUTPUT_DIR.exists():
return []
return sorted(entry.name for entry in VIDEO_OUTPUT_DIR.iterdir() if entry.is_file())
def get_testing_video_path(filename: str) -> Path:
"""Resolve the video file inside testing_video and ensure it exists."""
video_path = VIDEO_OUTPUT_DIR / filename
if not video_path.exists() or not video_path.is_file():
raise FileNotFoundError(f"Test video not found: {filename}")
return video_path
+1
View File
@@ -0,0 +1 @@
from . import routes # noqa: F401
-47
View File
@@ -1,47 +0,0 @@
import json
import os
from pathlib import Path
from aiohttp import web
from server import PromptServer
routes = PromptServer.instance.routes
CONFIG_FILE = Path(__file__).parent.parent / "veniceai_config.json"
def ensure_config_file_exists():
# Create the config file with a default value if it doesn't exist
if not CONFIG_FILE.exists():
default_key = "your_venice_api_key_here" # Set a default value (e.g., empty string)
set_venice_key(default_key)
def set_venice_key(apikey):
os.environ["VENICEAI_API_KEY"] = apikey
with open(CONFIG_FILE, "w") as f:
json.dump({"apikey": apikey}, f)
@routes.post("/veniceai/save_apikey")
async def post_key_server(request):
data = await request.json()
set_venice_key(data.get("apikey", ""))
return web.json_response({})
@routes.get("/veniceai/get_apikey")
async def get_key_server(request):
return web.json_response({"apikey": os.getenv("VENICEAI_API_KEY")})
ensure_config_file_exists()
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
saved_key = json.load(f).get("apikey", "")
set_venice_key(saved_key)
+89
View File
@@ -0,0 +1,89 @@
import asyncio
from aiohttp import web
from server import PromptServer # type: ignore
from ..venice_catalog import (
get_characters,
get_models,
get_styles,
refresh_characters,
refresh_models,
refresh_styles,
)
from ..venice_config import config as venice_config
routes = PromptServer.instance.routes
def _error_response(message: str) -> web.Response:
return web.json_response({"message": message, "error": True})
@routes.post("/veniceai/save_apikey")
async def save_key_server(request: web.Request) -> web.Response:
payload = await request.json()
venice_config.save_apikey(payload.get("apikey", ""))
return web.json_response({"message": "API key saved", "error": False})
@routes.get("/veniceai/get_apikey")
async def get_key_server(_: web.Request) -> web.Response:
return web.json_response({"apikey": venice_config.apikey})
# @routes.get("/veniceai/update_models_list")
# async def update_models_list_server(request: web.Request) -> web.Response:
# model_type = request.rel_url.query.get("type")
# try:
# payload = await asyncio.to_thread(refresh_models, model_type)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response({"message": "Model list updated", "error": False, "data": payload})
# @routes.get("/veniceai/get_models_list")
# async def get_models_list_server(request: web.Request) -> web.Response:
# model_type = request.rel_url.query.get("type")
# try:
# payload = await asyncio.to_thread(get_models, model_type)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response(payload)
# @routes.get("/veniceai/update_styles_list")
# async def update_styles_list_server(_: web.Request) -> web.Response:
# try:
# payload = await asyncio.to_thread(refresh_styles)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response({"message": "Styles updated", "error": False, "data": payload})
# @routes.get("/veniceai/get_styles_list")
# async def get_styles_list_server(_: web.Request) -> web.Response:
# try:
# payload = await asyncio.to_thread(get_styles)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response(payload)
# @routes.get("/veniceai/update_characters_list")
# async def update_characters_list_server(_: web.Request) -> web.Response:
# try:
# payload = await asyncio.to_thread(refresh_characters)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response({"message": "Characters updated", "error": False, "data": payload})
# @routes.get("/veniceai/get_characters_list")
# async def get_characters_list_server(_: web.Request) -> web.Response:
# try:
# payload = await asyncio.to_thread(get_characters)
# except Exception as exc:
# return _error_response(str(exc))
# return web.json_response(payload)
-56
View File
@@ -1,56 +0,0 @@
import json
import os
from pathlib import Path
import requests
from aiohttp import web
from server import PromptServer # type: ignore
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
routes = PromptServer.instance.routes
script_dir = Path(__file__).resolve().parent
data_dir = script_dir.parent / "data"
data_dir.mkdir(exist_ok=True)
characters_list_path = data_dir / "characters_list.json"
@routes.get("/veniceai/update_characters_list")
async def update_characters_list_server(request):
try:
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}"}
url = f"{VENICEAI_BASE_URL}{API_ENDPOINTS['characters']}"
response = requests.get(url, headers=headers)
response.raise_for_status() # Raises HTTPError for bad responses
response_data = response.json()
# remove "object" key from response_data
response_data.pop("object", None)
response_data["data"] = sorted(response_data.get("data", []), key=lambda item: item.get("slug", ""))
with open(characters_list_path, "w") as f:
json.dump(response_data, f, indent=4)
except Exception as e:
return (f"Unexpected error: {e}", True)
response = (response_data, False)
return web.json_response({"message": response[0], "error": response[1]})
@routes.get("/veniceai/get_characters_list")
async def get_local_characters_list(request):
with open(characters_list_path, "r") as f:
characters_list_json = json.load(f)
characters = []
for item in characters_list_json.get("data", []):
if isinstance(item, dict) and item.get("slug"):
characters.append(item.get("slug"))
return web.json_response({"characters": characters})
-155
View File
@@ -1,155 +0,0 @@
import json
import os
import re
from pathlib import Path
import requests
from aiohttp import web
from server import PromptServer # type: ignore
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
routes = PromptServer.instance.routes
script_dir = Path(__file__).resolve().parent
data_dir = script_dir.parent / "data"
data_dir.mkdir(exist_ok=True)
all_model_list_path = data_dir / "all_model_list.json"
async def fetch_models_list():
try:
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}"}
url = f"{VENICEAI_BASE_URL}{API_ENDPOINTS['list_models']}"
params = {"type": "all"}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status() # Raises HTTPError for bad responses
response_data = response.json()
# params = {"type": "embedding"}
# r2 = requests.get(url, headers=headers, params=params)
# r2.raise_for_status() # Raises HTTPError for bad responses
# r2_data = r2.json()
# # concatenate response_data and r2_data
# if isinstance(response_data, list) and isinstance(r2_data, list):
# response_data = response_data + r2_data
# except requests.exceptions.HTTPError as http_err:
# print(f"HTTP error occurred: {http_err}")
# except requests.exceptions.RequestException as req_err:
# print(f"Request error occurred: {req_err}")
# except (ValueError, KeyError) as data_err:
# print(f"Data parsing error: {data_err}")
except Exception as e:
return (f"Unexpected error: {e}", True)
return (response_data, False)
async def humanize_name(model_id: str) -> str:
# Handle number-letter combinations without space
model_id = re.sub(r"(\d+)([a-zA-Z]+)", lambda m: f"{m.group(1)}{m.group(2).upper()}", model_id)
# Replace remaining separators with spaces and title case
model_id = re.sub(r"[-_]", " ", model_id).title()
humanized = model_id.replace("xl", "XL").replace("vl", "VL").replace("sd", "SD").replace("llama", "LLaMA")
return humanized
@routes.get("/veniceai/update_models_list")
async def update_models_list_server(request):
# img_model_json = await fetch_model_list("image")
# txt_model_json = await fetch_model_list("text")
# merged_json = {"object": "list", "data": img_model_json.get("data", []) + txt_model_json.get("data", [])}
response = await fetch_models_list()
if response[1]: # true = error happened
return response
merged_json = response[0]
# Validating response structure
if not isinstance(merged_json, dict) or "data" not in merged_json:
raise ValueError("Unexpected API response format")
# Create lookup dictionary and enhanced JSON
enhanced_data = []
model_dict = {}
# Process all models for enhanced JSON
for model in merged_json["data"]:
model_id = model["id"]
model_spec = model.get("model_spec", {})
# Format traits excluding function_calling_default
traits = [
trait.replace("_", " ").title()
for trait in model_spec.get("traits", [])
if trait != "function_calling_default"
]
# Create humanized description
parts = [await humanize_name(model_id)]
if "availableContextTokens" in model_spec:
parts.append(f"ctx: {model_spec['availableContextTokens']}")
if traits:
parts.append(" | ".join(traits))
# Add to enhanced data
enhanced_model = model.copy()
enhanced_model["humanized"] = " | ".join(parts)
enhanced_data.append(enhanced_model)
model_dict[model_id] = enhanced_model
# Save enhanced JSON
enhanced_json = {"object": "list", "data": enhanced_data}
with open(all_model_list_path, "w") as f:
json.dump(enhanced_json, f, indent=2)
response = ("Model list update success", False)
return web.json_response({"message": response[0], "error": response[1]})
# NOTE: routes are frozen and don't update with ComfyUI-HotReloadHack
@routes.get("/veniceai/get_models_list")
async def get_local_models_list_server(request):
with open(all_model_list_path, "r") as f:
model_list_json = json.load(f)
data = model_list_json["data"]
# Create final sorted lists of names/ids
img_models = [m["id"] for m in data if m.get("type") == "image"]
txt_models = [m["id"] for m in data if m.get("type") == "text"]
tts_models = [m["id"] for m in data if m.get("type") == "tts"]
# Collect voices with humanized model name
tts_voices = []
for m in data:
if m.get("type") == "tts":
voices = m.get("model_spec", {}).get("voices", [])
# humanized = m.get("humanized", m.get("id", ""))
humanized = m.get("id", "")
tts_voices.extend([f"{humanized} - {voice}" for voice in voices])
# if tts_voices:
# print(f"TTS Voices: {', '.join(tts_voices[:100])}")
data = {
"image_models": sorted(img_models),
"text_models": sorted(txt_models),
"tts_models": sorted(tts_models),
"tts_voices": sorted(tts_voices),
"model_list_json": model_list_json,
}
return web.json_response(data)
-58
View File
@@ -1,58 +0,0 @@
import json
import os
from pathlib import Path
import requests
from aiohttp import web
from server import PromptServer # type: ignore
from ..globals import API_ENDPOINTS, VENICEAI_BASE_URL
routes = PromptServer.instance.routes
script_dir = Path(__file__).resolve().parent
data_dir = script_dir.parent / "data"
data_dir.mkdir(exist_ok=True)
styles_list_path = data_dir / "styles_list.json"
@routes.get("/veniceai/update_styles_list")
async def update_styles_list_server(request):
try:
headers = {"Authorization": f"Bearer {os.getenv('VENICEAI_API_KEY')}"}
url = f"{VENICEAI_BASE_URL}{API_ENDPOINTS['list_styles']}"
response = requests.get(url, headers=headers)
response.raise_for_status() # Raises HTTPError for bad responses
response_data = response.json()
# remove "object" key from response_data
response_data.pop("object", None)
response_data["data"] = sorted(response_data.get("data", []))
response_data["data"].insert(0, "none")
with open(styles_list_path, "w") as f:
json.dump(response_data, f, indent=2)
# except requests.exceptions.HTTPError as http_err:
# print(f"HTTP error occurred: {http_err}")
# except requests.exceptions.RequestException as req_err:
# print(f"Request error occurred: {req_err}")
# except (ValueError, KeyError) as data_err:
# print(f"Data parsing error: {data_err}")
except Exception as e:
return (f"Unexpected error: {e}", True)
response = (response_data, False)
return web.json_response({"message": response[0], "error": response[1]})
@routes.get("/veniceai/get_styles_list")
async def get_local_styles_list(requests):
with open(styles_list_path, "r") as f:
styles_list_json = json.load(f)
return web.json_response(styles_list_json)
+239
View File
@@ -0,0 +1,239 @@
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional
from .venice_client import VeniceAPIError, VeniceClient
DATA_DIR = Path(__file__).with_name("data")
DATA_DIR.mkdir(exist_ok=True)
class CatalogStore:
def __init__(
self,
name: str,
default_factory: Optional[Callable[[], Dict[str, Any]]] = None,
legacy_name: Optional[str] = None,
) -> None:
self.file = DATA_DIR / name
self._default_factory = default_factory or (lambda: {"object": "list", "data": []})
self._legacy_name = legacy_name
def load(self) -> Dict[str, Any]:
if not self.file.exists():
return self._load_legacy() or self._default_factory()
try:
with self.file.open("r", encoding="utf-8") as fp:
return json.load(fp)
except (ValueError, json.JSONDecodeError):
return self._load_legacy() or self._default_factory()
def save(self, payload: Dict[str, Any]) -> None:
with self.file.open("w", encoding="utf-8") as fp:
json.dump(payload, fp, indent=2)
def _load_legacy(self) -> Dict[str, Any] | None:
if not self._legacy_name:
return None
legacy_file = self.file.with_name(self._legacy_name)
if not legacy_file.exists():
return None
try:
with legacy_file.open("r", encoding="utf-8") as fp:
return json.load(fp)
except (ValueError, json.JSONDecodeError):
return None
def filter_by_type(self, payload: Dict[str, Any], *types: str) -> List[Dict[str, Any]]:
data = payload.get("data") or []
return [entry for entry in data if entry.get("type") in types]
_model_store = CatalogStore("models_list.json", legacy_name="all_model_list.json")
_styles_store = CatalogStore(
"styles_list.json",
lambda: {"data": ["none"], "object": "list"},
legacy_name="styles_list.json",
)
_characters_store = CatalogStore(
"characters_list.json",
lambda: {"data": [], "object": "list"},
legacy_name="characters_list.json",
)
_client = VeniceClient()
# to prevent spamming api.
# can be bypassed by setting force_refresh=True in get_* calls
_CACHE_TTL = float(os.environ.get("VENICE_CATALOG_TTL", "900"))
_models_last_refresh = 0.0
_styles_last_refresh = 0.0
_characters_last_refresh = 0.0
LOG = logging.getLogger(__name__)
def _enrich_models(raw: Dict[str, Any]) -> Dict[str, Any]:
return raw.copy()
def _should_refresh(last_refresh: float) -> bool:
if _CACHE_TTL <= 0:
return False
return (time.monotonic() - last_refresh) > _CACHE_TTL
def _record_models_refresh() -> None:
global _models_last_refresh
_models_last_refresh = time.monotonic()
def _record_styles_refresh() -> None:
global _styles_last_refresh
_styles_last_refresh = time.monotonic()
def _record_characters_refresh() -> None:
global _characters_last_refresh
_characters_last_refresh = time.monotonic()
def _should_attempt_refresh(payload: Dict[str, Any], should_refresh: bool) -> bool:
if not should_refresh:
return False
if _client.dry_run and payload.get("data"):
LOG.debug("Dry-run mode skipping catalog refresh because cached data is available.")
return False
return True
def refresh_models(model_type: str = "all") -> Dict[str, Any]:
try:
raw = _client.list_models(model_type or "all")
except VeniceAPIError as exc:
LOG.error("Failed to refresh Venice model list: %s", exc)
raise
enriched = _enrich_models(raw)
if not enriched.get("data"):
raise VeniceAPIError("Venice returned an empty model catalog")
_model_store.save(enriched)
_record_models_refresh()
return enriched
def refresh_styles() -> Dict[str, Any]:
try:
raw = _client.list_styles()
except VeniceAPIError as exc:
LOG.error("Failed to refresh Venice styles list: %s", exc)
raise
data = raw.get("data")
if not data:
raise VeniceAPIError("Venice returned an empty styles catalog")
sorted_data = sorted(data)
sorted_data.insert(0, "none")
payload = {"object": raw.get("object", "list"), "data": sorted_data}
_styles_store.save(payload)
_record_styles_refresh()
return payload
def refresh_characters() -> Dict[str, Any]:
try:
raw = _client.list_characters()
except VeniceAPIError as exc:
LOG.error("Failed to refresh Venice characters list: %s", exc)
raise
items = raw.get("data")
if not items:
raise VeniceAPIError("Venice returned an empty characters catalog")
sorted_items = sorted(items, key=lambda item: item.get("slug", "")) if isinstance(items, Iterable) else []
payload = {"object": raw.get("object", "list"), "data": sorted_items}
_characters_store.save(payload)
_record_characters_refresh()
return payload
def get_models(model_type: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]:
"""
Return model metadata from the Venice catalog, optionally filtered by type.
Parameters
----------
model_type : Optional[str]
If provided, filters models by the requested type (e.g., "image", "text", "tts") and
returns only that subset; when omitted, the response contains categorized lists
of model IDs, available TTS voices, and the raw payload.
Returns
-------
Dict[str, Any]
The filtered or fully categorized model information, keyed by categories such as
"image_models", "text_models", "tts_models", "tts_voices", and "model_list_json".
When a model_type filter is applied, returns a single "models" key with matching entries.
"""
payload = _model_store.load()
should_refresh = force_refresh or not payload.get("data") or _should_refresh(_models_last_refresh)
if _should_attempt_refresh(payload, should_refresh):
payload = refresh_models()
if model_type:
return {"models": _model_store.filter_by_type(payload, model_type)}
filtered = {
"image_models": sorted([m.get("id") for m in payload.get("data", []) if m.get("type") == "image"]),
"text_models": sorted([m.get("id") for m in payload.get("data", []) if m.get("type") == "text"]),
"tts_models": sorted([m.get("id") for m in payload.get("data", []) if m.get("type") == "tts"]),
"tts_voices": sorted(
[
f"{m.get('id', '')} - {voice}"
for m in payload.get("data", [])
if m.get("type") == "tts"
for voice in (m.get("model_spec", {}).get("voices") or [])
]
),
"text2video_models": sorted(
[
m.get("id")
for m in payload.get("data", [])
if m.get("type") == "video"
and isinstance(m.get("model_spec"), dict)
and isinstance(m["model_spec"].get("constraints"), dict)
and m["model_spec"]["constraints"].get("model_type") == "text-to-video"
]
),
"image2video_models": sorted(
[
m.get("id")
for m in payload.get("data", [])
if m.get("type") == "video"
and isinstance(m.get("model_spec"), dict)
and isinstance(m["model_spec"].get("constraints"), dict)
and m["model_spec"]["constraints"].get("model_type") == "image-to-video"
]
),
"model_list_json": payload,
}
return filtered
def get_styles(*, force_refresh: bool = False) -> Dict[str, Any]:
payload = _styles_store.load()
should_refresh = force_refresh or not payload.get("data") or _should_refresh(_styles_last_refresh)
if _should_attempt_refresh(payload, should_refresh):
payload = refresh_styles()
return payload
def get_characters(*, force_refresh: bool = False) -> Dict[str, Any]:
payload = _characters_store.load()
should_refresh = force_refresh or not payload.get("data") or _should_refresh(_characters_last_refresh)
if _should_attempt_refresh(payload, should_refresh):
payload = refresh_characters()
data = payload.get("data", [])
characters = [item.get("slug") for item in data if isinstance(item, dict) and item.get("slug")]
return {"characters": characters}
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import logging
import os
from typing import Any, Dict, Mapping, Optional
import requests
from .globals import API_ENDPOINTS, USER_AGENT, VENICEAI_BASE_URL
from .venice_config import config as venice_config
LOG = logging.getLogger(__name__)
if os.environ.get("VENICE_CLIENT_DEBUG", "").lower() in {"1", "true"}:
LOG.setLevel(logging.DEBUG)
class VeniceAPIError(Exception):
pass
class DummyResponse:
def __init__(self, payload: dict | None = None, status_code: int = 200) -> None:
self._payload = payload or {}
self.status_code = status_code
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self._payload
@property
def text(self) -> str:
return ""
def __getattr__(self, item: str) -> Any:
return None
class VeniceClient:
def __init__(self) -> None:
self._session = requests.Session()
self._session.headers.update({"User-Agent": USER_AGENT})
self._dry_run = os.environ.get("VENICE_CLIENT_DRY_RUN", "").lower() in {"1", "true"}
@property
def dry_run(self) -> bool:
return self._dry_run
def _ensure_api_key(self) -> str:
key = venice_config.apikey.strip()
if not key:
raise VeniceAPIError(
"VeniceAI API key is missing. Set it via the VeniceAI settings before using the nodes."
)
return key
def _build_headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
headers: Dict[str, str] = {"Authorization": f"Bearer {self._ensure_api_key()}"}
if extra:
headers.update(extra)
return headers
def _friendly_status_hint(self, status_code: int) -> str:
if status_code == 401:
return "The API key may be invalid, expired, or lack permissions."
if status_code == 404:
return "The requested VeniceAI endpoint was not found. Ensure the node and catalog data are up to date."
if status_code == 429:
return "Request rate limits were hit. Wait a moment before retrying."
if 500 <= status_code < 600:
return "VeniceAI appears to be experiencing server issues; try again in a bit."
return "Check your request parameters and ensure your API key is valid."
def _friendly_network_hint(self) -> str:
return "Unable to reach VeniceAI. Confirm your internet connection and that api.venice.ai is reachable."
def request(self, method: str, endpoint: str, **kwargs: Any) -> requests.Response:
if self._dry_run:
logging.debug("VeniceClient dry run skipping %s %s", method, endpoint)
return DummyResponse()
url = VENICEAI_BASE_URL + endpoint
headers = kwargs.pop("headers", None)
kwargs.setdefault("timeout", 30)
try:
response = self._session.request(method, url, headers=self._build_headers(headers), **kwargs)
response.raise_for_status()
except requests.HTTPError as exc:
self._log_response(method, endpoint, exc.response)
hint = (
self._friendly_status_hint(exc.response.status_code)
if exc.response is not None
else "Unexpected response from VeniceAI."
)
message = f"Venice request failed ({method} {endpoint}): {exc}. \n{hint}"
LOG.debug("Venice request failed: %s %s %s", method, endpoint, exc)
raise VeniceAPIError(message) from exc
except requests.RequestException as exc:
message = f"{self._friendly_network_hint()} Details: {exc}"
LOG.debug("Venice network error: %s %s %s", method, endpoint, exc)
raise VeniceAPIError(message) from exc
self._log_response(method, endpoint, response)
return response
def _log_response(self, method: str, endpoint: str, response: Optional[requests.Response | DummyResponse]) -> None:
if not LOG.isEnabledFor(logging.DEBUG) or response is None:
return
headers = getattr(response, "headers", None)
header_snapshot: Dict[str, Any] = dict(headers) if headers else {}
content_type = (header_snapshot.get("Content-Type") or "").lower()
body = ""
readable_body = content_type.startswith("application/json") or content_type.startswith("text/")
if readable_body or not content_type:
try:
body = response.text or ""
except Exception as exc:
body = f"<unable to read body: {exc}>"
if len(body) > 2000:
body = body[:2000] + "...[truncated]"
else:
body = f"<{content_type} response body omitted>"
LOG.debug(
"Venice response %s %s status=%s headers=%s body=%s",
method,
endpoint,
getattr(response, "status_code", "<?>"),
header_snapshot,
body,
)
def post_json(self, endpoint: str, payload: Mapping[str, Any], **kwargs: Any) -> Dict[str, Any]:
headers = {"Content-Type": "application/json"}
headers.update(kwargs.pop("headers", {}))
response = self.request("POST", endpoint, json=payload, headers=headers, **kwargs)
return response.json()
def get_json(self, endpoint: str, params: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
response = self.request("GET", endpoint, params=params)
return response.json()
def list_models(self, model_type: Optional[str] = None) -> Dict[str, Any]:
params = {"type": model_type} if model_type else None
return self.get_json(API_ENDPOINTS["list_models"], params=params)
def list_styles(self) -> Dict[str, Any]:
return self.get_json(API_ENDPOINTS["list_styles"])
def list_characters(self) -> Dict[str, Any]:
return self.get_json(API_ENDPOINTS["characters"])
client = VeniceClient()
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict
DEFAULT_CONFIG: Dict[str, Any] = {"apikey": ""}
class VeniceConfig:
_instance: "VeniceConfig" | None = None
def __new__(cls) -> "VeniceConfig":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if self._initialized:
return
self._initialized = True
self.path = Path(__file__).with_name("veniceai_config.json")
self.path.parent.mkdir(parents=True, exist_ok=True)
self._data = self._read()
self._sync_env()
def _read(self) -> Dict[str, Any]:
if not self.path.exists():
self._write(DEFAULT_CONFIG)
return DEFAULT_CONFIG.copy()
try:
with self.path.open("r", encoding="utf-8") as fp:
return json.load(fp)
except (ValueError, json.JSONDecodeError):
self._write(DEFAULT_CONFIG)
return DEFAULT_CONFIG.copy()
def _write(self, data: Dict[str, Any]) -> None:
with self.path.open("w", encoding="utf-8") as fp:
json.dump(data, fp, indent=2)
def _sync_env(self) -> None:
os.environ.setdefault("VENICEAI_API_KEY", self._data.get("apikey", ""))
@property
def apikey(self) -> str:
return self._data.get("apikey", "") or ""
def save_apikey(self, key: str) -> None:
self._data["apikey"] = key
self._write(self._data)
os.environ["VENICEAI_API_KEY"] = key
config = VeniceConfig()
-310
View File
@@ -1,310 +0,0 @@
{
"last_node_id": 22,
"last_link_id": 6,
"nodes": [
{
"id": 20,
"type": "SaveImage",
"pos": {
"0": 645,
"1": 514
},
"size": {
"0": 315,
"1": 270
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 6
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUIPro"
]
},
{
"id": 15,
"type": "SaveImage",
"pos": {
"0": 641,
"1": 913
},
"size": {
"0": 315,
"1": 270
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 5
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUIP11"
]
},
{
"id": 5,
"type": "SaveImage",
"pos": {
"0": 624,
"1": 91
},
"size": {
"0": 315,
"1": 270
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUIDev"
]
},
{
"id": 9,
"type": "FluxPro_TOGETHER",
"pos": {
"0": 116,
"1": 513
},
"size": {
"0": 400,
"1": 292
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "FluxPro_TOGETHER"
},
"widgets_values": [
"The image showcases a striking 3D CGI representation of a stylized angry muscle Doberman, characterized by a sleek black and silver coat that glistens under the soft lighting, enhancing its muscular physique. The dog is prominently positioned in the center of the composition against a stark black background, which accentuates its fierce expression and well-defined features. Its piercing eyes are shielded by trendy green sunglasses, adding a fashionable flair that contrasts with the dark tones of its fur. The meticulous detailing highlights the texture of the Doberman’s coat, revealing subtle variations in color that suggest a glossy sheen. Surrounding the Doberman, hints of a countryside landscape can be inferred in the background, possibly indicating an advertising theme, although it remains blurred and understated to maintain focus on the dog itself. The overall quality of the image is exceptional, marked by ultra-detailed craftsmanship that embodies a masterpiece in high-resolution, making it a captivating visual spectacle.\n\n",
1024,
1024,
28,
true,
"2",
2.5,
2,
324,
"randomize"
]
},
{
"id": 6,
"type": "FluxDev_TOGETHER",
"pos": {
"0": 112,
"1": 91
},
"size": {
"0": 400,
"1": 268
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "FluxDev_TOGETHER"
},
"widgets_values": [
"The image showcases a striking 3D CGI representation of a stylized angry muscle Doberman, characterized by a sleek black and silver coat that glistens under the soft lighting, enhancing its muscular physique. The dog is prominently positioned in the center of the composition against a stark black background, which accentuates its fierce expression and well-defined features. Its piercing eyes are shielded by trendy green sunglasses, adding a fashionable flair that contrasts with the dark tones of its fur. The meticulous detailing highlights the texture of the Doberman’s coat, revealing subtle variations in color that suggest a glossy sheen. Surrounding the Doberman, hints of a countryside landscape can be inferred in the background, possibly indicating an advertising theme, although it remains blurred and understated to maintain focus on the dog itself. The overall quality of the image is exceptional, marked by ultra-detailed craftsmanship that embodies a masterpiece in high-resolution, making it a captivating visual spectacle.\n\n",
1024,
1024,
4,
true,
"1",
2.5,
675,
"randomize"
]
},
{
"id": 14,
"type": "FluxPro11_TOGETHER",
"pos": {
"0": 115,
"1": 912
},
"size": {
"0": 400,
"1": 244
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "FluxPro11_TOGETHER"
},
"widgets_values": [
"The image showcases a striking 3D CGI representation of a stylized angry muscle Doberman, characterized by a sleek black and silver coat that glistens under the soft lighting, enhancing its muscular physique. The dog is prominently positioned in the center of the composition against a stark black background, which accentuates its fierce expression and well-defined features. Its piercing eyes are shielded by trendy green sunglasses, adding a fashionable flair that contrasts with the dark tones of its fur. The meticulous detailing highlights the texture of the Doberman’s coat, revealing subtle variations in color that suggest a glossy sheen. Surrounding the Doberman, hints of a countryside landscape can be inferred in the background, possibly indicating an advertising theme, although it remains blurred and understated to maintain focus on the dog itself. The overall quality of the image is exceptional, marked by ultra-detailed craftsmanship that embodies a masterpiece in high-resolution, making it a captivating visual spectacle.\n",
1024,
1024,
true,
1,
"2",
1148,
"randomize"
]
},
{
"id": 22,
"type": "Fast Groups Muter (rgthree)",
"pos": {
"0": -142,
"1": 36
},
"size": {
"0": 226.8000030517578,
"1": 130
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "OPT_CONNECTION",
"type": "*",
"links": null
}
],
"properties": {
"matchColors": "",
"matchTitle": "",
"showNav": true,
"sort": "position",
"customSortAlphabet": "",
"toggleRestriction": "default"
}
}
],
"links": [
[
4,
6,
0,
5,
0,
"IMAGE"
],
[
5,
14,
0,
15,
0,
"IMAGE"
],
[
6,
9,
0,
20,
0,
"IMAGE"
]
],
"groups": [
{
"title": "Flux Pro",
"bounding": [
102,
440,
872,
376
],
"color": "#8A8",
"font_size": 24,
"flags": {}
},
{
"title": "Flux Dev",
"bounding": [
103,
11,
871,
407
],
"color": "#A88",
"font_size": 24,
"flags": {}
},
{
"title": "Flux Pro 1.1",
"bounding": [
100,
834,
879,
373
],
"color": "#444",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [
442.1956952821454,
170.1053343944367
]
}
},
"version": 0.4
}