Compare commits

...
85 Commits
Author SHA1 Message Date
Gokay Aydogan aba69be9a2 feat: v2.0.0 — full fal catalog coverage, dynamic nodes, core rewrite
Auto-generated nodes for the entire live fal catalog (1,391 models →
1,482 total nodes) built at startup from a committed registry, plus a
full rewrite of the core layer. All 87 v1 node keys, inputs and outputs
are preserved — existing workflows load and run unchanged.

New:
- data/fal_registry.json + scripts/build_registry.py: distills fal's
  catalog and per-endpoint OpenAPI schemas into a deterministic registry
- nodes/dynamic/: schema→node factory (typed widgets, tooltips, pricing
  in node help, IMAGE/VIDEO/AUDIO auto-upload, native VIDEO/AUDIO
  outputs, seed -1=random, force_rerun), plus a generic Fal Any Endpoint
  node for arbitrary endpoints
- nodes/utils/: rewritten core — FalApiError surfacing fal's real error
  payloads, queue progress logs, ComfyUI cancel support, retries and
  timeouts everywhere, parallel uploads/downloads, structured logging
- tests/ (45 tests incl. a legacy-key snapshot lock), CI lint+test
  workflow, weekly registry-refresh workflow that opens a PR on changes
- README regenerated from the registry (scripts/build_readme.py)

Fixed:
- GPTImage2 / GPTImage2Edit were defined but never registered
- Topaz video upscale sent a nonexistent API field (desired_increase)
- SeedVR video upscaler had its error handling commented out + wrong
  model label; ProRes enum value mismatch translated at argument level
- 10x `is ""` comparisons, bare excepts, missing HTTP timeouts,
  temp-file leaks, fps=0 crash in LoadVideoURL, duplicate enum entry
- opencv-python & friends missing from pyproject (Registry installs)
- API failures now raise visible errors instead of silently returning
  blank images or "Error:" strings that downstream nodes tried to load

BREAKING (behavioral only): failed API calls raise instead of returning
blank/black images or error strings. Node signatures are unchanged.
2026-07-02 12:42:06 +03:00
Kozatiju 1b14ab3164 Update image_node.py (#71) 2026-05-05 20:43:46 +03:00
Rodolfo FantiandClaude Opus 4.6 a728d7e3ba Add Kling 3.0 (V3 + O3) and Nano Banana 2 model support (#66)
Add 7 new node classes covering 12 fal.ai endpoints:

Kling V3 (4 nodes):
- V3 Standard/Pro unified T2V/I2V with 3-15s duration, native audio, end frame control
- V3 Standard/Pro Motion Control for character animation via motion transfer

Kling O3 (2 nodes):
- O3 Standard/Pro unified T2V/I2V with 3-15s duration and native audio

Nano Banana 2 (1 node):
- Unified T2I/Edit with multi-resolution (0.5K-4K), web search grounding, and seed support

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:58:56 +03:00
648d4b5ab2 Added Wan 2.6 Unified I2V/T2V and Reference to Video Nodes. Added GPT Image 1.5 (#62)
* Add new video generation nodes and URL loader

- Added Kling v2.1 Pro and v2.5 Turbo Pro image-to-video nodes
- Added Sora 2 Pro image-to-video node
- Added Veo 3.1 and Veo 3.1 Fast first-last-frame-to-video nodes
- Added FalVideoURLLoader for converting video URLs to VideoHelperSuite-compatible IMAGE tensors
- FalVideoURLLoader includes automatic FPS detection with override option

* Update VLM and LLM nodes to OpenRouter API

- Migrated VLM node to openrouter/router/vision endpoint
- Migrated LLM node to openrouter/router endpoint
- Updated to latest model versions (Gemini 2.5, Claude Sonnet 4.5, GPT-4.1, etc.)
- Added multi-image support for VLM node
- Added temperature, max_tokens, and reasoning parameters
- Added custom model name support for both nodes
- Added reasoning output for LLM node
- Disabled streaming for consistent response handling

* Update README.md

* Removed redundant node that i created by accident

* Fix Veo 3.1 endpoint routing with conditional logic

- Added conditional routing to Veo 3.1 Standard node
- Added conditional routing to Veo 3.1 Fast node
- When last_frame is not provided, routes to image-to-video endpoints
- When last_frame is provided, routes to first-last-frame-to-video endpoints
- All parameters (duration, aspect_ratio, resolution, audio) work with both modes

* add nano banana pro

* Update README with Nano Banana Pro node

* change nano banana image input to accept batch tensor (14 images) and add example workflow

* Add Kling v2.6 Pro unified T2V/I2V node

* Add Wan 2.6 and GPT-Image 1.5 nodes

Video nodes:
- Wan 2.6 Video Generation: Unified T2V/I2V with conditional routing
- Wan 2.6 Reference-to-Video: Subject consistency with up to 3 reference videos

Image nodes:
- GPT-Image 1.5: High-fidelity text-to-image generation
- GPT-Image 1.5 Edit: Batch image editing (up to 16 images) with optional mask

---------

Co-authored-by: ShadowxShinigamI <shadowshingami123@gmail.com>
Co-authored-by: ShadoWxShinigamI <116374738+ShadoWxShinigamI@users.noreply.github.com>
2025-12-19 22:54:53 +03:00
Caitlyn E. Clabaugh 809cf424b4 Adjusted parameters of Wan2214bAnimate nodes (#59) 2025-12-10 00:44:38 +03:00
Caitlyn E. ClabaughandCaitlyn Clabaugh 56ac5e9613 Add nodes for Kling Omni endpoints & support multiple concurrent requests (#54)
* Made vace_mask_video argument optional in DYWanFun22Node

* Add nodes for Kling Omni endpoints: KlingO1ImageToVideoNode, KlingO1ReferenceToVideoNode, KlingO1VideoToVideoEditNode, and KlingO1VideoToVideoReferenceNode

* Added option to submit multiple requests asynchronously and get a list of results back

---------

Co-authored-by: Caitlyn Clabaugh <caitlyn.clabaugh.-nd@disney.com>
2025-12-02 10:58:20 +03:00
Caitlyn E. ClabaughandCaitlyn Clabaugh 492a963bd4 Add nodes for Qwen-Image-Edit-Plus with LoRA support, Seedance Pro image-to-video, Wan 2.2 VACE Fun 14b pose and depth, and custom DY endpoints (#53)
* Add Seedance Pro node with start/end frame support

* Wan 22 VACE Fun a14b (#3)

Added node for wan-22-vace-fun-a14b endpoints (depth, pose, etc.) with multi-control support

Simplified uploade of ref_images in Wan22VACEFun14bNode using fal utils

Limit Wan 2.2 VACE Fun 14b to pose and depth because will need aditional inputs for outpainting, inpainting, and reframe

* DY Custom Wan Fun 22 Node (#4)

Add custom DY Wan 2.2 Fun endpoint for complex video-to-video

Standardized default seed to be -1 and only set seed if not -1

Added optional mask video for DYWanFun22Node

* Dy Wan Upscaler (#5)

* Updated DYWanFun22Node to always set control strengths.
* Added custom DYWanUpscalerNode.

* Added node for Qwen Image Edit Plus with LoRAs support (#6)

* Updated README

---------

Co-authored-by: Caitlyn Clabaugh <caitlyn.clabaugh.-nd@disney.com>
2025-11-22 13:38:54 +03:00
Harsha B SubramanyamandShadowxShinigamI 57b78dcca3 Add Nano Banana Pro (#52)
* Add new video generation nodes and URL loader

- Added Kling v2.1 Pro and v2.5 Turbo Pro image-to-video nodes
- Added Sora 2 Pro image-to-video node
- Added Veo 3.1 and Veo 3.1 Fast first-last-frame-to-video nodes
- Added FalVideoURLLoader for converting video URLs to VideoHelperSuite-compatible IMAGE tensors
- FalVideoURLLoader includes automatic FPS detection with override option

* Update VLM and LLM nodes to OpenRouter API

- Migrated VLM node to openrouter/router/vision endpoint
- Migrated LLM node to openrouter/router endpoint
- Updated to latest model versions (Gemini 2.5, Claude Sonnet 4.5, GPT-4.1, etc.)
- Added multi-image support for VLM node
- Added temperature, max_tokens, and reasoning parameters
- Added custom model name support for both nodes
- Added reasoning output for LLM node
- Disabled streaming for consistent response handling

* Update README.md

* Removed redundant node that i created by accident

* Fix Veo 3.1 endpoint routing with conditional logic

- Added conditional routing to Veo 3.1 Standard node
- Added conditional routing to Veo 3.1 Fast node
- When last_frame is not provided, routes to image-to-video endpoints
- When last_frame is provided, routes to first-last-frame-to-video endpoints
- All parameters (duration, aspect_ratio, resolution, audio) work with both modes

* add nano banana pro

* Update README with Nano Banana Pro node

* change nano banana image input to accept batch tensor (14 images) and add example workflow

---------

Co-authored-by: ShadowxShinigamI <shadowshingami123@gmail.com>
2025-11-22 13:37:35 +03:00
VLT Media aa6e5b9531 Added Pixverse Swap and Infinity Star Text to Video (#49)
* Feature: Added prepare_images to make handling of batch images cleaner and easier for nodes.

Feature: Added images input to Nano Banana Edit (fal) so that a user isn't just stuck with only 4 image inputs. Had to do it this way to not break current user workflows.

* Feature: Added Infinity Start Text To Video Node.
Feature: Added Pixverse Swap Node.
2025-11-18 17:46:33 +03:00
Harsha B SubramanyamandShadowxShinigamI f6a650d407 Add new video generation nodes AND updated LLM and VLM nodes (#50)
* Add new video generation nodes and URL loader

- Added Kling v2.1 Pro and v2.5 Turbo Pro image-to-video nodes
- Added Sora 2 Pro image-to-video node
- Added Veo 3.1 and Veo 3.1 Fast first-last-frame-to-video nodes
- Added FalVideoURLLoader for converting video URLs to VideoHelperSuite-compatible IMAGE tensors
- FalVideoURLLoader includes automatic FPS detection with override option

* Update VLM and LLM nodes to OpenRouter API

- Migrated VLM node to openrouter/router/vision endpoint
- Migrated LLM node to openrouter/router endpoint
- Updated to latest model versions (Gemini 2.5, Claude Sonnet 4.5, GPT-4.1, etc.)
- Added multi-image support for VLM node
- Added temperature, max_tokens, and reasoning parameters
- Added custom model name support for both nodes
- Added reasoning output for LLM node
- Disabled streaming for consistent response handling

* Update README.md

* Removed redundant node that i created by accident

* Fix Veo 3.1 endpoint routing with conditional logic

- Added conditional routing to Veo 3.1 Standard node
- Added conditional routing to Veo 3.1 Fast node
- When last_frame is not provided, routes to image-to-video endpoints
- When last_frame is provided, routes to first-last-frame-to-video endpoints
- All parameters (duration, aspect_ratio, resolution, audio) work with both modes

---------

Co-authored-by: ShadowxShinigamI <shadowshingami123@gmail.com>
2025-11-18 17:46:14 +03:00
Gökay Aydoğan 54b3182c6a Update pyproject.toml 2025-10-31 21:30:28 +03:00
VLT Media 830a467f54 Added Upscale Nodes, along with Krea Wan 2.1 , Wan 2.2 Animate: Move and Flux Pro v1 Fill (#48)
* Feature: Added Bria Video Increase Resolution, Wan 2.2 Animate Replace , and Upload File nodes.
Feature: Added Bria Video Increase Resolution example workflow.
Chore: Added optional input_video_url to Wan VACE Video Edit

* Feature: Seedvr Upscale Video node added

* Feature: Added Topaz Upscale Video

* Chore: Added new video and upscale nodes

* Feature: Added Krea Wan 14b Video-to-Video and Wan 2.2 14b Animate: Move Character nodes.

* Bug: Removed wrong resolution values from Animate Move

* Feature: Added Flux v1 Fill Node and workflow example

* Bug: Fixed incorrect Seedvr Upscale Video  upscale_mode value.
2025-10-31 21:30:00 +03:00
Gökay Aydoğan 1fb220258f Update pyproject.toml 2025-10-26 10:36:29 +03:00
VLT Media 5382b69e64 Added Wan VACE Video Edit Node & upload_file method. (#47)
* Feature: Added Wan VACE Video Edit Node
Featuire: Added upload_file to aid in uploading data that isn't just images.

* Chore: Added Wan VACE return value label

* Chore: Added Wan VACE to README
2025-10-26 10:36:10 +03:00
Gökay Aydoğan 95b8a044ec Update pyproject.toml 2025-10-22 02:41:20 +03:00
Gökay Aydoğan 6273ea0fb2 Merge pull request #46 from mcmonkey4eva/add-new-image-apis
add 3 missing model apis and add missing entries to the readme
2025-10-22 02:41:04 +03:00
Alex "mcmonkey" Goodwin f65213ceb4 add 3 missing model apis and add missing entries to the readme 2025-10-21 16:26:59 -07:00
Gökay Aydoğan 1e60cc4a0b Update pyproject.toml 2025-10-20 01:41:17 +03:00
Gökay Aydoğan 7cd2900150 Merge pull request #45 from k1t4/feature/add_seedvr_upscaler
Added seedvr upscaler support
2025-10-20 01:40:32 +03:00
Chebanov Nikita 4200bbcedf keep clarity upscaler naming 2025-10-20 01:39:31 +03:00
Chebanov Nikita fedda31284 Added seedvr upscaler support 2025-10-20 01:14:24 +03:00
Gökay Aydoğan 105f6a9083 Update pyproject.toml 2025-10-18 01:17:22 +03:00
Gökay Aydoğan f65b8ea0fa Update image_node.py 2025-10-18 01:16:54 +03:00
Gökay Aydoğan 3f27dd7887 Merge pull request #43 from lericogit/feature/add-wan25-node
Add Wan2.5 node support
2025-10-03 14:33:51 +03:00
lericogit b95ff2c86e Fix README for Wan2.5 node 2025-10-03 13:04:31 +02:00
lericogit 04f19b26c2 Update README for Wan2.5 node 2025-10-03 13:00:44 +02:00
lericogit 13a05b8d6a Add Wan2.5 node support 2025-10-03 12:50:23 +02:00
Gökay Aydoğan a4f22a114b Update pyproject.toml 2025-09-17 19:32:06 +03:00
Gökay Aydoğan be7f74ebee Merge pull request #42 from jimlee2048/feat-seedream4.0
feat: support Seedream 4.0 Edit
2025-09-17 19:31:42 +03:00
Jim Lee 331ed2c058 feat: support Seedream 4.0 Edit 2025-09-18 00:16:34 +08:00
Gökay Aydoğan 97049f29c8 Update pyproject.toml 2025-09-11 16:13:03 +03:00
Gökay Aydoğan 9d8c754e8a Merge pull request #41 from gokayfem/change-optional
change optional image number
2025-09-11 16:12:49 +03:00
gökay aydoğan f9b21a5e93 change optional image number 2025-09-11 16:12:08 +03:00
Gökay Aydoğan fbee93b5b5 Update pyproject.toml 2025-09-11 15:55:31 +03:00
Gökay Aydoğan 845b9d46c5 Update requirements.txt 2025-09-05 11:34:43 +03:00
Gökay Aydoğan 31572e6e45 Merge pull request #39 from PierrunoYT/feature/add-qwen-image-edit
feat: add Qwen Image Edit node with parallel CFG support
2025-09-05 11:32:45 +03:00
Gökay Aydoğan b60c18d8a8 Merge pull request #40 from gokayfem/nano-banana
nano banana edit
2025-09-05 11:32:19 +03:00
gokayfem 58c54acbce nano banana edit 2025-09-05 11:31:40 +03:00
PierrunoYTandClaude 27580456ed feat: add Qwen Image Edit node with parallel CFG support
- Added QwenImageEdit class to nodes/image_node.py
- Supports image editing with text prompts using fal-ai/qwen-image-edit endpoint
- Features flexible image sizing, inference control, and acceleration options
- Includes safety checker, output format selection, and seed control
- Added to NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS
- Follows existing code patterns for consistency and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 17:30:08 +02:00
Gökay Aydoğan a68c56134c Merge pull request #33 from jimlee2048/main
feat: support SeedEdit 3.0
2025-07-20 18:40:57 +03:00
Jim Lee cd9eb99568 feat: support SeedEdit 3.0 2025-07-20 22:55:01 +08:00
Gökay Aydoğan ef774a511b Update pyproject.toml 2025-06-20 13:38:43 +03:00
Gökay Aydoğan 66d4dcf54d Merge pull request #28 from KhDu/feature/adding_new_nodes
Added new fal model nodes (Veo3 +Seedance + Imagen4)
2025-06-20 13:38:28 +03:00
Gökay Aydoğan a6d061c0eb Update image_node.py 2025-06-20 13:37:07 +03:00
KhDu 34d3a8396e Reverted Flux Kontext Max to being a boolean switch 2025-06-19 21:04:41 +03:00
KhDu 06a30a6f21 Added Veo3 model 2025-06-19 18:29:55 +03:00
KhDu f4f486edb0 Added Kontext Multi, seperated Kontext Max into its own node.
Added Seedance Video model.

Added Imagen4 Image model.
2025-06-19 18:21:20 +03:00
KhDu 1f6f476679 added imagen4 text-to-image, and seedance image-to-video 2025-06-16 21:21:37 +03:00
Gökay Aydoğan 1e561ac944 Update pyproject.toml 2025-06-02 20:39:18 +03:00
Gökay Aydoğan cf523888a7 Merge pull request #26 from gokayfem/big-refactor-cleaning
fix: big refactor and cleaning
2025-06-02 20:38:59 +03:00
gokayfem 93aa2cbc04 fix: big refactor and cleaning 2025-06-02 19:03:45 +03:00
Gökay Aydoğan 5be02175f3 Update pyproject.toml 2025-06-01 14:35:30 +03:00
gokayfem 4ff17aa6ef Merge branch 'main' of https://github.com/gokayfem/ComfyUI-FLUX-fal-API 2025-06-01 14:23:34 +03:00
gokayfem a6d29a2d4c readme 2025-06-01 14:23:13 +03:00
Gökay Aydoğan 4215edebf0 Merge pull request #23 from gokayfem/api-key-setup
feat: api key setup
2025-06-01 14:15:50 +03:00
gokayfem a5c55fd44b api key setup 2025-06-01 14:14:29 +03:00
Gökay Aydoğan 63cb3fcf0d Update pyproject.toml 2025-05-31 02:35:57 +03:00
gokayfem e1faa49e25 new workflow examples 2025-05-31 02:35:01 +03:00
Gökay Aydoğan c8c437b495 Merge pull request #22 from gokayfem/pr-21
Add Kontext
2025-05-31 02:09:04 +03:00
gokayfem 81f9031625 add none 2025-05-31 02:06:14 +03:00
Holger Will 1c1be8ae31 fix: remove opencv dependency to avoid ComfyUI ecosystem conflicts 2025-05-30 22:38:47 +02:00
Holger Will cd5c9ef258 feat: add example workflow 2025-05-30 20:42:11 +02:00
Holger Will ec8880895d fix: add missing aspect_ratio parameter to Kontext image generation methods and fix text-to-image endpoints. 2025-05-30 20:12:34 +02:00
Holger Will f8b1efa75d fix: update endpoint paths for multi quality image generation in image_node.py 2025-05-30 17:51:40 +02:00
Holger Will 975d555e29 feat: add new Flux Pro Kontext image generation nodes and update README and requirements 2025-05-30 15:56:47 +02:00
Gökay Aydoğan 4988995bf7 Merge pull request #11 from ComfyNodePRs/pyproject
Add pyproject.toml for Custom Node Registry
2025-05-28 05:55:31 +03:00
Gökay Aydoğan ee026dd560 Merge pull request #12 from ComfyNodePRs/publish
Add Github Action for Publishing to Comfy Registry
2025-05-28 05:55:22 +03:00
Gökay Aydoğan 93d6ad2875 Update pyproject.toml 2025-05-28 05:55:07 +03:00
Gökay Aydoğan c22792a581 Merge pull request #18 from venturero/new_image_nodes_2
New image nodes 2
2025-05-28 05:40:07 +03:00
Gökay Aydoğan 779e0b1028 Merge pull request #20 from gokayfem/trainers
feat: add wan and ltx trainer
2025-05-28 05:39:01 +03:00
gokayfem 2f7f43da45 feat: add wan and ltx trainer 2025-05-28 05:38:19 +03:00
semiventurero a8bdb5bc6d image_node.py file updated with ideogramv3 2025-05-25 14:14:16 +03:00
semiventurero c3fae085d6 hidream and ideogram 2025-05-25 13:56:12 +03:00
Gökay Aydoğan 1c67dda258 Merge pull request #15 from pixelworldai/main
Updated: KlingMaster/WanPro/CombinedVideoGeneration
2025-05-08 16:36:53 +03:00
pixelworld AI 96b0cd0976 docs: Add Wan Pro 2025-05-07 21:35:55 -05:00
pixelworld AI 116bfbd4e0 feat: Add Wan Pro, update Luma and Minimax endpoints, add Luma end image support 2025-05-07 21:35:29 -05:00
pixelworld AI 2797366781 feat: enhance video generation capabilities
- Add Kling Pro v1.6 node with tail image support

- Rename original Kling Pro to v1.0 for clarity

- Add Kling Master v2.0 node

- Update Combined Video Generation node:

  - Add service toggles for each provider

  - Use Kling Pro v1.6 instead of v1.0

  - Add version numbers to output names

  - Maintain concurrent processing of enabled services

- Fix API key initialization in combined node

- Add proper error handling for disabled services
2025-05-06 21:15:06 -05:00
pixelworld AI ebecad477a docs: update video generation section with new nodes and versions
- Add Kling Pro v1.0 and v1.6 nodes

- Add Kling Master v2.0 node

- Add MiniMax nodes (standard, text-to-video, subject reference)

- Add Google Veo2 node

- Add Video Upscaler node

- Add Combined Video Generation node with service toggles

- Update node descriptions and version information
2025-05-06 21:14:59 -05:00
Jacob Garner a46b9465e6 Veo2 and multivid update 2025-04-28 10:12:57 -05:00
Jacob Garner 1d2ecc823e SyncClient 2025-04-28 10:12:52 -05:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") a8c202b045 chore(publish): Add Github Action for Publishing to Comfy Registry 2025-04-26 06:00:37 +00:00
async () = process.env.GIT_USERNAME || (await ghUser()).email && (await ghUser()).name || DIE("Missing env.GIT_USERNAME") e2a41cc5ff chore(pyproject): Add pyproject.toml for Custom Node Registry 2025-04-26 06:00:37 +00:00
Gökay Aydoğan 68328f8526 Merge pull request #10 from gokayfem/sync-client
Sync client
2025-04-24 14:54:44 +03:00
pixelworldai 6a4b736773 fixed duration/aspect ratio definitions 2025-04-11 19:20:26 -05:00
pixelworldai cfd626541b Update video_node.py
Added Combined Video Generation (fal), for simultaneous Kling Pro, Luma Dream, and Minimax image2video generation.
2025-04-11 17:53:43 -05:00
52 changed files with 15214 additions and 1067 deletions
+64
View File
@@ -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\")
"
+28
View File
@@ -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 }}
+47
View File
@@ -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
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
{
"workbench.colorTheme": "Community Material Theme Ocean High Contrast"
}
+1786 -29
View File
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -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"]
+2
View File
@@ -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
}
+211
View File
@@ -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
}
+376
View File
@@ -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
}
+261
View File
@@ -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
}
+25
View File
@@ -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"]
+104
View File
@@ -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"]
}
]
}
+335
View File
@@ -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())
+190
View File
@@ -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),
)
+120
View File
@@ -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
+100
View File
@@ -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)
+124
View File
@@ -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),)
+126
View File
@@ -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 {}, {}
+185
View File
@@ -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}
+28
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+105 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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)",
}
+20
View File
@@ -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",
]
+212
View File
@@ -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)
+114
View File
@@ -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
+89
View File
@@ -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
+188
View File
@@ -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,)
+22
View File
@@ -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()
+276
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+103 -65
View File
@@ -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)",
}
}
+33
View File
@@ -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
View File
@@ -1,2 +1,6 @@
fal-client
torch
fal-client>=1.0,<2
torch
opencv-python
numpy
pillow
requests
+139
View File
@@ -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())
+640
View File
@@ -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()
+61
View File
@@ -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")
+38
View File
@@ -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}
+92
View File
@@ -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"
]
}
+6
View File
@@ -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 = .
+133
View File
@@ -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
+79
View File
@@ -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
+45
View File
@@ -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}"
+72
View File
@@ -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
+78
View File
@@ -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"
)
+117
View File
@@ -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"