Compare commits
85
Commits
sync-client
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba69be9a2 | ||
|
|
1b14ab3164 | ||
|
|
a728d7e3ba | ||
|
|
648d4b5ab2 | ||
|
|
809cf424b4 | ||
|
|
56ac5e9613 | ||
|
|
492a963bd4 | ||
|
|
57b78dcca3 | ||
|
|
aa6e5b9531 | ||
|
|
f6a650d407 | ||
|
|
54b3182c6a | ||
|
|
830a467f54 | ||
|
|
1fb220258f | ||
|
|
5382b69e64 | ||
|
|
95b8a044ec | ||
|
|
6273ea0fb2 | ||
|
|
f65213ceb4 | ||
|
|
1e60cc4a0b | ||
|
|
7cd2900150 | ||
|
|
4200bbcedf | ||
|
|
fedda31284 | ||
|
|
105f6a9083 | ||
|
|
f65b8ea0fa | ||
|
|
3f27dd7887 | ||
|
|
b95ff2c86e | ||
|
|
04f19b26c2 | ||
|
|
13a05b8d6a | ||
|
|
a4f22a114b | ||
|
|
be7f74ebee | ||
|
|
331ed2c058 | ||
|
|
97049f29c8 | ||
|
|
9d8c754e8a | ||
|
|
f9b21a5e93 | ||
|
|
fbee93b5b5 | ||
|
|
845b9d46c5 | ||
|
|
31572e6e45 | ||
|
|
b60c18d8a8 | ||
|
|
58c54acbce | ||
|
|
27580456ed | ||
|
|
a68c56134c | ||
|
|
cd9eb99568 | ||
|
|
ef774a511b | ||
|
|
66d4dcf54d | ||
|
|
a6d061c0eb | ||
|
|
34d3a8396e | ||
|
|
06a30a6f21 | ||
|
|
f4f486edb0 | ||
|
|
1f6f476679 | ||
|
|
1e561ac944 | ||
|
|
cf523888a7 | ||
|
|
93aa2cbc04 | ||
|
|
5be02175f3 | ||
|
|
4ff17aa6ef | ||
|
|
a6d29a2d4c | ||
|
|
4215edebf0 | ||
|
|
a5c55fd44b | ||
|
|
63cb3fcf0d | ||
|
|
e1faa49e25 | ||
|
|
c8c437b495 | ||
|
|
81f9031625 | ||
|
|
1c1be8ae31 | ||
|
|
cd5c9ef258 | ||
|
|
ec8880895d | ||
|
|
f8b1efa75d | ||
|
|
975d555e29 | ||
|
|
4988995bf7 | ||
|
|
ee026dd560 | ||
|
|
93d6ad2875 | ||
|
|
c22792a581 | ||
|
|
779e0b1028 | ||
|
|
2f7f43da45 | ||
|
|
a8bdb5bc6d | ||
|
|
c3fae085d6 | ||
|
|
1c67dda258 | ||
|
|
96b0cd0976 | ||
|
|
116bfbd4e0 | ||
|
|
2797366781 | ||
|
|
ebecad477a | ||
|
|
a46b9465e6 | ||
|
|
1d2ecc823e | ||
|
|
a8c202b045 | ||
|
|
e2a41cc5ff | ||
|
|
68328f8526 | ||
|
|
6a4b736773 | ||
|
|
cfd626541b |
@@ -0,0 +1,64 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
- name: Run ruff
|
||||
run: ruff check .
|
||||
|
||||
test:
|
||||
name: Test (python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install pytest
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d tests ]; then pytest tests -x -q; else echo "no tests yet"; fi
|
||||
- name: Registry builder smoke check
|
||||
run: |
|
||||
if [ -f scripts/build_registry.py ]; then python scripts/build_registry.py --help; else echo "no registry builder yet"; fi
|
||||
- name: Validate model registry
|
||||
run: |
|
||||
python -c "
|
||||
import json, pathlib
|
||||
path = pathlib.Path('data/fal_registry.json')
|
||||
if not path.exists():
|
||||
print('no registry file yet')
|
||||
else:
|
||||
registry = json.loads(path.read_text())
|
||||
assert {'version', 'models', 'model_count'} <= set(registry), 'missing required keys'
|
||||
assert registry['model_count'] == len(registry['models']), 'model_count mismatch'
|
||||
assert registry['model_count'] > 500, 'suspiciously few models'
|
||||
print(f\"registry OK: {registry['model_count']} models\")
|
||||
"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'gokayfem' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Refresh fal model registry
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Monday at 06:00 UTC
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Rebuild registry and open PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Rebuild registry
|
||||
run: python scripts/build_registry.py --out data/fal_registry.json
|
||||
- name: Summarize changes
|
||||
id: diff
|
||||
run: |
|
||||
{
|
||||
echo "stat<<EOF"
|
||||
git diff --stat
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# create-pull-request skips PR creation when there are no changes.
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
branch: chore/registry-refresh
|
||||
commit-message: "chore: refresh fal model registry"
|
||||
title: "Refresh fal model registry"
|
||||
body: |
|
||||
Automated weekly refresh of `data/fal_registry.json` via `scripts/build_registry.py`.
|
||||
|
||||
```
|
||||
${{ steps.diff.outputs.stat }}
|
||||
```
|
||||
delete-branch: true
|
||||
+14
@@ -1,3 +1,6 @@
|
||||
# Local configuration (contains API keys) — use config.ini.example as a template
|
||||
config.ini
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -160,3 +163,14 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Cursor and SpecStory
|
||||
.specstory/
|
||||
.cursor/
|
||||
.claude
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
memory-bank/
|
||||
.DS_Store
|
||||
.claude/
|
||||
Node-Docs/
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"workbench.colorTheme": "Community Material Theme Ocean High Contrast"
|
||||
}
|
||||
+28
-8
@@ -1,12 +1,13 @@
|
||||
import importlib.util
|
||||
import importlib
|
||||
import importlib.util
|
||||
|
||||
node_list = [
|
||||
"image_node",
|
||||
"video_node",
|
||||
"llm_node",
|
||||
"vlm_node",
|
||||
"trainer_node",
|
||||
"image_node",
|
||||
"video_node",
|
||||
"llm_node",
|
||||
"vlm_node",
|
||||
"trainer_node",
|
||||
"upscaler_node",
|
||||
]
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -16,7 +17,26 @@ for module_name in node_list:
|
||||
imported_module = importlib.import_module(f".nodes.{module_name}", __name__)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **imported_module.NODE_CLASS_MAPPINGS}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **imported_module.NODE_DISPLAY_NAME_MAPPINGS}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**NODE_DISPLAY_NAME_MAPPINGS,
|
||||
**imported_module.NODE_DISPLAY_NAME_MAPPINGS,
|
||||
}
|
||||
|
||||
try:
|
||||
from .nodes.dynamic import get_dynamic_mappings
|
||||
|
||||
dyn_classes, dyn_display = get_dynamic_mappings()
|
||||
# static nodes win on any key collision
|
||||
for k, v in dyn_classes.items():
|
||||
NODE_CLASS_MAPPINGS.setdefault(k, v)
|
||||
for k, v in dyn_display.items():
|
||||
NODE_DISPLAY_NAME_MAPPINGS.setdefault(k, v)
|
||||
except Exception as _dynamic_error: # never break static nodes
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(
|
||||
"Failed to load dynamic fal nodes: %s", _dynamic_error
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[API]
|
||||
FAL_KEY = <your_fal_api_key_here>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"id": "80767774-d39b-4f73-a75a-3c1327f92316",
|
||||
"revision": 0,
|
||||
"last_node_id": 94,
|
||||
"last_link_id": 199,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 93,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2148.41357421875,
|
||||
-644.6304931640625
|
||||
],
|
||||
"size": [
|
||||
284.00726318359375,
|
||||
497.4951477050781
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
198
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"image (24).png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 92,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1846.3321533203125,
|
||||
-654.7257080078125
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
510.1016845703125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
197
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"image (25).png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 94,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
2907.259765625,
|
||||
-712.0502319335938
|
||||
],
|
||||
"size": [
|
||||
660.7576293945312,
|
||||
723.7353515625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 199
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 91,
|
||||
"type": "FluxProKontextMulti_fal",
|
||||
"pos": [
|
||||
2462.2119140625,
|
||||
-595.9410400390625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
364
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image_1",
|
||||
"type": "IMAGE",
|
||||
"link": 197
|
||||
},
|
||||
{
|
||||
"name": "image_2",
|
||||
"type": "IMAGE",
|
||||
"link": 198
|
||||
},
|
||||
{
|
||||
"name": "image_3",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "image_4",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
199
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "FluxProKontextMulti_fal"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Woman wearing this backpack on her way to jungle",
|
||||
"9:16",
|
||||
false,
|
||||
3.5,
|
||||
1,
|
||||
"2",
|
||||
"jpeg",
|
||||
false,
|
||||
2075416510,
|
||||
"randomize"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
197,
|
||||
92,
|
||||
0,
|
||||
91,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
198,
|
||||
93,
|
||||
0,
|
||||
91,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
199,
|
||||
91,
|
||||
0,
|
||||
94,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.863837598531476,
|
||||
"offset": [
|
||||
-1745.427153953069,
|
||||
795.6355480141049
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.18.10",
|
||||
"ue_links": [],
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"id": "b7d0f93a-df07-4002-8769-ae88c70c403a",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-3530.263671875,
|
||||
-2397.567138671875
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.59",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"knight.jpeg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-3527.47119140625,
|
||||
-2022.4005126953125
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.59",
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"mask_knight.jpeg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "FluxPro1Fill_fal",
|
||||
"pos": [
|
||||
-3013.856689453125,
|
||||
-2388.482177734375
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
276
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "mask_image",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "88466f23804f2e9e6a905b82bf6693c754a466ba",
|
||||
"Node name for S&R": "FluxPro1Fill_fal"
|
||||
},
|
||||
"widgets_values": [
|
||||
"A big yellow smiley face.",
|
||||
1,
|
||||
"2",
|
||||
"png",
|
||||
1647,
|
||||
"randomize",
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-2539.11376953125,
|
||||
-2386.515625
|
||||
],
|
||||
"size": [
|
||||
427.3517150878906,
|
||||
400.66522216796875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.59",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.0152559799477112,
|
||||
"offset": [
|
||||
4064.0521902493365,
|
||||
2549.035899408263
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.27.10",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
{
|
||||
"id": "d3437cd7-7301-49a6-b533-dd71877f7816",
|
||||
"revision": 0,
|
||||
"last_node_id": 20,
|
||||
"last_link_id": 19,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "NanoBananaPro_fal",
|
||||
"pos": [
|
||||
2510,
|
||||
-1370
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
208
|
||||
],
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "54b3182c6adf925426dc25e28483951350edd477",
|
||||
"Node name for S&R": "NanoBananaPro_fal",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"a group photo of 14 people",
|
||||
1,
|
||||
"21:9",
|
||||
"png",
|
||||
"2K",
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "ImpactMakeImageBatch",
|
||||
"pos": [
|
||||
2240,
|
||||
-1370
|
||||
],
|
||||
"size": [
|
||||
156.6236328125,
|
||||
306
|
||||
],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image1",
|
||||
"shape": 7,
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "image2",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "image3",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "image4",
|
||||
"type": "IMAGE",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "image5",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "image6",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "image7",
|
||||
"type": "IMAGE",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "image8",
|
||||
"type": "IMAGE",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "image9",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"name": "image10",
|
||||
"type": "IMAGE",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "image11",
|
||||
"type": "IMAGE",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"name": "image12",
|
||||
"type": "IMAGE",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "image13",
|
||||
"type": "IMAGE",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"name": "image14",
|
||||
"type": "IMAGE",
|
||||
"link": 18
|
||||
},
|
||||
{
|
||||
"name": "image15",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
19
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-impact-pack",
|
||||
"ver": "8.25.1",
|
||||
"Node name for S&R": "ImpactMakeImageBatch",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1460,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_3.jpg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
2930,
|
||||
-1370
|
||||
],
|
||||
"size": [
|
||||
630,
|
||||
360
|
||||
],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1180,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_1.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1180,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_2.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1460,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_4.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1740,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_6.jpeg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2300,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_9.jpeg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1740,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_5.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2020,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_7.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2020,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_8.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2300,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_10.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2580,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_12.png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2580,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_11.jpg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2860,
|
||||
-2230
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_13.jpg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
2860,
|
||||
-1880
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
18
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.68",
|
||||
"Node name for S&R": "LoadImage",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {},
|
||||
"version": "7.4.1"
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Image_14.jpg",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
7,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
16,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
7,
|
||||
11,
|
||||
0,
|
||||
6,
|
||||
2,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
8,
|
||||
10,
|
||||
0,
|
||||
6,
|
||||
3,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
13,
|
||||
0,
|
||||
6,
|
||||
4,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
12,
|
||||
0,
|
||||
6,
|
||||
5,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
11,
|
||||
15,
|
||||
0,
|
||||
6,
|
||||
6,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
12,
|
||||
20,
|
||||
0,
|
||||
6,
|
||||
7,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
19,
|
||||
0,
|
||||
6,
|
||||
8,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
14,
|
||||
14,
|
||||
0,
|
||||
6,
|
||||
9,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
15,
|
||||
18,
|
||||
0,
|
||||
6,
|
||||
10,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
16,
|
||||
17,
|
||||
0,
|
||||
6,
|
||||
11,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
17,
|
||||
9,
|
||||
0,
|
||||
6,
|
||||
12,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
18,
|
||||
8,
|
||||
0,
|
||||
6,
|
||||
13,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
19,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8769226950000027,
|
||||
"offset": [
|
||||
-1077.4046047064173,
|
||||
2181.4808993231786
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"frontendVersion": "1.28.8",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
{
|
||||
"id": "8e5d7b17-8bec-4eae-8c78-0bcc142bd0c8",
|
||||
"revision": 0,
|
||||
"last_node_id": 17,
|
||||
"last_link_id": 28,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
-3565.794921875,
|
||||
-2337.682861328125
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
232.1231231689453
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.59",
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"AnimateDiff_00039.mp4",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VHS_VideoInfo",
|
||||
"pos": [
|
||||
-2305.713623046875,
|
||||
-2289.08837890625
|
||||
],
|
||||
"size": [
|
||||
225.59765625,
|
||||
206
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "source_fps🟨",
|
||||
"type": "FLOAT",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_frame_count🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_duration🟨",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_width🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_height🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_fps🟦",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_frame_count🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_duration🟦",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_width🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_height🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-videohelpersuite",
|
||||
"ver": "1.7.7",
|
||||
"Node name for S&R": "VHS_VideoInfo"
|
||||
},
|
||||
"widgets_values": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "Bria_Video_Increase_Resolution_fal",
|
||||
"pos": [
|
||||
-3215.152099609375,
|
||||
-2325.123291015625
|
||||
],
|
||||
"size": [
|
||||
319.8667907714844,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"shape": 7,
|
||||
"type": "VIDEO",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "1fb220258fdc77e1acabdd7f1cb32cf8194f57a9",
|
||||
"Node name for S&R": "Bria_Video_Increase_Resolution_fal"
|
||||
},
|
||||
"widgets_values": [
|
||||
2,
|
||||
"",
|
||||
"mp4_h264"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadVideoURL",
|
||||
"pos": [
|
||||
-2754.03515625,
|
||||
-2394.18994140625
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
266
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "frames",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
27
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "1fb220258fdc77e1acabdd7f1cb32cf8194f57a9",
|
||||
"Node name for S&R": "LoadVideoURL"
|
||||
},
|
||||
"widgets_values": [
|
||||
"https://example.com/video.mp4",
|
||||
0,
|
||||
"Disabled",
|
||||
512,
|
||||
512,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VHS_VideoCombine",
|
||||
"pos": [
|
||||
-1946.611328125,
|
||||
-2388.9658203125
|
||||
],
|
||||
"size": [
|
||||
214.7587890625,
|
||||
460.36083984375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 27
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"shape": 7,
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "meta_batch",
|
||||
"shape": 7,
|
||||
"type": "VHS_BatchManager",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"shape": 7,
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "frame_rate",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "frame_rate"
|
||||
},
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "Filenames",
|
||||
"type": "VHS_FILENAMES",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-videohelpersuite",
|
||||
"ver": "1.7.7",
|
||||
"Node name for S&R": "VHS_VideoCombine"
|
||||
},
|
||||
"widgets_values": {
|
||||
"frame_rate": 8,
|
||||
"loop_count": 0,
|
||||
"filename_prefix": "AnimateDiff",
|
||||
"format": "video/h264-mp4",
|
||||
"pix_fmt": "yuv420p",
|
||||
"crf": 19,
|
||||
"save_metadata": true,
|
||||
"trim_to_audio": false,
|
||||
"pingpong": false,
|
||||
"save_output": true,
|
||||
"videopreview": {
|
||||
"hidden": false,
|
||||
"paused": false,
|
||||
"params": {
|
||||
"filename": "AnimateDiff_00049.mp4",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
"format": "video/h264-mp4",
|
||||
"frame_rate": 25,
|
||||
"workflow": "AnimateDiff_00049.png",
|
||||
"fullpath": "/root/ComfyUI/output/AnimateDiff_00049.mp4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
"VHS_VIDEOINFO"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
4,
|
||||
"FLOAT"
|
||||
],
|
||||
[
|
||||
9,
|
||||
3,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"VIDEO"
|
||||
],
|
||||
[
|
||||
11,
|
||||
9,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
27,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.1167815779424788,
|
||||
"offset": [
|
||||
3727.7338670706927,
|
||||
2641.8882544232415
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.27.10",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"id": "1c871709-2293-44c4-9ba3-e1b5be72bbbe",
|
||||
"revision": 0,
|
||||
"last_node_id": 19,
|
||||
"last_link_id": 30,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VHS_VideoInfo",
|
||||
"pos": [
|
||||
-2305.713623046875,
|
||||
-2289.08837890625
|
||||
],
|
||||
"size": [
|
||||
225.59765625,
|
||||
206
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "source_fps🟨",
|
||||
"type": "FLOAT",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_frame_count🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_duration🟨",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_width🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "source_height🟨",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_fps🟦",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_frame_count🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_duration🟦",
|
||||
"type": "FLOAT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_width🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "loaded_height🟦",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-videohelpersuite",
|
||||
"ver": "1.7.7",
|
||||
"Node name for S&R": "VHS_VideoInfo"
|
||||
},
|
||||
"widgets_values": {}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadVideoURL",
|
||||
"pos": [
|
||||
-2754.03515625,
|
||||
-2394.18994140625
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
266
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": 30
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "frames",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
27
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "1fb220258fdc77e1acabdd7f1cb32cf8194f57a9",
|
||||
"Node name for S&R": "LoadVideoURL"
|
||||
},
|
||||
"widgets_values": [
|
||||
"https://example.com/video.mp4",
|
||||
0,
|
||||
"Disabled",
|
||||
512,
|
||||
512,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VHS_VideoCombine",
|
||||
"pos": [
|
||||
-1946.611328125,
|
||||
-2388.9658203125
|
||||
],
|
||||
"size": [
|
||||
214.7587890625,
|
||||
460.36083984375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 27
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"shape": 7,
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "meta_batch",
|
||||
"shape": 7,
|
||||
"type": "VHS_BatchManager",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"shape": 7,
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "frame_rate",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "frame_rate"
|
||||
},
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "Filenames",
|
||||
"type": "VHS_FILENAMES",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfyui-videohelpersuite",
|
||||
"ver": "1.7.7",
|
||||
"Node name for S&R": "VHS_VideoCombine"
|
||||
},
|
||||
"widgets_values": {
|
||||
"frame_rate": 8,
|
||||
"loop_count": 0,
|
||||
"filename_prefix": "AnimateDiff",
|
||||
"format": "video/h264-mp4",
|
||||
"pix_fmt": "yuv420p",
|
||||
"crf": 19,
|
||||
"save_metadata": true,
|
||||
"trim_to_audio": false,
|
||||
"pingpong": false,
|
||||
"save_output": true,
|
||||
"videopreview": {
|
||||
"hidden": false,
|
||||
"paused": false,
|
||||
"params": {
|
||||
"filename": "AnimateDiff_00050.mp4",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
"format": "video/h264-mp4",
|
||||
"frame_rate": 25,
|
||||
"workflow": "AnimateDiff_00050.png",
|
||||
"fullpath": "/root/ComfyUI/output/AnimateDiff_00050.mp4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
-3565.794921875,
|
||||
-2337.682861328125
|
||||
],
|
||||
"size": [
|
||||
274.080078125,
|
||||
232.1231231689453
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
29
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.59",
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"AnimateDiff_00039.mp4",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "Seedvr_Upscale_Video_fal",
|
||||
"pos": [
|
||||
-3179.730224609375,
|
||||
-2341.025390625
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
274
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"shape": 7,
|
||||
"type": "VIDEO",
|
||||
"link": 29
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
30
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "fal-api",
|
||||
"ver": "e650b470da94100c9315922f37156315e2eea42f",
|
||||
"Node name for S&R": "Seedvr_Upscale_Video_fal"
|
||||
},
|
||||
"widgets_values": [
|
||||
2,
|
||||
"",
|
||||
"factor",
|
||||
"1080p",
|
||||
0.1,
|
||||
"high",
|
||||
"balanced",
|
||||
"X264 (.mp4)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
6,
|
||||
0,
|
||||
"VHS_VIDEOINFO"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
4,
|
||||
"FLOAT"
|
||||
],
|
||||
[
|
||||
27,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
29,
|
||||
3,
|
||||
0,
|
||||
18,
|
||||
0,
|
||||
"VIDEO"
|
||||
],
|
||||
[
|
||||
30,
|
||||
18,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.1167815779424797,
|
||||
"offset": [
|
||||
3838.4549661482356,
|
||||
2565.8890490291633
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.27.10",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
{
|
||||
"id": "80767774-d39b-4f73-a75a-3c1327f92316",
|
||||
"revision": 0,
|
||||
"last_node_id": 97,
|
||||
"last_link_id": 202,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 92,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
1846.3321533203125,
|
||||
-654.7257080078125
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
510.1016845703125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"image (25).png",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"type": "LoadVideoURL",
|
||||
"pos": [
|
||||
2672.1376953125,
|
||||
-645.724609375
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
266
|
||||
],
|
||||
"flags": {
|
||||
"collapsed": false
|
||||
},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": 201
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "frames",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
202
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideoURL"
|
||||
},
|
||||
"widgets_values": [
|
||||
"https://example.com/video.mp4",
|
||||
0,
|
||||
"Disabled",
|
||||
512,
|
||||
512,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 97,
|
||||
"type": "VHS_VideoCombine",
|
||||
"pos": [
|
||||
2995.218994140625,
|
||||
-646.7775268554688
|
||||
],
|
||||
"size": [
|
||||
215.01171875,
|
||||
670.6875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 202
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"shape": 7,
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "meta_batch",
|
||||
"shape": 7,
|
||||
"type": "VHS_BatchManager",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"shape": 7,
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "Filenames",
|
||||
"type": "VHS_FILENAMES",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VHS_VideoCombine"
|
||||
},
|
||||
"widgets_values": {
|
||||
"frame_rate": 25,
|
||||
"loop_count": 0,
|
||||
"filename_prefix": "Veo2",
|
||||
"format": "video/h265-mp4",
|
||||
"pix_fmt": "yuv420p10le",
|
||||
"crf": 22,
|
||||
"save_metadata": false,
|
||||
"pingpong": false,
|
||||
"save_output": true,
|
||||
"videopreview": {
|
||||
"hidden": false,
|
||||
"paused": false,
|
||||
"params": {
|
||||
"filename": "Veo2_00001.mp4",
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
"format": "video/h265-mp4",
|
||||
"frame_rate": 25,
|
||||
"workflow": "Veo2_00001.png",
|
||||
"fullpath": "D:\\ComfyUI_windows_portable\\ComfyUI\\output\\Veo2_00001.mp4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 95,
|
||||
"type": "Veo2ImageToVideo_fal",
|
||||
"pos": [
|
||||
2199.614990234375,
|
||||
-652.0396118164062
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 200
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
201
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Veo2ImageToVideo_fal"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Woman slowly turning",
|
||||
"auto",
|
||||
"5s"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
200,
|
||||
92,
|
||||
0,
|
||||
95,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
201,
|
||||
95,
|
||||
0,
|
||||
96,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
202,
|
||||
96,
|
||||
0,
|
||||
97,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.863837598531476,
|
||||
"offset": [
|
||||
-1694.469448600796,
|
||||
792.8487236845591
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.18.10",
|
||||
"ue_links": [],
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Dynamic fal.ai node package: auto-generated nodes from the model registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def get_dynamic_mappings() -> tuple[dict[str, type], dict[str, str]]:
|
||||
"""Return (NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS) for dynamic nodes.
|
||||
|
||||
Never raises: any failure (missing registry, missing utils facade, bad
|
||||
schema) yields empty mappings so static node loading is never affected.
|
||||
"""
|
||||
try:
|
||||
from .registry_loader import load_dynamic_mappings
|
||||
|
||||
return load_dynamic_mappings()
|
||||
except Exception as err:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(
|
||||
"Failed to load dynamic fal nodes: %s", err
|
||||
)
|
||||
return {}, {}
|
||||
|
||||
|
||||
__all__ = ["get_dynamic_mappings"]
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generated_at": "2026-07-02T00:00:00Z",
|
||||
"model_count": 5,
|
||||
"models": [
|
||||
{
|
||||
"endpoint_id": "fal-ai/flux/dev",
|
||||
"title": "FLUX.1 [dev]",
|
||||
"category": "text-to-image",
|
||||
"lab": "Black Forest Labs",
|
||||
"family": "flux",
|
||||
"description": "FLUX.1 [dev] is a 12 billion parameter flow transformer for text-to-image generation.",
|
||||
"pricing": "$0.025 per megapixel",
|
||||
"published_at": "2024-08-01",
|
||||
"thumbnail": null,
|
||||
"inputs": [
|
||||
{"name": "prompt", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "The prompt to generate an image from", "media_kind": null, "is_list": false, "multiline": true, "has_custom_size": false},
|
||||
{"name": "image_size", "type": "enum", "required": false, "default": "landscape_4_3", "enum": ["square_hd", "square", "portrait_4_3", "portrait_16_9", "landscape_4_3", "landscape_16_9", "custom_size"], "min": null, "max": null, "description": "The size of the generated image", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": true},
|
||||
{"name": "num_inference_steps", "type": "integer", "required": false, "default": 28, "enum": null, "min": 1, "max": 50, "description": "Number of inference steps", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "guidance_scale", "type": "number", "required": false, "default": 3.5, "enum": null, "min": 1, "max": 20, "description": "CFG scale", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "seed", "type": "integer", "required": false, "default": null, "enum": null, "min": null, "max": null, "description": "Random seed", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "num_images", "type": "integer", "required": false, "default": 1, "enum": null, "min": 1, "max": 4, "description": "Number of images to generate", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "enable_safety_checker", "type": "boolean", "required": false, "default": true, "enum": null, "min": null, "max": null, "description": "Enable the safety checker", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "loras", "type": "json", "required": false, "default": null, "enum": null, "min": null, "max": null, "description": "LoRA weights to apply", "media_kind": null, "is_list": true, "multiline": false, "has_custom_size": false}
|
||||
],
|
||||
"output_kind": "images",
|
||||
"output_props": ["images"]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "fal-ai/kling-video/v2/master/image-to-video",
|
||||
"title": "Kling 2.0 Master",
|
||||
"category": "image-to-video",
|
||||
"lab": "Kuaishou",
|
||||
"family": "kling-video",
|
||||
"description": "Generate video clips from an image using Kling 2.0 Master.",
|
||||
"pricing": "$1.40 per 5s video",
|
||||
"published_at": "2025-04-15",
|
||||
"thumbnail": null,
|
||||
"inputs": [
|
||||
{"name": "prompt", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "Motion prompt", "media_kind": null, "is_list": false, "multiline": true, "has_custom_size": false},
|
||||
{"name": "image_url", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "Start frame image", "media_kind": "image", "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "duration", "type": "enum", "required": false, "default": "5", "enum": ["5", "10"], "min": null, "max": null, "description": "Duration of the video in seconds", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "negative_prompt", "type": "string", "required": false, "default": "blur, distort, and low quality", "enum": null, "min": null, "max": null, "description": "Negative prompt", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "cfg_scale", "type": "number", "required": false, "default": 0.5, "enum": null, "min": 0, "max": 1, "description": "CFG scale", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false}
|
||||
],
|
||||
"output_kind": "video",
|
||||
"output_props": ["video"]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "fal-ai/video-upscaler",
|
||||
"title": "Video Upscaler",
|
||||
"category": "video-to-video",
|
||||
"lab": "fal",
|
||||
"family": "video-upscaler",
|
||||
"description": "Upscale videos by a given factor.",
|
||||
"pricing": "$0.02 per video second",
|
||||
"published_at": "2024-11-01",
|
||||
"thumbnail": null,
|
||||
"inputs": [
|
||||
{"name": "video_url", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "Video to upscale", "media_kind": "video", "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "scale", "type": "number", "required": false, "default": 2, "enum": null, "min": 1, "max": 4, "description": "Upscale factor", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false}
|
||||
],
|
||||
"output_kind": "video",
|
||||
"output_props": ["video"]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "fal-ai/kokoro/american-english",
|
||||
"title": "Kokoro TTS",
|
||||
"category": "text-to-speech",
|
||||
"lab": "Kokoro",
|
||||
"family": "kokoro",
|
||||
"description": "Fast and expressive American English text-to-speech.",
|
||||
"pricing": "$0.02 per 1000 characters",
|
||||
"published_at": "2025-01-20",
|
||||
"thumbnail": null,
|
||||
"inputs": [
|
||||
{"name": "prompt", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "Text to convert to speech", "media_kind": null, "is_list": false, "multiline": true, "has_custom_size": false},
|
||||
{"name": "voice", "type": "enum", "required": false, "default": "af_heart", "enum": ["af_heart", "af_bella", "am_adam", "am_echo"], "min": null, "max": null, "description": "Voice to use", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "speed", "type": "number", "required": false, "default": 1.0, "enum": null, "min": 0.5, "max": 2.0, "description": "Speech speed", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false}
|
||||
],
|
||||
"output_kind": "audio",
|
||||
"output_props": ["audio"]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "tripo3d/tripo/v2.5/image-to-3d",
|
||||
"title": "Tripo3D v2.5",
|
||||
"category": "image-to-3d",
|
||||
"lab": "Tripo",
|
||||
"family": "tripo",
|
||||
"description": "Generate a textured 3D mesh from a single image.",
|
||||
"pricing": "$0.20 per generation",
|
||||
"published_at": "2025-02-10",
|
||||
"thumbnail": null,
|
||||
"inputs": [
|
||||
{"name": "image_url", "type": "string", "required": true, "default": null, "enum": null, "min": null, "max": null, "description": "Input image", "media_kind": "image", "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "image_urls", "type": "array", "required": false, "default": null, "enum": null, "min": null, "max": null, "description": "Optional multi-view images", "media_kind": "image", "is_list": true, "multiline": false, "has_custom_size": false},
|
||||
{"name": "texture", "type": "enum", "required": false, "default": "standard", "enum": ["no", "standard", "HD"], "min": null, "max": null, "description": "Texture quality", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false},
|
||||
{"name": "seed", "type": "integer", "required": false, "default": null, "enum": null, "min": null, "max": null, "description": "Random seed", "media_kind": null, "is_list": false, "multiline": false, "has_custom_size": false}
|
||||
],
|
||||
"output_kind": "file",
|
||||
"output_props": ["model_mesh"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Self-test for the dynamic node package. Stdlib only; stubs the utils facade.
|
||||
|
||||
Run: python3 nodes/dynamic/_selftest.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
NODES_DIR = PACKAGE_DIR.parent
|
||||
REPO_ROOT = NODES_DIR.parent
|
||||
REAL_REGISTRY = REPO_ROOT / "data" / "fal_registry.json"
|
||||
|
||||
PKG = "falapi_nodes"
|
||||
|
||||
|
||||
def _install_stub_facade() -> types.ModuleType:
|
||||
"""Install a stub falapi_nodes.fal_utils satisfying the facade contract."""
|
||||
stub = types.ModuleType(f"{PKG}.fal_utils")
|
||||
|
||||
class FalApiError(Exception):
|
||||
def __init__(self, endpoint, message):
|
||||
super().__init__(f"[{endpoint}] {message}")
|
||||
self.endpoint = endpoint
|
||||
self.message = message
|
||||
|
||||
class FalConfig:
|
||||
def get_setting(self, section, name, default=None):
|
||||
return default
|
||||
|
||||
class ImageUtils:
|
||||
@staticmethod
|
||||
def upload_image(tensor):
|
||||
return "https://stub.fal.media/image.png"
|
||||
|
||||
@staticmethod
|
||||
def prepare_images(images):
|
||||
return ["https://stub.fal.media/1.png", "https://stub.fal.media/2.png"]
|
||||
|
||||
class ResultProcessor:
|
||||
@staticmethod
|
||||
def process_image_result(result):
|
||||
return ("IMAGE_TENSOR",)
|
||||
|
||||
@staticmethod
|
||||
def process_single_image_result(result):
|
||||
return ("IMAGE_TENSOR",)
|
||||
|
||||
class ApiHandler:
|
||||
last_call = None
|
||||
|
||||
@staticmethod
|
||||
def submit_and_get_result(endpoint, arguments):
|
||||
ApiHandler.last_call = (endpoint, arguments)
|
||||
return _CANNED_RESULTS.get(endpoint, {"ok": True})
|
||||
|
||||
class MediaUtils:
|
||||
@staticmethod
|
||||
def video_from_url(url):
|
||||
return "VIDEO_OBJ"
|
||||
|
||||
@staticmethod
|
||||
def audio_from_url(url):
|
||||
return {"waveform": None, "sample_rate": 44100}
|
||||
|
||||
@staticmethod
|
||||
def upload_video(video):
|
||||
return "https://stub.fal.media/video.mp4"
|
||||
|
||||
@staticmethod
|
||||
def upload_audio(audio):
|
||||
return "https://stub.fal.media/audio.wav"
|
||||
|
||||
@staticmethod
|
||||
def download_url_to_temp(url, suffix):
|
||||
return "/tmp/stub" + suffix
|
||||
|
||||
stub.FalApiError = FalApiError
|
||||
stub.FalConfig = FalConfig
|
||||
stub.ImageUtils = ImageUtils
|
||||
stub.ResultProcessor = ResultProcessor
|
||||
stub.ApiHandler = ApiHandler
|
||||
stub.MediaUtils = MediaUtils
|
||||
stub.logger = logging.getLogger("fal_stub")
|
||||
sys.modules[stub.__name__] = stub
|
||||
return stub
|
||||
|
||||
|
||||
_CANNED_RESULTS = {
|
||||
"fal-ai/flux/dev": {"images": [{"url": "https://x/i.png"}], "seed": 1},
|
||||
"fal-ai/kling-video/v2/master/image-to-video": {
|
||||
"video": {"url": "https://x/v.mp4"}
|
||||
},
|
||||
"fal-ai/video-upscaler": {"video": {"url": "https://x/up.mp4"}},
|
||||
"fal-ai/kokoro/american-english": {"audio": {"url": "https://x/a.wav"}},
|
||||
"tripo3d/tripo/v2.5/image-to-3d": {"model_mesh": {"url": "https://x/m.glb"}},
|
||||
}
|
||||
|
||||
|
||||
def _install_package() -> None:
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [str(NODES_DIR)]
|
||||
sys.modules[PKG] = pkg
|
||||
|
||||
|
||||
def _load_models(path: Path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)["models"]
|
||||
|
||||
|
||||
def _check_registry(models, factory, outputs, label):
|
||||
keys = set()
|
||||
names = set()
|
||||
skipped = {}
|
||||
built = 0
|
||||
for model in models:
|
||||
try:
|
||||
cls = factory.build_node_class(model)
|
||||
input_types = cls.INPUT_TYPES()
|
||||
assert isinstance(input_types, dict) and "required" in input_types
|
||||
assert "force_rerun" in input_types.get("optional", {})
|
||||
kind = model.get("output_kind", "json")
|
||||
expected = outputs.RETURN_SPECS.get(kind, outputs.RETURN_SPECS["json"])
|
||||
assert cls.RETURN_TYPES == expected[0], (
|
||||
f"RETURN_TYPES mismatch for {model['endpoint_id']}"
|
||||
)
|
||||
assert cls.RETURN_NAMES == expected[1]
|
||||
key = factory.node_key(model)
|
||||
assert key not in keys, f"key collision: {key}"
|
||||
keys.add(key)
|
||||
names.add(factory.build_display_name(model))
|
||||
built += 1
|
||||
except Exception as err:
|
||||
reason = type(err).__name__ + ": " + str(err)[:80]
|
||||
skipped[reason] = skipped.get(reason, 0) + 1
|
||||
print(f"[{label}] built={built} skipped={sum(skipped.values())}")
|
||||
if skipped:
|
||||
print(f"[{label}] skip reasons histogram:")
|
||||
for reason, count in sorted(skipped.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {count:4d} {reason}")
|
||||
return built, skipped
|
||||
|
||||
|
||||
def _test_fixture_behaviour(dyn, stub):
|
||||
from importlib import import_module
|
||||
|
||||
arguments = import_module(f"{PKG}.dynamic.arguments")
|
||||
factory = import_module(f"{PKG}.dynamic.factory")
|
||||
import_module(f"{PKG}.dynamic.outputs")
|
||||
|
||||
models = _load_models(PACKAGE_DIR / "_fixture_registry.json")
|
||||
by_id = {m["endpoint_id"]: m for m in models}
|
||||
|
||||
# --- arguments: custom_size, seed=-1 omitted, empty json skipped ---
|
||||
flux = by_id["fal-ai/flux/dev"]
|
||||
kwargs = {
|
||||
"prompt": "a cat",
|
||||
"image_size": "custom_size",
|
||||
"width": 512,
|
||||
"height": 768,
|
||||
"num_inference_steps": 28,
|
||||
"guidance_scale": 3.5,
|
||||
"seed": -1,
|
||||
"num_images": 1,
|
||||
"enable_safety_checker": True,
|
||||
"loras": "",
|
||||
"force_rerun": False,
|
||||
}
|
||||
kwargs_snapshot = dict(kwargs)
|
||||
args = arguments.build_arguments(flux, kwargs)
|
||||
assert args["image_size"] == {"width": 512, "height": 768}, args
|
||||
assert "seed" not in args and "loras" not in args and "force_rerun" not in args
|
||||
assert kwargs == kwargs_snapshot, "kwargs were mutated"
|
||||
|
||||
# seed forwarded when != -1; enum passthrough
|
||||
args2 = arguments.build_arguments(flux, {**kwargs, "seed": 42, "image_size": "square"})
|
||||
assert args2["seed"] == 42 and args2["image_size"] == "square"
|
||||
|
||||
# invalid json raises FalApiError
|
||||
try:
|
||||
arguments.build_arguments(flux, {**kwargs, "loras": "{not json"})
|
||||
raise AssertionError("expected FalApiError for bad JSON")
|
||||
except stub.FalApiError:
|
||||
pass
|
||||
|
||||
# valid json parsed
|
||||
args3 = arguments.build_arguments(flux, {**kwargs, "loras": '[{"path": "x"}]'})
|
||||
assert args3["loras"] == [{"path": "x"}]
|
||||
|
||||
# --- media uploads ---
|
||||
kling = by_id["fal-ai/kling-video/v2/master/image-to-video"]
|
||||
kargs = arguments.build_arguments(
|
||||
kling, {"prompt": "move", "image_url": "TENSOR", "duration": "5",
|
||||
"negative_prompt": "", "cfg_scale": 0.5}
|
||||
)
|
||||
assert kargs["image_url"] == "https://stub.fal.media/image.png"
|
||||
assert "negative_prompt" not in kargs # optional empty string skipped
|
||||
|
||||
tripo = by_id["tripo3d/tripo/v2.5/image-to-3d"]
|
||||
targs = arguments.build_arguments(
|
||||
tripo, {"image_url": "TENSOR", "image_urls": "BATCH", "texture": "HD", "seed": -1}
|
||||
)
|
||||
assert targs["image_urls"] == [
|
||||
"https://stub.fal.media/1.png",
|
||||
"https://stub.fal.media/2.png",
|
||||
]
|
||||
|
||||
upscaler = by_id["fal-ai/video-upscaler"]
|
||||
uargs = arguments.build_arguments(upscaler, {"video_url": "VIDEO_OBJ", "scale": 2.0})
|
||||
assert uargs["video_url"] == "https://stub.fal.media/video.mp4"
|
||||
|
||||
# --- end-to-end run() per output kind ---
|
||||
flux_node = factory.build_node_class(flux)()
|
||||
assert flux_node.run(**kwargs) == ("IMAGE_TENSOR",)
|
||||
|
||||
kling_node = factory.build_node_class(kling)()
|
||||
out = kling_node.run(prompt="move", image_url="TENSOR", duration="5",
|
||||
negative_prompt="", cfg_scale=0.5)
|
||||
assert out == ("VIDEO_OBJ", "https://x/v.mp4"), out
|
||||
|
||||
tts = by_id["fal-ai/kokoro/american-english"]
|
||||
tts_node = factory.build_node_class(tts)()
|
||||
audio_out = tts_node.run(prompt="hello", voice="af_heart", speed=1.0)
|
||||
assert audio_out[1] == "https://x/a.wav" and isinstance(audio_out[0], dict)
|
||||
|
||||
tripo_node = factory.build_node_class(tripo)()
|
||||
file_out = tripo_node.run(image_url="TENSOR", texture="HD", seed=-1)
|
||||
assert file_out == ("https://x/m.glb",), file_out
|
||||
|
||||
# --- IS_CHANGED semantics ---
|
||||
cls = factory.build_node_class(flux)
|
||||
h1 = cls.IS_CHANGED(prompt="a", force_rerun=False)
|
||||
h2 = cls.IS_CHANGED(prompt="a", force_rerun=False)
|
||||
h3 = cls.IS_CHANGED(prompt="b", force_rerun=False)
|
||||
nan = cls.IS_CHANGED(prompt="a", force_rerun=True)
|
||||
assert h1 == h2 and h1 != h3 and nan != nan # nan != nan
|
||||
|
||||
# --- loader end-to-end (fixture fallback path) ---
|
||||
classes, display = dyn.get_dynamic_mappings()
|
||||
assert "FalAnyEndpoint_fal" in classes
|
||||
assert len(classes) == len(display)
|
||||
assert len(set(display.values())) == len(display), "display name collision"
|
||||
if not REAL_REGISTRY.is_file():
|
||||
assert len(classes) == 6, f"expected 5 fixture + any-endpoint, got {len(classes)}"
|
||||
|
||||
# --- any endpoint node ---
|
||||
any_cls = classes["FalAnyEndpoint_fal"]
|
||||
node = any_cls()
|
||||
any_cls.INPUT_TYPES()
|
||||
res = node.run(
|
||||
endpoint_id="fal-ai/flux/dev",
|
||||
arguments_json='{"prompt": "hi", "image_url": "should-be-overridden"}',
|
||||
image="TENSOR",
|
||||
image_2="TENSOR2",
|
||||
seed=7,
|
||||
)
|
||||
endpoint, sent = sys.modules[f"{PKG}.fal_utils"].ApiHandler.last_call
|
||||
assert sent["image_url"] == "https://stub.fal.media/image.png" # media wins
|
||||
assert sent["image_urls"] == [
|
||||
"https://stub.fal.media/image.png",
|
||||
"https://stub.fal.media/image.png",
|
||||
]
|
||||
assert sent["seed"] == 7 and sent["prompt"] == "hi"
|
||||
assert res[0] == "IMAGE_TENSOR" and json.loads(res[3])["seed"] == 1
|
||||
|
||||
print("[fixture] behaviour tests passed")
|
||||
|
||||
|
||||
def _dump_samples(models, factory):
|
||||
samples = [
|
||||
("flux", "text-to-image"),
|
||||
("kling", "image-to-video"),
|
||||
(None, "text-to-speech"),
|
||||
(None, "image-to-3d"),
|
||||
(None, "video-to-video"),
|
||||
]
|
||||
seen = set()
|
||||
for hint, category in samples:
|
||||
candidates = [
|
||||
m for m in models
|
||||
if m.get("category") == category and m["endpoint_id"] not in seen
|
||||
]
|
||||
model = next(
|
||||
(m for m in candidates if hint and hint in m["endpoint_id"]),
|
||||
candidates[0] if candidates else None,
|
||||
)
|
||||
if model is None:
|
||||
print(f"-- no sample for {hint or category}")
|
||||
continue
|
||||
seen.add(model["endpoint_id"])
|
||||
cls = factory.build_node_class(model)
|
||||
print(f"\n-- INPUT_TYPES for {model['endpoint_id']} "
|
||||
f"({model.get('output_kind')}):")
|
||||
print(json.dumps(cls.INPUT_TYPES(), indent=2, default=str)[:2500])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_install_package()
|
||||
stub = _install_stub_facade()
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
dyn = import_module(f"{PKG}.dynamic")
|
||||
factory = import_module(f"{PKG}.dynamic.factory")
|
||||
outputs = import_module(f"{PKG}.dynamic.outputs")
|
||||
|
||||
fixture_models = _load_models(PACKAGE_DIR / "_fixture_registry.json")
|
||||
built, _ = _check_registry(fixture_models, factory, outputs, "fixture")
|
||||
assert built == 5
|
||||
|
||||
_test_fixture_behaviour(dyn, stub)
|
||||
|
||||
if REAL_REGISTRY.is_file():
|
||||
real_models = _load_models(REAL_REGISTRY)
|
||||
built, skipped = _check_registry(real_models, factory, outputs, "real")
|
||||
classes, display = dyn.get_dynamic_mappings()
|
||||
print(f"[real] loader registered {len(classes)} nodes "
|
||||
f"(incl. any-endpoint), display names unique: "
|
||||
f"{len(set(display.values())) == len(display)}")
|
||||
_dump_samples(real_models, factory)
|
||||
else:
|
||||
print("[real] data/fal_registry.json not present; skipped real-registry checks")
|
||||
|
||||
print("\nSELFTEST OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Generic node that calls any fal.ai endpoint by id with free-form JSON arguments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..fal_utils import (
|
||||
ApiHandler,
|
||||
FalApiError,
|
||||
ImageUtils,
|
||||
MediaUtils,
|
||||
ResultProcessor,
|
||||
logger,
|
||||
)
|
||||
from .factory import stable_hash
|
||||
from .outputs import find_url
|
||||
|
||||
ANY_ENDPOINT_KEY = "FalAnyEndpoint_fal"
|
||||
ANY_ENDPOINT_DISPLAY_NAME = "Fal Any Endpoint (fal)"
|
||||
|
||||
|
||||
def _parse_arguments_json(endpoint_id: str, arguments_json: str) -> dict[str, Any]:
|
||||
text = (arguments_json or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError as err:
|
||||
raise FalApiError(endpoint_id, f"Invalid JSON in 'arguments_json': {err}") from err
|
||||
if not isinstance(parsed, dict):
|
||||
raise FalApiError(endpoint_id, "'arguments_json' must be a JSON object")
|
||||
return parsed
|
||||
|
||||
|
||||
def _media_overlay(
|
||||
image: Any, image_2: Any, video: Any, audio: Any, seed: int
|
||||
) -> dict[str, Any]:
|
||||
overlay: dict[str, Any] = {}
|
||||
if image is not None:
|
||||
first_url = ImageUtils.upload_image(image)
|
||||
overlay = {**overlay, "image_url": first_url}
|
||||
if image_2 is not None:
|
||||
second_url = ImageUtils.upload_image(image_2)
|
||||
overlay = {**overlay, "image_urls": [first_url, second_url]}
|
||||
if video is not None:
|
||||
overlay = {**overlay, "video_url": MediaUtils.upload_video(video)}
|
||||
if audio is not None:
|
||||
overlay = {**overlay, "audio_url": MediaUtils.upload_audio(audio)}
|
||||
if int(seed) != -1:
|
||||
overlay = {**overlay, "seed": int(seed)}
|
||||
return overlay
|
||||
|
||||
|
||||
def _extract_images(result: dict[str, Any]) -> Any | None:
|
||||
try:
|
||||
images = result.get("images")
|
||||
if isinstance(images, list) and images:
|
||||
return ResultProcessor.process_image_result(result)[0]
|
||||
if isinstance(result.get("image"), dict):
|
||||
return ResultProcessor.process_single_image_result(result)[0]
|
||||
except Exception as err:
|
||||
logger.debug("FalAnyEndpoint: could not extract images: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_video(result: dict[str, Any]) -> Any | None:
|
||||
try:
|
||||
url = find_url(result.get("video"))
|
||||
if url is not None:
|
||||
return MediaUtils.video_from_url(url)
|
||||
except Exception as err:
|
||||
logger.debug("FalAnyEndpoint: could not extract video: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_audio(result: dict[str, Any]) -> Any | None:
|
||||
try:
|
||||
url = find_url(result.get("audio"))
|
||||
if url is not None:
|
||||
return MediaUtils.audio_from_url(url)
|
||||
except Exception as err:
|
||||
logger.debug("FalAnyEndpoint: could not extract audio: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
class FalAnyEndpoint:
|
||||
"""Call any fal.ai endpoint with raw JSON arguments plus optional media inputs."""
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "VIDEO", "AUDIO", "STRING")
|
||||
RETURN_NAMES = ("images", "video", "audio", "result_json")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "FAL/Models"
|
||||
DESCRIPTION = (
|
||||
"Call any fal.ai endpoint by id. Provide arguments as a JSON object; "
|
||||
"connected media inputs are uploaded and override matching keys "
|
||||
"(image_url, image_urls, video_url, audio_url, seed) in the JSON. "
|
||||
"Outputs are extracted opportunistically; the raw result is always "
|
||||
"available as JSON."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"endpoint_id": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "fal-ai/flux/dev",
|
||||
"tooltip": "fal endpoint id, e.g. fal-ai/flux/dev",
|
||||
},
|
||||
),
|
||||
"arguments_json": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "{}",
|
||||
"multiline": True,
|
||||
"tooltip": (
|
||||
"JSON object of API arguments. Connected media inputs "
|
||||
"and seed override matching keys here."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE", {"tooltip": "Uploaded and sent as image_url"}),
|
||||
"image_2": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": (
|
||||
"Second image; when set together with 'image', both are "
|
||||
"also sent as image_urls [url1, url2]"
|
||||
)
|
||||
},
|
||||
),
|
||||
"video": ("VIDEO", {"tooltip": "Uploaded and sent as video_url"}),
|
||||
"audio": ("AUDIO", {"tooltip": "Uploaded and sent as audio_url"}),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"max": 2**31 - 1,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "-1 = omit seed; any other value is sent to the API",
|
||||
},
|
||||
),
|
||||
"force_rerun": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Bypass ComfyUI's cache and call the API again",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs: Any) -> Any:
|
||||
if kwargs.get("force_rerun"):
|
||||
return float("nan")
|
||||
return stable_hash(kwargs)
|
||||
|
||||
def run(
|
||||
self,
|
||||
endpoint_id: str,
|
||||
arguments_json: str = "{}",
|
||||
image: Any = None,
|
||||
image_2: Any = None,
|
||||
video: Any = None,
|
||||
audio: Any = None,
|
||||
seed: int = -1,
|
||||
force_rerun: bool = False,
|
||||
) -> tuple[Any, Any, Any, str]:
|
||||
endpoint = (endpoint_id or "").strip()
|
||||
if not endpoint:
|
||||
raise FalApiError("(any endpoint)", "endpoint_id is required")
|
||||
|
||||
parsed = _parse_arguments_json(endpoint, arguments_json)
|
||||
overlay = _media_overlay(image, image_2, video, audio, seed)
|
||||
arguments = {**parsed, **overlay}
|
||||
|
||||
result = ApiHandler.submit_and_get_result(endpoint, arguments)
|
||||
|
||||
return (
|
||||
_extract_images(result),
|
||||
_extract_video(result),
|
||||
_extract_audio(result),
|
||||
json.dumps(result, default=str),
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Pure translation of ComfyUI node kwargs back into fal API arguments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..fal_utils import FalApiError, ImageUtils, MediaUtils
|
||||
|
||||
_DEFAULT_DIMENSION = 1024
|
||||
|
||||
|
||||
def _upload_image(inp: dict[str, Any], value: Any) -> Any:
|
||||
if inp.get("is_list"):
|
||||
return ImageUtils.prepare_images(value)
|
||||
return ImageUtils.upload_image(value)
|
||||
|
||||
|
||||
def _media_argument(inp: dict[str, Any], value: Any) -> Any | None:
|
||||
media_kind = inp.get("media_kind")
|
||||
if media_kind == "image":
|
||||
return _upload_image(inp, value)
|
||||
if media_kind == "video":
|
||||
return MediaUtils.upload_video(value)
|
||||
if media_kind == "audio":
|
||||
return MediaUtils.upload_audio(value)
|
||||
# media_kind == "file": already a URL string in the widget
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _json_argument(endpoint: str, name: str, value: Any) -> Any | None:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError as err:
|
||||
raise FalApiError(endpoint, f"Invalid JSON in '{name}': {err}") from err
|
||||
|
||||
|
||||
def _multi_enum_argument(endpoint: str, inp: dict[str, Any], value: Any) -> Any | None:
|
||||
"""Comma-separated string widget → validated list of enum members."""
|
||||
selected = [part.strip() for part in str(value).split(",") if part.strip()]
|
||||
if not selected:
|
||||
return None
|
||||
allowed = set(inp.get("enum") or [])
|
||||
invalid = [part for part in selected if part not in allowed]
|
||||
if invalid:
|
||||
raise FalApiError(
|
||||
endpoint,
|
||||
f"Invalid value(s) {invalid} for '{inp['name']}'. "
|
||||
f"Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def _enum_argument(inp: dict[str, Any], value: Any, kwargs: dict[str, Any]) -> Any:
|
||||
if inp.get("has_custom_size") and value == "custom_size":
|
||||
return {
|
||||
"width": int(kwargs.get("width", _DEFAULT_DIMENSION)),
|
||||
"height": int(kwargs.get("height", _DEFAULT_DIMENSION)),
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _scalar_argument(
|
||||
endpoint: str, inp: dict[str, Any], value: Any, kwargs: dict[str, Any]
|
||||
) -> Any | None:
|
||||
input_type = inp.get("type")
|
||||
if input_type == "enum":
|
||||
if inp.get("is_list"):
|
||||
return _multi_enum_argument(endpoint, inp, value)
|
||||
return _enum_argument(inp, value, kwargs)
|
||||
if input_type in ("json", "object", "array"):
|
||||
return _json_argument(endpoint, inp["name"], value)
|
||||
if input_type == "integer":
|
||||
return int(value)
|
||||
if input_type == "number":
|
||||
return float(value)
|
||||
if input_type == "boolean":
|
||||
return bool(value)
|
||||
if input_type == "string":
|
||||
if not inp.get("required") and value == "":
|
||||
return None
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _argument_for(
|
||||
endpoint: str, inp: dict[str, Any], value: Any, kwargs: dict[str, Any]
|
||||
) -> Any | None:
|
||||
if inp.get("media_kind"):
|
||||
return _media_argument(inp, value)
|
||||
return _scalar_argument(endpoint, inp, value, kwargs)
|
||||
|
||||
|
||||
def build_arguments(model: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the fal API argument dict from node kwargs. Never mutates inputs."""
|
||||
endpoint = model["endpoint_id"]
|
||||
arguments: dict[str, Any] = {}
|
||||
|
||||
for inp in model.get("inputs", []):
|
||||
name = inp["name"]
|
||||
if name not in kwargs:
|
||||
continue
|
||||
value = kwargs[name]
|
||||
if value is None:
|
||||
continue
|
||||
if name == "seed":
|
||||
seed = int(value)
|
||||
if seed != -1:
|
||||
arguments = {**arguments, "seed": seed}
|
||||
continue
|
||||
resolved = _argument_for(endpoint, inp, value, kwargs)
|
||||
if resolved is None:
|
||||
continue
|
||||
arguments = {**arguments, name: resolved}
|
||||
|
||||
return arguments
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Builds concrete ComfyUI node classes from registry model entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ..fal_utils import ApiHandler
|
||||
from .arguments import build_arguments
|
||||
from .outputs import RETURN_SPECS, process_result
|
||||
from .schema_to_inputs import build_input_types
|
||||
|
||||
NODE_KEY_PREFIX = "FalAPI_"
|
||||
|
||||
|
||||
def node_key(model: dict[str, Any]) -> str:
|
||||
return NODE_KEY_PREFIX + model["endpoint_id"].replace("/", "-")
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", text.lower())
|
||||
|
||||
|
||||
def build_display_name(model: dict[str, Any]) -> str:
|
||||
endpoint_id = model["endpoint_id"]
|
||||
title = model.get("title") or endpoint_id
|
||||
parts = endpoint_id.split("/")
|
||||
remainder = "/".join(parts[1:]) if len(parts) > 1 else endpoint_id
|
||||
if not remainder or _slug(remainder) == _slug(title):
|
||||
return f"{title} (fal)"
|
||||
return f"{title} · {remainder} (fal)"
|
||||
|
||||
|
||||
def _value_fingerprint(value: Any) -> str:
|
||||
# torch tensors: repr() summarizes large tensors (edge elements only), so two
|
||||
# different images could hash identically — fingerprint the raw bytes instead
|
||||
detach = getattr(value, "detach", None)
|
||||
if callable(detach):
|
||||
try:
|
||||
tensor = value.detach().cpu().contiguous()
|
||||
digest = hashlib.sha256(tensor.numpy().tobytes()).hexdigest()
|
||||
return f"tensor:{tuple(tensor.shape)}:{tensor.dtype}:{digest}"
|
||||
except Exception: # non-numpy-compatible tensor; fall through to repr
|
||||
pass
|
||||
if isinstance(value, dict): # e.g. AUDIO dicts carrying a waveform tensor
|
||||
return repr(sorted((k, _value_fingerprint(v)) for k, v in value.items()))
|
||||
return repr(value)
|
||||
|
||||
|
||||
def stable_hash(kwargs: dict[str, Any]) -> str:
|
||||
payload = repr(sorted((key, _value_fingerprint(value)) for key, value in kwargs.items()))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
def _class_name(model: dict[str, Any]) -> str:
|
||||
return re.sub(r"[^0-9A-Za-z_]", "_", node_key(model))
|
||||
|
||||
|
||||
def _description(model: dict[str, Any]) -> str:
|
||||
description = model.get("description") or ""
|
||||
pricing = model.get("pricing") or ""
|
||||
if pricing:
|
||||
return f"{description}\n\nPricing: {pricing}".strip()
|
||||
return description
|
||||
|
||||
|
||||
def build_node_class(model: dict[str, Any]) -> type:
|
||||
"""Create a ComfyUI node class for a single registry model entry."""
|
||||
endpoint_id = model["endpoint_id"]
|
||||
kind = model.get("output_kind", "json")
|
||||
return_types, return_names = RETURN_SPECS.get(kind, RETURN_SPECS["json"])
|
||||
category = model.get("category") or "other"
|
||||
|
||||
def input_types(cls: type) -> dict[str, Any]:
|
||||
return build_input_types(model)
|
||||
|
||||
def is_changed(cls: type, **kwargs: Any) -> Any:
|
||||
if kwargs.get("force_rerun"):
|
||||
return float("nan")
|
||||
return stable_hash(kwargs)
|
||||
|
||||
def run(self: Any, **kwargs: Any) -> tuple:
|
||||
arguments = build_arguments(model, kwargs)
|
||||
result = ApiHandler.submit_and_get_result(endpoint_id, arguments)
|
||||
return process_result(model, result)
|
||||
|
||||
attrs = {
|
||||
"INPUT_TYPES": classmethod(input_types),
|
||||
"IS_CHANGED": classmethod(is_changed),
|
||||
"RETURN_TYPES": return_types,
|
||||
"RETURN_NAMES": return_names,
|
||||
"FUNCTION": "run",
|
||||
"CATEGORY": f"FAL/Models/{category}",
|
||||
"DESCRIPTION": _description(model),
|
||||
"run": run,
|
||||
"_FAL_ENDPOINT_ID": endpoint_id,
|
||||
}
|
||||
return type(_class_name(model), (object,), attrs)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Return-type specs per output kind and result post-processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from ..fal_utils import FalApiError, MediaUtils, ResultProcessor
|
||||
|
||||
RETURN_SPECS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
"images": (("IMAGE",), ("images",)),
|
||||
"image": (("IMAGE",), ("images",)),
|
||||
"video": (("VIDEO", "STRING"), ("video", "video_url")),
|
||||
"audio": (("AUDIO", "STRING"), ("audio", "audio_url")),
|
||||
"text": (("STRING",), ("text",)),
|
||||
"file": (("STRING",), ("file_url",)),
|
||||
"json": (("STRING",), ("json",)),
|
||||
}
|
||||
|
||||
_FILE_PROP_CANDIDATES = (
|
||||
"model_glb",
|
||||
"model_mesh",
|
||||
"model_url",
|
||||
"model_urls",
|
||||
"file",
|
||||
"file_url",
|
||||
"output",
|
||||
"outputs",
|
||||
)
|
||||
|
||||
|
||||
def find_url(value: Any) -> str | None:
|
||||
"""Recursively dig a result fragment for a URL string."""
|
||||
if isinstance(value, str):
|
||||
return value if value.startswith(("http://", "https://", "data:")) else None
|
||||
if isinstance(value, dict):
|
||||
direct = value.get("url")
|
||||
if isinstance(direct, str):
|
||||
return direct
|
||||
for nested in value.values():
|
||||
found = find_url(nested)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
found = find_url(item)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _url_from_props(result: dict[str, Any], props: Sequence[str]) -> str | None:
|
||||
for prop in props:
|
||||
if prop in result:
|
||||
found = find_url(result[prop])
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _media_url(model: dict[str, Any], result: dict[str, Any], primary: str) -> str:
|
||||
props: list[str] = [primary]
|
||||
for prop in model.get("output_props") or []:
|
||||
if prop not in props:
|
||||
props = [*props, prop]
|
||||
url = _url_from_props(result, props)
|
||||
if url is None:
|
||||
url = find_url(result)
|
||||
if url is None:
|
||||
raise FalApiError(
|
||||
model["endpoint_id"], f"No {primary} URL found in API result"
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _process_video(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
|
||||
url = _media_url(model, result, "video")
|
||||
return (MediaUtils.video_from_url(url), url)
|
||||
|
||||
|
||||
def _process_audio(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
|
||||
url = _media_url(model, result, "audio")
|
||||
return (MediaUtils.audio_from_url(url), url)
|
||||
|
||||
|
||||
def _process_text(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
|
||||
for prop in model.get("output_props") or []:
|
||||
value = result.get(prop)
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
for value in result.values():
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
return (json.dumps(result, default=str),)
|
||||
|
||||
|
||||
def _process_file(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
|
||||
props = [*(model.get("output_props") or []), *_FILE_PROP_CANDIDATES]
|
||||
url = _url_from_props(result, props)
|
||||
if url is None:
|
||||
url = find_url(result)
|
||||
if url is None:
|
||||
raise FalApiError(model["endpoint_id"], "No file URL found in API result")
|
||||
return (url,)
|
||||
|
||||
|
||||
def process_result(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
|
||||
"""Convert a raw fal API result dict into the node's return tuple."""
|
||||
kind = model.get("output_kind", "json")
|
||||
if kind == "images":
|
||||
return ResultProcessor.process_image_result(result)
|
||||
if kind == "image":
|
||||
return ResultProcessor.process_single_image_result(result)
|
||||
if kind == "video":
|
||||
return _process_video(model, result)
|
||||
if kind == "audio":
|
||||
return _process_audio(model, result)
|
||||
if kind == "text":
|
||||
return _process_text(model, result)
|
||||
if kind == "file":
|
||||
return _process_file(model, result)
|
||||
return (json.dumps(result, default=str),)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Loads the fal model registry and builds dynamic node mappings.
|
||||
|
||||
Must never raise: any failure results in empty (or partial) mappings so the
|
||||
static nodes keep loading no matter what.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..fal_utils import FalConfig, logger
|
||||
from .any_endpoint import ANY_ENDPOINT_DISPLAY_NAME, ANY_ENDPOINT_KEY, FalAnyEndpoint
|
||||
from .factory import build_display_name, build_node_class, node_key
|
||||
|
||||
_REGISTRY_FILENAME = "fal_registry.json"
|
||||
_FIXTURE_FILENAME = "_fixture_registry.json"
|
||||
|
||||
Mappings = tuple[dict[str, type], dict[str, str]]
|
||||
|
||||
|
||||
def _registry_path() -> Path:
|
||||
package_dir = Path(__file__).resolve().parent
|
||||
real = package_dir.parents[1] / "data" / _REGISTRY_FILENAME
|
||||
if real.is_file():
|
||||
return real
|
||||
return package_dir / _FIXTURE_FILENAME
|
||||
|
||||
|
||||
def _truthy(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _get_setting(section: str, name: str, default: Any) -> Any:
|
||||
config = FalConfig()
|
||||
getter = getattr(config, "get_setting", None)
|
||||
if getter is None:
|
||||
return default
|
||||
return getter(section, name, default)
|
||||
|
||||
|
||||
def _category_filter() -> set[str]:
|
||||
raw = _get_setting("dynamic_nodes", "categories", "") or ""
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
|
||||
def _read_models() -> list[dict[str, Any]]:
|
||||
path = _registry_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
registry = json.load(handle)
|
||||
models = registry.get("models", [])
|
||||
if not isinstance(models, list):
|
||||
raise ValueError("'models' is not a list")
|
||||
return models
|
||||
except Exception as err:
|
||||
logger.error("Failed to read fal registry at %s: %s", path, err)
|
||||
return []
|
||||
|
||||
|
||||
def _unique_display_name(name: str, used: set[str]) -> str:
|
||||
if name not in used:
|
||||
return name
|
||||
counter = 2
|
||||
while f"{name} #{counter}" in used:
|
||||
counter += 1
|
||||
return f"{name} #{counter}"
|
||||
|
||||
|
||||
def _build_model_mappings(
|
||||
models: list[dict[str, Any]], categories: set[str]
|
||||
) -> tuple[dict[str, type], dict[str, str], int]:
|
||||
classes: dict[str, type] = {}
|
||||
display: dict[str, str] = {}
|
||||
used_names: set[str] = {ANY_ENDPOINT_DISPLAY_NAME}
|
||||
skipped = 0
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
if categories and model.get("category") not in categories:
|
||||
continue
|
||||
key = node_key(model)
|
||||
if key in classes or key == ANY_ENDPOINT_KEY:
|
||||
skipped += 1
|
||||
logger.debug("Duplicate dynamic node key skipped: %s", key)
|
||||
continue
|
||||
node_class = build_node_class(model)
|
||||
name = _unique_display_name(build_display_name(model), used_names)
|
||||
classes = {**classes, key: node_class}
|
||||
display = {**display, key: name}
|
||||
used_names.add(name)
|
||||
except Exception as err:
|
||||
skipped += 1
|
||||
logger.debug(
|
||||
"Skipped dynamic node for %s: %s",
|
||||
model.get("endpoint_id", "<unknown>"),
|
||||
err,
|
||||
)
|
||||
|
||||
return classes, display, skipped
|
||||
|
||||
|
||||
def load_dynamic_mappings() -> Mappings:
|
||||
"""Build (NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS) for dynamic nodes."""
|
||||
try:
|
||||
if not _truthy(_get_setting("dynamic_nodes", "enabled", True)):
|
||||
logger.info("Dynamic fal nodes disabled via config")
|
||||
return {}, {}
|
||||
|
||||
categories = _category_filter()
|
||||
models = _read_models()
|
||||
classes, display, skipped = _build_model_mappings(models, categories)
|
||||
|
||||
all_classes = {ANY_ENDPOINT_KEY: FalAnyEndpoint, **classes}
|
||||
all_display = {ANY_ENDPOINT_KEY: ANY_ENDPOINT_DISPLAY_NAME, **display}
|
||||
|
||||
logger.info(
|
||||
"Registered %d dynamic fal nodes (skipped %d)", len(all_classes), skipped
|
||||
)
|
||||
return all_classes, all_display
|
||||
except Exception as err:
|
||||
logger.error("Dynamic fal node loading failed entirely: %s", err)
|
||||
return {}, {}
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Pure translation of a registry model schema into a ComfyUI INPUT_TYPES dict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
INT_MIN = -(2**31)
|
||||
INT_MAX = 2**31 - 1
|
||||
|
||||
SEED_SPEC = (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"max": INT_MAX,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "-1 = random (fal picks); any other value is sent to the API",
|
||||
},
|
||||
)
|
||||
|
||||
FORCE_RERUN_SPEC = (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Bypass ComfyUI's cache and call the API again"},
|
||||
)
|
||||
|
||||
WIDTH_HEIGHT_OPTS = {"default": 1024, "min": 64, "max": 14142, "step": 8}
|
||||
|
||||
_MEDIA_TYPES = {"image": "IMAGE", "video": "VIDEO", "audio": "AUDIO"}
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def _with_tooltip(opts: dict[str, Any], description: str | None) -> dict[str, Any]:
|
||||
if description:
|
||||
return {**opts, "tooltip": description}
|
||||
return opts
|
||||
|
||||
|
||||
def _int_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
lo = int(inp["min"]) if inp.get("min") is not None else INT_MIN
|
||||
hi = int(inp["max"]) if inp.get("max") is not None else INT_MAX
|
||||
raw_default = inp.get("default")
|
||||
default = int(raw_default) if isinstance(raw_default, (int, float)) else 0
|
||||
opts = {"default": int(_clamp(default, lo, hi)), "min": lo, "max": hi}
|
||||
return ("INT", _with_tooltip(opts, inp.get("description")))
|
||||
|
||||
|
||||
def _float_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
has_range = inp.get("min") is not None and inp.get("max") is not None
|
||||
lo = float(inp["min"]) if inp.get("min") is not None else -1e10
|
||||
hi = float(inp["max"]) if inp.get("max") is not None else 1e10
|
||||
step = 0.01 if has_range and (hi - lo) <= 10 else 0.1
|
||||
raw_default = inp.get("default")
|
||||
default = float(raw_default) if isinstance(raw_default, (int, float)) else 0.0
|
||||
opts = {"default": _clamp(default, lo, hi), "min": lo, "max": hi, "step": step}
|
||||
return ("FLOAT", _with_tooltip(opts, inp.get("description")))
|
||||
|
||||
|
||||
def _multi_enum_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
"""Array-of-enum inputs: ComfyUI has no multi-select widget, so use a
|
||||
comma-separated string validated at call time."""
|
||||
values = list(inp.get("enum") or [])
|
||||
default = inp.get("default")
|
||||
text = ", ".join(str(v) for v in default) if isinstance(default, list) else ""
|
||||
description = (inp.get("description") or "").strip()
|
||||
tooltip = f"{description} Comma-separated. Options: {', '.join(values)}".strip()
|
||||
return ("STRING", {"default": text, "tooltip": tooltip})
|
||||
|
||||
|
||||
def _enum_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
if inp.get("is_list"):
|
||||
return _multi_enum_spec(inp)
|
||||
values = list(inp.get("enum") or [])
|
||||
if not values:
|
||||
return _string_spec(inp)
|
||||
default = inp.get("default")
|
||||
if default not in values:
|
||||
# a dict default on a has_custom_size enum means the API defaults to an
|
||||
# explicit {width, height}; represent that as the custom_size preset
|
||||
if isinstance(default, dict) and "custom_size" in values:
|
||||
default = "custom_size"
|
||||
else:
|
||||
default = values[0]
|
||||
opts = _with_tooltip({"default": default}, inp.get("description"))
|
||||
return (values, opts)
|
||||
|
||||
|
||||
def _custom_size_default(inp: dict[str, Any], dimension: str) -> int:
|
||||
default = inp.get("default")
|
||||
if isinstance(default, dict):
|
||||
value = default.get(dimension)
|
||||
if isinstance(value, int) and value > 0:
|
||||
return int(_clamp(value, 64, 14142))
|
||||
return WIDTH_HEIGHT_OPTS["default"]
|
||||
|
||||
|
||||
def _bool_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
opts = {"default": bool(inp.get("default"))}
|
||||
return ("BOOLEAN", _with_tooltip(opts, inp.get("description")))
|
||||
|
||||
|
||||
def _string_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
default = inp.get("default")
|
||||
opts = {
|
||||
"default": default if isinstance(default, str) else "",
|
||||
"multiline": bool(inp.get("multiline")),
|
||||
}
|
||||
return ("STRING", _with_tooltip(opts, inp.get("description")))
|
||||
|
||||
|
||||
def _json_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
default = inp.get("default")
|
||||
if default is None:
|
||||
text = ""
|
||||
elif isinstance(default, str):
|
||||
text = default
|
||||
else:
|
||||
text = json.dumps(default)
|
||||
description = (inp.get("description") or "").strip()
|
||||
tooltip = (description + " (JSON)").strip()
|
||||
return ("STRING", {"default": text, "multiline": True, "tooltip": tooltip})
|
||||
|
||||
|
||||
def _media_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
media_kind = inp.get("media_kind")
|
||||
comfy_type = _MEDIA_TYPES.get(media_kind)
|
||||
if comfy_type is not None:
|
||||
return (comfy_type,)
|
||||
# media_kind == "file": plain URL string
|
||||
description = (inp.get("description") or "").strip()
|
||||
tooltip = (description + " (URL to file)").strip()
|
||||
return ("STRING", {"default": "", "tooltip": tooltip})
|
||||
|
||||
|
||||
def _input_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
if inp.get("media_kind"):
|
||||
return _media_spec(inp)
|
||||
input_type = inp.get("type")
|
||||
if input_type == "enum":
|
||||
return _enum_spec(inp)
|
||||
if input_type == "integer":
|
||||
return _int_spec(inp)
|
||||
if input_type == "number":
|
||||
return _float_spec(inp)
|
||||
if input_type == "boolean":
|
||||
return _bool_spec(inp)
|
||||
if input_type in ("json", "object", "array"):
|
||||
return _json_spec(inp)
|
||||
return _string_spec(inp)
|
||||
|
||||
|
||||
def build_input_types(model: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build a ComfyUI INPUT_TYPES dict from a registry model entry."""
|
||||
required: dict[str, Any] = {}
|
||||
optional: dict[str, Any] = {}
|
||||
custom_size_input: dict[str, Any] | None = None
|
||||
|
||||
for inp in model.get("inputs", []):
|
||||
name = inp["name"]
|
||||
if name == "seed":
|
||||
optional[name] = SEED_SPEC
|
||||
continue
|
||||
if inp.get("has_custom_size") and custom_size_input is None:
|
||||
custom_size_input = inp
|
||||
spec = _input_spec(inp)
|
||||
if inp.get("required"):
|
||||
required[name] = spec
|
||||
else:
|
||||
optional[name] = spec
|
||||
|
||||
if custom_size_input is not None:
|
||||
for dimension in ("width", "height"):
|
||||
if dimension not in required and dimension not in optional:
|
||||
opts = {
|
||||
**WIDTH_HEIGHT_OPTS,
|
||||
"default": _custom_size_default(custom_size_input, dimension),
|
||||
}
|
||||
optional[dimension] = ("INT", opts)
|
||||
|
||||
optional["force_rerun"] = FORCE_RERUN_SPEC
|
||||
|
||||
return {"required": required, "optional": optional}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Backward-compatible facade for the nodes.utils package.
|
||||
|
||||
Existing node modules import from here, e.g.:
|
||||
|
||||
from .fal_utils import FalConfig, ImageUtils, ResultProcessor, ApiHandler
|
||||
|
||||
The implementations now live in the ``nodes/utils`` package.
|
||||
"""
|
||||
|
||||
from .utils import (
|
||||
ApiHandler,
|
||||
FalApiError,
|
||||
FalConfig,
|
||||
ImageUtils,
|
||||
MediaUtils,
|
||||
ResultProcessor,
|
||||
logger,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ApiHandler",
|
||||
"FalApiError",
|
||||
"FalConfig",
|
||||
"ImageUtils",
|
||||
"MediaUtils",
|
||||
"ResultProcessor",
|
||||
"logger",
|
||||
]
|
||||
Regular → Executable
+2187
-335
File diff suppressed because it is too large
Load Diff
+105
-37
@@ -1,56 +1,124 @@
|
||||
import os
|
||||
import configparser
|
||||
from fal_client.client import SyncClient
|
||||
from .fal_utils import ApiHandler, FalConfig
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
# Initialize FalConfig
|
||||
fal_config = FalConfig()
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
try:
|
||||
fal_key = config['API']['FAL_KEY']
|
||||
os.environ["FAL_KEY"] = fal_key
|
||||
except KeyError:
|
||||
print("Error: FAL_KEY not found in config.ini")
|
||||
|
||||
# Create the client with API key
|
||||
fal_client = SyncClient(key=fal_key)
|
||||
|
||||
class LLMNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"model": (["google/gemini-flash-1.5-8b", "anthropic/claude-3.5-sonnet", "anthropic/claude-3-haiku",
|
||||
"google/gemini-pro-1.5", "google/gemini-flash-1.5", "meta-llama/llama-3.2-1b-instruct",
|
||||
"meta-llama/llama-3.2-3b-instruct", "meta-llama/llama-3.1-8b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct", "openai/gpt-4o-mini", "openai/gpt-4o"],
|
||||
{"default": "google/gemini-flash-1.5-8b"}),
|
||||
"system_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "User prompt sent to the model.",
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
[
|
||||
"google/gemini-2.5-flash",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-oss-120b",
|
||||
"meta-llama/llama-4-maverick",
|
||||
"Custom",
|
||||
],
|
||||
{
|
||||
"default": "google/gemini-2.5-flash",
|
||||
"tooltip": "Model to use. Select 'Custom' to type any OpenRouter model id in custom_model_name.",
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Optional system prompt to steer the model's behavior.",
|
||||
},
|
||||
),
|
||||
"temperature": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "Sampling temperature. Lower is more deterministic.",
|
||||
},
|
||||
),
|
||||
"reasoning": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Request the model's reasoning trace (returned on the 'reasoning' output).",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"max_tokens": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"tooltip": "Maximum output tokens. 0 uses the model default.",
|
||||
},
|
||||
),
|
||||
"custom_model_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "OpenRouter model id used when model is set to 'Custom'.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_TYPES = ("STRING", "STRING",)
|
||||
RETURN_NAMES = ("output", "reasoning",)
|
||||
FUNCTION = "generate_text"
|
||||
CATEGORY = "FAL/LLM"
|
||||
|
||||
def generate_text(self, prompt, model, system_prompt):
|
||||
arguments = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
|
||||
def generate_text(self, prompt, model, system_prompt, temperature, reasoning, max_tokens=0, custom_model_name=""):
|
||||
try:
|
||||
handler = fal_client.submit("fal-ai/any-llm", arguments=arguments)
|
||||
result = handler.get()
|
||||
return (result["output"],)
|
||||
# Handle custom model selection
|
||||
if model == "Custom":
|
||||
if not custom_model_name or custom_model_name.strip() == "":
|
||||
# Raises a clear FalApiError
|
||||
ApiHandler.handle_text_generation_error(
|
||||
"Custom", "Custom model name is required when 'Custom' is selected"
|
||||
)
|
||||
model = custom_model_name.strip()
|
||||
|
||||
arguments = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system_prompt": system_prompt,
|
||||
"temperature": temperature,
|
||||
"reasoning": reasoning,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
# Only include max_tokens if it's greater than 0
|
||||
if max_tokens > 0:
|
||||
arguments["max_tokens"] = max_tokens
|
||||
|
||||
result = ApiHandler.submit_and_get_result("openrouter/router", arguments)
|
||||
|
||||
# Extract output and reasoning
|
||||
output_text = result.get("output", "")
|
||||
reasoning_text = result.get("reasoning", "")
|
||||
|
||||
return (output_text, reasoning_text)
|
||||
except Exception as e:
|
||||
print(f"Error generating text with LLM: {str(e)}")
|
||||
return ("Error: Unable to generate text.",)
|
||||
# Raises a clear FalApiError (passes an existing FalApiError through
|
||||
# unchanged, so the custom-model validation error is not re-wrapped)
|
||||
return ApiHandler.handle_text_generation_error(model, e)
|
||||
|
||||
|
||||
# Node class mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
|
||||
+489
-84
@@ -1,84 +1,143 @@
|
||||
import os
|
||||
import configparser
|
||||
from fal_client.client import SyncClient
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
from .fal_utils import ApiHandler, FalConfig, ImageUtils
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
# Initialize FalConfig
|
||||
fal_config = FalConfig()
|
||||
|
||||
try:
|
||||
fal_key = config['API']['FAL_KEY']
|
||||
os.environ["FAL_KEY"] = fal_key
|
||||
except KeyError:
|
||||
print("Error: FAL_KEY not found in config.ini")
|
||||
|
||||
# Create the client with API key
|
||||
fal_client = SyncClient(key=fal_key)
|
||||
|
||||
def create_zip_from_images(images):
|
||||
"""Create a zip file from a list of images."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as temp_zip:
|
||||
with zipfile.ZipFile(temp_zip, 'w') as zf:
|
||||
for idx, img_tensor in enumerate(images):
|
||||
# Convert tensor to PIL Image
|
||||
if isinstance(img_tensor, torch.Tensor):
|
||||
# Convert to numpy and scale to 0-255 range
|
||||
img_np = (img_tensor.cpu().numpy() * 255).astype('uint8')
|
||||
# Handle different tensor formats
|
||||
if img_np.shape[0] == 3: # If in format (C, H, W)
|
||||
img_np = img_np.transpose(1, 2, 0)
|
||||
img = Image.fromarray(img_np)
|
||||
else:
|
||||
img = img_tensor
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
|
||||
with zipfile.ZipFile(temp_zip, "w") as zf:
|
||||
for idx, img_tensor in enumerate(images):
|
||||
# Convert tensor to PIL Image
|
||||
if isinstance(img_tensor, torch.Tensor):
|
||||
# Convert to numpy and scale to 0-255 range
|
||||
img_np = (img_tensor.cpu().numpy() * 255).astype("uint8")
|
||||
# Handle different tensor formats
|
||||
if img_np.shape[0] == 3: # If in format (C, H, W)
|
||||
img_np = img_np.transpose(1, 2, 0)
|
||||
img = Image.fromarray(img_np)
|
||||
else:
|
||||
img = img_tensor
|
||||
|
||||
# Save image to temporary file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".png", delete=False
|
||||
) as temp_img:
|
||||
img.save(temp_img, format="PNG")
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
# Add to zip file
|
||||
zf.write(temp_img_path, f"image_{idx}.png")
|
||||
os.unlink(temp_img_path)
|
||||
|
||||
# Upload the zip through the shared utility (raises on failure)
|
||||
return ImageUtils.upload_file(temp_zip.name)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"flux-lora-fast-training", f"Failed to create zip file: {str(e)}"
|
||||
)
|
||||
|
||||
# Save image to temporary file
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_img:
|
||||
img.save(temp_img, format='PNG')
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
# Add to zip file
|
||||
zf.write(temp_img_path, f'image_{idx}.png')
|
||||
os.unlink(temp_img_path)
|
||||
|
||||
return fal_client.upload_file(temp_zip.name)
|
||||
|
||||
class FluxLoraTrainerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"steps": ("INT", {"default": 1000, "min": 100, "max": 10000, "step": 100}),
|
||||
"create_masks": ("BOOLEAN", {"default": True}),
|
||||
"is_style": ("BOOLEAN", {"default": False}),
|
||||
"images": (
|
||||
"IMAGE",
|
||||
{"tooltip": "Training images. Ignored when images_zip_url is set."},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1000,
|
||||
"min": 100,
|
||||
"max": 10000,
|
||||
"step": 100,
|
||||
"tooltip": "Number of training steps.",
|
||||
},
|
||||
),
|
||||
"create_masks": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Automatically create segmentation masks for subject training.",
|
||||
},
|
||||
),
|
||||
"is_style": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Enable for style LoRAs instead of subject LoRAs.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_word": ("STRING", {"default": ""}),
|
||||
"images_zip_url": ("STRING", {"default": ""}),
|
||||
"is_input_format_already_preprocessed": ("BOOLEAN", {"default": False}),
|
||||
"data_archive_format": ("STRING", {"default": ""}),
|
||||
}
|
||||
"trigger_word": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Token used to invoke the trained concept in prompts.",
|
||||
},
|
||||
),
|
||||
"images_zip_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of a pre-uploaded zip of training images. Overrides the IMAGE input.",
|
||||
},
|
||||
),
|
||||
"is_input_format_already_preprocessed": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Set when the archive already contains preprocessed data (images + captions).",
|
||||
},
|
||||
),
|
||||
"data_archive_format": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Archive format hint (e.g. 'zip') when it cannot be inferred from the URL.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("lora_file_url",)
|
||||
FUNCTION = "train_lora"
|
||||
CATEGORY = "FAL/Training"
|
||||
|
||||
def train_lora(self, images, steps, create_masks, is_style, trigger_word="", images_zip_url="",
|
||||
is_input_format_already_preprocessed=False, data_archive_format=""):
|
||||
def train_lora(
|
||||
self,
|
||||
images,
|
||||
steps,
|
||||
create_masks,
|
||||
is_style,
|
||||
trigger_word="",
|
||||
images_zip_url="",
|
||||
is_input_format_already_preprocessed=False,
|
||||
data_archive_format="",
|
||||
):
|
||||
try:
|
||||
# Use provided zip URL if available, otherwise create and upload zip file
|
||||
images_url = images_zip_url if images_zip_url else create_zip_from_images(images)
|
||||
images_url = (
|
||||
images_zip_url if images_zip_url else create_zip_from_images(images)
|
||||
)
|
||||
if not images_url:
|
||||
return ("Error: Unable to upload images.", "")
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"flux-lora-fast-training", "Failed to upload images"
|
||||
)
|
||||
|
||||
# Prepare arguments for the API
|
||||
arguments = {
|
||||
@@ -88,89 +147,435 @@ class FluxLoraTrainerNode:
|
||||
"is_style": is_style,
|
||||
"is_input_format_already_preprocessed": is_input_format_already_preprocessed,
|
||||
}
|
||||
|
||||
|
||||
if trigger_word:
|
||||
arguments["trigger_word"] = trigger_word
|
||||
|
||||
|
||||
if data_archive_format:
|
||||
arguments["data_archive_format"] = data_archive_format
|
||||
|
||||
# Submit training job
|
||||
handler = fal_client.submit("fal-ai/flux-lora-fast-training", arguments=arguments)
|
||||
result = handler.get()
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/flux-lora-fast-training", arguments
|
||||
)
|
||||
lora_url = result["diffusers_lora_file"]["url"]
|
||||
|
||||
return (lora_url, )
|
||||
return (lora_url,)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during LoRA training: {str(e)}")
|
||||
return ("Error: Training failed.", "")
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"flux-lora-fast-training", e
|
||||
)
|
||||
|
||||
|
||||
class HunyuanVideoLoraTrainerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"steps": ("INT", {"default": 1000, "min": 100, "max": 10000, "step": 100}),
|
||||
"images": (
|
||||
"IMAGE",
|
||||
{"tooltip": "Training images. Ignored when images_zip_url is set."},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1000,
|
||||
"min": 100,
|
||||
"max": 10000,
|
||||
"step": 100,
|
||||
"tooltip": "Number of training steps.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_word": ("STRING", {"default": ""}),
|
||||
"learning_rate": ("FLOAT", {"default": 0.0001, "min": 0.00001, "max": 0.01}),
|
||||
"do_caption": ("BOOLEAN", {"default": True}),
|
||||
"images_zip_url": ("STRING", {"default": ""}),
|
||||
"data_archive_format": ("STRING", {"default": ""}),
|
||||
}
|
||||
"trigger_word": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Token used to invoke the trained concept in prompts.",
|
||||
},
|
||||
),
|
||||
"learning_rate": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0001,
|
||||
"min": 0.00001,
|
||||
"max": 0.01,
|
||||
"tooltip": "Training learning rate.",
|
||||
},
|
||||
),
|
||||
"do_caption": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Automatically caption the training images.",
|
||||
},
|
||||
),
|
||||
"images_zip_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of a pre-uploaded zip of training images. Overrides the IMAGE input.",
|
||||
},
|
||||
),
|
||||
"data_archive_format": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Archive format hint (e.g. 'zip') when it cannot be inferred from the URL.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("lora_file_url",)
|
||||
FUNCTION = "train_lora"
|
||||
CATEGORY = "FAL/Training"
|
||||
|
||||
def train_lora(self, images, steps, trigger_word="", learning_rate=0.0001, do_caption=True,
|
||||
images_zip_url="", data_archive_format=""):
|
||||
def train_lora(
|
||||
self,
|
||||
images,
|
||||
steps,
|
||||
trigger_word="",
|
||||
learning_rate=0.0001,
|
||||
do_caption=True,
|
||||
images_zip_url="",
|
||||
data_archive_format="",
|
||||
):
|
||||
try:
|
||||
# Use provided zip URL if available, otherwise create and upload zip file
|
||||
images_url = images_zip_url if images_zip_url else create_zip_from_images(images)
|
||||
images_url = (
|
||||
images_zip_url if images_zip_url else create_zip_from_images(images)
|
||||
)
|
||||
if not images_url:
|
||||
return ("Error: Unable to upload images.", "")
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"hunyuan-video-lora-training", "Failed to upload images"
|
||||
)
|
||||
|
||||
# Prepare arguments for the API
|
||||
arguments = {
|
||||
"images_data_url": images_url,
|
||||
"steps": steps,
|
||||
"learning_rate": learning_rate,
|
||||
"do_caption": do_caption
|
||||
"do_caption": do_caption,
|
||||
}
|
||||
|
||||
|
||||
if trigger_word:
|
||||
arguments["trigger_word"] = trigger_word
|
||||
|
||||
|
||||
if data_archive_format:
|
||||
arguments["data_archive_format"] = data_archive_format
|
||||
|
||||
# Submit training job
|
||||
handler = fal_client.submit("fal-ai/hunyuan-video-lora-training", arguments=arguments)
|
||||
result = handler.get()
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/hunyuan-video-lora-training", arguments
|
||||
)
|
||||
lora_url = result["diffusers_lora_file"]["url"]
|
||||
|
||||
return (lora_url,)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during LoRA training: {str(e)}")
|
||||
return ("Error: Training failed.", "")
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"hunyuan-video-lora-training", e
|
||||
)
|
||||
|
||||
|
||||
class WanLoraTrainerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"training_data_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of the training data archive (images/videos with optional captions).",
|
||||
},
|
||||
),
|
||||
"number_of_steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 400,
|
||||
"min": 5,
|
||||
"max": 10000,
|
||||
"step": 1,
|
||||
"tooltip": "Number of training steps.",
|
||||
},
|
||||
),
|
||||
"learning_rate": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0002,
|
||||
"min": 0.00001,
|
||||
"max": 0.01,
|
||||
"tooltip": "Training learning rate.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_phrase": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Phrase used to invoke the trained concept in prompts.",
|
||||
},
|
||||
),
|
||||
"auto_scale_input": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Automatically rescale input media to the training resolution.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("lora_file_url",)
|
||||
FUNCTION = "train_lora"
|
||||
CATEGORY = "FAL/Training"
|
||||
|
||||
def train_lora(
|
||||
self,
|
||||
training_data_url,
|
||||
number_of_steps,
|
||||
learning_rate,
|
||||
trigger_phrase="",
|
||||
auto_scale_input=True,
|
||||
):
|
||||
try:
|
||||
if not training_data_url:
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"wan-trainer", "No training data URL provided"
|
||||
)
|
||||
|
||||
# Prepare arguments for the API
|
||||
arguments = {
|
||||
"training_data_url": training_data_url,
|
||||
"number_of_steps": number_of_steps,
|
||||
"learning_rate": learning_rate,
|
||||
"auto_scale_input": auto_scale_input,
|
||||
}
|
||||
|
||||
if trigger_phrase:
|
||||
arguments["trigger_phrase"] = trigger_phrase
|
||||
|
||||
# Submit training job
|
||||
result = ApiHandler.submit_and_get_result("fal-ai/wan-trainer", arguments)
|
||||
lora_url = result["lora_file"]["url"]
|
||||
return (lora_url,)
|
||||
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_text_generation_error("wan-trainer", e)
|
||||
|
||||
|
||||
class LtxVideoTrainerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"training_data_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of the training data archive (videos/images with optional captions).",
|
||||
},
|
||||
),
|
||||
"rank": (
|
||||
["8", "16", "32", "64", "128"],
|
||||
{
|
||||
"default": "128",
|
||||
"tooltip": "LoRA rank. Higher rank captures more detail but produces larger files.",
|
||||
},
|
||||
),
|
||||
"number_of_steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1000,
|
||||
"min": 100,
|
||||
"max": 10000,
|
||||
"step": 1,
|
||||
"tooltip": "Number of training steps.",
|
||||
},
|
||||
),
|
||||
"number_of_frames": (
|
||||
"INT",
|
||||
{
|
||||
"default": 81,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Frames per training sample.",
|
||||
},
|
||||
),
|
||||
"frame_rate": (
|
||||
"INT",
|
||||
{
|
||||
"default": 25,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"tooltip": "Frame rate used for training samples.",
|
||||
},
|
||||
),
|
||||
"resolution": (
|
||||
["low", "medium", "high"],
|
||||
{"default": "medium", "tooltip": "Training resolution."},
|
||||
),
|
||||
"aspect_ratio": (
|
||||
["16:9", "1:1", "9:16"],
|
||||
{"default": "1:1", "tooltip": "Training aspect ratio."},
|
||||
),
|
||||
"learning_rate": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0002,
|
||||
"min": 0.00001,
|
||||
"max": 0.01,
|
||||
"tooltip": "Training learning rate.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_phrase": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Phrase used to invoke the trained concept in prompts.",
|
||||
},
|
||||
),
|
||||
"auto_scale_input": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Automatically rescale input media to the training resolution.",
|
||||
},
|
||||
),
|
||||
"split_input_into_scenes": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Split long input videos into individual scenes before training.",
|
||||
},
|
||||
),
|
||||
"split_input_duration_threshold": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 30.0,
|
||||
"min": 1.0,
|
||||
"max": 300.0,
|
||||
"tooltip": "Videos longer than this many seconds are split into scenes.",
|
||||
},
|
||||
),
|
||||
"validation_negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "blurry, low quality, bad quality, out of focus",
|
||||
"tooltip": "Negative prompt used for validation renders during training.",
|
||||
},
|
||||
),
|
||||
"validation_number_of_frames": (
|
||||
"INT",
|
||||
{
|
||||
"default": 81,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Frames per validation render.",
|
||||
},
|
||||
),
|
||||
"validation_resolution": (
|
||||
["low", "medium", "high"],
|
||||
{"default": "high", "tooltip": "Resolution of validation renders."},
|
||||
),
|
||||
"validation_aspect_ratio": (
|
||||
["16:9", "1:1", "9:16"],
|
||||
{"default": "1:1", "tooltip": "Aspect ratio of validation renders."},
|
||||
),
|
||||
"validation_reverse": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Also render reversed validation videos.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("lora_file_url",)
|
||||
FUNCTION = "train_lora"
|
||||
CATEGORY = "FAL/Training"
|
||||
|
||||
def train_lora(
|
||||
self,
|
||||
training_data_url,
|
||||
rank,
|
||||
number_of_steps,
|
||||
number_of_frames,
|
||||
frame_rate,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
learning_rate,
|
||||
trigger_phrase="",
|
||||
auto_scale_input=False,
|
||||
split_input_into_scenes=True,
|
||||
split_input_duration_threshold=30.0,
|
||||
validation_negative_prompt="blurry, low quality, bad quality, out of focus",
|
||||
validation_number_of_frames=81,
|
||||
validation_resolution="high",
|
||||
validation_aspect_ratio="1:1",
|
||||
validation_reverse=False,
|
||||
):
|
||||
try:
|
||||
if not training_data_url:
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"ltx-video-trainer", "No training data URL provided"
|
||||
)
|
||||
|
||||
# Prepare arguments for the API
|
||||
arguments = {
|
||||
"training_data_url": training_data_url,
|
||||
"rank": int(rank),
|
||||
"number_of_steps": number_of_steps,
|
||||
"number_of_frames": number_of_frames,
|
||||
"frame_rate": frame_rate,
|
||||
"resolution": resolution,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"learning_rate": learning_rate,
|
||||
"auto_scale_input": auto_scale_input,
|
||||
"split_input_into_scenes": split_input_into_scenes,
|
||||
"split_input_duration_threshold": split_input_duration_threshold,
|
||||
"validation_negative_prompt": validation_negative_prompt,
|
||||
"validation_number_of_frames": validation_number_of_frames,
|
||||
"validation_resolution": validation_resolution,
|
||||
"validation_aspect_ratio": validation_aspect_ratio,
|
||||
"validation_reverse": validation_reverse,
|
||||
}
|
||||
|
||||
if trigger_phrase:
|
||||
arguments["trigger_phrase"] = trigger_phrase
|
||||
|
||||
# Submit training job
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/ltx-video-trainer", arguments
|
||||
)
|
||||
lora_url = result["lora_file"]["url"]
|
||||
return (lora_url,)
|
||||
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_text_generation_error("ltx-video-trainer", e)
|
||||
|
||||
|
||||
# Node class mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FluxLoraTrainer_fal": FluxLoraTrainerNode,
|
||||
"HunyuanVideoLoraTrainer_fal": HunyuanVideoLoraTrainerNode,
|
||||
"WanLoraTrainer_fal": WanLoraTrainerNode,
|
||||
"LtxVideoTrainer_fal": LtxVideoTrainerNode,
|
||||
}
|
||||
|
||||
# Node display name mappings
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FluxLoraTrainer_fal": "Flux LoRA Trainer (fal)",
|
||||
"HunyuanVideoLoraTrainer_fal": "Hunyuan Video LoRA Trainer (fal)",
|
||||
}
|
||||
"WanLoraTrainer_fal": "WAN LoRA Trainer (fal)",
|
||||
"LtxVideoTrainer_fal": "LTX Video LoRA Trainer (fal)",
|
||||
}
|
||||
|
||||
+486
-107
@@ -1,142 +1,521 @@
|
||||
import os
|
||||
import configparser
|
||||
import tempfile
|
||||
import requests
|
||||
from PIL import Image
|
||||
import io
|
||||
import numpy as np
|
||||
import torch
|
||||
from fal_client.client import SyncClient
|
||||
from .fal_utils import ApiHandler, FalConfig, ImageUtils, ResultProcessor
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
# Initialize FalConfig
|
||||
fal_config = FalConfig()
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
try:
|
||||
fal_key = config['API']['FAL_KEY']
|
||||
os.environ["FAL_KEY"] = fal_key
|
||||
except KeyError:
|
||||
print("Error: FAL_KEY not found in config.ini")
|
||||
|
||||
# Create the client with API key
|
||||
fal_client = SyncClient(key=fal_key)
|
||||
|
||||
def upload_image(image):
|
||||
try:
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.cpu().numpy()
|
||||
else:
|
||||
image_np = np.array(image)
|
||||
|
||||
if image_np.ndim == 4:
|
||||
image_np = image_np.squeeze(0)
|
||||
if image_np.ndim == 2:
|
||||
image_np = np.stack([image_np] * 3, axis=-1)
|
||||
elif image_np.shape[0] == 3:
|
||||
image_np = np.transpose(image_np, (1, 2, 0))
|
||||
|
||||
if image_np.dtype == np.float32 or image_np.dtype == np.float64:
|
||||
image_np = (image_np * 255).astype(np.uint8)
|
||||
|
||||
pil_image = Image.fromarray(image_np)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
pil_image.save(temp_file, format="PNG")
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
image_url = fal_client.upload_file(temp_file_path)
|
||||
return image_url
|
||||
except Exception as e:
|
||||
print(f"Error uploading image: {str(e)}")
|
||||
return None
|
||||
finally:
|
||||
if 'temp_file_path' in locals():
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
class UpscalerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"upscale_factor": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.5}),
|
||||
"negative_prompt": ("STRING", {"default": "(worst quality, low quality, normal quality:2)", "multiline": True}),
|
||||
"creativity": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"resemblance": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"guidance_scale": ("FLOAT", {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.5}),
|
||||
"num_inference_steps": ("INT", {"default": 18, "min": 1, "max": 100}),
|
||||
"enable_safety_checker": ("BOOLEAN", {"default": True}),
|
||||
"image": ("IMAGE", {"tooltip": "Input image to upscale."}),
|
||||
"upscale_factor": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 2.0,
|
||||
"min": 1.0,
|
||||
"max": 4.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "How much to enlarge the image (1x-4x).",
|
||||
},
|
||||
),
|
||||
"negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "(worst quality, low quality, normal quality:2)",
|
||||
"multiline": True,
|
||||
"tooltip": "Concepts to avoid during the creative upscale.",
|
||||
},
|
||||
),
|
||||
"creativity": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.35,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
"tooltip": "Higher values allow the model to invent more detail.",
|
||||
},
|
||||
),
|
||||
"resemblance": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.6,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
"tooltip": "Higher values keep the result closer to the input image.",
|
||||
},
|
||||
),
|
||||
"guidance_scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 4.0,
|
||||
"min": 1.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "Classifier-free guidance scale for the diffusion pass.",
|
||||
},
|
||||
),
|
||||
"num_inference_steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 18,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"tooltip": "Number of diffusion steps; more steps is slower but can add detail.",
|
||||
},
|
||||
),
|
||||
"enable_safety_checker": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Filter potentially unsafe output images.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"seed": ("INT", {"default": -1}),
|
||||
}
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"tooltip": "Random seed for reproducibility. -1 uses a random seed.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_upscaled_image"
|
||||
CATEGORY = "FAL/Image"
|
||||
|
||||
def generate_upscaled_image(self, image, upscale_factor, negative_prompt, creativity, resemblance, guidance_scale, num_inference_steps, enable_safety_checker, seed=-1):
|
||||
image_url = upload_image(image)
|
||||
if not image_url:
|
||||
print("Failed to upload image for upscaling.")
|
||||
return self.create_blank_image()
|
||||
def generate_upscaled_image(
|
||||
self,
|
||||
image,
|
||||
upscale_factor,
|
||||
negative_prompt,
|
||||
creativity,
|
||||
resemblance,
|
||||
guidance_scale,
|
||||
num_inference_steps,
|
||||
enable_safety_checker,
|
||||
seed=-1,
|
||||
):
|
||||
try:
|
||||
# Upload the image using ImageUtils (raises on failure)
|
||||
image_url = ImageUtils.upload_image(image)
|
||||
|
||||
arguments = {
|
||||
"image_url": image_url,
|
||||
"prompt": "masterpiece, best quality, highres",
|
||||
"upscale_factor": upscale_factor,
|
||||
"negative_prompt": negative_prompt,
|
||||
"creativity": creativity,
|
||||
"resemblance": resemblance,
|
||||
"guidance_scale": guidance_scale,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"enable_safety_checker": enable_safety_checker
|
||||
arguments = {
|
||||
"image_url": image_url,
|
||||
"prompt": "masterpiece, best quality, highres",
|
||||
"upscale_factor": upscale_factor,
|
||||
"negative_prompt": negative_prompt,
|
||||
"creativity": creativity,
|
||||
"resemblance": resemblance,
|
||||
"guidance_scale": guidance_scale,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"enable_safety_checker": enable_safety_checker,
|
||||
}
|
||||
|
||||
if seed != -1:
|
||||
arguments["seed"] = seed
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/clarity-upscaler", arguments
|
||||
)
|
||||
return ResultProcessor.process_image_result(result)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_image_generation_error("clarity-upscaler", e)
|
||||
|
||||
|
||||
class SeedvrUpscalerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {"tooltip": "Input image to upscale."}),
|
||||
"upscale_factor": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 2.0,
|
||||
"min": 1.0,
|
||||
"max": 4.0,
|
||||
"step": 0.5,
|
||||
"tooltip": "How much to enlarge the image (1x-4x).",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"tooltip": "Random seed for reproducibility. -1 uses a random seed.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
if seed != -1:
|
||||
arguments["seed"] = seed
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_upscaled_image"
|
||||
CATEGORY = "FAL/Image"
|
||||
|
||||
def generate_upscaled_image(
|
||||
self,
|
||||
image,
|
||||
upscale_factor,
|
||||
seed=-1,
|
||||
):
|
||||
try:
|
||||
handler = fal_client.submit("fal-ai/clarity-upscaler", arguments=arguments)
|
||||
result = handler.get()
|
||||
return self.process_result(result)
|
||||
except Exception as e:
|
||||
print(f"Error generating upscaled image: {str(e)}")
|
||||
return self.create_blank_image()
|
||||
# Upload the image using ImageUtils (raises on failure)
|
||||
image_url = ImageUtils.upload_image(image)
|
||||
|
||||
def process_result(self, result):
|
||||
arguments = {
|
||||
"image_url": image_url,
|
||||
"upscale_factor": upscale_factor,
|
||||
}
|
||||
|
||||
if seed != -1:
|
||||
arguments["seed"] = seed
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/seedvr/upscale/image", arguments
|
||||
)
|
||||
return ResultProcessor.process_single_image_result(result)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_image_generation_error("seedvr-upscaler", e)
|
||||
|
||||
|
||||
class SeedvrUpscaleVideoNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"upscale_factor": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 2.0,
|
||||
"min": 0.00,
|
||||
"max": 5.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Upscaling factor applied when upscale_mode is 'factor'.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"video": (
|
||||
"VIDEO",
|
||||
{
|
||||
"tooltip": "Video to upscale. Takes precedence over input_video_url when connected.",
|
||||
},
|
||||
),
|
||||
"input_video_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of the video to upscale. Used when no VIDEO input is connected.",
|
||||
},
|
||||
),
|
||||
"upscale_mode": (
|
||||
["factor", "target"],
|
||||
{
|
||||
"default": "factor",
|
||||
"tooltip": "'factor' scales by upscale_factor; 'target' scales to target_resolution.",
|
||||
},
|
||||
),
|
||||
"target_resolution": (
|
||||
["720p", "1080p", "1440p", "2160p"],
|
||||
{
|
||||
"default": "1080p",
|
||||
"tooltip": "Output resolution used when upscale_mode is 'target'.",
|
||||
},
|
||||
),
|
||||
"noise_scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.1,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
"tooltip": "Amount of noise conditioning; higher can hallucinate more detail.",
|
||||
},
|
||||
),
|
||||
"output_quality": (
|
||||
["low", "medium", "high", "maximum"],
|
||||
{
|
||||
"default": "high",
|
||||
"tooltip": "Encoding quality of the output video.",
|
||||
},
|
||||
),
|
||||
"output_write_mode": (
|
||||
["fast", "balanced", "small"],
|
||||
{
|
||||
"default": "balanced",
|
||||
"tooltip": "Encoder speed/size trade-off for writing the output file.",
|
||||
},
|
||||
),
|
||||
"output_format": (
|
||||
[
|
||||
"X264 (.mp4)",
|
||||
"VP9 (.webm)",
|
||||
"PRORES444 (.mov)",
|
||||
"GIF (.gif)",
|
||||
],
|
||||
{
|
||||
"default": "X264 (.mp4)",
|
||||
"tooltip": "Container and codec of the output video.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("video_url",)
|
||||
FUNCTION = "generate_upscaled_video"
|
||||
CATEGORY = "FAL/VideoUpscaling"
|
||||
|
||||
def generate_upscaled_video(
|
||||
self,
|
||||
upscale_factor=2.0,
|
||||
video=None,
|
||||
input_video_url=None,
|
||||
upscale_mode="factor",
|
||||
target_resolution="1080p",
|
||||
noise_scale=0.1,
|
||||
output_format="X264 (.mp4)",
|
||||
output_quality="high",
|
||||
output_write_mode="balanced",
|
||||
):
|
||||
try:
|
||||
img_url = result["image"]["url"]
|
||||
img_response = requests.get(img_url)
|
||||
img = Image.open(io.BytesIO(img_response.content))
|
||||
img_array = np.array(img).astype(np.float32) / 255.0
|
||||
video_url = input_video_url
|
||||
if video is not None:
|
||||
video_url = ImageUtils.upload_file(video.get_stream_source())
|
||||
if not video_url:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"seedvr-upscale-video",
|
||||
"No video provided. Connect a VIDEO input or set input_video_url.",
|
||||
)
|
||||
|
||||
# Stack the images along a new first dimension
|
||||
stacked_images = np.stack([img_array], axis=0)
|
||||
|
||||
# Convert to PyTorch tensor
|
||||
img_tensor = torch.from_numpy(stacked_images)
|
||||
return (img_tensor,)
|
||||
# The API enum is "PRORES4444 (.mov)"; the dropdown historically
|
||||
# exposes "PRORES444 (.mov)", so translate at the argument level.
|
||||
api_output_format = (
|
||||
"PRORES4444 (.mov)"
|
||||
if output_format == "PRORES444 (.mov)"
|
||||
else output_format
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"video_url": video_url,
|
||||
"upscale_mode": upscale_mode,
|
||||
"upscale_factor": upscale_factor,
|
||||
"target_resolution": target_resolution,
|
||||
"noise_scale": noise_scale,
|
||||
"output_format": api_output_format,
|
||||
"output_quality": output_quality,
|
||||
"output_write_mode": output_write_mode,
|
||||
}
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/seedvr/upscale/video", arguments
|
||||
)
|
||||
return (result["video"]["url"],)
|
||||
except Exception as e:
|
||||
print(f"Error processing result: {str(e)}")
|
||||
return self.create_blank_image()
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"seedvr-upscale-video", e
|
||||
)
|
||||
|
||||
|
||||
class BriaVideoIncreaseResolutionNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"upscale_factor": (
|
||||
"INT",
|
||||
{
|
||||
"default": 2,
|
||||
"min": 2,
|
||||
"max": 4,
|
||||
"step": 2,
|
||||
"tooltip": "Resolution increase factor. The API accepts 2 or 4.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"video": (
|
||||
"VIDEO",
|
||||
{
|
||||
"tooltip": "Video to upscale. Takes precedence over input_video_url when connected.",
|
||||
},
|
||||
),
|
||||
"input_video_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of the video to upscale. Used when no VIDEO input is connected.",
|
||||
},
|
||||
),
|
||||
"output_container_and_codec": (
|
||||
[
|
||||
"mp4_h264",
|
||||
"mp4_h265",
|
||||
"mov_h265",
|
||||
"mov_proresks",
|
||||
"webm_vp9",
|
||||
"mkv_h265",
|
||||
"mkv_vp9",
|
||||
"gif",
|
||||
],
|
||||
{
|
||||
"default": "mp4_h264",
|
||||
"tooltip": "Container and codec of the output video.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("video_url",)
|
||||
FUNCTION = "generate_upscaled_video"
|
||||
CATEGORY = "FAL/VideoUpscaling"
|
||||
|
||||
def generate_upscaled_video(
|
||||
self,
|
||||
video=None,
|
||||
input_video_url=None,
|
||||
upscale_factor=2,
|
||||
output_container_and_codec="mp4_h264",
|
||||
):
|
||||
try:
|
||||
video_url = input_video_url
|
||||
if video is not None:
|
||||
video_url = ImageUtils.upload_file(video.get_stream_source())
|
||||
if not video_url:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"bria-video-increase-resolution",
|
||||
"No video provided. Connect a VIDEO input or set input_video_url.",
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"video_url": video_url,
|
||||
"desired_increase": str(upscale_factor),
|
||||
"output_container_and_codec": output_container_and_codec,
|
||||
}
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"bria/video/increase-resolution", arguments
|
||||
)
|
||||
return (result["video"]["url"],)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"bria-video-increase-resolution", e
|
||||
)
|
||||
|
||||
|
||||
class TopazUpscaleVideoNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"upscale_factor": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 2.0,
|
||||
"min": 1.0,
|
||||
"max": 5.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "How much to enlarge the video (1x-5x).",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"video": (
|
||||
"VIDEO",
|
||||
{
|
||||
"tooltip": "Video to upscale. Takes precedence over input_video_url when connected.",
|
||||
},
|
||||
),
|
||||
"input_video_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "URL of the video to upscale. Used when no VIDEO input is connected.",
|
||||
},
|
||||
),
|
||||
"use_fps": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Enable frame interpolation to target_fps.",
|
||||
},
|
||||
),
|
||||
"target_fps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 60,
|
||||
"tooltip": "Target output frame rate. Only used when use_fps is enabled and value is non-zero.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("video_url",)
|
||||
FUNCTION = "generate_upscaled_video"
|
||||
CATEGORY = "FAL/VideoUpscaling"
|
||||
|
||||
def generate_upscaled_video(
|
||||
self,
|
||||
video=None,
|
||||
input_video_url=None,
|
||||
upscale_factor=2.0,
|
||||
use_fps=False,
|
||||
target_fps=0,
|
||||
):
|
||||
try:
|
||||
video_url = input_video_url
|
||||
if video is not None:
|
||||
video_url = ImageUtils.upload_file(video.get_stream_source())
|
||||
if not video_url:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"fal-ai/topaz/upscale/video",
|
||||
"No video provided. Connect a VIDEO input or set input_video_url.",
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"video_url": video_url,
|
||||
"upscale_factor": upscale_factor,
|
||||
}
|
||||
if target_fps != 0 and use_fps:
|
||||
arguments["target_fps"] = target_fps
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"fal-ai/topaz/upscale/video", arguments
|
||||
)
|
||||
return (result["video"]["url"],)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
"fal-ai/topaz/upscale/video", e
|
||||
)
|
||||
|
||||
def create_blank_image(self):
|
||||
blank_img = Image.new('RGB', (512, 512), color='black')
|
||||
img_array = np.array(blank_img).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_array)[None,]
|
||||
return (img_tensor,)
|
||||
|
||||
# Node class mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Upscaler_fal": UpscalerNode,
|
||||
"Seedvr_Upscaler_fal": SeedvrUpscalerNode,
|
||||
"Seedvr_Upscale_Video_fal": SeedvrUpscaleVideoNode,
|
||||
"Bria_Video_Increase_Resolution_fal": BriaVideoIncreaseResolutionNode,
|
||||
"Topaz_Upscale_Video_fal": TopazUpscaleVideoNode,
|
||||
}
|
||||
|
||||
# Node display name mappings
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Upscaler_fal": "Clarity Upscaler (fal)",
|
||||
}
|
||||
"Seedvr_Upscaler_fal": "Seedvr Upscaler (fal)",
|
||||
"Seedvr_Upscale_Video_fal": "Seedvr Upscale Video (fal)",
|
||||
"Bria_Video_Increase_Resolution_fal": "Bria Video Increase Resolution (fal)",
|
||||
"Topaz_Upscale_Video_fal": "Topaz Upscale Video (fal)",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Core utilities for the ComfyUI-fal-API node pack."""
|
||||
|
||||
from .api import ApiHandler
|
||||
from .config import FalConfig
|
||||
from .errors import FalApiError, extract_error_message, raise_fal_error
|
||||
from .images import ImageUtils, ResultProcessor
|
||||
from .logger import logger
|
||||
from .media import MediaUtils
|
||||
|
||||
__all__ = [
|
||||
"ApiHandler",
|
||||
"FalApiError",
|
||||
"FalConfig",
|
||||
"ImageUtils",
|
||||
"MediaUtils",
|
||||
"ResultProcessor",
|
||||
"extract_error_message",
|
||||
"logger",
|
||||
"raise_fal_error",
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
"""fal.ai API submission helpers for ComfyUI-fal-API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, Callable, NoReturn
|
||||
|
||||
from .config import FalConfig
|
||||
from .errors import FalApiError, extract_error_message, raise_fal_error
|
||||
from .logger import logger
|
||||
|
||||
_MAX_QUEUE_LOG_LINES = 10_000
|
||||
|
||||
|
||||
def _check_interruption() -> None:
|
||||
"""Raise ComfyUI's InterruptProcessingException if the user cancelled.
|
||||
|
||||
A no-op when running outside ComfyUI.
|
||||
"""
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
except ImportError:
|
||||
return
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
|
||||
def _is_interruption(exc: BaseException) -> bool:
|
||||
"""Detect ComfyUI's interruption exception without importing comfy."""
|
||||
return exc.__class__.__name__ == "InterruptProcessingException"
|
||||
|
||||
|
||||
def _log_message_from_entry(entry: Any) -> str | None:
|
||||
"""Extract a printable message from a fal queue log entry."""
|
||||
if isinstance(entry, dict):
|
||||
message = entry.get("message")
|
||||
return str(message) if message else None
|
||||
text = str(entry)
|
||||
return text if text else None
|
||||
|
||||
|
||||
def _make_queue_callback(endpoint: str) -> Callable[[Any], None]:
|
||||
"""Build an on_queue_update callback that logs progress and honors cancel."""
|
||||
import fal_client
|
||||
|
||||
seen_lines: set = set()
|
||||
last_position: list[int | None] = [None]
|
||||
|
||||
def on_queue_update(status: Any) -> None:
|
||||
# Anything raised here (interruption) must propagate to the caller.
|
||||
_check_interruption()
|
||||
|
||||
if isinstance(status, fal_client.InProgress):
|
||||
for entry in status.logs or []:
|
||||
message = _log_message_from_entry(entry)
|
||||
if message and message not in seen_lines:
|
||||
if len(seen_lines) < _MAX_QUEUE_LOG_LINES:
|
||||
seen_lines.add(message)
|
||||
logger.info("[%s] %s", endpoint, message)
|
||||
elif isinstance(status, fal_client.Queued):
|
||||
position = getattr(status, "position", None)
|
||||
if position != last_position[0]:
|
||||
last_position[0] = position
|
||||
logger.info("[%s] queued (position %s)", endpoint, position)
|
||||
|
||||
return on_queue_update
|
||||
|
||||
|
||||
async def _submit_multiple_async(
|
||||
endpoint: str, arguments: dict[str, Any], variations: int
|
||||
) -> list[Any]:
|
||||
"""Submit multiple jobs concurrently and gather results (with exceptions).
|
||||
|
||||
Interruption is only observed between the submit and gather phases — the
|
||||
per-request polling here has no queue callback, so a ComfyUI Cancel takes
|
||||
effect once the in-flight variations settle (known limitation).
|
||||
"""
|
||||
from fal_client import AsyncClient
|
||||
|
||||
# Validate the key via get_client() first so a missing/placeholder key
|
||||
# raises the actionable config error instead of a raw auth failure.
|
||||
FalConfig().get_client()
|
||||
client = AsyncClient(key=FalConfig().get_key())
|
||||
|
||||
def variation_arguments(index: int) -> dict[str, Any]:
|
||||
if "seed" in arguments:
|
||||
return {**arguments, "seed": arguments.get("seed", 0) + index}
|
||||
return arguments
|
||||
|
||||
async def submit_and_get(index: int) -> Any:
|
||||
handler = await client.submit(endpoint, arguments=variation_arguments(index))
|
||||
return await handler.get()
|
||||
|
||||
# One flow per variation so a single submit failure only loses that
|
||||
# variation instead of failing the whole batch.
|
||||
return await asyncio.gather(
|
||||
*[submit_and_get(i) for i in range(variations)], return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
def _partition_results(
|
||||
endpoint: str, raw_results: list[Any]
|
||||
) -> tuple[list[Any], list[tuple]]:
|
||||
"""Split gathered results into successes and logged failures."""
|
||||
successes: list[Any] = []
|
||||
failures: list[tuple] = []
|
||||
for index, item in enumerate(raw_results):
|
||||
if isinstance(item, BaseException):
|
||||
message, status_code = extract_error_message(item)
|
||||
logger.error("[%s] variation %d failed: %s", endpoint, index, message)
|
||||
failures.append((index, message, status_code))
|
||||
else:
|
||||
successes.append(item)
|
||||
return successes, failures
|
||||
|
||||
|
||||
def _raise_generation_error(model_name: str, error: Exception | str) -> NoReturn:
|
||||
"""Normalize an exception or error string into a raised FalApiError."""
|
||||
if isinstance(error, BaseException):
|
||||
if _is_interruption(error) or not isinstance(error, Exception):
|
||||
raise error
|
||||
raise_fal_error(model_name, error)
|
||||
raise FalApiError(model_name, str(error))
|
||||
|
||||
|
||||
class ApiHandler:
|
||||
"""Utility functions for fal.ai API interactions."""
|
||||
|
||||
@staticmethod
|
||||
def submit_and_get_result(
|
||||
endpoint: str,
|
||||
arguments: dict[str, Any],
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
"""Submit a job via client.subscribe and return the final result.
|
||||
|
||||
Logs queue position and in-progress log lines, and checks for ComfyUI
|
||||
interruption on every queue update. ``timeout`` is reserved for future
|
||||
use (fal_client 1.0 subscribe does not accept one).
|
||||
"""
|
||||
del timeout # Reserved; not supported by fal_client 1.0 subscribe.
|
||||
client = FalConfig().get_client()
|
||||
callback = _make_queue_callback(endpoint)
|
||||
try:
|
||||
return client.subscribe(
|
||||
endpoint,
|
||||
arguments=arguments,
|
||||
with_logs=True,
|
||||
on_queue_update=callback,
|
||||
)
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if _is_interruption(exc):
|
||||
raise
|
||||
raise_fal_error(endpoint, exc)
|
||||
|
||||
@staticmethod
|
||||
def submit_multiple_and_get_results(
|
||||
endpoint: str, arguments: dict[str, Any], variations: int
|
||||
) -> list[Any]:
|
||||
"""Submit multiple variations concurrently and return successful results.
|
||||
|
||||
Failed variations are logged; raises FalApiError only if ALL fail.
|
||||
"""
|
||||
try:
|
||||
# Run the async code in a dedicated thread to avoid event loop
|
||||
# conflicts with ComfyUI's own loop.
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run,
|
||||
_submit_multiple_async(endpoint, arguments, variations),
|
||||
)
|
||||
raw_results = future.result()
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if _is_interruption(exc):
|
||||
raise
|
||||
raise_fal_error(endpoint, exc)
|
||||
|
||||
successes, failures = _partition_results(endpoint, raw_results)
|
||||
if not successes:
|
||||
first_message = failures[0][1] if failures else "no results returned"
|
||||
first_status = failures[0][2] if failures else None
|
||||
raise FalApiError(
|
||||
endpoint,
|
||||
f"All {variations} variations failed: {first_message}",
|
||||
first_status,
|
||||
)
|
||||
return successes
|
||||
|
||||
@staticmethod
|
||||
def handle_video_generation_error(
|
||||
model_name: str, error: Exception | str
|
||||
) -> NoReturn:
|
||||
"""Raise a normalized FalApiError for a video generation failure."""
|
||||
_raise_generation_error(model_name, error)
|
||||
|
||||
@staticmethod
|
||||
def handle_image_generation_error(
|
||||
model_name: str, error: Exception | str
|
||||
) -> NoReturn:
|
||||
"""Raise a normalized FalApiError for an image generation failure."""
|
||||
_raise_generation_error(model_name, error)
|
||||
|
||||
@staticmethod
|
||||
def handle_text_generation_error(
|
||||
model_name: str, error: Exception | str
|
||||
) -> NoReturn:
|
||||
"""Raise a normalized FalApiError for a text generation failure."""
|
||||
_raise_generation_error(model_name, error)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""fal.ai API key/config resolution for the ComfyUI-fal-API node pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from .errors import FalApiError
|
||||
from .logger import logger
|
||||
|
||||
_PLACEHOLDER_KEY = "<your_fal_api_key_here>"
|
||||
_MISSING_KEY_MESSAGE = (
|
||||
"FAL_KEY is not configured. Set the FAL_KEY environment variable or add it "
|
||||
"to config.ini under the [API] section. Get your API key from "
|
||||
"https://fal.ai/dashboard/keys"
|
||||
)
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
"""Return the path to config.ini at the repo root (one dir above nodes/)."""
|
||||
utils_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
nodes_dir = os.path.dirname(utils_dir)
|
||||
repo_root = os.path.dirname(nodes_dir)
|
||||
return os.path.join(repo_root, "config.ini")
|
||||
|
||||
|
||||
def _read_config() -> configparser.ConfigParser:
|
||||
"""Read config.ini; returns an empty parser if the file is absent."""
|
||||
parser = configparser.ConfigParser()
|
||||
try:
|
||||
parser.read(_config_path())
|
||||
except configparser.Error as exc:
|
||||
logger.warning("Failed to parse config.ini: %s", exc)
|
||||
return parser
|
||||
|
||||
|
||||
def _resolve_key(parser: configparser.ConfigParser) -> str | None:
|
||||
"""Resolve the FAL key: environment first, then config.ini [API] FAL_KEY."""
|
||||
env_key = os.environ.get("FAL_KEY")
|
||||
if env_key:
|
||||
logger.info("Using FAL_KEY from environment")
|
||||
return env_key
|
||||
|
||||
config_key = parser.get("API", "FAL_KEY", fallback=None)
|
||||
if config_key:
|
||||
logger.info("Using FAL_KEY from config.ini")
|
||||
return config_key
|
||||
return None
|
||||
|
||||
|
||||
def _is_valid_key(key: str | None) -> bool:
|
||||
return bool(key) and key != _PLACEHOLDER_KEY
|
||||
|
||||
|
||||
class FalConfig:
|
||||
"""Singleton holding fal.ai configuration and a cached client."""
|
||||
|
||||
_instance: FalConfig | None = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls) -> FalConfig:
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
instance = super().__new__(cls)
|
||||
instance._initialize()
|
||||
cls._instance = instance
|
||||
return cls._instance
|
||||
|
||||
def _initialize(self) -> None:
|
||||
"""Resolve the API key once; never raises at import/construction time."""
|
||||
self._parser = _read_config()
|
||||
self._key: str | None = _resolve_key(self._parser)
|
||||
self._client: Any | None = None
|
||||
|
||||
if not _is_valid_key(self._key):
|
||||
logger.warning(_MISSING_KEY_MESSAGE)
|
||||
|
||||
def get_client(self) -> Any:
|
||||
"""Get or create the cached fal_client SyncClient.
|
||||
|
||||
Raises FalApiError if no valid key is configured.
|
||||
"""
|
||||
if self._client is None:
|
||||
if not _is_valid_key(self._key):
|
||||
raise FalApiError("config", _MISSING_KEY_MESSAGE)
|
||||
from fal_client.client import SyncClient
|
||||
|
||||
self._client = SyncClient(key=self._key)
|
||||
return self._client
|
||||
|
||||
def get_key(self) -> str | None:
|
||||
"""Return the resolved FAL API key (may be None or a placeholder)."""
|
||||
return self._key
|
||||
|
||||
def get_setting(self, section: str, name: str, default: Any = None) -> Any:
|
||||
"""Read an arbitrary config.ini setting, with bool parsing.
|
||||
|
||||
Returns ``default`` if the section or option is absent. Values equal to
|
||||
"true"/"false" (case-insensitive) are returned as booleans.
|
||||
"""
|
||||
try:
|
||||
value = self._parser.get(section, name)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
return default
|
||||
|
||||
lowered = value.strip().lower()
|
||||
if lowered == "true":
|
||||
return True
|
||||
if lowered == "false":
|
||||
return False
|
||||
return value
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Error types and helpers for normalizing fal.ai API failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
|
||||
|
||||
class FalApiError(Exception):
|
||||
"""Raised when a fal.ai API call (or related processing) fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
self.model_name = model_name
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
formatted = f"[{model_name}] {message}"
|
||||
if status_code is not None:
|
||||
formatted = f"{formatted} (HTTP {status_code})"
|
||||
super().__init__(formatted)
|
||||
|
||||
|
||||
def _flatten_validation_detail(detail: list[Any]) -> str:
|
||||
"""Flatten a FastAPI validation-error list into a readable string."""
|
||||
parts: list[str] = []
|
||||
for item in detail:
|
||||
if isinstance(item, dict):
|
||||
loc = ".".join(str(part) for part in (item.get("loc") or []))
|
||||
msg = str(item.get("msg", item))
|
||||
parts.append(f"{loc}: {msg}" if loc else msg)
|
||||
else:
|
||||
parts.append(str(item))
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _detail_to_message(detail: Any) -> str:
|
||||
"""Convert a response 'detail' payload into a message string."""
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(detail, list):
|
||||
return _flatten_validation_detail(detail)
|
||||
return str(detail)
|
||||
|
||||
|
||||
def _message_from_response(response: Any) -> str | None:
|
||||
"""Extract a human-readable message from an httpx-like response."""
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
|
||||
if isinstance(payload, dict) and "detail" in payload:
|
||||
return _detail_to_message(payload["detail"])
|
||||
if payload is not None:
|
||||
return str(payload)
|
||||
|
||||
text = getattr(response, "text", None)
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_error_message(exc: BaseException) -> tuple[str, int | None]:
|
||||
"""Extract a readable message and HTTP status code from an exception.
|
||||
|
||||
Duck-types fal_client.FalClientHTTPError (``.status_code`` plus an
|
||||
httpx ``.response``) so this works without importing fal_client.
|
||||
"""
|
||||
raw_status = getattr(exc, "status_code", None)
|
||||
status_code = raw_status if isinstance(raw_status, int) else None
|
||||
|
||||
response = getattr(exc, "response", None)
|
||||
if response is not None:
|
||||
message = _message_from_response(response)
|
||||
if message:
|
||||
return message, status_code
|
||||
|
||||
return str(exc) or exc.__class__.__name__, status_code
|
||||
|
||||
|
||||
def raise_fal_error(model_name: str, exc: Exception) -> NoReturn:
|
||||
"""Normalize any exception into a FalApiError and raise it."""
|
||||
if isinstance(exc, FalApiError):
|
||||
raise exc
|
||||
message, status_code = extract_error_message(exc)
|
||||
raise FalApiError(model_name, message, status_code) from exc
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Image tensor helpers and API result processing for ComfyUI-fal-API.
|
||||
|
||||
ComfyUI IMAGE convention: float32 tensors in [0, 1] with shape (B, H, W, C).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .config import FalConfig
|
||||
from .errors import FalApiError, raise_fal_error
|
||||
from .logger import logger
|
||||
|
||||
_DOWNLOAD_TIMEOUT = (10, 180)
|
||||
_MAX_PARALLEL_TRANSFERS = 8
|
||||
|
||||
|
||||
def _safe_unlink(path: str) -> None:
|
||||
"""Delete a temp file, ignoring errors."""
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _download_image_array(url: str) -> np.ndarray:
|
||||
"""Download an image URL and return a float32 (H, W, 3) array in [0, 1]."""
|
||||
response = requests.get(url, timeout=_DOWNLOAD_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
img = Image.open(io.BytesIO(response.content)).convert("RGB")
|
||||
return np.array(img).astype(np.float32) / 255.0
|
||||
|
||||
|
||||
def _download_image_arrays(urls: list[str]) -> list[np.ndarray]:
|
||||
"""Download image URLs (in parallel when multiple), preserving order."""
|
||||
if len(urls) == 1:
|
||||
return [_download_image_array(urls[0])]
|
||||
max_workers = min(len(urls), _MAX_PARALLEL_TRANSFERS)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
return list(executor.map(_download_image_array, urls))
|
||||
|
||||
|
||||
def _split_image_batch(images: Any) -> list[Any]:
|
||||
"""Split an IMAGE input into a list of single images, preserving order."""
|
||||
if isinstance(images, torch.Tensor):
|
||||
if images.ndim == 4 and images.shape[0] > 1:
|
||||
return [images[i : i + 1] for i in range(images.shape[0])]
|
||||
return [images]
|
||||
if isinstance(images, (list, tuple)):
|
||||
return list(images)
|
||||
return [images]
|
||||
|
||||
|
||||
class ImageUtils:
|
||||
"""Utility functions for image processing and uploads."""
|
||||
|
||||
@staticmethod
|
||||
def tensor_to_pil(image: Any) -> Image.Image:
|
||||
"""Convert an image tensor (or array-like) to a PIL Image."""
|
||||
try:
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.detach().cpu().numpy()
|
||||
else:
|
||||
image_np = np.array(image)
|
||||
|
||||
if image_np.ndim == 4:
|
||||
image_np = image_np[0] # Drop batch dimension
|
||||
if image_np.ndim == 2:
|
||||
image_np = np.stack([image_np] * 3, axis=-1) # Grayscale -> RGB
|
||||
elif (
|
||||
image_np.ndim == 3
|
||||
and image_np.shape[0] == 3
|
||||
and image_np.shape[2] not in (1, 3, 4)
|
||||
):
|
||||
image_np = np.transpose(image_np, (1, 2, 0)) # (C, H, W) -> (H, W, C)
|
||||
|
||||
if image_np.dtype in (np.float32, np.float64):
|
||||
image_np = np.clip(image_np * 255.0, 0, 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(image_np)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to convert tensor to PIL image: %s", exc)
|
||||
raise FalApiError(
|
||||
"image-utils", f"Failed to convert tensor to image: {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def upload_image(image: Any) -> str:
|
||||
"""Upload an image tensor to fal.ai and return its URL."""
|
||||
pil_image = ImageUtils.tensor_to_pil(image)
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
pil_image.save(temp_file, format="PNG")
|
||||
return ImageUtils.upload_file(temp_path)
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
_safe_unlink(temp_path)
|
||||
|
||||
@staticmethod
|
||||
def upload_file(file_path: Any) -> str:
|
||||
"""Upload a local file to fal.ai and return its URL."""
|
||||
try:
|
||||
client = FalConfig().get_client()
|
||||
return client.upload_file(file_path)
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to upload file %s: %s", file_path, exc)
|
||||
raise_fal_error("file-upload", exc)
|
||||
|
||||
@staticmethod
|
||||
def mask_to_image(mask: torch.Tensor) -> torch.Tensor:
|
||||
"""Convert a MASK tensor to an IMAGE tensor (B, H, W, 3)."""
|
||||
return (
|
||||
mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1]))
|
||||
.movedim(1, -1)
|
||||
.expand(-1, -1, -1, 3)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prepare_images(images: Any) -> list[str]:
|
||||
"""Upload image input(s) to fal.ai in parallel, preserving order."""
|
||||
if images is None:
|
||||
return []
|
||||
singles = _split_image_batch(images)
|
||||
if not singles:
|
||||
return []
|
||||
if len(singles) == 1:
|
||||
return [ImageUtils.upload_image(singles[0])]
|
||||
max_workers = min(len(singles), _MAX_PARALLEL_TRANSFERS)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
return list(executor.map(ImageUtils.upload_image, singles))
|
||||
|
||||
|
||||
class ResultProcessor:
|
||||
"""Utility functions for turning API results into ComfyUI tensors."""
|
||||
|
||||
@staticmethod
|
||||
def process_image_result(result: dict[str, Any]) -> tuple:
|
||||
"""Process a multi-image result ({"images": [{"url": ...}, ...]})."""
|
||||
try:
|
||||
urls = [img_info["url"] for img_info in result["images"]]
|
||||
if not urls:
|
||||
raise ValueError("API result contained no images")
|
||||
arrays = _download_image_arrays(urls)
|
||||
stacked = np.stack(arrays, axis=0)
|
||||
return (torch.from_numpy(stacked),)
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to process image result: %s", exc)
|
||||
raise FalApiError(
|
||||
"image-result", f"Failed to process image result: {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def process_single_image_result(result: dict[str, Any]) -> tuple:
|
||||
"""Process a single-image result ({"image": {"url": ...}})."""
|
||||
try:
|
||||
img_array = _download_image_array(result["image"]["url"])
|
||||
stacked = np.stack([img_array], axis=0)
|
||||
return (torch.from_numpy(stacked),)
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to process single image result: %s", exc)
|
||||
raise FalApiError(
|
||||
"image-result", f"Failed to process single image result: {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def create_blank_image() -> tuple:
|
||||
"""Create a blank black 512x512 IMAGE tensor (kept for compatibility)."""
|
||||
blank_img = Image.new("RGB", (512, 512), color="black")
|
||||
img_array = np.array(blank_img).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_array)[None,]
|
||||
return (img_tensor,)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared logger for the ComfyUI-fal-API node pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER_NAME = "ComfyUI-fal-API"
|
||||
_LOG_FORMAT = "[%(name)s] %(levelname)s: %(message)s"
|
||||
|
||||
|
||||
def _configure_logger() -> logging.Logger:
|
||||
"""Configure the package logger exactly once."""
|
||||
log = logging.getLogger(_LOGGER_NAME)
|
||||
if not log.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||
log.addHandler(handler)
|
||||
log.setLevel(logging.INFO)
|
||||
return log
|
||||
|
||||
|
||||
logger = _configure_logger()
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Video/audio helpers for ComfyUI-fal-API (download, decode, upload)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
|
||||
from .errors import FalApiError
|
||||
from .images import ImageUtils
|
||||
from .logger import logger
|
||||
|
||||
_DOWNLOAD_TIMEOUT = (10, 600)
|
||||
_CHUNK_SIZE = 1 << 20 # 1 MiB
|
||||
_video_warning = {"emitted": False, "lock": threading.Lock()}
|
||||
|
||||
|
||||
def _safe_unlink(path: str) -> None:
|
||||
"""Delete a temp file, ignoring errors."""
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _is_http_url(value: str) -> bool:
|
||||
return value.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _suffix_from_url(url: str, default: str) -> str:
|
||||
"""Derive a file suffix from a URL path, falling back to a default."""
|
||||
suffix = os.path.splitext(urlparse(url).path)[1]
|
||||
return suffix if suffix else default
|
||||
|
||||
|
||||
def _resolve_video_from_file() -> type | None:
|
||||
"""Locate ComfyUI's VideoFromFile class across API layouts."""
|
||||
try:
|
||||
from comfy_api.input_impl import VideoFromFile
|
||||
|
||||
return VideoFromFile
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from comfy_api.latest import input_impl
|
||||
|
||||
return getattr(input_impl, "VideoFromFile", None)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def _warn_video_unavailable_once() -> None:
|
||||
"""Warn (once) that ComfyUI VIDEO output support is unavailable."""
|
||||
with _video_warning["lock"]:
|
||||
if not _video_warning["emitted"]:
|
||||
_video_warning["emitted"] = True
|
||||
logger.warning(
|
||||
"comfy_api VideoFromFile is unavailable; VIDEO outputs will be "
|
||||
"None. Update ComfyUI to a version that provides comfy_api."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_av_frame(array: np.ndarray, channels: int) -> np.ndarray:
|
||||
"""Normalize a PyAV audio frame array to float32 with shape (C, N)."""
|
||||
if np.issubdtype(array.dtype, np.integer):
|
||||
info = np.iinfo(array.dtype)
|
||||
scale = float(max(abs(info.min), info.max))
|
||||
array = array.astype(np.float32) / scale
|
||||
else:
|
||||
array = array.astype(np.float32)
|
||||
|
||||
if array.ndim == 1:
|
||||
array = array[np.newaxis, :]
|
||||
if array.shape[0] == 1 and channels > 1:
|
||||
# Packed/interleaved format: (1, N * C) -> (C, N)
|
||||
array = array.reshape(-1, channels).T
|
||||
return array
|
||||
|
||||
|
||||
def _load_audio_with_av(path: str) -> tuple[torch.Tensor, int]:
|
||||
"""Decode audio with PyAV; returns (waveform (1, C, T) float32, rate)."""
|
||||
import av
|
||||
|
||||
with av.open(path) as container:
|
||||
stream = container.streams.audio[0]
|
||||
sample_rate = int(stream.rate or 44100)
|
||||
channels = int(getattr(stream, "channels", 1) or 1)
|
||||
frames = [
|
||||
_normalize_av_frame(frame.to_ndarray(), channels)
|
||||
for frame in container.decode(stream)
|
||||
]
|
||||
|
||||
if not frames:
|
||||
raise FalApiError("audio-decode", f"No audio frames decoded from {path}")
|
||||
waveform = torch.from_numpy(np.concatenate(frames, axis=1))
|
||||
return waveform.unsqueeze(0), sample_rate
|
||||
|
||||
|
||||
def _load_audio(path: str) -> tuple[torch.Tensor, int]:
|
||||
"""Decode an audio file to (waveform (1, C, T) float32, sample_rate)."""
|
||||
try:
|
||||
import torchaudio
|
||||
|
||||
waveform, sample_rate = torchaudio.load(path)
|
||||
return waveform.to(torch.float32).unsqueeze(0), int(sample_rate)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _load_audio_with_av(path)
|
||||
except ImportError as exc:
|
||||
raise FalApiError(
|
||||
"audio-decode",
|
||||
"Decoding audio requires torchaudio or av (PyAV); neither is "
|
||||
"installed. Install one of them (e.g. 'pip install torchaudio').",
|
||||
) from exc
|
||||
|
||||
|
||||
def _save_wav(path: str, waveform: torch.Tensor, sample_rate: int) -> None:
|
||||
"""Save a (C, T) float32 waveform as WAV (torchaudio, else stdlib PCM16)."""
|
||||
try:
|
||||
import torchaudio
|
||||
|
||||
torchaudio.save(path, waveform, sample_rate)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import wave
|
||||
|
||||
clipped = np.clip(waveform.numpy(), -1.0, 1.0)
|
||||
pcm = (clipped * 32767.0).astype(np.int16)
|
||||
with wave.open(path, "wb") as wav_file:
|
||||
wav_file.setnchannels(pcm.shape[0])
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(int(sample_rate))
|
||||
wav_file.writeframes(pcm.T.reshape(-1).tobytes())
|
||||
|
||||
|
||||
def _stream_to_temp_file(source: Any, suffix: str) -> str:
|
||||
"""Write a readable stream to a temp file and return its path."""
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
while True:
|
||||
chunk = source.read(_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
temp_file.write(chunk)
|
||||
return temp_path
|
||||
|
||||
|
||||
class MediaUtils:
|
||||
"""Utility functions for video/audio download, conversion, and upload."""
|
||||
|
||||
@staticmethod
|
||||
def download_url_to_temp(url: str, suffix: str) -> str:
|
||||
"""Stream a URL to a temp file and return its local path."""
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=_DOWNLOAD_TIMEOUT) as resp:
|
||||
resp.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=suffix, delete=False
|
||||
) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
||||
if chunk:
|
||||
temp_file.write(chunk)
|
||||
return temp_path
|
||||
except Exception as exc:
|
||||
if temp_path is not None:
|
||||
_safe_unlink(temp_path)
|
||||
logger.error("Failed to download %s: %s", url, exc)
|
||||
raise FalApiError(
|
||||
"media-download", f"Failed to download {url}: {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def video_from_url(url: str) -> Any | None:
|
||||
"""Download a video URL and wrap it as a ComfyUI VIDEO object."""
|
||||
video_cls = _resolve_video_from_file()
|
||||
if video_cls is None:
|
||||
_warn_video_unavailable_once()
|
||||
return None
|
||||
# NOTE: the temp file is deliberately not unlinked here — VideoFromFile
|
||||
# reads the path lazily (e.g. when a downstream save node consumes it),
|
||||
# so deleting early would break playback. The OS temp dir reclaims it.
|
||||
local_path = MediaUtils.download_url_to_temp(
|
||||
url, _suffix_from_url(url, default=".mp4")
|
||||
)
|
||||
return video_cls(local_path)
|
||||
|
||||
@staticmethod
|
||||
def audio_from_url(url: str) -> dict[str, Any]:
|
||||
"""Download and decode audio into a ComfyUI AUDIO dict.
|
||||
|
||||
Returns {"waveform": float32 tensor (1, C, T), "sample_rate": int}.
|
||||
"""
|
||||
local_path = MediaUtils.download_url_to_temp(
|
||||
url, _suffix_from_url(url, default=".wav")
|
||||
)
|
||||
try:
|
||||
waveform, sample_rate = _load_audio(local_path)
|
||||
return {"waveform": waveform, "sample_rate": sample_rate}
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to decode audio from %s: %s", url, exc)
|
||||
raise FalApiError(
|
||||
"audio-decode", f"Failed to decode audio from {url}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
_safe_unlink(local_path)
|
||||
|
||||
@staticmethod
|
||||
def upload_video(video: Any) -> str:
|
||||
"""Upload a ComfyUI VIDEO input (or path/url string) and return a URL."""
|
||||
if isinstance(video, str):
|
||||
return video if _is_http_url(video) else ImageUtils.upload_file(video)
|
||||
|
||||
source = (
|
||||
video.get_stream_source()
|
||||
if hasattr(video, "get_stream_source")
|
||||
else video
|
||||
)
|
||||
if isinstance(source, str) and _is_http_url(source):
|
||||
return source
|
||||
if hasattr(source, "read"):
|
||||
temp_path = _stream_to_temp_file(source, suffix=".mp4")
|
||||
try:
|
||||
return ImageUtils.upload_file(temp_path)
|
||||
finally:
|
||||
_safe_unlink(temp_path)
|
||||
return ImageUtils.upload_file(source)
|
||||
|
||||
@staticmethod
|
||||
def upload_audio(audio: Any) -> str:
|
||||
"""Upload a ComfyUI AUDIO dict (or path/url string) and return a URL."""
|
||||
if isinstance(audio, str):
|
||||
return audio if _is_http_url(audio) else ImageUtils.upload_file(audio)
|
||||
|
||||
try:
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = int(audio["sample_rate"])
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise FalApiError(
|
||||
"audio-upload",
|
||||
"Expected an AUDIO dict with 'waveform' and 'sample_rate'",
|
||||
) from exc
|
||||
|
||||
tensor = waveform.detach().cpu().to(torch.float32)
|
||||
if tensor.ndim == 3:
|
||||
tensor = tensor[0] # (1, C, T) -> (C, T)
|
||||
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
_save_wav(temp_path, tensor, sample_rate)
|
||||
return ImageUtils.upload_file(temp_path)
|
||||
except FalApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save/upload audio: %s", exc)
|
||||
raise FalApiError(
|
||||
"audio-upload", f"Failed to save/upload audio: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
_safe_unlink(temp_path)
|
||||
+3669
-400
File diff suppressed because it is too large
Load Diff
+103
-65
@@ -1,38 +1,86 @@
|
||||
import os
|
||||
import configparser
|
||||
from fal_client.client import SyncClient
|
||||
import torch
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from .fal_utils import ApiHandler, FalConfig, ImageUtils
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
# Initialize FalConfig
|
||||
fal_config = FalConfig()
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
try:
|
||||
fal_key = config['API']['FAL_KEY']
|
||||
os.environ["FAL_KEY"] = fal_key
|
||||
except KeyError:
|
||||
print("Error: FAL_KEY not found in config.ini")
|
||||
|
||||
# Create the client with API key
|
||||
fal_client = SyncClient(key=fal_key)
|
||||
|
||||
class VLMNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"model": (["google/gemini-flash-1.5-8b", "anthropic/claude-3.5-sonnet", "anthropic/claude-3-haiku",
|
||||
"google/gemini-pro-1.5", "google/gemini-flash-1.5", "openai/gpt-4o"],
|
||||
{"default": "google/gemini-flash-1.5-8b"}),
|
||||
"system_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"image": ("IMAGE",),
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "User prompt sent to the model.",
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
[
|
||||
"google/gemini-2.5-flash",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"openai/gpt-4o",
|
||||
"qwen/qwen3-vl-235b-a22b-instruct",
|
||||
"x-ai/grok-4-fast",
|
||||
"Custom",
|
||||
],
|
||||
{
|
||||
"default": "google/gemini-2.5-flash",
|
||||
"tooltip": "Vision model to use. Select 'Custom' to type any OpenRouter model id in custom_model_name.",
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Optional system prompt to steer the model's behavior.",
|
||||
},
|
||||
),
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": "Image(s) for the model to analyze. Batches are sent as multiple images.",
|
||||
},
|
||||
),
|
||||
"temperature": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "Sampling temperature. Lower is more deterministic.",
|
||||
},
|
||||
),
|
||||
"reasoning": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Request reasoning from the model.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"max_tokens": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100000,
|
||||
"tooltip": "Maximum output tokens. 0 uses the model default.",
|
||||
},
|
||||
),
|
||||
"custom_model_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "OpenRouter model id used when model is set to 'Custom'.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,54 +88,44 @@ class VLMNode:
|
||||
FUNCTION = "generate_text"
|
||||
CATEGORY = "FAL/VLM"
|
||||
|
||||
def generate_text(self, prompt, model, system_prompt, image):
|
||||
def generate_text(self, prompt, model, system_prompt, image, temperature, reasoning, max_tokens=0, custom_model_name=""):
|
||||
try:
|
||||
# Convert the image tensor to a numpy array
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.cpu().numpy()
|
||||
else:
|
||||
image_np = np.array(image)
|
||||
# Handle custom model selection
|
||||
if model == "Custom":
|
||||
if not custom_model_name or custom_model_name.strip() == "":
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
"Custom", "Custom model name is required when 'Custom' is selected"
|
||||
)
|
||||
model = custom_model_name.strip()
|
||||
|
||||
# Ensure the image is in the correct format (H, W, C)
|
||||
if image_np.ndim == 4:
|
||||
image_np = image_np.squeeze(0) # Remove batch dimension if present
|
||||
if image_np.ndim == 2:
|
||||
image_np = np.stack([image_np] * 3, axis=-1) # Convert grayscale to RGB
|
||||
elif image_np.shape[0] == 3:
|
||||
image_np = np.transpose(image_np, (1, 2, 0)) # Change from (C, H, W) to (H, W, C)
|
||||
|
||||
# Normalize the image data to 0-255 range
|
||||
if image_np.dtype == np.float32 or image_np.dtype == np.float64:
|
||||
image_np = (image_np * 255).astype(np.uint8)
|
||||
|
||||
# Convert to PIL Image
|
||||
pil_image = Image.fromarray(image_np)
|
||||
|
||||
# Save the image to a temporary file
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
pil_image.save(temp_file, format="PNG")
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
# Upload the temporary file
|
||||
image_url = fal_client.upload_file(temp_file_path)
|
||||
# Upload single image or batch and collect URLs
|
||||
image_urls = ImageUtils.prepare_images(image)
|
||||
if not image_urls:
|
||||
return ApiHandler.handle_text_generation_error(
|
||||
model, "Failed to upload image(s)"
|
||||
)
|
||||
|
||||
arguments = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system_prompt": system_prompt,
|
||||
"image_url": image_url,
|
||||
"image_urls": image_urls,
|
||||
"temperature": temperature,
|
||||
"reasoning": reasoning,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
handler = fal_client.submit("fal-ai/any-llm/vision", arguments=arguments)
|
||||
result = handler.get()
|
||||
# Only include max_tokens if it's greater than 0
|
||||
if max_tokens > 0:
|
||||
arguments["max_tokens"] = max_tokens
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
"openrouter/router/vision", arguments
|
||||
)
|
||||
return (result["output"],)
|
||||
except Exception as e:
|
||||
print(f"Error generating text with VLM: {str(e)}")
|
||||
return ("Error: Unable to generate text.",)
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
if 'temp_file_path' in locals():
|
||||
os.unlink(temp_file_path)
|
||||
return ApiHandler.handle_text_generation_error(model, e)
|
||||
|
||||
|
||||
# Node class mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -97,4 +135,4 @@ NODE_CLASS_MAPPINGS = {
|
||||
# Node display name mappings
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"VLM_fal": "VLM (fal)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "fal-api"
|
||||
description = "Custom nodes for using fal API with auto-generated full-catalog coverage of fal.ai models. Video generation with Kling, Runway, Luma. Image generation with Flux. LLMs and VLMs OpenAI, Claude, Llama and Gemini."
|
||||
version = "2.0.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"fal-client>=1.0,<2",
|
||||
"torch",
|
||||
"opencv-python",
|
||||
"numpy",
|
||||
"pillow",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/gokayfem/ComfyUI-fal-API"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "gokayfem"
|
||||
DisplayName = "ComfyUI-fal-API"
|
||||
Icon = ""
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py39"
|
||||
line-length = 120
|
||||
exclude = ["example_workflows"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
# E501: legacy long lines throughout the codebase; revisit once files are refactored.
|
||||
ignore = ["E501"]
|
||||
+6
-2
@@ -1,2 +1,6 @@
|
||||
fal-client
|
||||
torch
|
||||
fal-client>=1.0,<2
|
||||
torch
|
||||
opencv-python
|
||||
numpy
|
||||
pillow
|
||||
requests
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the auto-generated model list section of README.md.
|
||||
|
||||
Reads data/fal_registry.json and rewrites ONLY the section between
|
||||
`<!-- BEGIN GENERATED MODEL LIST -->` and `<!-- END GENERATED MODEL LIST -->`
|
||||
in README.md. Everything outside the markers is left untouched, and running
|
||||
the script twice in a row produces no diff.
|
||||
|
||||
Usage:
|
||||
python scripts/build_readme.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = REPO_ROOT / "data" / "fal_registry.json"
|
||||
README_PATH = REPO_ROOT / "README.md"
|
||||
|
||||
BEGIN_MARKER = "<!-- BEGIN GENERATED MODEL LIST -->"
|
||||
END_MARKER = "<!-- END GENERATED MODEL LIST -->"
|
||||
|
||||
MODEL_URL_TEMPLATE = "https://fal.ai/models/{endpoint_id}"
|
||||
|
||||
|
||||
def load_registry(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
registry = json.load(handle)
|
||||
except (OSError, ValueError) as err:
|
||||
raise SystemExit(f"Failed to read registry at {path}: {err}") from err
|
||||
if not isinstance(registry.get("models"), list):
|
||||
raise SystemExit(f"Registry at {path} has no 'models' list")
|
||||
return registry
|
||||
|
||||
|
||||
def escape_cell(text: str) -> str:
|
||||
"""Make a value safe inside a markdown table cell."""
|
||||
return " ".join(str(text).split()).replace("|", "\\|")
|
||||
|
||||
|
||||
def group_by_category(
|
||||
models: list[dict[str, Any]],
|
||||
) -> list[tuple[str, list[dict[str, Any]]]]:
|
||||
"""Group models by category, categories sorted by size desc then name."""
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for model in models:
|
||||
category = str(model.get("category") or "other")
|
||||
grouped = {**grouped, category: [*grouped.get(category, []), model]}
|
||||
return sorted(grouped.items(), key=lambda item: (-len(item[1]), item[0]))
|
||||
|
||||
|
||||
def model_sort_key(model: dict[str, Any]) -> tuple[str, str]:
|
||||
title = str(model.get("title") or model.get("endpoint_id") or "")
|
||||
return (title.casefold(), str(model.get("endpoint_id") or ""))
|
||||
|
||||
|
||||
def render_model_row(model: dict[str, Any]) -> str:
|
||||
endpoint_id = str(model.get("endpoint_id") or "")
|
||||
title = escape_cell(model.get("title") or endpoint_id)
|
||||
lab = escape_cell(model.get("lab") or "—") or "—"
|
||||
output = escape_cell(model.get("output_kind") or "json")
|
||||
url = MODEL_URL_TEMPLATE.format(endpoint_id=endpoint_id)
|
||||
endpoint_cell = f"[`{escape_cell(endpoint_id)}`]({url})"
|
||||
return f"| {title} | {endpoint_cell} | {lab} | {output} |"
|
||||
|
||||
|
||||
def render_category(category: str, models: list[dict[str, Any]]) -> str:
|
||||
rows = [render_model_row(m) for m in sorted(models, key=model_sort_key)]
|
||||
count = len(models)
|
||||
noun = "model" if count == 1 else "models"
|
||||
return "\n".join(
|
||||
[
|
||||
"<details>",
|
||||
f"<summary><strong>{category}</strong> — {count} {noun}</summary>",
|
||||
"",
|
||||
"| Model | Endpoint | Lab | Output |",
|
||||
"| --- | --- | --- | --- |",
|
||||
*rows,
|
||||
"",
|
||||
"</details>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def render_generated_section(registry: dict[str, Any]) -> str:
|
||||
models = registry["models"]
|
||||
model_count = registry.get("model_count", len(models))
|
||||
published = [str(m.get("published_at", "")) for m in registry.get("models", [])]
|
||||
generated_date = max(published)[:10] if any(published) else "unknown"
|
||||
summary = (
|
||||
f"{model_count} models · newest model {generated_date} · "
|
||||
"refresh with `scripts/build_registry.py`"
|
||||
)
|
||||
blocks = [
|
||||
render_category(category, grouped)
|
||||
for category, grouped in group_by_category(models)
|
||||
]
|
||||
return "\n\n".join([summary, *blocks])
|
||||
|
||||
|
||||
def replace_between_markers(readme: str, generated: str) -> str:
|
||||
begin = readme.find(BEGIN_MARKER)
|
||||
end = readme.find(END_MARKER)
|
||||
if begin == -1 or end == -1 or end < begin:
|
||||
raise SystemExit(
|
||||
f"README.md must contain '{BEGIN_MARKER}' followed by '{END_MARKER}'"
|
||||
)
|
||||
head = readme[: begin + len(BEGIN_MARKER)]
|
||||
tail = readme[end:]
|
||||
return f"{head}\n\n{generated}\n\n{tail}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
registry = load_registry(REGISTRY_PATH)
|
||||
try:
|
||||
readme = README_PATH.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
raise SystemExit(f"Failed to read {README_PATH}: {err}") from err
|
||||
|
||||
updated = replace_between_markers(readme, render_generated_section(registry))
|
||||
if updated == readme:
|
||||
print(f"README.md already up to date ({registry.get('model_count')} models)")
|
||||
return 0
|
||||
|
||||
README_PATH.write_text(updated, encoding="utf-8")
|
||||
print(
|
||||
f"README.md model list regenerated: {registry.get('model_count')} models, "
|
||||
f"{len(group_by_category(registry['models']))} categories"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a compact registry of fal.ai model endpoints.
|
||||
|
||||
Distills the fal.ai model catalog plus per-endpoint OpenAPI schemas into a
|
||||
single registry JSON (``data/fal_registry.json``) that a node factory can use
|
||||
to auto-generate ComfyUI nodes.
|
||||
|
||||
Stdlib only. Usage:
|
||||
|
||||
python scripts/build_registry.py \
|
||||
--out data/fal_registry.json \
|
||||
--since-days 0 \
|
||||
--catalog-cache /path/to/fal_models_all.json \
|
||||
--schemas-cache /path/to/fal_schemas_recent.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
CATALOG_URL = "https://fal.ai/api/models?page={page}&total=100"
|
||||
SCHEMA_URL = "https://fal.ai/api/openapi/queue/openapi.json?endpoint_id={endpoint_id}"
|
||||
USER_AGENT = "ComfyUI-fal-API-registry-builder/1.0"
|
||||
|
||||
FETCH_ATTEMPTS = 3
|
||||
BACKOFF_BASE_SECONDS = 1.5
|
||||
MAX_INPUT_PROPERTIES = 40
|
||||
MAX_DESCRIPTION_CHARS = 500
|
||||
MULTILINE_NAMES = frozenset({"prompt", "negative_prompt", "text", "script", "dialogue"})
|
||||
MULTILINE_DESCRIPTION_THRESHOLD = 120
|
||||
SKIPPED_PROPERTY_NAMES = frozenset({"sync_mode"})
|
||||
FILE_OUTPUT_PROPS = frozenset({"model_glb", "model_mesh", "model_url", "model_urls", "mesh"})
|
||||
|
||||
logger = logging.getLogger("build_registry")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_json(url):
|
||||
"""Fetch a URL and parse JSON, with retries and backoff.
|
||||
|
||||
Returns the parsed document, or None for a 404 (skip-and-log).
|
||||
Raises on persistent non-404 failure.
|
||||
"""
|
||||
last_error = None
|
||||
for attempt in range(FETCH_ATTEMPTS):
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404:
|
||||
logger.warning("404 for %s, skipping", url)
|
||||
return None
|
||||
last_error = error
|
||||
except (urllib.error.URLError, TimeoutError, ValueError) as error:
|
||||
last_error = error
|
||||
time.sleep(BACKOFF_BASE_SECONDS * (2 ** attempt))
|
||||
raise RuntimeError(f"Failed to fetch {url} after {FETCH_ATTEMPTS} attempts: {last_error}")
|
||||
|
||||
|
||||
def extract_catalog_items(payload):
|
||||
"""Normalize a catalog API response page into a list of items."""
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
for key in ("items", "models", "data", "results"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
|
||||
|
||||
def fetch_catalog():
|
||||
"""Fetch all catalog pages until an empty page is returned."""
|
||||
items = []
|
||||
page = 1
|
||||
while True:
|
||||
payload = fetch_json(CATALOG_URL.format(page=page))
|
||||
page_items = extract_catalog_items(payload)
|
||||
if not page_items:
|
||||
break
|
||||
items = items + page_items
|
||||
logger.info("Fetched catalog page %d (%d items)", page, len(page_items))
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def fetch_schemas(endpoint_ids, max_workers):
|
||||
"""Fetch OpenAPI docs for endpoint ids concurrently. Returns id -> doc."""
|
||||
schemas = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(fetch_json, SCHEMA_URL.format(endpoint_id=endpoint_id)): endpoint_id
|
||||
for endpoint_id in endpoint_ids
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
endpoint_id = futures[future]
|
||||
try:
|
||||
doc = future.result()
|
||||
except RuntimeError as error:
|
||||
logger.warning("Schema fetch failed for %s: %s", endpoint_id, error)
|
||||
continue
|
||||
if doc is not None:
|
||||
schemas = {**schemas, endpoint_id: doc}
|
||||
return schemas
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_published_at(item):
|
||||
"""Parse the model's publication timestamp, or None."""
|
||||
raw = item.get("publishedAt") or item.get("date") or ""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def filter_catalog(catalog, since):
|
||||
"""Keep live, public models (optionally within a publish window; since=None keeps all).
|
||||
|
||||
Returns (kept_items, skip_reason_counter).
|
||||
"""
|
||||
kept = []
|
||||
skipped = Counter()
|
||||
seen_ids = set()
|
||||
for item in catalog:
|
||||
endpoint_id = item.get("id") or ""
|
||||
if not endpoint_id or endpoint_id in seen_ids:
|
||||
skipped["duplicate_or_missing_id"] += 1
|
||||
continue
|
||||
seen_ids.add(endpoint_id)
|
||||
if item.get("status") != "public":
|
||||
skipped["not_public"] += 1
|
||||
continue
|
||||
if item.get("deprecated"):
|
||||
skipped["deprecated"] += 1
|
||||
continue
|
||||
if item.get("removed"):
|
||||
skipped["removed"] += 1
|
||||
continue
|
||||
if since is not None:
|
||||
published = parse_published_at(item)
|
||||
if published is None or published < since:
|
||||
skipped["outside_window"] += 1
|
||||
continue
|
||||
kept = kept + [item]
|
||||
return kept, skipped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema resolution helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_ref(schema, components):
|
||||
"""Resolve a local $ref against components.schemas, one level."""
|
||||
ref = schema.get("$ref", "")
|
||||
if not ref.startswith("#/components/schemas/"):
|
||||
return schema
|
||||
name = ref.rsplit("/", 1)[-1]
|
||||
resolved = components.get(name)
|
||||
if not isinstance(resolved, dict):
|
||||
return schema
|
||||
siblings = {key: value for key, value in schema.items() if key != "$ref"}
|
||||
return {**resolved, **siblings}
|
||||
|
||||
|
||||
def non_null_branches(branches, components):
|
||||
"""Resolve and drop null branches from an anyOf/oneOf list."""
|
||||
resolved = [resolve_ref(branch, components) for branch in branches if isinstance(branch, dict)]
|
||||
return [branch for branch in resolved if branch.get("type") != "null"]
|
||||
|
||||
|
||||
def merge_all_of(schema, components):
|
||||
"""Merge an allOf list (one level), with sibling keys taking precedence."""
|
||||
merged = {}
|
||||
for branch in schema.get("allOf", []):
|
||||
if isinstance(branch, dict):
|
||||
merged = {**merged, **resolve_ref(branch, components)}
|
||||
siblings = {key: value for key, value in schema.items() if key != "allOf"}
|
||||
return {**merged, **siblings}
|
||||
|
||||
|
||||
def is_custom_size_pair(branches):
|
||||
"""Detect the image_size pattern: [enum-of-presets, width/height object]."""
|
||||
enum_branch = next((b for b in branches if b.get("enum")), None)
|
||||
object_branch = next(
|
||||
(
|
||||
b
|
||||
for b in branches
|
||||
if b.get("type") == "object" or "properties" in b
|
||||
),
|
||||
None,
|
||||
)
|
||||
if enum_branch is None or object_branch is None:
|
||||
return None
|
||||
properties = object_branch.get("properties", {})
|
||||
if "width" in properties and "height" in properties:
|
||||
return enum_branch
|
||||
return None
|
||||
|
||||
|
||||
def normalize_schema(schema, components):
|
||||
"""Resolve $ref / allOf / anyOf / oneOf one level.
|
||||
|
||||
Returns (resolved_schema, has_custom_size, custom_size_enum_values).
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return {}, False, None
|
||||
resolved = resolve_ref(schema, components)
|
||||
if "allOf" in resolved:
|
||||
resolved = merge_all_of(resolved, components)
|
||||
branches_key = "anyOf" if "anyOf" in resolved else ("oneOf" if "oneOf" in resolved else None)
|
||||
if branches_key is None:
|
||||
return resolved, False, None
|
||||
|
||||
branches = non_null_branches(resolved[branches_key], components)
|
||||
siblings = {key: value for key, value in resolved.items() if key != branches_key}
|
||||
if not branches:
|
||||
return siblings, False, None
|
||||
|
||||
custom_enum_branch = is_custom_size_pair(branches)
|
||||
if custom_enum_branch is not None:
|
||||
values = list(custom_enum_branch.get("enum", [])) + ["custom_size"]
|
||||
return {**custom_enum_branch, **siblings}, True, values
|
||||
|
||||
enum_branch = next((branch for branch in branches if branch.get("enum")), None)
|
||||
chosen = enum_branch if enum_branch is not None else branches[0]
|
||||
return {**chosen, **siblings}, False, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input distillation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_media_kind(name, schema, is_list):
|
||||
"""Heuristic media kind from a property name (string-typed props only)."""
|
||||
lowered = name.lower()
|
||||
description = str(schema.get("description", "")).lower()
|
||||
if "image_url" in lowered or "mask_url" in lowered:
|
||||
return "image"
|
||||
if lowered.endswith("_image"):
|
||||
return "image"
|
||||
if "video_url" in lowered:
|
||||
return "video"
|
||||
if "audio_url" in lowered or "voice_url" in lowered:
|
||||
return "audio"
|
||||
if "_url" in lowered or lowered == "url" or schema.get("format") == "uri":
|
||||
for kind in ("image", "video", "audio"):
|
||||
if kind in description:
|
||||
return kind
|
||||
return "file"
|
||||
del is_list # signature symmetry; list-ness does not change the kind
|
||||
return None
|
||||
|
||||
|
||||
def trim_text(value, limit=MAX_DESCRIPTION_CHARS):
|
||||
"""Trim a description/title string."""
|
||||
return str(value or "").strip()[:limit]
|
||||
|
||||
|
||||
def scalar_type_of(schema):
|
||||
"""Map an OpenAPI scalar type to a registry type."""
|
||||
type_name = schema.get("type")
|
||||
if schema.get("enum"):
|
||||
return "enum"
|
||||
if type_name in ("integer", "number", "boolean", "string"):
|
||||
return type_name
|
||||
if type_name == "object" or "properties" in schema:
|
||||
return "json"
|
||||
return "json"
|
||||
|
||||
|
||||
def distill_property(name, raw_schema, required_names, components):
|
||||
"""Distill one input property into a registry input record, or None."""
|
||||
if name in SKIPPED_PROPERTY_NAMES or name.startswith("_"):
|
||||
return None
|
||||
|
||||
schema, has_custom_size, custom_enum = normalize_schema(raw_schema, components)
|
||||
|
||||
is_list = False
|
||||
if schema.get("type") == "array":
|
||||
is_list = True
|
||||
items, _, _ = normalize_schema(schema.get("items", {}), components)
|
||||
item_type = scalar_type_of(items)
|
||||
if item_type == "json":
|
||||
type_name = "json"
|
||||
is_list = False # rendered as a single JSON field
|
||||
else:
|
||||
type_name = item_type
|
||||
item_schema = items
|
||||
else:
|
||||
type_name = scalar_type_of(schema)
|
||||
item_schema = schema
|
||||
|
||||
enum_values = None
|
||||
if has_custom_size:
|
||||
type_name = "enum"
|
||||
enum_values = custom_enum
|
||||
elif type_name == "enum":
|
||||
enum_values = list(item_schema.get("enum", []))
|
||||
|
||||
minimum = schema.get("minimum", schema.get("exclusiveMinimum"))
|
||||
maximum = schema.get("maximum", schema.get("exclusiveMaximum"))
|
||||
if type_name not in ("integer", "number"):
|
||||
minimum = None
|
||||
maximum = None
|
||||
|
||||
default = schema.get("default", raw_schema.get("default") if isinstance(raw_schema, dict) else None)
|
||||
if type_name == "json" and default is not None and not isinstance(default, str):
|
||||
default = json.dumps(default, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
# Some upstream schemas declare enum members and the default with mismatched
|
||||
# types (e.g. enum ["1","2","4","8"] with default 4). Normalize the default
|
||||
# onto the literal enum member it string-matches so widgets get a valid value.
|
||||
if enum_values and default is not None and default not in enum_values:
|
||||
match = next((v for v in enum_values if str(v) == str(default)), None)
|
||||
if match is not None:
|
||||
default = match
|
||||
|
||||
description = trim_text(schema.get("description") or schema.get("title"))
|
||||
|
||||
media_kind = None
|
||||
if type_name == "string" or (is_list and type_name == "string"):
|
||||
media_kind = detect_media_kind(name, schema, is_list)
|
||||
|
||||
multiline = name in MULTILINE_NAMES or (
|
||||
type_name == "string"
|
||||
and not enum_values
|
||||
and len(description) > MULTILINE_DESCRIPTION_THRESHOLD
|
||||
)
|
||||
|
||||
record = {
|
||||
"name": name,
|
||||
"type": type_name,
|
||||
"required": name in required_names,
|
||||
"default": default,
|
||||
"enum": enum_values,
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
"description": description,
|
||||
"media_kind": media_kind,
|
||||
"is_list": is_list,
|
||||
"multiline": multiline,
|
||||
}
|
||||
if has_custom_size:
|
||||
record = {**record, "has_custom_size": True}
|
||||
return record
|
||||
|
||||
|
||||
def ordered_property_names(schema):
|
||||
"""Property names, preferring fal's declared ordering."""
|
||||
properties = schema.get("properties", {})
|
||||
declared = schema.get("x-fal-order-properties")
|
||||
if isinstance(declared, list):
|
||||
ordered = [name for name in declared if name in properties]
|
||||
remainder = [name for name in properties if name not in ordered]
|
||||
return ordered + remainder
|
||||
return list(properties)
|
||||
|
||||
|
||||
def distill_inputs(schema, components, endpoint_id):
|
||||
"""Distill an Input schema's properties into registry input records."""
|
||||
properties = schema.get("properties", {})
|
||||
required_names = set(schema.get("required", []))
|
||||
names = ordered_property_names(schema)
|
||||
|
||||
if len(names) > MAX_INPUT_PROPERTIES:
|
||||
required_first = [n for n in names if n in required_names]
|
||||
optional = [n for n in names if n not in required_names]
|
||||
budget = max(MAX_INPUT_PROPERTIES - len(required_first), 0)
|
||||
names = required_first + optional[:budget]
|
||||
logger.info(
|
||||
"%s: input schema has %d properties, capped to %d",
|
||||
endpoint_id,
|
||||
len(properties),
|
||||
len(names),
|
||||
)
|
||||
|
||||
inputs = []
|
||||
for name in names:
|
||||
record = distill_property(name, properties.get(name, {}), required_names, components)
|
||||
if record is not None:
|
||||
inputs = inputs + [record]
|
||||
return inputs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ref_name(schema):
|
||||
"""Extract the local component name from a {'$ref': ...} node."""
|
||||
ref = schema.get("$ref", "") if isinstance(schema, dict) else ""
|
||||
return ref.rsplit("/", 1)[-1] if ref.startswith("#/components/schemas/") else None
|
||||
|
||||
|
||||
def input_ref_from_paths(doc):
|
||||
"""Name of the schema referenced by the app POST requestBody."""
|
||||
for operations in doc.get("paths", {}).values():
|
||||
post = operations.get("post") if isinstance(operations, dict) else None
|
||||
if not isinstance(post, dict):
|
||||
continue
|
||||
content = post.get("requestBody", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
name = ref_name(schema)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def output_ref_from_paths(doc):
|
||||
"""Name of the schema referenced by result GET responses."""
|
||||
for operations in doc.get("paths", {}).values():
|
||||
get = operations.get("get") if isinstance(operations, dict) else None
|
||||
if not isinstance(get, dict):
|
||||
continue
|
||||
for response in get.get("responses", {}).values():
|
||||
content = response.get("content", {}) if isinstance(response, dict) else {}
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
name = ref_name(schema)
|
||||
if name and name.endswith("Output"):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def select_schema(doc, endpoint_id, suffix, path_lookup):
|
||||
"""Select the app Input/Output schema from components.schemas."""
|
||||
components = doc.get("components", {}).get("schemas", {})
|
||||
referenced = path_lookup(doc)
|
||||
if referenced and referenced in components:
|
||||
return components[referenced]
|
||||
|
||||
candidates = [name for name in components if name.endswith(suffix)]
|
||||
if not candidates:
|
||||
return None
|
||||
normalized_endpoint = "".join(ch for ch in endpoint_id.lower() if ch.isalnum())
|
||||
matching = [
|
||||
name
|
||||
for name in candidates
|
||||
if "".join(ch for ch in name.lower() if ch.isalnum()).replace(suffix.lower(), "")
|
||||
in normalized_endpoint
|
||||
]
|
||||
pool = matching or candidates
|
||||
return components[max(pool, key=len)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output kind detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_output(schema, components):
|
||||
"""Classify an Output schema. Returns (output_kind, output_props)."""
|
||||
if schema is None:
|
||||
return "json", []
|
||||
properties = schema.get("properties", {})
|
||||
prop_names = list(properties)
|
||||
lowered = {name.lower() for name in prop_names}
|
||||
|
||||
def prop_is_array(name):
|
||||
resolved, _, _ = normalize_schema(properties.get(name, {}), components)
|
||||
return resolved.get("type") == "array"
|
||||
|
||||
if "images" in lowered and prop_is_array("images"):
|
||||
return "images", prop_names
|
||||
if "image" in lowered:
|
||||
return "image", prop_names
|
||||
if "video" in lowered or "videos" in lowered:
|
||||
return "video", prop_names
|
||||
if "audio" in lowered or "audios" in lowered:
|
||||
return "audio", prop_names
|
||||
if lowered & FILE_OUTPUT_PROPS:
|
||||
return "file", prop_names
|
||||
|
||||
if prop_names:
|
||||
all_stringlike = True
|
||||
for name in prop_names:
|
||||
resolved, _, _ = normalize_schema(properties.get(name, {}), components)
|
||||
if scalar_type_of(resolved) != "string":
|
||||
all_stringlike = False
|
||||
break
|
||||
if all_stringlike:
|
||||
return "text", prop_names
|
||||
|
||||
return "json", prop_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_record(item, doc):
|
||||
"""Build a single registry record from a catalog item + OpenAPI doc."""
|
||||
endpoint_id = item["id"]
|
||||
components = doc.get("components", {}).get("schemas", {})
|
||||
|
||||
input_schema = select_schema(doc, endpoint_id, "Input", input_ref_from_paths)
|
||||
if input_schema is None:
|
||||
logger.warning("%s: no Input schema found, skipping", endpoint_id)
|
||||
return None
|
||||
|
||||
output_schema = select_schema(doc, endpoint_id, "Output", output_ref_from_paths)
|
||||
output_kind, output_props = detect_output(output_schema, components)
|
||||
|
||||
published = parse_published_at(item)
|
||||
pricing = str(item.get("pricingInfoOverride") or "").replace("**", "").strip()
|
||||
|
||||
return {
|
||||
"endpoint_id": endpoint_id,
|
||||
"title": str(item.get("title") or "").strip(),
|
||||
"category": str(item.get("category") or "").strip(),
|
||||
"lab": str(item.get("modelLab") or "").strip(),
|
||||
"family": str(item.get("modelFamily") or "").strip(),
|
||||
"description": trim_text(item.get("shortDescription")),
|
||||
"pricing": pricing,
|
||||
"published_at": published.isoformat() if published else "",
|
||||
"thumbnail": str(item.get("thumbnailUrl") or "").strip(),
|
||||
"inputs": distill_inputs(input_schema, components, endpoint_id),
|
||||
"output_kind": output_kind,
|
||||
"output_props": output_props,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_json_file(path):
|
||||
"""Load a JSON cache file."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError) as error:
|
||||
raise RuntimeError(f"Failed to load cache file {path}: {error}") from error
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Build the fal.ai model registry JSON.")
|
||||
parser.add_argument("--out", default="data/fal_registry.json", help="Output registry path")
|
||||
parser.add_argument(
|
||||
"--since-days",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Rolling publish window in days; 0 (default) = all live models",
|
||||
)
|
||||
parser.add_argument("--catalog-cache", default=None, help="Path to cached catalog JSON")
|
||||
parser.add_argument("--schemas-cache", default=None, help="Path to cached endpoint_id->OpenAPI JSON")
|
||||
parser.add_argument("--max-workers", type=int, default=16, help="Concurrent schema fetches")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def log_summary(records, skipped):
|
||||
"""Log counts by category / output kind and skip reasons."""
|
||||
category_counts = Counter(record["category"] for record in records)
|
||||
kind_counts = Counter(record["output_kind"] for record in records)
|
||||
logger.info("Models by category:")
|
||||
for category, count in category_counts.most_common():
|
||||
logger.info(" %-28s %d", category or "(none)", count)
|
||||
logger.info("Models by output_kind:")
|
||||
for kind, count in kind_counts.most_common():
|
||||
logger.info(" %-10s %d", kind, count)
|
||||
logger.info("Skipped: %s", dict(skipped) or "none")
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
args = parse_args()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(days=args.since_days) if args.since_days > 0 else None
|
||||
|
||||
catalog = (
|
||||
load_json_file(args.catalog_cache) if args.catalog_cache else fetch_catalog()
|
||||
)
|
||||
logger.info("Catalog: %d items", len(catalog))
|
||||
|
||||
kept, skipped = filter_catalog(catalog, since)
|
||||
logger.info("After filtering: %d live public models in window", len(kept))
|
||||
|
||||
if args.schemas_cache:
|
||||
schemas = load_json_file(args.schemas_cache)
|
||||
else:
|
||||
schemas = fetch_schemas([item["id"] for item in kept], args.max_workers)
|
||||
logger.info("Schemas available: %d", len(schemas))
|
||||
|
||||
records = []
|
||||
for item in kept:
|
||||
doc = schemas.get(item["id"])
|
||||
if doc is None:
|
||||
skipped["no_schema"] += 1
|
||||
logger.warning("%s: no schema available, skipping", item["id"])
|
||||
continue
|
||||
record = build_record(item, doc)
|
||||
if record is None:
|
||||
skipped["no_input_schema"] += 1
|
||||
continue
|
||||
records = records + [record]
|
||||
|
||||
records = sorted(records, key=lambda record: record["endpoint_id"])
|
||||
|
||||
# NOTE: no wall-clock fields (generated_at etc.) — the committed registry
|
||||
# must be content-deterministic so the weekly refresh workflow only opens a
|
||||
# PR when the model set actually changes.
|
||||
registry = {
|
||||
"version": 1,
|
||||
"window_days": args.since_days,
|
||||
"model_count": len(records),
|
||||
"models": records,
|
||||
}
|
||||
|
||||
with open(args.out, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
registry,
|
||||
handle,
|
||||
indent=None,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
handle.write("\n")
|
||||
|
||||
log_summary(records, skipped)
|
||||
logger.info("Wrote %d models to %s", len(records), args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Shared fixtures: load the pack exactly like ComfyUI does (hyphenated dir)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG = "ComfyUI_fal_API"
|
||||
|
||||
|
||||
def _load_package():
|
||||
if PKG in sys.modules:
|
||||
return sys.modules[PKG]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
PKG, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[PKG] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pack():
|
||||
"""The fully loaded node pack (static + dynamic mappings)."""
|
||||
return _load_package()
|
||||
|
||||
|
||||
def _submodule(name: str):
|
||||
_load_package()
|
||||
return importlib.import_module(f"{PKG}.{name}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def schema_to_inputs():
|
||||
return _submodule("nodes.dynamic.schema_to_inputs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def arguments_mod():
|
||||
return _submodule("nodes.dynamic.arguments")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def outputs_mod():
|
||||
return _submodule("nodes.dynamic.outputs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def factory_mod():
|
||||
return _submodule("nodes.dynamic.factory")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def errors_mod():
|
||||
return _submodule("nodes.utils.errors")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Shared model/input fixture builders for the dynamic-node tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _model(inputs, **overrides):
|
||||
base = {
|
||||
"endpoint_id": "fal-ai/test/model",
|
||||
"title": "Test Model",
|
||||
"category": "text-to-image",
|
||||
"lab": "Test Lab",
|
||||
"family": "",
|
||||
"description": "",
|
||||
"pricing": "",
|
||||
"published_at": "2026-01-01T00:00:00Z",
|
||||
"thumbnail": "",
|
||||
"inputs": inputs,
|
||||
"output_kind": "images",
|
||||
"output_props": ["images"],
|
||||
}
|
||||
return {**base, **overrides}
|
||||
|
||||
|
||||
def _input(name, type_, **kw):
|
||||
base = {
|
||||
"name": name,
|
||||
"type": type_,
|
||||
"required": False,
|
||||
"default": None,
|
||||
"enum": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"description": "",
|
||||
"media_kind": None,
|
||||
"is_list": False,
|
||||
"multiline": False,
|
||||
}
|
||||
return {**base, **kw}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Node keys registered at v1.0.12 (commit 1b14ab3). These must NEVER be removed or renamed - existing user workflows reference them.",
|
||||
"keys": [
|
||||
"Bria_Video_Increase_Resolution_fal",
|
||||
"CombinedVideoGeneration_fal",
|
||||
"DYWanFun22_fal",
|
||||
"DYWanUpscaler_fal",
|
||||
"Dreamina31TextToImage_fal",
|
||||
"FluxDev_fal",
|
||||
"FluxGeneral_fal",
|
||||
"FluxLoraTrainer_fal",
|
||||
"FluxLora_fal",
|
||||
"FluxPro11_fal",
|
||||
"FluxPro1Fill_fal",
|
||||
"FluxProKontextMulti_fal",
|
||||
"FluxProKontextTextToImage_fal",
|
||||
"FluxProKontext_fal",
|
||||
"FluxPro_fal",
|
||||
"FluxSchnell_fal",
|
||||
"FluxUltra_fal",
|
||||
"GPTImage15Edit_fal",
|
||||
"GPTImage15_fal",
|
||||
"Hidreamfull_fal",
|
||||
"HunyuanVideoLoraTrainer_fal",
|
||||
"Ideogramv3_fal",
|
||||
"Imagen4Preview_fal",
|
||||
"InfinityStarTextToVideo_fal",
|
||||
"Kling21Pro_fal",
|
||||
"Kling25TurboPro_fal",
|
||||
"Kling26Pro_fal",
|
||||
"KlingMaster_fal",
|
||||
"KlingO3Pro_fal",
|
||||
"KlingO3Standard_fal",
|
||||
"KlingOmniImageToVideo_fal",
|
||||
"KlingOmniReferenceToVideo_fal",
|
||||
"KlingOmniVideoToVideoEdit_fal",
|
||||
"KlingOmniVideoToVideoReference_fal",
|
||||
"KlingPro10_fal",
|
||||
"KlingPro16_fal",
|
||||
"KlingV3ProMotionControl_fal",
|
||||
"KlingV3Pro_fal",
|
||||
"KlingV3StandardMotionControl_fal",
|
||||
"KlingV3Standard_fal",
|
||||
"Kling_fal",
|
||||
"Krea_Wan14b_VideoToVideo_fal",
|
||||
"LLM_fal",
|
||||
"LoadVideoURL",
|
||||
"LtxVideoTrainer_fal",
|
||||
"LumaDreamMachine_fal",
|
||||
"MiniMaxSubjectReference_fal",
|
||||
"MiniMaxTextToVideo_fal",
|
||||
"MiniMax_fal",
|
||||
"NanoBanana2_fal",
|
||||
"NanoBananaEdit_fal",
|
||||
"NanoBananaPro_fal",
|
||||
"NanoBananaTextToImage_fal",
|
||||
"PixverseSwapNode_fal",
|
||||
"QwenImageEditPlusLoRA_fal",
|
||||
"QwenImageEdit_fal",
|
||||
"Recraft_fal",
|
||||
"ReveTextToImage_fal",
|
||||
"RunwayGen3_fal",
|
||||
"Sana_fal",
|
||||
"SeedEditV3_fal",
|
||||
"SeedanceImageToVideo_fal",
|
||||
"SeedanceProImageToVideo_fal",
|
||||
"SeedanceTextToVideo_fal",
|
||||
"SeedreamV4Edit_fal",
|
||||
"Seedvr_Upscale_Video_fal",
|
||||
"Seedvr_Upscaler_fal",
|
||||
"Sora2Pro_fal",
|
||||
"Topaz_Upscale_Video_fal",
|
||||
"UploadFile_fal",
|
||||
"UploadVideo_fal",
|
||||
"Upscaler_fal",
|
||||
"VLM_fal",
|
||||
"Veo2ImageToVideo_fal",
|
||||
"Veo31Fast_fal",
|
||||
"Veo31_fal",
|
||||
"Veo3_fal",
|
||||
"VideoUpscaler_fal",
|
||||
"Wan2214b_animate_move_character_fal",
|
||||
"Wan2214b_animate_replace_character_fal",
|
||||
"Wan22VACEFun14b_fal",
|
||||
"Wan25_preview_fal",
|
||||
"Wan26ReferenceToVideo_fal",
|
||||
"Wan26_fal",
|
||||
"WanLoraTrainer_fal",
|
||||
"WanPro_fal",
|
||||
"WanVACEVideoEdit_fal"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Anchors pytest's rootdir here so the ComfyUI pack's root __init__.py
|
||||
# (which makes the repo root look like a package) is never collected/imported
|
||||
# by pytest itself — the pack is loaded properly via conftest.py instead.
|
||||
[pytest]
|
||||
addopts = --import-mode=importlib
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for kwargs→API-arguments translation (uploads stubbed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from helpers import _input, _model
|
||||
|
||||
|
||||
class _FakeImageUtils:
|
||||
@staticmethod
|
||||
def upload_image(_value):
|
||||
return "https://fal.media/img.png"
|
||||
|
||||
@staticmethod
|
||||
def prepare_images(_value):
|
||||
return ["https://fal.media/img1.png", "https://fal.media/img2.png"]
|
||||
|
||||
|
||||
class _FakeMediaUtils:
|
||||
@staticmethod
|
||||
def upload_video(_value):
|
||||
return "https://fal.media/vid.mp4"
|
||||
|
||||
@staticmethod
|
||||
def upload_audio(_value):
|
||||
return "https://fal.media/aud.wav"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_uploads(monkeypatch, arguments_mod):
|
||||
monkeypatch.setattr(arguments_mod, "ImageUtils", _FakeImageUtils)
|
||||
monkeypatch.setattr(arguments_mod, "MediaUtils", _FakeMediaUtils)
|
||||
|
||||
|
||||
def test_seed_minus_one_omitted(arguments_mod):
|
||||
model = _model([_input("seed", "integer")])
|
||||
args = arguments_mod.build_arguments(model, {"seed": -1})
|
||||
assert "seed" not in args
|
||||
|
||||
|
||||
def test_seed_value_sent(arguments_mod):
|
||||
model = _model([_input("seed", "integer")])
|
||||
args = arguments_mod.build_arguments(model, {"seed": 42})
|
||||
assert args["seed"] == 42
|
||||
|
||||
|
||||
def test_custom_size_expands_to_object(arguments_mod):
|
||||
model = _model([
|
||||
_input("image_size", "enum", enum=["square", "custom_size"],
|
||||
default="square", has_custom_size=True)
|
||||
])
|
||||
args = arguments_mod.build_arguments(
|
||||
model, {"image_size": "custom_size", "width": 832, "height": 1216}
|
||||
)
|
||||
assert args["image_size"] == {"width": 832, "height": 1216}
|
||||
|
||||
|
||||
def test_preset_size_passes_through(arguments_mod):
|
||||
model = _model([
|
||||
_input("image_size", "enum", enum=["square", "custom_size"],
|
||||
default="square", has_custom_size=True)
|
||||
])
|
||||
args = arguments_mod.build_arguments(
|
||||
model, {"image_size": "square", "width": 832, "height": 1216}
|
||||
)
|
||||
assert args["image_size"] == "square"
|
||||
assert "width" not in args and "height" not in args
|
||||
|
||||
|
||||
def test_image_upload_single_and_list(arguments_mod):
|
||||
model = _model([
|
||||
_input("image_url", "string", media_kind="image"),
|
||||
_input("image_urls", "array", media_kind="image", is_list=True),
|
||||
])
|
||||
args = arguments_mod.build_arguments(
|
||||
model, {"image_url": object(), "image_urls": object()}
|
||||
)
|
||||
assert args["image_url"] == "https://fal.media/img.png"
|
||||
assert args["image_urls"] == [
|
||||
"https://fal.media/img1.png",
|
||||
"https://fal.media/img2.png",
|
||||
]
|
||||
|
||||
|
||||
def test_video_and_audio_upload(arguments_mod):
|
||||
model = _model([
|
||||
_input("video_url", "string", media_kind="video"),
|
||||
_input("audio_url", "string", media_kind="audio"),
|
||||
])
|
||||
args = arguments_mod.build_arguments(
|
||||
model, {"video_url": object(), "audio_url": object()}
|
||||
)
|
||||
assert args["video_url"] == "https://fal.media/vid.mp4"
|
||||
assert args["audio_url"] == "https://fal.media/aud.wav"
|
||||
|
||||
|
||||
def test_invalid_json_raises_fal_error(arguments_mod, errors_mod):
|
||||
model = _model([_input("loras", "json")])
|
||||
with pytest.raises(errors_mod.FalApiError):
|
||||
arguments_mod.build_arguments(model, {"loras": "{not json"})
|
||||
|
||||
|
||||
def test_valid_json_parsed(arguments_mod):
|
||||
model = _model([_input("loras", "json")])
|
||||
args = arguments_mod.build_arguments(model, {"loras": '[{"path": "x"}]'})
|
||||
assert args["loras"] == [{"path": "x"}]
|
||||
|
||||
|
||||
def test_empty_optional_string_skipped(arguments_mod):
|
||||
model = _model([_input("negative_prompt", "string")])
|
||||
args = arguments_mod.build_arguments(model, {"negative_prompt": ""})
|
||||
assert "negative_prompt" not in args
|
||||
|
||||
|
||||
def test_multi_enum_split_and_validated(arguments_mod, errors_mod):
|
||||
model = _model([
|
||||
_input("stems", "enum", enum=["vocals", "drums", "bass"], is_list=True)
|
||||
])
|
||||
args = arguments_mod.build_arguments(model, {"stems": "vocals, bass"})
|
||||
assert args["stems"] == ["vocals", "bass"]
|
||||
|
||||
assert "stems" not in arguments_mod.build_arguments(model, {"stems": " "})
|
||||
|
||||
with pytest.raises(errors_mod.FalApiError):
|
||||
arguments_mod.build_arguments(model, {"stems": "vocals, kazoo"})
|
||||
|
||||
|
||||
def test_kwargs_not_mutated(arguments_mod):
|
||||
model = _model([_input("seed", "integer"), _input("prompt", "string", required=True)])
|
||||
kwargs = {"seed": -1, "prompt": "hi"}
|
||||
snapshot = dict(kwargs)
|
||||
arguments_mod.build_arguments(model, kwargs)
|
||||
assert kwargs == snapshot
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for fal error extraction and FalApiError formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeHTTPError(Exception):
|
||||
"""Duck-typed stand-in for fal_client.FalClientHTTPError."""
|
||||
|
||||
def __init__(self, message, status_code, payload):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response = _FakeResponse(payload)
|
||||
|
||||
|
||||
def test_error_message_includes_model_and_status(errors_mod):
|
||||
err = errors_mod.FalApiError("fal-ai/flux/dev", "boom", 422)
|
||||
assert "fal-ai/flux/dev" in str(err)
|
||||
assert "boom" in str(err)
|
||||
assert "422" in str(err)
|
||||
|
||||
|
||||
def test_extract_string_detail(errors_mod):
|
||||
exc = _FakeHTTPError("HTTP 403", 403, {"detail": "Content policy violation"})
|
||||
message, status = errors_mod.extract_error_message(exc)
|
||||
assert message == "Content policy violation"
|
||||
assert status == 403
|
||||
|
||||
|
||||
def test_extract_validation_list(errors_mod):
|
||||
exc = _FakeHTTPError(
|
||||
"HTTP 422", 422,
|
||||
{"detail": [
|
||||
{"loc": ["body", "prompt"], "msg": "field required"},
|
||||
{"loc": ["body", "seed"], "msg": "not an int"},
|
||||
]},
|
||||
)
|
||||
message, status = errors_mod.extract_error_message(exc)
|
||||
assert "prompt: field required" in message
|
||||
assert "seed: not an int" in message
|
||||
assert status == 422
|
||||
|
||||
|
||||
def test_extract_falls_back_to_str(errors_mod):
|
||||
message, status = errors_mod.extract_error_message(RuntimeError("plain failure"))
|
||||
assert message == "plain failure"
|
||||
assert status is None
|
||||
|
||||
|
||||
def test_extract_survives_bad_response_json(errors_mod):
|
||||
exc = _FakeHTTPError("HTTP 500", 500, ValueError("not json"))
|
||||
message, status = errors_mod.extract_error_message(exc)
|
||||
assert message # falls back to str(exc)
|
||||
assert status == 500
|
||||
|
||||
|
||||
def test_raise_fal_error_chains(errors_mod):
|
||||
original = RuntimeError("root cause")
|
||||
with pytest.raises(errors_mod.FalApiError) as excinfo:
|
||||
errors_mod.raise_fal_error("some-model", original)
|
||||
assert excinfo.value.__cause__ is original
|
||||
|
||||
|
||||
def test_raise_fal_error_passthrough(errors_mod):
|
||||
already = errors_mod.FalApiError("m", "msg")
|
||||
with pytest.raises(errors_mod.FalApiError) as excinfo:
|
||||
errors_mod.raise_fal_error("other", already)
|
||||
assert excinfo.value is already
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Integration: the loaded pack must keep every legacy key and stay coherent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LEGACY_SNAPSHOT = Path(__file__).with_name("legacy_node_keys.json")
|
||||
|
||||
|
||||
def test_all_legacy_keys_present(pack):
|
||||
"""Backward-compat lock: keys registered at v1.0.12 must never disappear."""
|
||||
legacy = set(json.loads(LEGACY_SNAPSHOT.read_text())["keys"])
|
||||
current = set(pack.NODE_CLASS_MAPPINGS)
|
||||
missing = legacy - current
|
||||
assert not missing, f"legacy node keys removed (breaks user workflows): {sorted(missing)}"
|
||||
|
||||
|
||||
def test_display_names_complete(pack):
|
||||
missing = [k for k in pack.NODE_CLASS_MAPPINGS if k not in pack.NODE_DISPLAY_NAME_MAPPINGS]
|
||||
assert not missing
|
||||
|
||||
|
||||
def test_dynamic_nodes_registered(pack):
|
||||
dynamic = [k for k in pack.NODE_CLASS_MAPPINGS if k.startswith("FalAPI_")]
|
||||
assert len(dynamic) > 500, "dynamic registry failed to load"
|
||||
assert "FalAnyEndpoint_fal" in pack.NODE_CLASS_MAPPINGS
|
||||
|
||||
|
||||
def test_every_node_class_is_valid(pack):
|
||||
for key, cls in pack.NODE_CLASS_MAPPINGS.items():
|
||||
input_types = cls.INPUT_TYPES()
|
||||
assert isinstance(input_types, dict), key
|
||||
assert "required" in input_types or "optional" in input_types, key
|
||||
assert isinstance(cls.RETURN_TYPES, tuple), key
|
||||
assert isinstance(cls.FUNCTION, str) and hasattr(cls, cls.FUNCTION), key
|
||||
assert isinstance(cls.CATEGORY, str) and cls.CATEGORY, key
|
||||
|
||||
|
||||
def test_no_bare_video_category_left(pack):
|
||||
bare = [
|
||||
k for k, cls in pack.NODE_CLASS_MAPPINGS.items()
|
||||
if cls.CATEGORY.lower() == "video"
|
||||
]
|
||||
assert not bare, f"nodes escaped the FAL/ menu namespace: {bare}"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Unit tests for result→ComfyUI-outputs mapping (media decode stubbed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from helpers import _model
|
||||
|
||||
_VIDEO_SENTINEL = object()
|
||||
_AUDIO_SENTINEL = {"waveform": "stub", "sample_rate": 44100}
|
||||
|
||||
|
||||
class _FakeMediaUtils:
|
||||
@staticmethod
|
||||
def video_from_url(_url):
|
||||
return _VIDEO_SENTINEL
|
||||
|
||||
@staticmethod
|
||||
def audio_from_url(_url):
|
||||
return _AUDIO_SENTINEL
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_media(monkeypatch, outputs_mod):
|
||||
monkeypatch.setattr(outputs_mod, "MediaUtils", _FakeMediaUtils)
|
||||
|
||||
|
||||
def test_return_specs_cover_all_kinds(outputs_mod):
|
||||
assert set(outputs_mod.RETURN_SPECS) >= {
|
||||
"images", "image", "video", "audio", "text", "file", "json",
|
||||
}
|
||||
for types, names in outputs_mod.RETURN_SPECS.values():
|
||||
assert len(types) == len(names)
|
||||
|
||||
|
||||
def test_video_result(outputs_mod):
|
||||
model = _model([], output_kind="video", output_props=["video"])
|
||||
result = {"video": {"url": "https://fal.media/v.mp4"}}
|
||||
out = outputs_mod.process_result(model, result)
|
||||
assert out == (_VIDEO_SENTINEL, "https://fal.media/v.mp4")
|
||||
|
||||
|
||||
def test_audio_result(outputs_mod):
|
||||
model = _model([], output_kind="audio", output_props=["audio"])
|
||||
result = {"audio": {"url": "https://fal.media/a.mp3"}}
|
||||
out = outputs_mod.process_result(model, result)
|
||||
assert out == (_AUDIO_SENTINEL, "https://fal.media/a.mp3")
|
||||
|
||||
|
||||
def test_text_result(outputs_mod):
|
||||
model = _model([], output_kind="text", output_props=["text"])
|
||||
assert outputs_mod.process_result(model, {"text": "hello"}) == ("hello",)
|
||||
|
||||
|
||||
def test_file_result_digs_url(outputs_mod):
|
||||
model = _model([], output_kind="file", output_props=["model_glb"])
|
||||
result = {"model_glb": {"url": "https://fal.media/m.glb"}}
|
||||
assert outputs_mod.process_result(model, result) == ("https://fal.media/m.glb",)
|
||||
|
||||
|
||||
def test_json_fallback(outputs_mod):
|
||||
model = _model([], output_kind="json", output_props=[])
|
||||
result = {"anything": [1, 2, 3]}
|
||||
(payload,) = outputs_mod.process_result(model, result)
|
||||
assert json.loads(payload) == result
|
||||
|
||||
|
||||
def test_find_url_recursive(outputs_mod):
|
||||
nested = {"a": [{"b": {"url": "https://x/y.bin"}}]}
|
||||
assert outputs_mod.find_url(nested) == "https://x/y.bin"
|
||||
assert outputs_mod.find_url({"no": "url here"}) is None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Validate the committed model registry — pure JSON, no heavy imports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REGISTRY = Path(__file__).resolve().parents[1] / "data" / "fal_registry.json"
|
||||
|
||||
VALID_INPUT_TYPES = {"string", "integer", "number", "boolean", "enum", "object", "array", "json"}
|
||||
VALID_OUTPUT_KINDS = {"images", "image", "video", "audio", "text", "file", "json"}
|
||||
VALID_MEDIA_KINDS = {None, "image", "video", "audio", "file"}
|
||||
|
||||
|
||||
def _registry():
|
||||
return json.loads(REGISTRY.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_top_level_shape():
|
||||
reg = _registry()
|
||||
assert reg["version"] == 1
|
||||
assert reg["model_count"] == len(reg["models"])
|
||||
assert reg["model_count"] > 500
|
||||
|
||||
|
||||
def test_models_well_formed():
|
||||
reg = _registry()
|
||||
seen_ids = set()
|
||||
for model in reg["models"]:
|
||||
eid = model["endpoint_id"]
|
||||
assert eid and "/" in eid, f"bad endpoint_id: {eid!r}"
|
||||
assert eid not in seen_ids, f"duplicate endpoint_id: {eid}"
|
||||
seen_ids.add(eid)
|
||||
assert model["title"], f"{eid}: missing title"
|
||||
assert model["category"], f"{eid}: missing category"
|
||||
assert model["output_kind"] in VALID_OUTPUT_KINDS, f"{eid}: {model['output_kind']}"
|
||||
assert isinstance(model["inputs"], list)
|
||||
|
||||
|
||||
def test_inputs_well_formed():
|
||||
reg = _registry()
|
||||
for model in reg["models"]:
|
||||
eid = model["endpoint_id"]
|
||||
names = set()
|
||||
for inp in model["inputs"]:
|
||||
name = inp["name"]
|
||||
assert name not in names, f"{eid}: duplicate input {name}"
|
||||
names.add(name)
|
||||
assert inp["type"] in VALID_INPUT_TYPES, f"{eid}.{name}: {inp['type']}"
|
||||
assert inp.get("media_kind") in VALID_MEDIA_KINDS, f"{eid}.{name}"
|
||||
if inp["type"] == "enum":
|
||||
assert inp.get("enum"), f"{eid}.{name}: enum without values"
|
||||
|
||||
|
||||
def test_enum_defaults_are_members_or_custom_size():
|
||||
reg = _registry()
|
||||
for model in reg["models"]:
|
||||
for inp in model["inputs"]:
|
||||
if inp["type"] == "enum" and inp.get("default") is not None:
|
||||
if inp["default"] in inp["enum"]:
|
||||
continue
|
||||
# two legitimate non-member shapes exist in the wild:
|
||||
# 1. has_custom_size enums defaulting to an explicit
|
||||
# {width, height} object (mapped to the custom_size preset)
|
||||
# 2. multi-select enums (is_list) defaulting to a list of
|
||||
# members (mapped to a comma-separated string widget)
|
||||
if inp.get("has_custom_size") and isinstance(inp["default"], dict):
|
||||
continue
|
||||
if inp.get("is_list") and isinstance(inp["default"], list):
|
||||
assert all(v in inp["enum"] for v in inp["default"]), (
|
||||
f"{model['endpoint_id']}.{inp['name']}: list default "
|
||||
f"contains non-members"
|
||||
)
|
||||
continue
|
||||
raise AssertionError(
|
||||
f"{model['endpoint_id']}.{inp['name']}: default "
|
||||
f"{inp['default']!r} not in enum"
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Unit tests for the schema→INPUT_TYPES converter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from helpers import _input, _model
|
||||
|
||||
|
||||
def test_required_and_optional_buckets(schema_to_inputs):
|
||||
model = _model([
|
||||
_input("prompt", "string", required=True, multiline=True),
|
||||
_input("guidance", "number", default=3.5, min=1, max=20),
|
||||
])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
assert "prompt" in it["required"]
|
||||
assert "guidance" in it["optional"]
|
||||
assert it["required"]["prompt"][0] == "STRING"
|
||||
assert it["required"]["prompt"][1]["multiline"] is True
|
||||
|
||||
|
||||
def test_enum_becomes_dropdown(schema_to_inputs):
|
||||
model = _model([_input("style", "enum", enum=["a", "b"], default="b")])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
spec = it["optional"]["style"]
|
||||
assert spec[0] == ["a", "b"]
|
||||
assert spec[1]["default"] == "b"
|
||||
|
||||
|
||||
def test_int_range_and_default_clamp(schema_to_inputs):
|
||||
model = _model([_input("steps", "integer", default=28, min=1, max=50)])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
typ, opts = it["optional"]["steps"]
|
||||
assert typ == "INT"
|
||||
assert opts["min"] == 1 and opts["max"] == 50 and opts["default"] == 28
|
||||
|
||||
|
||||
def test_seed_spec(schema_to_inputs):
|
||||
model = _model([_input("seed", "integer", required=True)])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
# seed is always optional regardless of the API marking it required
|
||||
typ, opts = it["optional"]["seed"]
|
||||
assert typ == "INT"
|
||||
assert opts["default"] == -1
|
||||
assert opts["min"] == -1
|
||||
assert opts.get("control_after_generate") is True
|
||||
|
||||
|
||||
def test_media_inputs(schema_to_inputs):
|
||||
model = _model([
|
||||
_input("image_url", "string", required=True, media_kind="image"),
|
||||
_input("video_url", "string", media_kind="video"),
|
||||
_input("audio_url", "string", media_kind="audio"),
|
||||
])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
assert it["required"]["image_url"][0] == "IMAGE"
|
||||
assert it["optional"]["video_url"][0] == "VIDEO"
|
||||
assert it["optional"]["audio_url"][0] == "AUDIO"
|
||||
|
||||
|
||||
def test_custom_size_companions(schema_to_inputs):
|
||||
model = _model([
|
||||
_input(
|
||||
"image_size", "enum",
|
||||
enum=["square", "landscape_4_3", "custom_size"],
|
||||
default="landscape_4_3", has_custom_size=True,
|
||||
)
|
||||
])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
assert "width" in it["optional"] and "height" in it["optional"]
|
||||
assert it["optional"]["width"][0] == "INT"
|
||||
|
||||
|
||||
def test_dict_default_maps_to_custom_size(schema_to_inputs):
|
||||
model = _model([
|
||||
_input(
|
||||
"image_size", "enum",
|
||||
enum=["square", "custom_size"],
|
||||
default={"width": 2048, "height": 1536},
|
||||
has_custom_size=True,
|
||||
)
|
||||
])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
assert it["optional"]["image_size"][1]["default"] == "custom_size"
|
||||
assert it["optional"]["width"][1]["default"] == 2048
|
||||
assert it["optional"]["height"][1]["default"] == 1536
|
||||
|
||||
|
||||
def test_multi_select_enum_is_comma_string(schema_to_inputs):
|
||||
model = _model([
|
||||
_input("stems", "enum", enum=["vocals", "drums", "bass"],
|
||||
default=["vocals", "drums"], is_list=True)
|
||||
])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
typ, opts = it["optional"]["stems"]
|
||||
assert typ == "STRING"
|
||||
assert opts["default"] == "vocals, drums"
|
||||
assert "vocals, drums, bass" in opts["tooltip"]
|
||||
|
||||
|
||||
def test_json_field_is_multiline_string(schema_to_inputs):
|
||||
model = _model([_input("loras", "json")])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
typ, opts = it["optional"]["loras"]
|
||||
assert typ == "STRING"
|
||||
assert opts["multiline"] is True
|
||||
|
||||
|
||||
def test_force_rerun_always_present(schema_to_inputs):
|
||||
it = schema_to_inputs.build_input_types(_model([]))
|
||||
typ, opts = it["optional"]["force_rerun"]
|
||||
assert typ == "BOOLEAN"
|
||||
assert opts["default"] is False
|
||||
|
||||
|
||||
def test_every_input_has_tooltip_when_description_given(schema_to_inputs):
|
||||
model = _model([_input("prompt", "string", required=True, description="What to draw")])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
assert it["required"]["prompt"][1]["tooltip"] == "What to draw"
|
||||
Reference in New Issue
Block a user