Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d9392bf7 |
@@ -9,24 +9,14 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
sidebar-test:
|
||||
name: Frontend regression tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --experimental-vm-modules --test tests/test_*.mjs
|
||||
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install ruff
|
||||
@@ -43,9 +33,9 @@ jobs:
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
@@ -54,7 +44,7 @@ jobs:
|
||||
pip install pytest
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d tests ]; then python -m pytest tests -x -q; else echo "no tests yet"; fi
|
||||
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
|
||||
|
||||
@@ -1,103 +1,49 @@
|
||||
name: Refresh fal model registry
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/registry-refresh.yml"
|
||||
- "data/fal_registry.json"
|
||||
- "scripts/build_readme.py"
|
||||
- "scripts/build_registry.py"
|
||||
- "scripts/validate_registry.py"
|
||||
- "tests/test_registry_format.py"
|
||||
- "tests/test_build_registry.py"
|
||||
- "tests/test_validate_registry.py"
|
||||
schedule:
|
||||
# Daily, off the hour to avoid peak GitHub Actions scheduling delays.
|
||||
- cron: "17 4 * * *"
|
||||
# Every Monday at 06:00 UTC
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
allow_large_change:
|
||||
description: Allow endpoint additions/removals above the automatic safety limits
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
allow_input_removal:
|
||||
description: Allow reviewed removal of existing input controls or enum choices
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: fal-registry-refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Validate and publish registry refresh
|
||||
name: Rebuild registry and open PR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out the default branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build candidate registry
|
||||
run: >-
|
||||
python scripts/build_registry.py
|
||||
--out data/fal_registry.next.json
|
||||
--preserve-from data/fal_registry.json
|
||||
|
||||
- name: Validate candidate against the committed registry
|
||||
env:
|
||||
ALLOW_LARGE_CHANGE: ${{ inputs.allow_large_change || 'false' }}
|
||||
ALLOW_INPUT_REMOVAL: ${{ inputs.allow_input_removal || 'false' }}
|
||||
- name: Rebuild registry
|
||||
run: python scripts/build_registry.py --out data/fal_registry.json
|
||||
- name: Regenerate MODELS.md
|
||||
run: python scripts/build_readme.py
|
||||
- name: Summarize changes
|
||||
id: diff
|
||||
run: |
|
||||
args=(
|
||||
data/fal_registry.next.json
|
||||
--baseline data/fal_registry.json
|
||||
--max-removal-fraction 0.05
|
||||
--max-addition-fraction 0.25
|
||||
)
|
||||
if [[ "$ALLOW_LARGE_CHANGE" == "true" ]]; then
|
||||
args+=(--allow-large-change)
|
||||
fi
|
||||
if [[ "$ALLOW_INPUT_REMOVAL" == "true" ]]; then
|
||||
args+=(--allow-input-removal)
|
||||
fi
|
||||
python scripts/validate_registry.py "${args[@]}"
|
||||
{
|
||||
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`.
|
||||
|
||||
- name: Promote candidate and regenerate model catalog
|
||||
run: |
|
||||
mv data/fal_registry.next.json data/fal_registry.json
|
||||
python scripts/build_readme.py
|
||||
|
||||
- name: Run refresh safety checks
|
||||
run: |
|
||||
python -m pip install pytest
|
||||
python -m pytest tests/test_registry_format.py tests/test_validate_registry.py tests/test_build_registry.py -q
|
||||
git diff --check
|
||||
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
if git diff --quiet -- data/fal_registry.json MODELS.md; then
|
||||
echo "Registry is already current."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add data/fal_registry.json MODELS.md
|
||||
git commit -m "chore: refresh fal model registry"
|
||||
git pull --rebase origin main
|
||||
git push origin HEAD:main
|
||||
```
|
||||
${{ steps.diff.outputs.stat }}
|
||||
```
|
||||
delete-branch: true
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
cff-version: 1.2.0
|
||||
message: "If you use ComfyUI-fal-API in your work, please cite it using the metadata below."
|
||||
type: software
|
||||
title: "ComfyUI-fal-API"
|
||||
version: "2.5.0"
|
||||
date-released: 2026-07-02
|
||||
authors:
|
||||
- family-names: "Aydoğan"
|
||||
given-names: "Gökay"
|
||||
orcid: "https://orcid.org/0000-0002-2343-9433"
|
||||
abstract: "A ComfyUI integration for the fal model catalog, including curated and generated nodes, workflow utilities, caching, and spend controls."
|
||||
keywords:
|
||||
- ComfyUI
|
||||
- fal
|
||||
- generative AI
|
||||
- image generation
|
||||
- video generation
|
||||
- API integration
|
||||
license: Apache-2.0
|
||||
repository-code: "https://github.com/gokayfem/ComfyUI-fal-API"
|
||||
url: "https://github.com/gokayfem/ComfyUI-fal-API"
|
||||
+3
-14
@@ -6,19 +6,10 @@ Thanks for helping! Before you write anything, read this — it will probably sa
|
||||
|
||||
Historically, adding a model to this pack meant hand-writing a node class. **That is no longer how it works.** Every live public model on fal gets a node automatically, generated at ComfyUI startup from the committed snapshot at `data/fal_registry.json`. No node class, no mapping entry, no code.
|
||||
|
||||
The snapshot stays fresh three ways:
|
||||
The snapshot stays fresh two ways:
|
||||
|
||||
- A **daily GitHub Action** (`.github/workflows/registry-refresh.yml`) builds a candidate, validates it against the committed baseline, and commits safe changes automatically.
|
||||
- A **weekly GitHub Action** (`.github/workflows/registry-refresh.yml`) rebuilds the registry and opens a PR.
|
||||
- Anyone can run it locally: `python scripts/build_registry.py --out data/fal_registry.json` (then `python scripts/build_readme.py` to regenerate [MODELS.md](MODELS.md)).
|
||||
- The fal sidebar can rebuild the local registry; restart ComfyUI afterward so new node classes register.
|
||||
|
||||
The automated validator rejects malformed records, duplicate or unsorted endpoint IDs, suspiciously small catalogs, and unexpectedly large additions or removals. A maintainer can override the change-size thresholds when manually dispatching the workflow; all structural checks still apply.
|
||||
|
||||
Refresh validation also rejects removed input controls, lost dropdowns, and removed enum choices on existing endpoints. Inspect these changes against the live API before using the separate `allow_input_removal` workflow option (or `validate_registry.py --allow-input-removal`). The sidebar builds and validates a candidate before replacing the local snapshot, so a failed refresh keeps the previous registry intact.
|
||||
|
||||
The generator handles nested references, composed schemas, nullable types and literal choices for every endpoint, and never caps the number of exposed fields. Short string examples become suggested dropdown choices with a **custom value** input; they are not treated as exhaustive enums. Prompts, prose and URLs remain text/media controls. Fix schema patterns in the shared generator and widget/argument translators rather than adding model-specific patches. Add upstream schema fixtures and regressions in `tests/test_build_registry.py`; H3 and the catalog's suggested controls are checked against the shipped registry too.
|
||||
|
||||
Missing endpoints are preserved by default with `deprecated: true`, which keeps their node keys available under `FAL/Compatibility` for old workflows. Use `--prune-missing` only for an intentional breaking cleanup. A fresh endpoint record automatically replaces its deprecated copy if it returns to the live catalog.
|
||||
|
||||
To check whether a model is already covered:
|
||||
|
||||
@@ -56,7 +47,6 @@ If you're writing one, the rules are non-negotiable:
|
||||
pip install -r requirements.txt
|
||||
python -m pytest tests # the suite MUST pass
|
||||
ruff check . # lint, same as CI
|
||||
node --experimental-vm-modules --test tests/test_*.mjs
|
||||
```
|
||||
|
||||
CI runs both on every PR (Python 3.10 and 3.12). The most important test to understand is the **compatibility snapshot**: `tests/test_mappings.py` asserts that every node key recorded in `tests/legacy_node_keys.json` still registers. If your change makes it fail, the fix is to restore the key — not to edit the snapshot.
|
||||
@@ -65,8 +55,7 @@ CI runs both on every PR (Python 3.10 and 3.12). The most important test to unde
|
||||
|
||||
```
|
||||
scripts/build_registry.py queries fal's platform APIs → writes the snapshot
|
||||
scripts/validate_registry.py safety gate for generated registry candidates
|
||||
data/fal_registry.json committed model catalog (~1,400 models)
|
||||
data/fal_registry.json committed model catalog (~1,391 models)
|
||||
data/featured_models.json curation: featured tier + display-name overrides
|
||||
scripts/build_readme.py renders MODELS.md from the snapshot
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Every fal model in ComfyUI, one API key.**
|
||||
|
||||
Custom nodes that bring the entire [fal.ai](https://fal.ai) catalog into ComfyUI: ~90 curated hand-written nodes for the most popular models, plus ~1,400 auto-generated nodes covering every live public model on fal — image, video, audio, 3D, LLMs and more. One `FAL_KEY` unlocks all of them. With a persistent result cache (never pay for the same call twice), spend guards, async fan-out, and zero-I/O fal→fal chaining. The exact current catalog lives in [MODELS.md](MODELS.md).
|
||||
Custom nodes that bring the entire [fal.ai](https://fal.ai) catalog into ComfyUI: ~90 curated hand-written nodes for the most popular models, plus ~1,391 auto-generated nodes covering every live public model on fal — image, video, audio, 3D, LLMs and more. One `FAL_KEY` unlocks all of them. With a persistent result cache (never pay for the same call twice), spend guards, async fan-out, and zero-I/O fal→fal chaining. The full model catalog lives in [MODELS.md](MODELS.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -20,20 +20,13 @@ Custom nodes that bring the entire [fal.ai](https://fal.ai) catalog into ComfyUI
|
||||
|
||||
## What's New
|
||||
|
||||
### 2.5.1
|
||||
|
||||
- **Schema controls across the catalog** — nested schema definitions and literal choices are resolved, and inputs are no longer truncated by a 40-field limit.
|
||||
- **Editable suggestions** — modes, voices, languages and model IDs with short example values get dropdowns with an **Enter custom value…** option. Saved text values and `STRING` connections keep working.
|
||||
- **Safer updates** — refresh remains available when only existing models changed. Scheduled and sidebar refreshes reject disappearing controls or choices before replacing the registry.
|
||||
|
||||
### 2.5
|
||||
|
||||
- **Async execution** — fal calls run asynchronously on modern ComfyUI, so the UI stays responsive while jobs are in flight.
|
||||
- **Typed builder nodes** — build LoRA lists and reference-element configs with dedicated, connectable nodes instead of hand-typed JSON.
|
||||
- **Featured tier** — hand-picked models surface under **FAL/Featured** in the node menu, and models superseded by newer versions are flagged.
|
||||
- **Registry freshness** — the pack warns when the committed model registry snapshot is stale, and you can refresh it from the fal sidebar without leaving ComfyUI.
|
||||
- **Automatic daily catalog updates** — a guarded GitHub workflow validates and commits new fal endpoints without waiting for a manual PR merge. Missing historical endpoints remain registered under **FAL/Compatibility** so saved workflows still load.
|
||||
- **MODELS.md** — the full ~1,400-model catalog moved out of this README into [MODELS.md](MODELS.md).
|
||||
- **MODELS.md** — the full ~1,391-model catalog moved out of this README into [MODELS.md](MODELS.md).
|
||||
|
||||
### 2.4
|
||||
|
||||
@@ -53,32 +46,23 @@ Platform utilities under `FAL/Platform`: **Fal Submit + Fal Collect** for parall
|
||||
|
||||
### 2.0
|
||||
|
||||
Every live public model on fal became a node: ~1,400 auto-generated nodes built at startup from the committed `data/fal_registry.json`, with native `IMAGE`/`VIDEO`/`AUDIO` sockets, schema-derived tooltips, and pricing in the help panel. Plus the generic **Fal Any Endpoint** node, visible errors (failed calls raise fal's actual error message instead of silently returning blanks), progress + cancellation, and full backward compatibility for all ~90 curated nodes.
|
||||
Every live public model on fal became a node: ~1,391 auto-generated nodes built at startup from the committed `data/fal_registry.json`, with native `IMAGE`/`VIDEO`/`AUDIO` sockets, schema-derived tooltips, and pricing in the help panel. Plus the generic **Fal Any Endpoint** node, visible errors (failed calls raise fal's actual error message instead of silently returning blanks), progress + cancellation, and full backward compatibility for all ~90 curated nodes.
|
||||
|
||||
## Installation
|
||||
|
||||
The recommended installation method is **ComfyUI Manager**: search for
|
||||
`ComfyUI-fal-API`, install it, and restart ComfyUI. Manager installs the
|
||||
dependencies into the same Python environment ComfyUI uses.
|
||||
|
||||
For a manual installation:
|
||||
|
||||
1. Navigate to your ComfyUI custom-nodes directory and clone this repository:
|
||||
1. Navigate to your ComfyUI custom nodes directory:
|
||||
```
|
||||
cd custom_nodes
|
||||
```
|
||||
2. Clone this repository:
|
||||
```
|
||||
git clone https://github.com/gokayfem/ComfyUI-fal-API.git
|
||||
cd ComfyUI-fal-API
|
||||
```
|
||||
2. Install the dependencies with **ComfyUI's Python**, not an unrelated system
|
||||
`pip`:
|
||||
3. Install the required dependencies:
|
||||
```
|
||||
python -m pip install -r requirements.txt
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
From the root of **ComfyUI Windows Portable**, use its embedded interpreter:
|
||||
```powershell
|
||||
.\python_embeded\python.exe -m pip install -r .\ComfyUI\custom_nodes\ComfyUI-fal-API\requirements.txt
|
||||
```
|
||||
3. Configure your API key (below) and restart ComfyUI. Curated nodes appear under the **FAL** category, auto-generated nodes under **FAL/Models/<category>** (e.g. `FAL/Models/text-to-image`), and hand-picked models under **FAL/Featured** — or just search for any model by name.
|
||||
4. Configure your API key (below) and restart ComfyUI. Curated nodes appear under the **FAL** category, auto-generated nodes under **FAL/Models/<category>** (e.g. `FAL/Models/text-to-image`), and hand-picked models under **FAL/Featured** — or just search for any model by name.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -138,7 +122,7 @@ Find them in the node browser under `FAL/Models/<category>`, or search by model
|
||||
|
||||
**Fal Any Endpoint (fal)** is the escape hatch: one generic node that calls *any* fal endpoint by id with free-form JSON arguments plus optional image/video/audio inputs (uploaded and merged into the matching keys). Outputs are extracted as `IMAGE`/`VIDEO`/`AUDIO`, with the raw result always available as `result_json`. Even brand-new models work the day they launch.
|
||||
|
||||
**Keeping the catalog fresh:** a daily GitHub Action builds a candidate registry, validates its structure and endpoint changes, regenerates [MODELS.md](MODELS.md), and commits the result when it is safe. Large unexpected catalog changes are blocked rather than published. Endpoints missing from a refresh are retained under **FAL/Compatibility** instead of deleting their node keys and breaking saved workflows. You can also run `python scripts/build_registry.py` yourself (then `python scripts/build_readme.py`), or use the refresh button in the fal sidebar. Install updates through ComfyUI Manager to receive the latest committed snapshot; locally refreshed nodes appear after restarting ComfyUI. If ~1,400 extra nodes is more than you want, the `[dynamic_nodes]` config section disables or filters them — see [Configuration](#configuration).
|
||||
**Keeping the catalog fresh:** a weekly GitHub Action refreshes `data/fal_registry.json` and opens a PR; you can also run `python scripts/build_registry.py` yourself (then `python scripts/build_readme.py` to regenerate MODELS.md), or use the refresh button in the fal sidebar. If ~1,391 extra nodes is more than you want, the `[dynamic_nodes]` config section disables or filters them — see [Configuration](#configuration).
|
||||
|
||||
## Platform Utilities
|
||||
|
||||
@@ -184,52 +168,20 @@ The full LoRA-training pipeline needs nothing else: Load Image Folder → Batch
|
||||
```
|
||||
cd custom_nodes/ComfyUI-fal-API
|
||||
git pull
|
||||
python -m pip install -r requirements.txt
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
3. **Windows Portable Python is blocked or no longer starts after installing a node?**
|
||||
Do not keep rerunning `pip`. From the portable root, first check the exact
|
||||
interpreter and dependency state:
|
||||
```powershell
|
||||
.\python_embeded\python.exe -c "import sys; print(sys.executable); print(sys.version)"
|
||||
.\python_embeded\python.exe -m pip check
|
||||
3. If you're using ComfyUI Windows Portable, you may need to install fal-client manually:
|
||||
```
|
||||
ComfyUI_windows_portable>.\python_embeded\python.exe -m pip install fal-client
|
||||
```
|
||||
This project installs Python packages only; it does not replace or modify
|
||||
`python.exe`. If the first command itself is blocked or the executable was
|
||||
quarantined, review Windows Security **Protection history**. Restore a file
|
||||
only when the portable archive came from the official ComfyUI release, or
|
||||
re-extract a clean official portable build and move your `models`, `input`,
|
||||
`output`, and `user` data across. Avoid disabling antivirus globally. Then
|
||||
reinstall this node through ComfyUI Manager, or use the exact embedded-
|
||||
interpreter requirements command from the Installation section.
|
||||
4. **Dynamic nodes not appearing?** Check the ComfyUI console for a line like `Registered N dynamic fal nodes` at startup. If it says the nodes are disabled, remove `enabled = false` from the `[dynamic_nodes]` section of your `config.ini` (and check the `categories` filter isn't excluding what you're looking for). Any registry loading error is also printed there.
|
||||
5. **`VIDEO` output is `None` or video sockets are missing?** Update ComfyUI — native `VIDEO`/`AUDIO` types require a recent ComfyUI version.
|
||||
6. **API calls failing?** Failed fal requests raise visible errors that include fal's actual error message (validation issues, content policy, quota). Read the error text in ComfyUI — it usually tells you exactly which parameter to fix.
|
||||
7. **Missing duration, resolution, or other model controls?** Update the pack in ComfyUI Manager, then use **fal sidebar → Registry → Refresh registry**. The button is available even when no new model IDs are found: existing APIs can add or change controls without adding a model. Restart ComfyUI and reload the browser afterward. If an existing canvas node still shows old widgets, add a fresh instance of the same node. H3 generation nodes expose **duration (5–15 seconds)**; H3 Max and Max Turbo expose **480P / 768P / 1080P** and a **disabled / balanced / quality** prompt expansion dropdown, following the current [H3 Max API](https://fal.ai/models/minimax/h3-max/image-to-video/api). Original H3 uses its own schema options, including the `fast` prompt expansion mode.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome — but note that **a new fal model usually needs no code at all**: it appears automatically via the registry. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR; it covers when a hand-written node is (and isn't) justified, the compatibility rules, and dev setup.
|
||||
|
||||
<details>
|
||||
<summary><strong>Cite this project</strong></summary>
|
||||
|
||||
If ComfyUI-fal-API supports your work, please cite the software. GitHub also
|
||||
provides ready-to-copy APA and BibTeX entries via **Cite this repository**.
|
||||
|
||||
```bibtex
|
||||
@software{Aydogan_ComfyUI_fal_API_2026,
|
||||
author = {Aydoğan, Gökay},
|
||||
title = {ComfyUI-fal-API},
|
||||
version = {2.5.0},
|
||||
year = {2026},
|
||||
url = {https://github.com/gokayfem/ComfyUI-fal-API}
|
||||
}
|
||||
```
|
||||
|
||||
[ORCID](https://orcid.org/0000-0002-2343-9433) · [Citation metadata](CITATION.cff)
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,7 +67,7 @@ def _multi_enum_argument(endpoint: str, inp: dict[str, Any], value: Any) -> Any
|
||||
selected = [part.strip() for part in str(value).split(",") if part.strip()]
|
||||
if not selected:
|
||||
return None
|
||||
allowed = {str(member): member for member in inp.get("enum") or []}
|
||||
allowed = set(inp.get("enum") or [])
|
||||
invalid = [part for part in selected if part not in allowed]
|
||||
if invalid:
|
||||
raise FalApiError(
|
||||
@@ -75,7 +75,7 @@ def _multi_enum_argument(endpoint: str, inp: dict[str, Any], value: Any) -> Any
|
||||
f"Invalid value(s) {invalid} for '{inp['name']}'. "
|
||||
f"Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
return [allowed[part] for part in selected]
|
||||
return selected
|
||||
|
||||
|
||||
def _enum_argument(inp: dict[str, Any], value: Any, kwargs: dict[str, Any]) -> Any:
|
||||
|
||||
@@ -107,8 +107,6 @@ def _superseded_map(models: list[dict[str, Any]]) -> dict[str, tuple[str, str]]:
|
||||
"""
|
||||
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for model in models:
|
||||
if _truthy(model.get("deprecated")):
|
||||
continue
|
||||
family = str(model.get("family") or "").strip()
|
||||
if not family or not model.get("endpoint_id"):
|
||||
continue
|
||||
@@ -139,15 +137,6 @@ def _apply_superseded_note(node_class: type, newest_id: str, newest_date: str) -
|
||||
node_class.DESCRIPTION = f"{note}\n\n{existing}".rstrip()
|
||||
|
||||
|
||||
def _apply_deprecated_note(node_class: type, reason: str) -> None:
|
||||
"""Mark a compatibility-only endpoint without breaking its node key."""
|
||||
note = "Compatibility node: this endpoint is absent from the latest live fal catalog."
|
||||
if reason:
|
||||
note = f"{note} {reason}"
|
||||
existing = str(getattr(node_class, "DESCRIPTION", "") or "")
|
||||
node_class.DESCRIPTION = f"{note}\n\n{existing}".rstrip()
|
||||
|
||||
|
||||
def _unique_display_name(name: str, used: set[str]) -> str:
|
||||
if name not in used:
|
||||
return name
|
||||
@@ -162,7 +151,7 @@ def _build_model_mappings(
|
||||
categories: set[str],
|
||||
featured: dict[str, str | None] | None = None,
|
||||
superseded: dict[str, tuple[str, str]] | None = None,
|
||||
) -> tuple[dict[str, type], dict[str, str], int, int, int]:
|
||||
) -> tuple[dict[str, type], dict[str, str], int, int]:
|
||||
classes: dict[str, type] = {}
|
||||
display: dict[str, str] = {}
|
||||
used_names: set[str] = {ANY_ENDPOINT_DISPLAY_NAME}
|
||||
@@ -170,7 +159,6 @@ def _build_model_mappings(
|
||||
superseded = superseded or {}
|
||||
skipped = 0
|
||||
flagged = 0
|
||||
deprecated_count = 0
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
@@ -185,20 +173,11 @@ def _build_model_mappings(
|
||||
endpoint_id = str(model.get("endpoint_id") or "")
|
||||
|
||||
preferred = build_display_name(model)
|
||||
deprecated = _truthy(model.get("deprecated"))
|
||||
if deprecated:
|
||||
category = str(model.get("category") or "other")
|
||||
node_class.CATEGORY = f"FAL/Compatibility/{category}"
|
||||
preferred = f"[Unavailable] {preferred}"
|
||||
_apply_deprecated_note(
|
||||
node_class, str(model.get("deprecated_reason") or "").strip()
|
||||
)
|
||||
deprecated_count += 1
|
||||
elif endpoint_id in featured:
|
||||
if endpoint_id in featured:
|
||||
category = str(model.get("category") or "other")
|
||||
node_class.CATEGORY = f"FAL/Featured/{category}"
|
||||
preferred = featured[endpoint_id] or preferred
|
||||
if not deprecated and endpoint_id in superseded:
|
||||
if endpoint_id in superseded:
|
||||
newest_id, newest_date = superseded[endpoint_id]
|
||||
_apply_superseded_note(node_class, newest_id, newest_date)
|
||||
flagged += 1
|
||||
@@ -215,7 +194,7 @@ def _build_model_mappings(
|
||||
err,
|
||||
)
|
||||
|
||||
return classes, display, skipped, flagged, deprecated_count
|
||||
return classes, display, skipped, flagged
|
||||
|
||||
|
||||
def _log_missing_featured(featured: dict[str, str | None], models: list[dict[str, Any]]) -> int:
|
||||
@@ -249,7 +228,7 @@ def load_dynamic_mappings() -> Mappings:
|
||||
featured = _read_featured()
|
||||
featured_count = _log_missing_featured(featured, models)
|
||||
superseded = _superseded_map(models)
|
||||
classes, display, skipped, flagged, deprecated_count = _build_model_mappings(
|
||||
classes, display, skipped, flagged = _build_model_mappings(
|
||||
models, categories, featured=featured, superseded=superseded
|
||||
)
|
||||
|
||||
@@ -258,12 +237,11 @@ def load_dynamic_mappings() -> Mappings:
|
||||
|
||||
logger.info(
|
||||
"Registered %d dynamic fal nodes (skipped %d, featured %d, "
|
||||
"%d superseded, %d compatibility-preserved)",
|
||||
"%d flagged as superseded within their family)",
|
||||
len(all_classes),
|
||||
skipped,
|
||||
featured_count,
|
||||
flagged,
|
||||
deprecated_count,
|
||||
)
|
||||
_schedule_freshness_check()
|
||||
return all_classes, all_display
|
||||
|
||||
@@ -71,7 +71,7 @@ def _multi_enum_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
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(str(value) for value in values)}".strip()
|
||||
tooltip = f"{description} Comma-separated. Options: {', '.join(values)}".strip()
|
||||
return ("STRING", {"default": text, "tooltip": tooltip})
|
||||
|
||||
|
||||
@@ -113,10 +113,6 @@ def _string_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
|
||||
"default": default if isinstance(default, str) else "",
|
||||
"multiline": bool(inp.get("multiline")),
|
||||
}
|
||||
if inp.get("suggestions"):
|
||||
# Keep STRING at the API/socket boundary. The frontend presents an
|
||||
# editable dropdown, preserving saved text values and STRING links.
|
||||
opts.update(fal_suggestions=list(inp["suggestions"]), multiline=False)
|
||||
return ("STRING", _with_tooltip(opts, inp.get("description")))
|
||||
|
||||
|
||||
|
||||
+14
-20
@@ -197,7 +197,7 @@ def _search_models(
|
||||
|
||||
# -- registry freshness + refresh -----------------------------------------------
|
||||
|
||||
_RESTART_NOTE = "Restart ComfyUI and reload the browser after the refresh to load updated controls and new nodes."
|
||||
_RESTART_NOTE = "Restart ComfyUI after the refresh finishes: new nodes register at import time."
|
||||
_REFRESH_TIMEOUT_S = 1800
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
@@ -229,29 +229,23 @@ def _refresh_status() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _run_refresh_subprocess() -> tuple[bool, str]:
|
||||
"""Build and validate a candidate before atomically replacing the registry."""
|
||||
"""Run scripts/build_registry.py; returns (ok, message)."""
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
root = _repo_root()
|
||||
baseline = os.path.join(root, "data", "fal_registry.json")
|
||||
with tempfile.TemporaryDirectory(prefix="fal-registry-", dir=os.path.dirname(baseline)) as workdir:
|
||||
candidate = os.path.join(workdir, "candidate.json")
|
||||
commands = [
|
||||
[sys.executable, os.path.join(root, "scripts", "build_registry.py"),
|
||||
"--out", candidate, "--preserve-from", baseline],
|
||||
[sys.executable, os.path.join(root, "scripts", "validate_registry.py"),
|
||||
candidate, "--baseline", baseline],
|
||||
]
|
||||
for command in commands:
|
||||
completed = subprocess.run(
|
||||
command, cwd=root, capture_output=True, text=True, timeout=_REFRESH_TIMEOUT_S
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
tail = (completed.stderr or completed.stdout or "").strip()[-500:]
|
||||
return False, f"{os.path.basename(command[1])} exited with {completed.returncode}: {tail}"
|
||||
os.replace(candidate, baseline)
|
||||
command = [
|
||||
sys.executable,
|
||||
os.path.join(root, "scripts", "build_registry.py"),
|
||||
"--out",
|
||||
os.path.join("data", "fal_registry.json"),
|
||||
]
|
||||
completed = subprocess.run(
|
||||
command, cwd=root, capture_output=True, text=True, timeout=_REFRESH_TIMEOUT_S
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
tail = (completed.stderr or completed.stdout or "").strip()[-500:]
|
||||
return False, f"build_registry.py exited with {completed.returncode}: {tail}"
|
||||
return True, f"Registry refreshed. {_RESTART_NOTE}"
|
||||
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ def _startup_worker() -> None:
|
||||
new_count,
|
||||
)
|
||||
else:
|
||||
logger.debug("fal catalog: no new model IDs found; existing schemas may still have updates")
|
||||
logger.debug("fal catalog: local registry is up to date")
|
||||
except Exception as err:
|
||||
logger.debug("fal registry freshness check failed: %s", err)
|
||||
|
||||
|
||||
@@ -196,9 +196,7 @@ class JobStore:
|
||||
if status:
|
||||
query += " WHERE status = ?"
|
||||
params = (status,)
|
||||
# SQLite's timestamp resolution can tie for back-to-back submits.
|
||||
# rowid preserves insertion order, so the newest job stays first.
|
||||
query += " ORDER BY submitted_at DESC, rowid DESC LIMIT ?"
|
||||
query += " ORDER BY submitted_at DESC LIMIT ?"
|
||||
params = (*params, int(limit))
|
||||
with self._lock:
|
||||
conn = self._connection()
|
||||
|
||||
@@ -33,27 +33,6 @@ def _is_http_url(value: str) -> bool:
|
||||
return value.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _require_http_url(value: Any, operation: str) -> str:
|
||||
"""Return a normalized HTTP(S) URL or raise an actionable fal error.
|
||||
|
||||
``requests`` otherwise turns values such as ``"E"`` (historically the
|
||||
first character of an error string routed through a list output) into a
|
||||
cryptic ``MissingSchema`` exception. Validate at the media boundary so
|
||||
the bad upstream value and the responsible operation remain visible.
|
||||
"""
|
||||
url = str(value or "").strip()
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
|
||||
preview = repr(url if len(url) <= 120 else f"{url[:117]}...")
|
||||
raise FalApiError(
|
||||
operation,
|
||||
f"Expected an HTTP(S) media URL, got {preview}. "
|
||||
"The upstream generation may have failed, or a non-URL output "
|
||||
"may be connected to a media input.",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
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]
|
||||
@@ -185,15 +164,9 @@ class MediaUtils:
|
||||
http(s) URL inputs pass through untouched.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def require_http_url(value: Any, operation: str = "media-download") -> str:
|
||||
"""Public validation hook for legacy nodes that return media URLs."""
|
||||
return _require_http_url(value, operation)
|
||||
|
||||
@staticmethod
|
||||
def download_url_to_temp(url: str, suffix: str) -> str:
|
||||
"""Stream a URL to a temp file and return its local path."""
|
||||
url = _require_http_url(url, "media-download")
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=_DOWNLOAD_TIMEOUT) as resp:
|
||||
|
||||
+2
-125
@@ -2770,16 +2770,8 @@ class SeedanceProImageToVideoNode:
|
||||
"fal-ai/bytedance/seedance/v1/pro/image-to-video", arguments, variations
|
||||
)
|
||||
|
||||
# Validate before exposing URLs to downstream loaders. Older error
|
||||
# paths could leak an error string through this list output; a
|
||||
# downstream loader would then receive its first character ("E")
|
||||
# and raise requests.exceptions.MissingSchema.
|
||||
endpoint = "fal-ai/bytedance/seedance/v1/pro/image-to-video"
|
||||
video_urls = [
|
||||
MediaUtils.require_http_url(r["video"]["url"], endpoint)
|
||||
for r in results
|
||||
]
|
||||
return (video_urls,)
|
||||
# Return list of video URLs
|
||||
return ([r["video"]["url"] for r in results],)
|
||||
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_video_generation_error(
|
||||
@@ -2787,119 +2779,6 @@ class SeedanceProImageToVideoNode:
|
||||
)
|
||||
|
||||
|
||||
class Seedance25VideoToVideoNode:
|
||||
"""Curated Seedance 2.5 video editing node."""
|
||||
|
||||
ENDPOINT = "bytedance/seedance-2.5/reference-to-video"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"video": (
|
||||
"VIDEO",
|
||||
{
|
||||
"tooltip": "Source video to edit. URL-backed VIDEO inputs are passed through without re-uploading.",
|
||||
},
|
||||
),
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Describe the edits to apply to the source video.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"resolution": (
|
||||
["480p", "720p", "1080p"],
|
||||
{"default": "720p", "tooltip": "Output video resolution."},
|
||||
),
|
||||
"generate_audio": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Generate synchronized audio for the edited video.",
|
||||
},
|
||||
),
|
||||
"bitrate_mode": (
|
||||
["standard", "high"],
|
||||
{
|
||||
"default": "standard",
|
||||
"tooltip": "Use the standard bitrate or request a larger, higher-quality encode.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"max": 2147483647,
|
||||
"tooltip": "Random seed for reproducible results; -1 lets the API choose.",
|
||||
},
|
||||
),
|
||||
"force_rerun": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Bypass the persistent result cache and submit a new fal request.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "STRING")
|
||||
RETURN_NAMES = ("video", "video_url")
|
||||
FUNCTION = "edit_video"
|
||||
CATEGORY = "FAL/VideoGeneration"
|
||||
DESCRIPTION = (
|
||||
"Edit one video with Seedance 2.5. The endpoint is always called with "
|
||||
"task='editing' and the source as a single video_urls entry."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, force_rerun=False, **_kwargs):
|
||||
if force_rerun:
|
||||
return float("nan")
|
||||
return False
|
||||
|
||||
def edit_video(
|
||||
self,
|
||||
video,
|
||||
prompt,
|
||||
resolution="720p",
|
||||
generate_audio=True,
|
||||
bitrate_mode="standard",
|
||||
seed=-1,
|
||||
force_rerun=False,
|
||||
):
|
||||
try:
|
||||
uploaded_url = MediaUtils.upload_video(video)
|
||||
arguments = {
|
||||
"prompt": prompt,
|
||||
"task": "editing",
|
||||
"video_urls": [uploaded_url],
|
||||
"resolution": resolution,
|
||||
"generate_audio": generate_audio,
|
||||
"bitrate_mode": bitrate_mode,
|
||||
}
|
||||
if seed != -1:
|
||||
arguments["seed"] = seed
|
||||
|
||||
result = ApiHandler.submit_and_get_result(
|
||||
self.ENDPOINT,
|
||||
arguments,
|
||||
skip_cache=bool(force_rerun),
|
||||
)
|
||||
video_url = MediaUtils.require_http_url(
|
||||
result["video"]["url"], self.ENDPOINT
|
||||
)
|
||||
return (MediaUtils.video_from_url(video_url), video_url)
|
||||
except Exception as e:
|
||||
return ApiHandler.handle_video_generation_error(self.ENDPOINT, e)
|
||||
|
||||
|
||||
class Veo3Node:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -3846,7 +3725,6 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DYWanFun22_fal": DYWanFun22Node,
|
||||
"DYWanUpscaler_fal": DYWanUpscalerNode,
|
||||
"SeedanceImageToVideo_fal": SeedanceImageToVideoNode,
|
||||
"Seedance25VideoToVideo_fal": Seedance25VideoToVideoNode,
|
||||
"SeedanceProImageToVideo_fal": SeedanceProImageToVideoNode,
|
||||
"SeedanceTextToVideo_fal": SeedanceTextToVideoNode,
|
||||
"Veo3_fal": Veo3Node,
|
||||
@@ -3893,7 +3771,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Veo2ImageToVideo_fal": "Google Veo2 Image-to-Video (fal)",
|
||||
"WanPro_fal": "Wan Pro Image-to-Video (fal)",
|
||||
"SeedanceImageToVideo_fal": "Seedance Image-to-Video (fal)",
|
||||
"Seedance25VideoToVideo_fal": "Seedance 2.5 Video-to-Video (fal)",
|
||||
"SeedanceProImageToVideo_fal": "Seedance Pro Image-to-Video (fal)",
|
||||
"SeedanceTextToVideo_fal": "Seedance Text-to-Video (fal)",
|
||||
"Veo3_fal": "Veo3 Video Generation (fal)",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[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.5.1"
|
||||
version = "2.5.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
+9
-21
@@ -27,10 +27,8 @@ END_MARKER = "<!-- END GENERATED MODEL LIST -->"
|
||||
|
||||
MODELS_TEMPLATE = f"""# fal Model Catalog — auto-generated
|
||||
|
||||
Every live auto-generated model node in [ComfyUI-fal-API](README.md), grouped
|
||||
by category (largest first). Click a category to expand it. Historical endpoint
|
||||
schemas retained for workflow compatibility are registered under
|
||||
`FAL/Compatibility` and intentionally omitted from this live catalog.
|
||||
Every auto-generated model node in [ComfyUI-fal-API](README.md), grouped by
|
||||
category (largest first). Click a category to expand it.
|
||||
|
||||
Do not edit this file by hand — refresh `data/fal_registry.json` with
|
||||
`python scripts/build_registry.py`, then regenerate this catalog with
|
||||
@@ -105,19 +103,16 @@ def render_category(category: str, models: list[dict[str, Any]]) -> str:
|
||||
|
||||
def render_generated_section(registry: dict[str, Any]) -> str:
|
||||
models = registry["models"]
|
||||
live_models = [model for model in models if not model.get("deprecated")]
|
||||
live_count = registry.get("live_model_count", len(live_models))
|
||||
deprecated_count = registry.get("deprecated_model_count", len(models) - len(live_models))
|
||||
published = [str(model.get("published_at", "")) for model in live_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"{live_count} live models · {deprecated_count} compatibility-preserved · "
|
||||
f"newest model {generated_date} · "
|
||||
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(live_models)
|
||||
for category, grouped in group_by_category(models)
|
||||
]
|
||||
return "\n\n".join([summary, *blocks])
|
||||
|
||||
@@ -149,20 +144,13 @@ def main() -> int:
|
||||
|
||||
updated = replace_between_markers(document, render_generated_section(registry))
|
||||
if MODELS_PATH.is_file() and updated == document:
|
||||
print(
|
||||
"MODELS.md already up to date "
|
||||
f"({registry.get('live_model_count', registry.get('model_count'))} live, "
|
||||
f"{registry.get('deprecated_model_count', 0)} compatibility-preserved)"
|
||||
)
|
||||
print(f"MODELS.md already up to date ({registry.get('model_count')} models)")
|
||||
return 0
|
||||
|
||||
MODELS_PATH.write_text(updated, encoding="utf-8")
|
||||
print(
|
||||
f"MODELS.md model catalog regenerated: "
|
||||
f"{registry.get('live_model_count', registry.get('model_count'))} live models, "
|
||||
f"{registry.get('deprecated_model_count', 0)} compatibility-preserved, "
|
||||
f"{len(group_by_category([m for m in registry['models'] if not m.get('deprecated')]))} "
|
||||
"categories"
|
||||
f"MODELS.md model catalog regenerated: {registry.get('model_count')} models, "
|
||||
f"{len(group_by_category(registry['models']))} categories"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
+39
-162
@@ -18,7 +18,6 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -32,6 +31,7 @@ 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
|
||||
@@ -171,7 +171,7 @@ def resolve_ref(schema, components):
|
||||
ref = schema.get("$ref", "")
|
||||
if not ref.startswith("#/components/schemas/"):
|
||||
return schema
|
||||
name = ref.rsplit("/", 1)[-1].replace("~1", "/").replace("~0", "~")
|
||||
name = ref.rsplit("/", 1)[-1]
|
||||
resolved = components.get(name)
|
||||
if not isinstance(resolved, dict):
|
||||
return schema
|
||||
@@ -179,6 +179,22 @@ def resolve_ref(schema, components):
|
||||
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)
|
||||
@@ -198,92 +214,30 @@ def is_custom_size_pair(branches):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_schema(schema, components, _seen_refs=frozenset()):
|
||||
"""Resolve nested references and composition without dropping enum choices.
|
||||
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
|
||||
ref = schema.get("$ref")
|
||||
if ref and ref in _seen_refs:
|
||||
return {}, False, None
|
||||
seen = _seen_refs | {ref} if ref else _seen_refs
|
||||
resolved = resolve_ref(schema, components)
|
||||
if "$ref" in resolved and resolved != schema:
|
||||
return normalize_schema(resolved, components, seen)
|
||||
has_custom_size, custom_values = False, None
|
||||
if "allOf" in resolved:
|
||||
merged = {}
|
||||
for branch in resolved["allOf"]:
|
||||
normalized, branch_custom, branch_values = normalize_schema(branch, components, seen)
|
||||
if branch_custom:
|
||||
has_custom_size, custom_values = True, branch_values
|
||||
properties = {**merged.get("properties", {}), **normalized.get("properties", {})}
|
||||
required = list(dict.fromkeys(merged.get("required", []) + normalized.get("required", [])))
|
||||
if "enum" in merged and "enum" in normalized:
|
||||
normalized = {**normalized, "enum": [v for v in merged["enum"] if v in normalized["enum"]]}
|
||||
merged = {**merged, **normalized}
|
||||
if properties:
|
||||
merged["properties"] = properties
|
||||
if required:
|
||||
merged["required"] = required
|
||||
siblings = {key: value for key, value in resolved.items() if key != "allOf"}
|
||||
if "properties" in siblings:
|
||||
siblings["properties"] = {**merged.get("properties", {}), **siblings["properties"]}
|
||||
if "required" in siblings:
|
||||
siblings["required"] = list(dict.fromkeys(merged.get("required", []) + siblings["required"]))
|
||||
resolved = {**merged, **siblings}
|
||||
if "const" in resolved:
|
||||
literal = resolved["const"]
|
||||
resolved = {key: value for key, value in resolved.items() if key != "const"}
|
||||
if literal is None:
|
||||
resolved = {**resolved, "type": "null"}
|
||||
else:
|
||||
resolved = {**resolved, "enum": [literal]}
|
||||
if isinstance(resolved.get("type"), list):
|
||||
types = [value for value in resolved["type"] if value != "null"]
|
||||
if len(types) == 1:
|
||||
resolved = {**resolved, "type": types[0]}
|
||||
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, has_custom_size, custom_values
|
||||
return resolved, False, None
|
||||
|
||||
normalized_branches = [normalize_schema(branch, components, seen) for branch in resolved[branches_key]]
|
||||
normalized_branches = [entry for entry in normalized_branches if entry[0] and entry[0].get("type") != "null"]
|
||||
branches = [entry[0] for entry in normalized_branches]
|
||||
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
|
||||
if len(branches) == 1:
|
||||
branch, branch_custom, branch_values = normalized_branches[0]
|
||||
return {**branch, **siblings}, branch_custom, branch_values
|
||||
|
||||
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
|
||||
|
||||
if all(branch.get("enum") for branch in branches):
|
||||
values = []
|
||||
for branch in branches:
|
||||
for value in branch["enum"]:
|
||||
if value is not None and value not in values:
|
||||
values.append(value)
|
||||
if "enum" in siblings:
|
||||
values = [value for value in values if value in siblings["enum"]]
|
||||
return {**branches[0], **siblings, "enum": values}, False, None
|
||||
|
||||
# An enum plus an open string branch is still an open string. Keep the
|
||||
# literals as suggestions instead of incorrectly restricting API values.
|
||||
open_string = next((b for b in branches if b.get("type") == "string" and not b.get("enum")), None)
|
||||
if open_string is not None and all(
|
||||
b.get("type") == "string" or (b.get("enum") and all(isinstance(value, str) for value in b["enum"]))
|
||||
for b in branches
|
||||
):
|
||||
examples = [value for b in branches for value in b.get("enum", b.get("examples", []))]
|
||||
return {**open_string, "examples": examples, **siblings}, False, None
|
||||
|
||||
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
|
||||
@@ -331,27 +285,6 @@ def scalar_type_of(schema):
|
||||
return "json"
|
||||
|
||||
|
||||
def string_suggestions(name, schema):
|
||||
"""Short example identifiers are suggestions, never strict enum constraints.
|
||||
|
||||
Keep prose, prompts and formatted strings as text. The frontend offers
|
||||
custom values so undocumented languages, voices, model IDs and
|
||||
future modes remain usable even when examples are incomplete.
|
||||
"""
|
||||
if name in MULTILINE_NAMES or name.endswith(("_prompt", "_text")) or schema.get("format"):
|
||||
return None
|
||||
examples = schema.get("examples")
|
||||
if not isinstance(examples, list) or not all(
|
||||
isinstance(value, str) and re.fullmatch(r"[\w./:+-]{1,80}", value) and "://" not in value
|
||||
for value in examples
|
||||
):
|
||||
return None
|
||||
values = list(dict.fromkeys(examples))
|
||||
if len(values) < 2:
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
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("_"):
|
||||
@@ -426,10 +359,6 @@ def distill_property(name, raw_schema, required_names, components):
|
||||
}
|
||||
if has_custom_size:
|
||||
record = {**record, "has_custom_size": True}
|
||||
if type_name == "string" and not is_list and not media_kind:
|
||||
suggestions = string_suggestions(name, schema)
|
||||
if suggestions:
|
||||
record = {**record, "suggestions": suggestions}
|
||||
return record
|
||||
|
||||
|
||||
@@ -446,11 +375,22 @@ def ordered_property_names(schema):
|
||||
|
||||
def distill_inputs(schema, components, endpoint_id):
|
||||
"""Distill an Input schema's properties into registry input records."""
|
||||
schema, _, _ = normalize_schema(schema, components)
|
||||
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)
|
||||
@@ -466,7 +406,7 @@ def distill_inputs(schema, components, endpoint_id):
|
||||
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].replace("~1", "/").replace("~0", "~") if ref.startswith("#/components/schemas/") else None
|
||||
return ref.rsplit("/", 1)[-1] if ref.startswith("#/components/schemas/") else None
|
||||
|
||||
|
||||
def input_ref_from_paths(doc):
|
||||
@@ -501,21 +441,9 @@ def output_ref_from_paths(doc):
|
||||
def select_schema(doc, endpoint_id, suffix, path_lookup):
|
||||
"""Select the app Input/Output schema from components.schemas."""
|
||||
components = doc.get("components", {}).get("schemas", {})
|
||||
# A document can describe several sibling endpoints. Prefer this endpoint's
|
||||
# operation, including inline schemas, over whichever path happens to be first.
|
||||
path = "/" + endpoint_id.strip("/")
|
||||
if suffix == "Input":
|
||||
operation = doc.get("paths", {}).get(path, {}).get("post", {})
|
||||
content = operation.get("requestBody", {}).get("content", {})
|
||||
else:
|
||||
operation = doc.get("paths", {}).get(path + "/requests/{request_id}", {}).get("get", {})
|
||||
content = operation.get("responses", {}).get("200", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema")
|
||||
if isinstance(schema, dict):
|
||||
return normalize_schema(schema, components)[0]
|
||||
referenced = path_lookup(doc)
|
||||
if referenced and referenced in components:
|
||||
return normalize_schema(components[referenced], components)[0]
|
||||
return components[referenced]
|
||||
|
||||
candidates = [name for name in components if name.endswith(suffix)]
|
||||
if not candidates:
|
||||
@@ -528,7 +456,7 @@ def select_schema(doc, endpoint_id, suffix, path_lookup):
|
||||
in normalized_endpoint
|
||||
]
|
||||
pool = matching or candidates
|
||||
return normalize_schema(components[max(pool, key=len)], components)[0]
|
||||
return components[max(pool, key=len)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -620,30 +548,6 @@ def load_json_file(path):
|
||||
raise RuntimeError(f"Failed to load cache file {path}: {error}") from error
|
||||
|
||||
|
||||
def preserve_missing_records(records, baseline):
|
||||
"""Retain historical endpoints so saved ComfyUI workflows keep loading.
|
||||
|
||||
A missing endpoint may be retired or its OpenAPI schema may be temporarily
|
||||
unavailable. The old schema remains usable for node registration and is
|
||||
moved to a compatibility tier at runtime. If the endpoint returns in a
|
||||
later catalog build, its fresh record automatically replaces this copy.
|
||||
"""
|
||||
current_ids = {record["endpoint_id"] for record in records}
|
||||
preserved = []
|
||||
for model in baseline.get("models") or []:
|
||||
if not isinstance(model, dict) or not model.get("endpoint_id"):
|
||||
continue
|
||||
if model["endpoint_id"] in current_ids:
|
||||
continue
|
||||
compatibility_record = {
|
||||
**model,
|
||||
"deprecated": True,
|
||||
"deprecated_reason": "Endpoint absent from the latest live fal catalog or schema fetch.",
|
||||
}
|
||||
preserved.append(compatibility_record)
|
||||
return records + preserved
|
||||
|
||||
|
||||
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")
|
||||
@@ -656,16 +560,6 @@ def parse_args():
|
||||
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")
|
||||
parser.add_argument(
|
||||
"--preserve-from",
|
||||
default=None,
|
||||
help="Registry whose missing endpoints should be retained for workflow compatibility",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prune-missing",
|
||||
action="store_true",
|
||||
help="Drop endpoints missing from the live build instead of preserving compatibility nodes",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -686,11 +580,6 @@ def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
args = parse_args()
|
||||
|
||||
baseline = None
|
||||
baseline_path = args.preserve_from or args.out
|
||||
if not args.prune_missing and os.path.isfile(baseline_path):
|
||||
baseline = load_json_file(baseline_path)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(days=args.since_days) if args.since_days > 0 else None
|
||||
|
||||
@@ -721,11 +610,7 @@ def main():
|
||||
continue
|
||||
records = records + [record]
|
||||
|
||||
live_model_count = len(records)
|
||||
if baseline is not None:
|
||||
records = preserve_missing_records(records, baseline)
|
||||
records = sorted(records, key=lambda record: record["endpoint_id"])
|
||||
deprecated_model_count = sum(bool(record.get("deprecated")) for record in records)
|
||||
|
||||
# NOTE: no wall-clock fields (generated_at etc.) — the committed registry
|
||||
# must be content-deterministic so the weekly refresh workflow only opens a
|
||||
@@ -734,8 +619,6 @@ def main():
|
||||
"version": 1,
|
||||
"window_days": args.since_days,
|
||||
"model_count": len(records),
|
||||
"live_model_count": live_model_count,
|
||||
"deprecated_model_count": deprecated_model_count,
|
||||
"models": records,
|
||||
}
|
||||
|
||||
@@ -756,13 +639,7 @@ def main():
|
||||
os.replace(tmp_out, args.out)
|
||||
|
||||
log_summary(records, skipped)
|
||||
logger.info(
|
||||
"Wrote %d models to %s (%d live, %d compatibility-preserved)",
|
||||
len(records),
|
||||
args.out,
|
||||
live_model_count,
|
||||
deprecated_model_count,
|
||||
)
|
||||
logger.info("Wrote %d models to %s", len(records), args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a generated fal model registry before it replaces the baseline.
|
||||
|
||||
The scheduled registry refresh uses this dependency-free gate to reject
|
||||
truncated catalog responses, duplicate or malformed records, nondeterministic
|
||||
ordering, and unexpectedly large endpoint changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REQUIRED_TOP_LEVEL = {
|
||||
"version",
|
||||
"models",
|
||||
"model_count",
|
||||
"live_model_count",
|
||||
"deprecated_model_count",
|
||||
}
|
||||
REQUIRED_MODEL_FIELDS = {
|
||||
"endpoint_id",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"family",
|
||||
"lab",
|
||||
"pricing",
|
||||
"published_at",
|
||||
"inputs",
|
||||
"output_kind",
|
||||
"output_props",
|
||||
"thumbnail",
|
||||
}
|
||||
OUTPUT_KINDS = {"audio", "file", "image", "images", "json", "text", "video"}
|
||||
INPUT_TYPES = {"string", "integer", "number", "boolean", "enum", "object", "array", "json"}
|
||||
ENDPOINT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
||||
|
||||
|
||||
class RegistryValidationError(ValueError):
|
||||
"""Raised when a registry cannot safely be promoted."""
|
||||
|
||||
|
||||
def load_registry(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RegistryValidationError(f"Could not read {path}: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RegistryValidationError(f"{path} must contain a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_registry(registry: dict[str, Any], *, min_models: int = 500) -> set[str]:
|
||||
missing_top = REQUIRED_TOP_LEVEL - registry.keys()
|
||||
if missing_top:
|
||||
raise RegistryValidationError(f"Registry is missing top-level fields: {sorted(missing_top)}")
|
||||
|
||||
if registry["version"] != 1:
|
||||
raise RegistryValidationError(f"Unsupported registry version: {registry['version']!r}")
|
||||
|
||||
models = registry["models"]
|
||||
if not isinstance(models, list):
|
||||
raise RegistryValidationError("Registry 'models' must be a list")
|
||||
if registry["model_count"] != len(models):
|
||||
raise RegistryValidationError(f"model_count={registry['model_count']} does not match {len(models)} records")
|
||||
deprecated_count = sum(bool(model.get("deprecated")) for model in models if isinstance(model, dict))
|
||||
live_count = len(models) - deprecated_count
|
||||
if registry["live_model_count"] != live_count:
|
||||
raise RegistryValidationError(
|
||||
f"live_model_count={registry['live_model_count']} does not match {live_count} records"
|
||||
)
|
||||
if registry["deprecated_model_count"] != deprecated_count:
|
||||
raise RegistryValidationError(
|
||||
f"deprecated_model_count={registry['deprecated_model_count']} does not match "
|
||||
f"{deprecated_count} records"
|
||||
)
|
||||
if len(models) < min_models:
|
||||
raise RegistryValidationError(f"Registry has only {len(models)} models; minimum is {min_models}")
|
||||
|
||||
endpoint_ids: list[str] = []
|
||||
for index, model in enumerate(models):
|
||||
if not isinstance(model, dict):
|
||||
raise RegistryValidationError(f"Model {index} is not an object")
|
||||
missing = REQUIRED_MODEL_FIELDS - model.keys()
|
||||
if missing:
|
||||
raise RegistryValidationError(f"Model {index} is missing fields: {sorted(missing)}")
|
||||
endpoint_id = model["endpoint_id"]
|
||||
if (
|
||||
not isinstance(endpoint_id, str)
|
||||
or not endpoint_id.strip()
|
||||
or not ENDPOINT_ID_PATTERN.fullmatch(endpoint_id)
|
||||
):
|
||||
raise RegistryValidationError(f"Model {index} has an invalid endpoint_id")
|
||||
if not isinstance(model["title"], str) or not model["title"].strip():
|
||||
raise RegistryValidationError(f"{endpoint_id} has an invalid title")
|
||||
if not isinstance(model["inputs"], list):
|
||||
raise RegistryValidationError(f"{endpoint_id} inputs must be a list")
|
||||
input_names = set()
|
||||
for inp in model["inputs"]:
|
||||
if not isinstance(inp, dict) or not isinstance(inp.get("name"), str) or not inp["name"]:
|
||||
raise RegistryValidationError(f"{endpoint_id} has an invalid input record")
|
||||
name = inp["name"]
|
||||
if name in input_names:
|
||||
raise RegistryValidationError(f"{endpoint_id} has duplicate input {name}")
|
||||
input_names.add(name)
|
||||
if inp.get("type") not in INPUT_TYPES:
|
||||
raise RegistryValidationError(f"{endpoint_id}.{name} has an invalid input type")
|
||||
if not isinstance(inp.get("required"), bool):
|
||||
raise RegistryValidationError(f"{endpoint_id}.{name} required must be a boolean")
|
||||
if inp["type"] == "enum" and (not isinstance(inp.get("enum"), list) or not inp["enum"]):
|
||||
raise RegistryValidationError(f"{endpoint_id}.{name} has no enum choices")
|
||||
if "suggestions" in inp and (
|
||||
inp["type"] != "string" or not isinstance(inp["suggestions"], list)
|
||||
or not inp["suggestions"] or not all(isinstance(v, str) for v in inp["suggestions"])
|
||||
):
|
||||
raise RegistryValidationError(f"{endpoint_id}.{name} has invalid suggestions")
|
||||
if not isinstance(model["output_props"], list):
|
||||
raise RegistryValidationError(f"{endpoint_id} output_props must be a list")
|
||||
if model["output_kind"] not in OUTPUT_KINDS:
|
||||
raise RegistryValidationError(f"{endpoint_id} has unknown output_kind={model['output_kind']!r}")
|
||||
if "deprecated" in model and not isinstance(model["deprecated"], bool):
|
||||
raise RegistryValidationError(f"{endpoint_id} deprecated must be a boolean")
|
||||
endpoint_ids.append(endpoint_id)
|
||||
|
||||
if len(endpoint_ids) != len(set(endpoint_ids)):
|
||||
duplicates = sorted(endpoint_id for endpoint_id in set(endpoint_ids) if endpoint_ids.count(endpoint_id) > 1)
|
||||
raise RegistryValidationError(f"Duplicate endpoint IDs: {duplicates[:10]}")
|
||||
if endpoint_ids != sorted(endpoint_ids):
|
||||
raise RegistryValidationError("Models must be sorted by endpoint_id")
|
||||
return set(endpoint_ids)
|
||||
|
||||
|
||||
def compare_registries(
|
||||
baseline_ids: set[str],
|
||||
candidate_ids: set[str],
|
||||
*,
|
||||
max_removal_fraction: float = 0.05,
|
||||
max_addition_fraction: float = 0.25,
|
||||
allow_large_change: bool = False,
|
||||
) -> tuple[set[str], set[str]]:
|
||||
if not 0 <= max_removal_fraction <= 1:
|
||||
raise RegistryValidationError("max_removal_fraction must be between 0 and 1")
|
||||
if not 0 <= max_addition_fraction <= 1:
|
||||
raise RegistryValidationError("max_addition_fraction must be between 0 and 1")
|
||||
added = candidate_ids - baseline_ids
|
||||
removed = baseline_ids - candidate_ids
|
||||
removal_fraction = len(removed) / len(baseline_ids) if baseline_ids else 0.0
|
||||
addition_fraction = len(added) / len(baseline_ids) if baseline_ids else 0.0
|
||||
if removal_fraction > max_removal_fraction and not allow_large_change:
|
||||
raise RegistryValidationError(
|
||||
f"Candidate removes {len(removed)}/{len(baseline_ids)} endpoints "
|
||||
f"({removal_fraction:.1%}), above the {max_removal_fraction:.1%} limit"
|
||||
)
|
||||
if addition_fraction > max_addition_fraction and not allow_large_change:
|
||||
raise RegistryValidationError(
|
||||
f"Candidate adds {len(added)}/{len(baseline_ids)} endpoints "
|
||||
f"({addition_fraction:.1%}), above the {max_addition_fraction:.1%} limit"
|
||||
)
|
||||
return added, removed
|
||||
|
||||
|
||||
def compare_model_inputs(baseline: dict[str, Any], candidate: dict[str, Any]) -> list[str]:
|
||||
"""Find controls or choices that a refresh would remove from existing nodes.
|
||||
|
||||
Intentional upstream removals require review; silently accepting a partial
|
||||
schema can otherwise publish missing duration/resolution controls as valid.
|
||||
"""
|
||||
previous = {model["endpoint_id"]: model for model in baseline["models"]}
|
||||
regressions = []
|
||||
for model in candidate["models"]:
|
||||
endpoint_id = model["endpoint_id"]
|
||||
if endpoint_id not in previous:
|
||||
continue
|
||||
current = {inp["name"]: inp for inp in model["inputs"]}
|
||||
for old in previous[endpoint_id]["inputs"]:
|
||||
name = old["name"]
|
||||
new = current.get(name)
|
||||
if new is None:
|
||||
regressions.append(f"{endpoint_id}: removed input {name}")
|
||||
elif old["type"] in ("integer", "number", "boolean") and new["type"] == "json":
|
||||
regressions.append(f"{endpoint_id}.{name}: lost {old['type']} control")
|
||||
elif old["type"] == "enum" or old.get("suggestions"):
|
||||
choices = new.get("enum") if new["type"] == "enum" else new.get("suggestions")
|
||||
if not choices:
|
||||
control = "enum control" if old["type"] == "enum" else "suggested choices"
|
||||
regressions.append(f"{endpoint_id}.{name}: lost {control}")
|
||||
else:
|
||||
old_choices = old["enum"] if old["type"] == "enum" else old["suggestions"]
|
||||
lost = [value for value in old_choices if value not in choices]
|
||||
if lost:
|
||||
regressions.append(f"{endpoint_id}.{name}: removed choices {lost}")
|
||||
return regressions
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("candidate", type=Path, help="Generated registry to validate")
|
||||
parser.add_argument("--baseline", type=Path, help="Committed registry to compare")
|
||||
parser.add_argument("--min-models", type=int, default=500)
|
||||
parser.add_argument("--max-removal-fraction", type=float, default=0.05)
|
||||
parser.add_argument("--max-addition-fraction", type=float, default=0.25)
|
||||
parser.add_argument("--allow-large-change", action="store_true")
|
||||
parser.add_argument("--allow-input-removal", action="store_true",
|
||||
help="Allow reviewed removals of existing input controls or enum choices")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
candidate = load_registry(args.candidate)
|
||||
candidate_ids = validate_registry(candidate, min_models=args.min_models)
|
||||
added: set[str] = set()
|
||||
removed: set[str] = set()
|
||||
if args.baseline:
|
||||
baseline = load_registry(args.baseline)
|
||||
baseline_ids = validate_registry(baseline, min_models=args.min_models)
|
||||
added, removed = compare_registries(
|
||||
baseline_ids,
|
||||
candidate_ids,
|
||||
max_removal_fraction=args.max_removal_fraction,
|
||||
max_addition_fraction=args.max_addition_fraction,
|
||||
allow_large_change=args.allow_large_change,
|
||||
)
|
||||
regressions = compare_model_inputs(baseline, candidate)
|
||||
if regressions and not args.allow_input_removal:
|
||||
raise RegistryValidationError(
|
||||
"Candidate removes existing controls; review before using --allow-input-removal:\n"
|
||||
+ "\n".join(regressions[:20])
|
||||
)
|
||||
print(f"Registry valid: {len(candidate_ids)} models " f"(+{len(added)} / -{len(removed)} vs baseline)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,12 +14,6 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG = "ComfyUI_fal_API"
|
||||
|
||||
# Keep local helper modules (notably scripts/) ahead of unrelated installed
|
||||
# packages when pytest is invoked via its console entry point instead of
|
||||
# ``python -m pytest``.
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# never let the freshness daemon make live network calls during tests
|
||||
os.environ.setdefault("FAL_DISABLE_STARTUP_CHECK", "1")
|
||||
|
||||
@@ -76,8 +70,3 @@ def factory_mod():
|
||||
@pytest.fixture(scope="session")
|
||||
def errors_mod():
|
||||
return _submodule("nodes.utils.errors")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def media_mod():
|
||||
return _submodule("nodes.utils.media")
|
||||
|
||||
Vendored
-370
@@ -1,370 +0,0 @@
|
||||
{
|
||||
"minimax/h3/image-to-video": {
|
||||
"source": "https://fal.ai/api/openapi/queue/openapi.json?endpoint_id=minimax/h3/image-to-video",
|
||||
"input": {
|
||||
"title": "ImageToVideoHailuo03Input",
|
||||
"properties": {
|
||||
"seed": {
|
||||
"title": "Seed",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Random seed. A random seed is selected when omitted."
|
||||
},
|
||||
"sync_mode": {
|
||||
"default": false,
|
||||
"title": "Sync Mode",
|
||||
"type": "boolean",
|
||||
"description": "Return the generated video as base64 instead of a CDN URL."
|
||||
},
|
||||
"prompt_expansion_mode": {
|
||||
"default": "balanced",
|
||||
"examples": [
|
||||
"disabled",
|
||||
"fast",
|
||||
"balanced",
|
||||
"quality"
|
||||
],
|
||||
"title": "Prompt Expansion Mode",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "How much effort to spend rewriting the prompt before generation. 'disabled' skips prompt expansion. 'fast' returns in about a second. 'balanced' picks per request. 'quality' spends up to ~30s on a richer prompt."
|
||||
},
|
||||
"prompt": {
|
||||
"maxLength": 50000,
|
||||
"examples": [
|
||||
"The camera slowly pulls back from the scene, revealing the full landscape as clouds drift overhead and light shifts across the terrain."
|
||||
],
|
||||
"minLength": 1,
|
||||
"title": "Prompt",
|
||||
"type": "string",
|
||||
"description": "Text prompt for video generation"
|
||||
},
|
||||
"target_audio_url": {
|
||||
"title": "Target Audio Url",
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"pattern": "\\S",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional URL of a 2-15 second audio clip (maximum 15 MB) to pin to the generated soundtrack. The original audio replaces the output soundtrack, trimmed or padded with silence to the video duration without changing playback speed. Accepts an HTTP(S) URL or a base64 data URI."
|
||||
},
|
||||
"duration": {
|
||||
"minimum": 5,
|
||||
"description": "The duration of the video in seconds.",
|
||||
"title": "Duration",
|
||||
"default": 5,
|
||||
"type": "integer",
|
||||
"maximum": 15
|
||||
},
|
||||
"end_image_url": {
|
||||
"title": "End Image URL",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional URL of the image to use as the last frame. It may be provided alone for end-only keyframe generation; in that case the output canvas follows this image."
|
||||
},
|
||||
"enable_safety_checker": {
|
||||
"default": true,
|
||||
"title": "Enable Safety Checker",
|
||||
"type": "boolean",
|
||||
"description": "If set to true, the safety checker will be enabled."
|
||||
},
|
||||
"image_url": {
|
||||
"examples": [
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg"
|
||||
],
|
||||
"title": "Image URL",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional URL of the image to use as the first frame. When provided, the output canvas follows this image. If only end_image_url is provided, the canvas follows that last frame instead. If both images are omitted, the request is handled as text-to-video (16:9 by default)."
|
||||
},
|
||||
"resolution": {
|
||||
"default": "2K",
|
||||
"type": "string",
|
||||
"title": "Resolution",
|
||||
"enum": [
|
||||
"480P",
|
||||
"768P",
|
||||
"2K",
|
||||
"4K"
|
||||
],
|
||||
"description": "The resolution of the generated video. 480P and 768P are native generation modes; 2K and 4K upscale a 768P base result."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt"
|
||||
],
|
||||
"type": "object",
|
||||
"x-fal-order-properties": [
|
||||
"prompt",
|
||||
"duration",
|
||||
"resolution",
|
||||
"seed",
|
||||
"enable_safety_checker",
|
||||
"sync_mode",
|
||||
"prompt_expansion_mode",
|
||||
"target_audio_url",
|
||||
"image_url",
|
||||
"end_image_url"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimax/h3-max/image-to-video": {
|
||||
"source": "https://fal.ai/api/openapi/queue/openapi.json?endpoint_id=minimax/h3-max/image-to-video",
|
||||
"input": {
|
||||
"properties": {
|
||||
"sync_mode": {
|
||||
"type": "boolean",
|
||||
"title": "Sync Mode",
|
||||
"default": false,
|
||||
"description": "Return the generated video as base64 instead of a CDN URL."
|
||||
},
|
||||
"prompt_expansion_mode": {
|
||||
"type": "string",
|
||||
"title": "Prompt Expansion Mode",
|
||||
"examples": [
|
||||
"disabled",
|
||||
"balanced",
|
||||
"quality"
|
||||
],
|
||||
"description": "How much effort to spend rewriting the prompt before generation. 'disabled' skips prompt expansion. 'balanced' returns in about a second. 'quality' spends up to ~30s on a richer prompt.",
|
||||
"default": "balanced"
|
||||
},
|
||||
"end_image_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "End Image URL",
|
||||
"description": "Optional URL of the image to use as the last frame. It may be provided alone for end-only keyframe generation; in that case the output canvas follows this image."
|
||||
},
|
||||
"duration": {
|
||||
"type": "integer",
|
||||
"maximum": 15,
|
||||
"title": "Duration",
|
||||
"default": 5,
|
||||
"description": "The duration of the video in seconds.",
|
||||
"minimum": 5
|
||||
},
|
||||
"seed": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Seed",
|
||||
"description": "Random seed. A random seed is selected when omitted."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"title": "Prompt",
|
||||
"examples": [
|
||||
"The camera slowly pulls back from the scene, revealing the full landscape as clouds drift overhead and light shifts across the terrain."
|
||||
],
|
||||
"minLength": 1,
|
||||
"maxLength": 50000,
|
||||
"description": "Text prompt for video generation"
|
||||
},
|
||||
"resolution": {
|
||||
"type": "string",
|
||||
"title": "Resolution",
|
||||
"enum": [
|
||||
"480P",
|
||||
"768P",
|
||||
"1080P"
|
||||
],
|
||||
"description": "The native generation resolution, or 1080P latent refinement from a native 768P source.",
|
||||
"default": "768P"
|
||||
},
|
||||
"image_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"examples": [
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg"
|
||||
],
|
||||
"description": "Optional URL of the image to use as the first frame. When provided, the output canvas follows this image. If only end_image_url is provided, the canvas follows that last frame instead. If both images are omitted, the request is handled as text-to-video (16:9 by default).",
|
||||
"title": "Image URL"
|
||||
},
|
||||
"enable_safety_checker": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Safety Checker",
|
||||
"default": true,
|
||||
"description": "If set to true, the safety checker will be enabled."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-fal-order-properties": [
|
||||
"prompt",
|
||||
"duration",
|
||||
"resolution",
|
||||
"seed",
|
||||
"enable_safety_checker",
|
||||
"sync_mode",
|
||||
"prompt_expansion_mode",
|
||||
"image_url",
|
||||
"end_image_url"
|
||||
],
|
||||
"title": "TurboImageToVideoHailuo03Input",
|
||||
"required": [
|
||||
"prompt",
|
||||
"prompt_expansion_mode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimax/h3-max-turbo/image-to-video": {
|
||||
"source": "https://fal.ai/api/openapi/queue/openapi.json?endpoint_id=minimax/h3-max-turbo/image-to-video",
|
||||
"input": {
|
||||
"properties": {
|
||||
"sync_mode": {
|
||||
"type": "boolean",
|
||||
"title": "Sync Mode",
|
||||
"default": false,
|
||||
"description": "Return the generated video as base64 instead of a CDN URL."
|
||||
},
|
||||
"prompt_expansion_mode": {
|
||||
"type": "string",
|
||||
"title": "Prompt Expansion Mode",
|
||||
"examples": [
|
||||
"disabled",
|
||||
"balanced",
|
||||
"quality"
|
||||
],
|
||||
"description": "How much effort to spend rewriting the prompt before generation. 'disabled' skips prompt expansion. 'balanced' returns in about a second. 'quality' spends up to ~30s on a richer prompt.",
|
||||
"default": "balanced"
|
||||
},
|
||||
"end_image_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "End Image URL",
|
||||
"description": "Optional URL of the image to use as the last frame. It may be provided alone for end-only keyframe generation; in that case the output canvas follows this image."
|
||||
},
|
||||
"duration": {
|
||||
"type": "integer",
|
||||
"maximum": 15,
|
||||
"title": "Duration",
|
||||
"default": 5,
|
||||
"description": "The duration of the video in seconds.",
|
||||
"minimum": 5
|
||||
},
|
||||
"seed": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Seed",
|
||||
"description": "Random seed. A random seed is selected when omitted."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"title": "Prompt",
|
||||
"examples": [
|
||||
"The camera slowly pulls back from the scene, revealing the full landscape as clouds drift overhead and light shifts across the terrain."
|
||||
],
|
||||
"minLength": 1,
|
||||
"maxLength": 50000,
|
||||
"description": "Text prompt for video generation"
|
||||
},
|
||||
"resolution": {
|
||||
"type": "string",
|
||||
"title": "Resolution",
|
||||
"enum": [
|
||||
"480P",
|
||||
"768P",
|
||||
"1080P"
|
||||
],
|
||||
"description": "The native generation resolution, or 1080P latent refinement from a native 768P source.",
|
||||
"default": "768P"
|
||||
},
|
||||
"image_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"examples": [
|
||||
"https://storage.googleapis.com/falserverless/example_inputs/hailuo23/pro_i2v_in.jpg"
|
||||
],
|
||||
"description": "Optional URL of the image to use as the first frame. When provided, the output canvas follows this image. If only end_image_url is provided, the canvas follows that last frame instead. If both images are omitted, the request is handled as text-to-video (16:9 by default).",
|
||||
"title": "Image URL"
|
||||
},
|
||||
"enable_safety_checker": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Safety Checker",
|
||||
"default": true,
|
||||
"description": "If set to true, the safety checker will be enabled."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-fal-order-properties": [
|
||||
"prompt",
|
||||
"duration",
|
||||
"resolution",
|
||||
"seed",
|
||||
"enable_safety_checker",
|
||||
"sync_mode",
|
||||
"prompt_expansion_mode",
|
||||
"image_url",
|
||||
"end_image_url"
|
||||
],
|
||||
"title": "TurboImageToVideoHailuo03Input",
|
||||
"required": [
|
||||
"prompt",
|
||||
"prompt_expansion_mode"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Offline regressions for controls lost or mistyped during schema distillation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.build_registry import (
|
||||
build_record,
|
||||
distill_inputs,
|
||||
distill_property,
|
||||
normalize_schema,
|
||||
)
|
||||
|
||||
H3_INPUTS = json.loads((Path(__file__).parent / "fixtures" / "h3_inputs.json").read_text())
|
||||
|
||||
|
||||
def _doc(schema):
|
||||
return {"components": {"schemas": {"ModelInput": schema}}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint_id", H3_INPUTS)
|
||||
def test_h3_controls_from_upstream_schema(endpoint_id):
|
||||
inputs = distill_inputs(H3_INPUTS[endpoint_id]["input"], {}, endpoint_id)
|
||||
by_name = {inp["name"]: inp for inp in inputs}
|
||||
assert by_name["duration"]["type"] == "integer"
|
||||
assert (by_name["duration"]["min"], by_name["duration"]["max"]) == (5, 15)
|
||||
assert by_name["duration"]["default"] == 5
|
||||
assert by_name["prompt_expansion_mode"]["type"] == "string"
|
||||
assert by_name["prompt_expansion_mode"]["default"] == "balanced"
|
||||
required = H3_INPUTS[endpoint_id]["input"]["required"]
|
||||
assert by_name["prompt_expansion_mode"]["required"] == ("prompt_expansion_mode" in required)
|
||||
if "h3-max" in endpoint_id:
|
||||
assert by_name["resolution"]["enum"] == ["480P", "768P", "1080P"]
|
||||
assert by_name["prompt_expansion_mode"]["suggestions"] == ["disabled", "balanced", "quality"]
|
||||
else:
|
||||
assert by_name["resolution"]["enum"] == ["480P", "768P", "2K", "4K"]
|
||||
assert by_name["prompt_expansion_mode"]["suggestions"] == ["disabled", "fast", "balanced", "quality"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,examples", [
|
||||
("mode", ["balanced", "quality"]),
|
||||
("language", ["en", "tr", "ja"]),
|
||||
("voice", ["Aria", "Rachel"]),
|
||||
("model", ["vendor/model-a", "vendor/model-b"]),
|
||||
])
|
||||
def test_suggestions_are_generic_and_preserve_free_text(name, examples):
|
||||
raw = {"type": "string", "examples": examples, "default": "new-value"}
|
||||
original = copy.deepcopy(raw)
|
||||
inp = distill_property(name, raw, set(), {})
|
||||
assert inp["type"] == "string"
|
||||
assert inp["enum"] is None
|
||||
assert inp["suggestions"] == examples
|
||||
assert inp["default"] == "new-value"
|
||||
assert raw == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,raw", [
|
||||
("prompt", {"type": "string", "examples": ["cat", "dog"]}),
|
||||
("custom_prompt", {"type": "string", "examples": ["cat", "dog"]}),
|
||||
("description", {"type": "string", "examples": ["a cat", "a dog"]}),
|
||||
("image_url", {"type": "string", "examples": ["https://example.com/a", "https://example.com/b"]}),
|
||||
("contact", {"type": "string", "format": "email", "examples": ["a", "b"]}),
|
||||
("mode", {"type": "string", "examples": ["only-one"]}),
|
||||
("mode", {"type": "string", "enum": ["a", "b"], "examples": ["a", "c"]}),
|
||||
])
|
||||
def test_free_text_and_true_enums_do_not_get_suggestions(name, raw):
|
||||
assert "suggestions" not in distill_property(name, raw, set(), {})
|
||||
|
||||
|
||||
def test_large_schema_does_not_silently_drop_optional_controls():
|
||||
properties = {f"field_{i}": {"type": "boolean"} for i in range(50)}
|
||||
properties["duration"] = {"type": "integer", "minimum": 5, "maximum": 15}
|
||||
inputs = distill_inputs({"properties": properties}, {}, "any/future-model")
|
||||
assert len(inputs) == 51
|
||||
assert inputs[-1]["name"] == "duration"
|
||||
|
||||
|
||||
def test_nested_refs_and_nullable_composition_keep_controls():
|
||||
components = {
|
||||
"Alias": {"$ref": "#/components/schemas/Resolution"},
|
||||
"Resolution": {"allOf": [{"type": "string", "enum": ["768P", "1080P"]}]},
|
||||
}
|
||||
inp = distill_property("resolution", {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Alias"}, {"type": "null"}],
|
||||
"default": "1080P",
|
||||
"description": "Output resolution",
|
||||
}, set(), components)
|
||||
assert inp["type"] == "enum"
|
||||
assert inp["enum"] == ["768P", "1080P"]
|
||||
assert inp["default"] == "1080P"
|
||||
assert inp["description"] == "Output resolution"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keyword", ["anyOf", "oneOf"])
|
||||
def test_union_of_literals_preserves_all_choices(keyword):
|
||||
inp = distill_property("resolution", {keyword: [
|
||||
{"const": "480P"}, {"enum": ["768P", "1080P"]}, {"type": "null"},
|
||||
]}, set(), {})
|
||||
assert inp["enum"] == ["480P", "768P", "1080P"]
|
||||
|
||||
|
||||
def test_literal_union_survives_nested_composition_and_repeated_normalization():
|
||||
raw = {"allOf": [{"anyOf": [{"const": "480P"}, {"const": "1080P"}]}]}
|
||||
normalized = normalize_schema(raw, {})[0]
|
||||
assert normalized["enum"] == ["480P", "1080P"]
|
||||
assert normalize_schema(normalized, {})[0] == normalized
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema", [
|
||||
{"allOf": [{"enum": ["480P", "768P"]}, {"enum": ["768P", "1080P"]}]},
|
||||
{"anyOf": [{"const": "480P"}, {"const": "768P"}], "enum": ["768P"]},
|
||||
])
|
||||
def test_composed_enum_respects_intersecting_constraints(schema):
|
||||
assert distill_property("resolution", schema, set(), {})["enum"] == ["768P"]
|
||||
|
||||
|
||||
def test_all_of_input_objects_keep_inherited_fields_and_required_names():
|
||||
doc = _doc({"allOf": [
|
||||
{"$ref": "#/components/schemas/Base"},
|
||||
{"properties": {"image_url": {"type": "string"}}, "required": ["image_url"]},
|
||||
], "properties": {"duration": {"type": "integer", "default": 5, "minimum": 5, "maximum": 15}}})
|
||||
doc["components"]["schemas"]["Base"] = {
|
||||
"properties": {"prompt": {"type": "string"}}, "required": ["prompt"],
|
||||
}
|
||||
record = build_record({"id": "test/model"}, doc)
|
||||
assert [inp["name"] for inp in record["inputs"]] == ["prompt", "image_url", "duration"]
|
||||
assert [inp["required"] for inp in record["inputs"]] == [True, True, False]
|
||||
|
||||
|
||||
def test_nullable_type_array_is_a_numeric_control():
|
||||
inp = distill_property("duration", {"type": ["integer", "null"], "default": 5}, set(), {})
|
||||
assert inp["type"] == "integer"
|
||||
|
||||
|
||||
def test_reference_cycles_do_not_recurse_forever():
|
||||
components = {"A": {"$ref": "#/components/schemas/B"}, "B": {"$ref": "#/components/schemas/A"}}
|
||||
assert normalize_schema({"$ref": "#/components/schemas/A"}, components) == ({}, False, None)
|
||||
|
||||
|
||||
def test_custom_image_size_still_has_preset_and_dimensions():
|
||||
inp = distill_property("image_size", {"anyOf": [
|
||||
{"type": "string", "enum": ["square", "landscape"]},
|
||||
{"type": "object", "properties": {"width": {"type": "integer"}, "height": {"type": "integer"}}},
|
||||
]}, set(), {})
|
||||
assert inp["has_custom_size"] is True
|
||||
assert inp["enum"] == ["square", "landscape", "custom_size"]
|
||||
|
||||
|
||||
def test_nullable_nested_custom_size_preserves_dimension_controls():
|
||||
inp = distill_property("image_size", {"anyOf": [
|
||||
{"allOf": [{"anyOf": [
|
||||
{"enum": ["square", "landscape"]},
|
||||
{"type": "object", "properties": {"width": {}, "height": {}}},
|
||||
]}]},
|
||||
{"const": None},
|
||||
]}, set(), {})
|
||||
assert inp["has_custom_size"] is True
|
||||
assert inp["enum"] == ["square", "landscape", "custom_size"]
|
||||
|
||||
|
||||
def test_open_string_union_keeps_literal_suggestions_without_restricting_values():
|
||||
inp = distill_property("voice", {"anyOf": [
|
||||
{"enum": ["Aria", "Rachel"]}, {"type": "string"},
|
||||
]}, set(), {})
|
||||
assert inp["type"] == "string"
|
||||
assert inp["enum"] is None
|
||||
assert inp["suggestions"] == ["Aria", "Rachel"]
|
||||
|
||||
|
||||
def test_exact_endpoint_path_wins_over_first_path_and_accepts_inline_schema():
|
||||
def request(schema):
|
||||
return {"post": {"requestBody": {"content": {"application/json": {"schema": schema}}}}}
|
||||
|
||||
doc = _doc({"properties": {"wrong_field": {"type": "string"}}})
|
||||
doc["paths"] = {
|
||||
"/test/other": request({"$ref": "#/components/schemas/ModelInput"}),
|
||||
"/test/model": request({"properties": {"duration": {"type": "integer", "default": 5}}}),
|
||||
}
|
||||
record = build_record({"id": "test/model"}, doc)
|
||||
assert [inp["name"] for inp in record["inputs"]] == ["duration"]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Check shared controls on shipped H3 generation nodes through the API boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REGISTRY = Path(__file__).resolve().parents[1] / "data" / "fal_registry.json"
|
||||
H3_SHARED_CONTROL_MODELS = [
|
||||
model for model in json.loads(REGISTRY.read_text())["models"]
|
||||
if model["endpoint_id"].startswith(("minimax/h3/", "minimax/h3-max/", "minimax/h3-max-turbo/"))
|
||||
and not any(
|
||||
specialty in model["endpoint_id"]
|
||||
for specialty in ("/trainer", "/lip-sync/", "/styles/")
|
||||
)
|
||||
and not model.get("deprecated")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", H3_SHARED_CONTROL_MODELS, ids=lambda model: model["endpoint_id"])
|
||||
def test_shipped_h3_shared_control_widgets_and_api_arguments(model, factory_mod, monkeypatch):
|
||||
monkeypatch.setattr(factory_mod, "_ASYNC_CAPABLE", False)
|
||||
captured = []
|
||||
monkeypatch.setattr(factory_mod, "_call_api", lambda *args: captured.append(args) or {})
|
||||
monkeypatch.setattr(factory_mod, "process_result", lambda *args: ())
|
||||
cls = factory_mod.build_node_class(model)
|
||||
inputs = cls.INPUT_TYPES()
|
||||
widgets = {**inputs["required"], **inputs["optional"]}
|
||||
assert widgets["duration"][0] == "INT"
|
||||
assert widgets["duration"][1]["min"] == 5
|
||||
assert widgets["duration"][1]["max"] == 15
|
||||
assert widgets["duration"][1]["default"] == 5
|
||||
bucket = "required" if "h3-max" in model["endpoint_id"] else "optional"
|
||||
assert "prompt_expansion_mode" in inputs[bucket]
|
||||
assert widgets["prompt_expansion_mode"][0] == "STRING"
|
||||
assert "balanced" in widgets["prompt_expansion_mode"][1]["fal_suggestions"]
|
||||
assert "quality" in widgets["prompt_expansion_mode"][1]["fal_suggestions"]
|
||||
resolution = "1080P" if "h3-max" in model["endpoint_id"] else "2K"
|
||||
assert resolution in widgets["resolution"][0]
|
||||
cls().run(prompt="A camera pans", duration=15, resolution=resolution, prompt_expansion_mode="quality")
|
||||
assert captured == [(model["endpoint_id"], {
|
||||
"prompt": "A camera pans", "duration": 15, "resolution": resolution, "prompt_expansion_mode": "quality",
|
||||
}, False)]
|
||||
@@ -33,9 +33,7 @@ def test_submit_and_collect_lifecycle(store):
|
||||
assert pending[0]["request_id"] == "req-b"
|
||||
|
||||
|
||||
def test_entries_newest_first(store, monkeypatch):
|
||||
mod = importlib.import_module(f"{PKG}.nodes.utils.job_store")
|
||||
monkeypatch.setattr(mod.time, "time", lambda: 1_700_000_000.0)
|
||||
def test_entries_newest_first(store):
|
||||
store.record_submit("fal-ai/a", "req-1")
|
||||
store.record_submit("fal-ai/b", "req-2")
|
||||
entries = store.entries()
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Regression tests for malformed media URLs and legacy Seedance output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_download_rejects_missing_schema_before_requests(monkeypatch, media_mod):
|
||||
called = False
|
||||
|
||||
def unexpected_get(*_args, **_kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("requests.get must not receive a malformed URL")
|
||||
|
||||
monkeypatch.setattr(media_mod.requests, "get", unexpected_get)
|
||||
|
||||
with pytest.raises(media_mod.FalApiError, match=r"Expected an HTTP\(S\) media URL"):
|
||||
media_mod.MediaUtils.download_url_to_temp("E", ".mp4")
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_url_validator_normalizes_whitespace(media_mod):
|
||||
assert (
|
||||
media_mod.MediaUtils.require_http_url(" https://fal.media/video.mp4 ")
|
||||
== "https://fal.media/video.mp4"
|
||||
)
|
||||
|
||||
|
||||
def test_seedance_pro_rejects_non_url_result(pack, monkeypatch):
|
||||
node_cls = pack.NODE_CLASS_MAPPINGS["SeedanceProImageToVideo_fal"]
|
||||
module = sys.modules[node_cls.__module__]
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.ImageUtils,
|
||||
"upload_image",
|
||||
staticmethod(lambda _image: "https://fal.media/input.png"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.ApiHandler,
|
||||
"submit_multiple_and_get_results",
|
||||
staticmethod(lambda *_args, **_kwargs: [{"video": {"url": "E"}}]),
|
||||
)
|
||||
|
||||
with pytest.raises(module.FalApiError, match=r"Expected an HTTP\(S\) media URL"):
|
||||
node_cls().generate_video("prompt", object(), "5")
|
||||
|
||||
|
||||
def test_seedance_pro_returns_validated_url_list(pack, monkeypatch):
|
||||
node_cls = pack.NODE_CLASS_MAPPINGS["SeedanceProImageToVideo_fal"]
|
||||
module = sys.modules[node_cls.__module__]
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.ImageUtils,
|
||||
"upload_image",
|
||||
staticmethod(lambda _image: "https://fal.media/input.png"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.ApiHandler,
|
||||
"submit_multiple_and_get_results",
|
||||
staticmethod(
|
||||
lambda *_args, **_kwargs: [
|
||||
{"video": {"url": "https://fal.media/output.mp4"}}
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert node_cls().generate_video("prompt", object(), "5") == (
|
||||
["https://fal.media/output.mp4"],
|
||||
)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,9 +20,6 @@ def test_top_level_shape():
|
||||
reg = _registry()
|
||||
assert reg["version"] == 1
|
||||
assert reg["model_count"] == len(reg["models"])
|
||||
deprecated = sum(bool(model.get("deprecated")) for model in reg["models"])
|
||||
assert reg["live_model_count"] == reg["model_count"] - deprecated
|
||||
assert reg["deprecated_model_count"] == deprecated
|
||||
assert reg["model_count"] > 500
|
||||
|
||||
|
||||
@@ -80,20 +76,3 @@ def test_enum_defaults_are_members_or_custom_size():
|
||||
f"{model['endpoint_id']}.{inp['name']}: default "
|
||||
f"{inp['default']!r} not in enum"
|
||||
)
|
||||
|
||||
|
||||
def test_every_shipped_input_can_build_a_widget_without_comfyui():
|
||||
"""The nightly refresh must render every control, without torch/ComfyUI.
|
||||
|
||||
The translator has only stdlib dependencies. Load it directly so the
|
||||
scheduled registry checks don't need to import the whole node pack.
|
||||
"""
|
||||
path = REGISTRY.parents[1] / "nodes" / "dynamic" / "schema_to_inputs.py"
|
||||
spec = importlib.util.spec_from_file_location("registry_widget_check", path)
|
||||
translator = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(translator)
|
||||
for model in _registry()["models"]:
|
||||
input_types = translator.build_input_types(model)
|
||||
widgets = {**input_types["required"], **input_types["optional"]}
|
||||
for inp in model["inputs"]:
|
||||
assert inp["name"] in widgets, f"{model['endpoint_id']}: missing {inp['name']} widget"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
from conftest import PKG, _load_package
|
||||
from helpers import _model
|
||||
|
||||
|
||||
def test_deprecated_endpoint_keeps_key_in_compatibility_tier():
|
||||
_load_package()
|
||||
loader = importlib.import_module(f"{PKG}.nodes.dynamic.registry_loader")
|
||||
model = _model(
|
||||
[],
|
||||
endpoint_id="fal-ai/retired",
|
||||
deprecated=True,
|
||||
deprecated_reason="Endpoint retired.",
|
||||
)
|
||||
|
||||
classes, display, skipped, flagged, deprecated = loader._build_model_mappings(
|
||||
[model], set()
|
||||
)
|
||||
|
||||
key = "FalAPI_fal-ai-retired"
|
||||
assert key in classes
|
||||
assert classes[key].CATEGORY == "FAL/Compatibility/text-to-image"
|
||||
assert display[key].startswith("[Unavailable]")
|
||||
assert "absent from the latest live fal catalog" in classes[key].DESCRIPTION
|
||||
assert (skipped, flagged, deprecated) == (0, 0, 1)
|
||||
|
||||
|
||||
def test_deprecated_endpoint_is_not_marked_as_superseded():
|
||||
_load_package()
|
||||
loader = importlib.import_module(f"{PKG}.nodes.dynamic.registry_loader")
|
||||
models = [
|
||||
_model(
|
||||
[],
|
||||
endpoint_id="fal-ai/old",
|
||||
family="family",
|
||||
published_at="2025-01-01T00:00:00Z",
|
||||
deprecated=True,
|
||||
),
|
||||
_model(
|
||||
[],
|
||||
endpoint_id="fal-ai/new",
|
||||
family="family",
|
||||
published_at="2026-01-01T00:00:00Z",
|
||||
),
|
||||
]
|
||||
assert loader._superseded_map(models) == {}
|
||||
@@ -96,14 +96,6 @@ def test_multi_select_enum_is_comma_string(schema_to_inputs):
|
||||
assert "vocals, drums, bass" in opts["tooltip"]
|
||||
|
||||
|
||||
def test_numeric_multi_select_enum_keeps_api_value_types(schema_to_inputs, arguments_mod):
|
||||
model = _model([_input("layers", "enum", enum=[1, 2, 4], default=[1, 4], is_list=True)])
|
||||
typ, opts = schema_to_inputs.build_input_types(model)["optional"]["layers"]
|
||||
assert typ == "STRING"
|
||||
assert opts["default"] == "1, 4"
|
||||
assert arguments_mod.build_arguments(model, {"layers": "1, 4"}) == {"layers": [1, 4]}
|
||||
|
||||
|
||||
def test_json_field_is_multiline_string(schema_to_inputs):
|
||||
model = _model([_input("loras", "json")])
|
||||
it = schema_to_inputs.build_input_types(model)
|
||||
@@ -123,34 +115,3 @@ 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"
|
||||
|
||||
|
||||
def test_suggestions_keep_string_socket_and_default(schema_to_inputs, arguments_mod):
|
||||
model = _model([_input("voice", "string", default="my-voice-id", suggestions=["Aria", "Rachel"], multiline=True)])
|
||||
spec = schema_to_inputs.build_input_types(model)["optional"]["voice"]
|
||||
assert spec[0] == "STRING"
|
||||
assert spec[1]["default"] == "my-voice-id"
|
||||
assert spec[1]["fal_suggestions"] == ["Aria", "Rachel"]
|
||||
assert spec[1]["multiline"] is False
|
||||
assert arguments_mod.build_arguments(model, {"voice": "new-custom-voice"}) == {"voice": "new-custom-voice"}
|
||||
|
||||
|
||||
def test_all_catalog_suggestions_preserve_input_names_and_arbitrary_values(schema_to_inputs, arguments_mod):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
registry = json.loads((Path(__file__).resolve().parents[1] / "data" / "fal_registry.json").read_text())
|
||||
count = 0
|
||||
for model in registry["models"]:
|
||||
inputs = schema_to_inputs.build_input_types(model)
|
||||
widgets = {**inputs["required"], **inputs["optional"]}
|
||||
for inp in model["inputs"]:
|
||||
if not inp.get("suggestions"):
|
||||
continue
|
||||
count += 1
|
||||
assert widgets[inp["name"]][0] == "STRING", model["endpoint_id"]
|
||||
assert widgets[inp["name"]][1]["fal_suggestions"] == inp["suggestions"]
|
||||
assert arguments_mod.build_arguments(model, {inp["name"]: "arbitrary-future-value"}) == {
|
||||
inp["name"]: "arbitrary-future-value",
|
||||
}
|
||||
assert count > 20
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Focused tests for the curated Seedance 2.5 video editing node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
|
||||
|
||||
class _URLVideo:
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
|
||||
def get_stream_source(self):
|
||||
return self.url
|
||||
|
||||
|
||||
def _node_and_module(pack):
|
||||
node_cls = pack.NODE_CLASS_MAPPINGS["Seedance25VideoToVideo_fal"]
|
||||
return node_cls, sys.modules[node_cls.__module__]
|
||||
|
||||
|
||||
def test_seedance25_video_to_video_schema_and_registration(pack):
|
||||
node_cls, _module = _node_and_module(pack)
|
||||
inputs = node_cls.INPUT_TYPES()
|
||||
|
||||
assert inputs["required"]["video"][0] == "VIDEO"
|
||||
assert inputs["required"]["prompt"][0] == "STRING"
|
||||
assert set(inputs["optional"]) == {
|
||||
"resolution",
|
||||
"generate_audio",
|
||||
"bitrate_mode",
|
||||
"seed",
|
||||
"force_rerun",
|
||||
}
|
||||
assert node_cls.RETURN_TYPES == ("VIDEO", "STRING")
|
||||
assert node_cls.RETURN_NAMES == ("video", "video_url")
|
||||
assert pack.NODE_DISPLAY_NAME_MAPPINGS["Seedance25VideoToVideo_fal"] == (
|
||||
"Seedance 2.5 Video-to-Video (fal)"
|
||||
)
|
||||
|
||||
|
||||
def test_seedance25_edit_payload_outputs_and_force_rerun(pack, monkeypatch):
|
||||
node_cls, module = _node_and_module(pack)
|
||||
submitted = {}
|
||||
native_output = object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.MediaUtils,
|
||||
"upload_video",
|
||||
staticmethod(lambda _video: "https://fal.media/input.mp4"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.MediaUtils,
|
||||
"video_from_url",
|
||||
staticmethod(lambda url: native_output if url.endswith("output.mp4") else None),
|
||||
)
|
||||
|
||||
def submit(endpoint, arguments, *, skip_cache=False):
|
||||
submitted.update(
|
||||
endpoint=endpoint, arguments=arguments, skip_cache=skip_cache
|
||||
)
|
||||
return {"video": {"url": "https://fal.media/output.mp4"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.ApiHandler, "submit_and_get_result", staticmethod(submit)
|
||||
)
|
||||
|
||||
result = node_cls().edit_video(
|
||||
object(),
|
||||
"Turn the daytime scene into night",
|
||||
resolution="1080p",
|
||||
generate_audio=False,
|
||||
bitrate_mode="high",
|
||||
seed=123,
|
||||
force_rerun=True,
|
||||
)
|
||||
|
||||
assert submitted == {
|
||||
"endpoint": "bytedance/seedance-2.5/reference-to-video",
|
||||
"arguments": {
|
||||
"prompt": "Turn the daytime scene into night",
|
||||
"task": "editing",
|
||||
"video_urls": ["https://fal.media/input.mp4"],
|
||||
"resolution": "1080p",
|
||||
"generate_audio": False,
|
||||
"bitrate_mode": "high",
|
||||
"seed": 123,
|
||||
},
|
||||
"skip_cache": True,
|
||||
}
|
||||
assert result == (native_output, "https://fal.media/output.mp4")
|
||||
assert math.isnan(node_cls.IS_CHANGED(force_rerun=True))
|
||||
|
||||
|
||||
def test_seedance25_url_backed_video_passes_through_without_upload(
|
||||
pack, monkeypatch
|
||||
):
|
||||
node_cls, module = _node_and_module(pack)
|
||||
source_url = "https://fal.media/already-uploaded.mp4"
|
||||
captured = {}
|
||||
|
||||
def unexpected_upload(_value):
|
||||
raise AssertionError("URL-backed VIDEO input must not be re-uploaded")
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.ImageUtils, "upload_file", staticmethod(unexpected_upload)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.MediaUtils,
|
||||
"video_from_url",
|
||||
staticmethod(lambda _url: object()),
|
||||
)
|
||||
|
||||
def submit(_endpoint, arguments, *, skip_cache=False):
|
||||
captured.update(arguments)
|
||||
assert skip_cache is False
|
||||
return {"video": {"url": "https://fal.media/output.mp4"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.ApiHandler, "submit_and_get_result", staticmethod(submit)
|
||||
)
|
||||
|
||||
node_cls().edit_video(_URLVideo(source_url), "Restyle as watercolor")
|
||||
|
||||
assert captured["video_urls"] == [source_url]
|
||||
assert captured["task"] == "editing"
|
||||
assert "seed" not in captured
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from conftest import PKG, _load_package
|
||||
@@ -50,36 +48,3 @@ def test_session_shape(routes):
|
||||
def test_jobs_degrades_gracefully(routes):
|
||||
payload = routes._jobs(limit=5)
|
||||
assert "jobs" in payload and "counts" in payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_step", [None, "build_registry.py", "validate_registry.py"])
|
||||
def test_refresh_promotes_only_validated_candidates(routes, monkeypatch, tmp_path, failure_step):
|
||||
import subprocess
|
||||
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
baseline = data / "fal_registry.json"
|
||||
baseline.write_text("original registry")
|
||||
monkeypatch.setattr(routes, "_repo_root", lambda: str(tmp_path))
|
||||
steps = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
step = Path(command[1]).name
|
||||
steps.append(step)
|
||||
assert baseline.read_text() == "original registry"
|
||||
assert command[-2:] == ["--preserve-from" if step == "build_registry.py" else "--baseline", str(baseline)]
|
||||
if step == "build_registry.py":
|
||||
Path(command[3]).write_text("validated candidate")
|
||||
else:
|
||||
assert Path(command[2]).read_text() == "validated candidate"
|
||||
return SimpleNamespace(returncode=int(step == failure_step), stdout="", stderr="schema rejected")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run)
|
||||
ok, message = routes._run_refresh_subprocess()
|
||||
assert ok == (failure_step is None)
|
||||
assert baseline.read_text() == ("validated candidate" if ok else "original registry")
|
||||
assert len(list(data.iterdir())) == 1
|
||||
assert steps[0] == "build_registry.py"
|
||||
if failure_step != "build_registry.py":
|
||||
assert steps[1] == "validate_registry.py"
|
||||
assert "Restart ComfyUI" in message if ok else "schema rejected" in message
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// Exercise the real sidebar module without ComfyUI or third-party DOM libraries.
|
||||
// Run: node --experimental-vm-modules --test tests/test_sidebar.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.children = [];
|
||||
this.listeners = {};
|
||||
this.isConnected = true;
|
||||
this.disabled = false;
|
||||
this.textContent = "";
|
||||
this.classList = { toggle() {} };
|
||||
}
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
addEventListener(event, listener) { this.listeners[event] = listener; }
|
||||
get lastChild() { return this.children.at(-1); }
|
||||
find(className) {
|
||||
if (this.className === className) return this;
|
||||
return this.children.map((child) => child.find(className)).find(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(status, { failPost = false, refreshOK = true } = {}) {
|
||||
const calls = [];
|
||||
const context = vm.createContext({
|
||||
document: { createElement: (tag) => new Element(tag), hidden: false },
|
||||
console: { debug() {} },
|
||||
setInterval: () => 1,
|
||||
clearInterval: () => {},
|
||||
setTimeout: (callback) => { callback(); },
|
||||
});
|
||||
const api = new vm.SyntheticModule(["formatUsd", "getJson", "humanAge", "postJson", "shortEndpoint"], function () {
|
||||
this.setExport("formatUsd", () => "$0");
|
||||
this.setExport("humanAge", () => "now");
|
||||
this.setExport("shortEndpoint", (value) => value);
|
||||
this.setExport("getJson", async (path) => {
|
||||
if (path === "/registry_status") {
|
||||
if (status instanceof Error) throw status;
|
||||
return status;
|
||||
}
|
||||
if (path === "/registry_refresh") return { running: false, finished_at: 1, ok: refreshOK, message: "Validation failed" };
|
||||
return {};
|
||||
});
|
||||
this.setExport("postJson", async (path) => {
|
||||
calls.push(path);
|
||||
if (failPost) throw new Error("offline");
|
||||
return { started: true, running: true };
|
||||
});
|
||||
}, { context });
|
||||
const source = await readFile(new URL("../web/fal_sidebar.js", import.meta.url), "utf8");
|
||||
const sidebar = new vm.SourceTextModule(source, { context });
|
||||
await sidebar.link(() => api);
|
||||
await sidebar.evaluate();
|
||||
const root = new Element("div");
|
||||
sidebar.namespace.mountPanel(root);
|
||||
await setImmediate();
|
||||
return { root, calls };
|
||||
}
|
||||
|
||||
for (const status of [{ new_count: 0, new_models: [] }, new Error("catalog unavailable"), { new_count: 1, new_models: [{ title: "New model" }] }]) {
|
||||
test(`refresh is available with status ${JSON.stringify(status)}`, async () => {
|
||||
const { root, calls } = await mount(status);
|
||||
const button = root.find("fal-registry-refresh");
|
||||
assert.ok(button, "Existing models need schema refresh even with no new IDs or an unavailable catalog check");
|
||||
button.listeners.click();
|
||||
await setImmediate();
|
||||
assert.deepEqual(calls, ["/registry_refresh"]);
|
||||
assert.match(root.find("fal-registry-done").textContent, /updated controls/);
|
||||
assert.equal(button.disabled, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const options of [{ failPost: true }, { refreshOK: false }]) {
|
||||
test(`failed refresh can be retried: ${JSON.stringify(options)}`, async () => {
|
||||
const { root, calls } = await mount({ new_count: 0 }, options);
|
||||
const button = root.find("fal-registry-refresh");
|
||||
button.listeners.click();
|
||||
await setImmediate();
|
||||
assert.ok(root.find("fal-registry-error"));
|
||||
assert.equal(button.disabled, false);
|
||||
button.listeners.click();
|
||||
await setImmediate();
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const source = await readFile(new URL("../web/fal_suggestions.js", import.meta.url), "utf8");
|
||||
const module = new vm.SourceTextModule(source, { context: vm.createContext({ console }) });
|
||||
await module.link(() => { throw new Error("Unexpected import"); });
|
||||
await module.evaluate();
|
||||
const { setupSuggestedWidgets } = module.namespace;
|
||||
|
||||
function setup(name = "mode", defaultValue = "balanced") {
|
||||
const callbackValues = [];
|
||||
const promptCalls = [];
|
||||
class Node {
|
||||
constructor() {
|
||||
this.widgets = [
|
||||
{ name: "prompt", type: "text", value: "test" },
|
||||
{ name, type: "text", value: defaultValue, options: {}, callback: (value) => callbackValues.push(value) },
|
||||
{ name: "seed", type: "number", value: 42 },
|
||||
];
|
||||
}
|
||||
onNodeCreated() { this.originalCalled = true; return "original-result"; }
|
||||
addWidget(type, name, value, callback, options) {
|
||||
const widget = { type, name, value, callback, options };
|
||||
this.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
setDirtyCanvas() {}
|
||||
}
|
||||
const data = {
|
||||
name: "FalAPI_any-future-model",
|
||||
input: { optional: { [name]: ["STRING", { fal_suggestions: ["balanced", "quality"] }] } },
|
||||
};
|
||||
setupSuggestedWidgets(Node, data, { canvas: { prompt: (...args) => promptCalls.push(args) } });
|
||||
const node = new Node();
|
||||
assert.equal(node.onNodeCreated(), "original-result");
|
||||
return { node, widget: node.widgets[1], promptCalls, callbackValues, data };
|
||||
}
|
||||
|
||||
for (const name of ["mode", "voice", "language", "model_id"]) {
|
||||
test(`generic suggested ${name} keeps widget order, default and STRING socket`, () => {
|
||||
const { node, widget, data } = setup(name, "custom-default");
|
||||
assert.equal(widget.type, "combo");
|
||||
assert.equal(widget.value, "custom-default");
|
||||
assert.deepEqual(node.widgets.map((w) => w.name), ["prompt", name, "seed"]);
|
||||
assert.equal(node.widgets[2].value, 42);
|
||||
assert.equal(data.input.optional[name][0], "STRING");
|
||||
assert.equal(node.originalCalled, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("saved values missing from examples survive load and serialization", () => {
|
||||
const { node, widget } = setup();
|
||||
widget.value = "older-workflow-value";
|
||||
node.onConfigure({});
|
||||
assert.ok(widget.options.values.includes("older-workflow-value"));
|
||||
assert.equal(node.widgets.map((w) => w.value)[1], "older-workflow-value");
|
||||
});
|
||||
|
||||
test("suggestion selection and custom entry preserve original callbacks", () => {
|
||||
const { widget, promptCalls, callbackValues } = setup();
|
||||
widget.value = "quality";
|
||||
widget.callback("quality");
|
||||
assert.deepEqual(callbackValues, ["quality"]);
|
||||
const custom = widget.options.values.at(-1);
|
||||
widget.value = custom;
|
||||
widget.callback(custom);
|
||||
assert.equal(widget.value, "quality", "UI-only label must never reach a queued API request");
|
||||
assert.equal(promptCalls[0][1], "quality");
|
||||
promptCalls[0][2]("new-mode-from-api");
|
||||
assert.equal(widget.value, "new-mode-from-api");
|
||||
assert.ok(widget.options.values.includes("new-mode-from-api"));
|
||||
assert.deepEqual(callbackValues, ["quality", "new-mode-from-api"]);
|
||||
});
|
||||
|
||||
test("canceling custom entry leaves the current value intact", () => {
|
||||
const { widget, promptCalls } = setup();
|
||||
const custom = widget.options.values.at(-1);
|
||||
widget.value = custom;
|
||||
widget.callback(custom);
|
||||
promptCalls[0][2](null);
|
||||
assert.equal(widget.value, "balanced");
|
||||
});
|
||||
|
||||
test("nodes without suggestion metadata are unchanged", () => {
|
||||
class Node {}
|
||||
setupSuggestedWidgets(Node, { name: "FalAPI_plain-model", input: { required: { prompt: ["STRING", {}] } } }, {});
|
||||
assert.equal(Node.prototype.onNodeCreated, undefined);
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.build_readme import render_generated_section
|
||||
from scripts.build_registry import preserve_missing_records
|
||||
from scripts.validate_registry import (
|
||||
RegistryValidationError,
|
||||
compare_model_inputs,
|
||||
compare_registries,
|
||||
validate_registry,
|
||||
)
|
||||
|
||||
|
||||
def _model(endpoint_id: str) -> dict:
|
||||
return {
|
||||
"endpoint_id": endpoint_id,
|
||||
"title": endpoint_id,
|
||||
"category": "text-to-image",
|
||||
"description": "",
|
||||
"family": "",
|
||||
"lab": "",
|
||||
"pricing": "",
|
||||
"published_at": "",
|
||||
"inputs": [],
|
||||
"output_kind": "images",
|
||||
"output_props": [],
|
||||
"thumbnail": "",
|
||||
}
|
||||
|
||||
|
||||
def _registry(*endpoint_ids: str) -> dict:
|
||||
models = [_model(endpoint_id) for endpoint_id in endpoint_ids]
|
||||
return {
|
||||
"version": 1,
|
||||
"model_count": len(models),
|
||||
"live_model_count": len(models),
|
||||
"deprecated_model_count": 0,
|
||||
"models": models,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_registry_returns_endpoint_ids():
|
||||
assert validate_registry(_registry("fal-ai/a", "fal-ai/b"), min_models=2) == {
|
||||
"fal-ai/a",
|
||||
"fal-ai/b",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"registry, message",
|
||||
[
|
||||
({"version": 1, "models": []}, "top-level"),
|
||||
(
|
||||
{
|
||||
"version": 1,
|
||||
"model_count": 2,
|
||||
"live_model_count": 1,
|
||||
"deprecated_model_count": 0,
|
||||
"models": [_model("fal-ai/a")],
|
||||
},
|
||||
"does not match",
|
||||
),
|
||||
(_registry("fal-ai/b", "fal-ai/a"), "sorted"),
|
||||
(_registry("fal-ai/a", "fal-ai/a"), "Duplicate"),
|
||||
],
|
||||
)
|
||||
def test_invalid_registry_is_rejected(registry, message):
|
||||
with pytest.raises(RegistryValidationError, match=message):
|
||||
validate_registry(registry, min_models=1)
|
||||
|
||||
|
||||
def test_unknown_output_kind_is_rejected():
|
||||
registry = _registry("fal-ai/a")
|
||||
registry["models"][0]["output_kind"] = "binary"
|
||||
with pytest.raises(RegistryValidationError, match="unknown output_kind"):
|
||||
validate_registry(registry, min_models=1)
|
||||
|
||||
|
||||
def test_invalid_endpoint_id_is_rejected():
|
||||
with pytest.raises(RegistryValidationError, match="invalid endpoint_id"):
|
||||
validate_registry(_registry("fal-ai/bad endpoint"), min_models=1)
|
||||
|
||||
|
||||
def test_deprecated_counts_must_match_records():
|
||||
registry = _registry("fal-ai/a")
|
||||
registry["models"][0]["deprecated"] = True
|
||||
with pytest.raises(RegistryValidationError, match="live_model_count"):
|
||||
validate_registry(registry, min_models=1)
|
||||
|
||||
|
||||
def test_large_removal_is_blocked_by_default():
|
||||
baseline = {f"fal-ai/{index}" for index in range(100)}
|
||||
candidate = {f"fal-ai/{index}" for index in range(90)}
|
||||
with pytest.raises(RegistryValidationError, match="above the 5.0% limit"):
|
||||
compare_registries(baseline, candidate)
|
||||
|
||||
|
||||
def test_large_removal_can_be_explicitly_allowed():
|
||||
baseline = {f"fal-ai/{index}" for index in range(100)}
|
||||
candidate = {f"fal-ai/{index}" for index in range(90)}
|
||||
added, removed = compare_registries(baseline, candidate, allow_large_change=True)
|
||||
assert added == set()
|
||||
assert len(removed) == 10
|
||||
|
||||
|
||||
def test_large_addition_is_blocked_by_default():
|
||||
baseline = {f"fal-ai/{index}" for index in range(100)}
|
||||
candidate = baseline | {f"new/{index}" for index in range(30)}
|
||||
with pytest.raises(RegistryValidationError, match="above the 25.0% limit"):
|
||||
compare_registries(baseline, candidate)
|
||||
|
||||
|
||||
def test_missing_baseline_record_is_preserved_as_deprecated():
|
||||
current = [_model("fal-ai/current")]
|
||||
baseline = {"models": [_model("fal-ai/current"), _model("fal-ai/retired")]}
|
||||
merged = preserve_missing_records(current, baseline)
|
||||
by_id = {model["endpoint_id"]: model for model in merged}
|
||||
assert set(by_id) == {"fal-ai/current", "fal-ai/retired"}
|
||||
assert "deprecated" not in by_id["fal-ai/current"]
|
||||
assert by_id["fal-ai/retired"]["deprecated"] is True
|
||||
|
||||
|
||||
def test_generated_catalog_lists_live_models_only():
|
||||
live = _model("fal-ai/live")
|
||||
retired = {**_model("fal-ai/retired"), "deprecated": True}
|
||||
registry = {
|
||||
"models": [live, retired],
|
||||
"model_count": 2,
|
||||
"live_model_count": 1,
|
||||
"deprecated_model_count": 1,
|
||||
}
|
||||
rendered = render_generated_section(registry)
|
||||
assert "1 live models" in rendered
|
||||
assert "1 compatibility-preserved" in rendered
|
||||
assert "fal-ai/live" in rendered
|
||||
assert "fal-ai/retired" not in rendered
|
||||
|
||||
|
||||
def _input_registry():
|
||||
registry = _registry("fal-ai/video")
|
||||
registry["models"][0]["inputs"] = [
|
||||
{"name": "duration", "type": "integer", "default": 5, "required": False},
|
||||
{"name": "resolution", "type": "enum", "enum": ["768P", "1080P"], "required": False},
|
||||
]
|
||||
return registry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change, message", [
|
||||
(lambda inputs: inputs.pop(0), "removed input duration"),
|
||||
(lambda inputs: inputs[1].update(type="string"), "lost enum control"),
|
||||
(lambda inputs: inputs[1].update(enum=["768P"]), "removed choices"),
|
||||
(lambda inputs: inputs[0].update(type="json"), "lost integer control"),
|
||||
])
|
||||
def test_refresh_detects_disappearing_controls(change, message):
|
||||
baseline = _input_registry()
|
||||
candidate = copy.deepcopy(baseline)
|
||||
change(candidate["models"][0]["inputs"])
|
||||
assert message in compare_model_inputs(baseline, candidate)[0]
|
||||
|
||||
|
||||
def test_refresh_allows_new_inputs_choices_and_string_to_dropdown():
|
||||
baseline = _input_registry()
|
||||
baseline["models"][0]["inputs"].append({"name": "mode", "type": "string", "required": False})
|
||||
candidate = copy.deepcopy(baseline)
|
||||
inputs = candidate["models"][0]["inputs"]
|
||||
inputs.append({"name": "seed", "type": "integer", "required": False})
|
||||
inputs[1]["enum"].append("4K")
|
||||
inputs[2].update(type="enum", enum=["balanced", "quality"])
|
||||
assert compare_model_inputs(baseline, candidate) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change, message", [
|
||||
(lambda inputs: inputs.append(dict(inputs[0])), "duplicate input"),
|
||||
(lambda inputs: inputs[0].update(type="unknown"), "invalid input type"),
|
||||
(lambda inputs: inputs[1].update(enum=[]), "no enum choices"),
|
||||
(lambda inputs: inputs[0].update(required="false"), "required must be a boolean"),
|
||||
(lambda inputs: inputs.append(None), "invalid input record"),
|
||||
])
|
||||
def test_refresh_rejects_malformed_controls(change, message):
|
||||
registry = _input_registry()
|
||||
change(registry["models"][0]["inputs"])
|
||||
with pytest.raises(RegistryValidationError, match=message):
|
||||
validate_registry(registry, min_models=1)
|
||||
|
||||
|
||||
def test_refresh_rejects_lost_suggested_choices():
|
||||
baseline = _input_registry()
|
||||
baseline["models"][0]["inputs"].append({
|
||||
"name": "mode", "type": "string", "required": False, "suggestions": ["fast", "quality"],
|
||||
})
|
||||
candidate = copy.deepcopy(baseline)
|
||||
del candidate["models"][0]["inputs"][-1]["suggestions"]
|
||||
assert "lost suggested choices" in compare_model_inputs(baseline, candidate)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow", [False, True])
|
||||
def test_cli_requires_separate_explicit_override_for_input_removal(monkeypatch, tmp_path, allow):
|
||||
import json
|
||||
import sys
|
||||
|
||||
from scripts.validate_registry import main
|
||||
|
||||
baseline = _input_registry()
|
||||
candidate = copy.deepcopy(baseline)
|
||||
candidate["models"][0]["inputs"].pop(0)
|
||||
for name, registry in [("baseline", baseline), ("candidate", candidate)]:
|
||||
(tmp_path / f"{name}.json").write_text(json.dumps(registry))
|
||||
argv = ["validate_registry.py", str(tmp_path / "candidate.json"), "--baseline",
|
||||
str(tmp_path / "baseline.json"), "--min-models", "1", "--allow-large-change"]
|
||||
if allow:
|
||||
argv.append("--allow-input-removal")
|
||||
monkeypatch.setattr(sys, "argv", argv)
|
||||
if allow:
|
||||
main()
|
||||
else:
|
||||
with pytest.raises(RegistryValidationError, match="removes existing controls"):
|
||||
main()
|
||||
@@ -5,7 +5,6 @@ import { app } from "../../scripts/app.js";
|
||||
import { loadPricingMap, setupNodeBadges } from "./fal_badges.js";
|
||||
import { registerSidebar } from "./fal_sidebar.js";
|
||||
import { installAutocomplete } from "./fal_autocomplete.js";
|
||||
import { setupSuggestedWidgets } from "./fal_suggestions.js";
|
||||
|
||||
// Start loading the pricing map immediately: node definitions register before
|
||||
// setup() runs, and the badge drawer looks the map up lazily at draw time.
|
||||
@@ -31,11 +30,6 @@ app.registerExtension({
|
||||
} catch (error) {
|
||||
console.debug("[fal] badge setup failed", error);
|
||||
}
|
||||
try {
|
||||
setupSuggestedWidgets(nodeType, nodeData, app);
|
||||
} catch (error) {
|
||||
console.debug("[fal] suggested control setup failed", error);
|
||||
}
|
||||
},
|
||||
|
||||
async setup() {
|
||||
|
||||
+7
-16
@@ -113,7 +113,7 @@ function registryDone(view, ok, message) {
|
||||
const note = element(
|
||||
"div",
|
||||
ok ? "fal-registry-done" : "fal-registry-error",
|
||||
ok ? "done — restart ComfyUI and reload this page to load updated controls and new nodes" : message || "refresh failed"
|
||||
ok ? "done — restart ComfyUI to load new nodes" : message || "refresh failed"
|
||||
);
|
||||
view.registry.append(note);
|
||||
}
|
||||
@@ -131,10 +131,6 @@ async function pollRefresh(view, button) {
|
||||
}
|
||||
if (status && status.running === false && status.finished_at) {
|
||||
registryDone(view, status.ok === true, status.message);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Refresh registry";
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -148,30 +144,25 @@ async function startRegistryRefresh(view, button) {
|
||||
const result = await postJson("/registry_refresh", {});
|
||||
if (!result?.started && result?.running !== true) {
|
||||
registryDone(view, false, result?.message || "could not start refresh");
|
||||
button.disabled = false;
|
||||
button.textContent = "Refresh registry";
|
||||
return;
|
||||
}
|
||||
await pollRefresh(view, button);
|
||||
} catch (error) {
|
||||
console.debug("[fal] registry refresh failed", error);
|
||||
registryDone(view, false, "refresh request failed");
|
||||
button.disabled = false;
|
||||
button.textContent = "Refresh registry";
|
||||
}
|
||||
}
|
||||
|
||||
function renderRegistry(view, status) {
|
||||
try {
|
||||
const count = Number(status?.new_count) || 0;
|
||||
if (count <= 0) {
|
||||
view.registry.replaceChildren(element("div", "fal-muted", "Registry is up to date."));
|
||||
return;
|
||||
}
|
||||
const box = element("div", "fal-registry-news");
|
||||
box.append(
|
||||
element("div", "fal-registry-count", status == null
|
||||
? "Registry status unavailable."
|
||||
: count > 0 ? `${count} new model${count === 1 ? "" : "s"} on fal` : "No new model IDs found.")
|
||||
);
|
||||
box.append(
|
||||
element("div", "fal-muted", "Refresh to fetch updated controls for existing models too. Restart ComfyUI and reload this page afterward.")
|
||||
element("div", "fal-registry-count", `${count} new model${count === 1 ? "" : "s"} on fal`)
|
||||
);
|
||||
const models = Array.isArray(status?.new_models) ? status.new_models : [];
|
||||
for (const model of models.slice(0, REGISTRY_TITLE_LIMIT)) {
|
||||
@@ -204,7 +195,7 @@ async function loadRegistrySection(view) {
|
||||
} catch (error) {
|
||||
console.debug("[fal] registry status failed", error);
|
||||
try {
|
||||
renderRegistry(view, null);
|
||||
view.registry.replaceChildren(element("div", "fal-muted", "Registry status unavailable."));
|
||||
} catch (renderError) {
|
||||
console.debug("[fal] registry fallback render failed", renderError);
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Suggested string values are an editable dropdown, not an API enum.
|
||||
// Keep the original input name, STRING socket and serialized widget position.
|
||||
const CUSTOM = "Enter custom value…";
|
||||
|
||||
function replaceStringWidget(node, name, inputOptions, app) {
|
||||
const suggestions = inputOptions.fal_suggestions;
|
||||
const index = (node.widgets || []).findIndex((widget) => widget.name === name);
|
||||
if (index < 0 || !suggestions.length) return;
|
||||
const original = node.widgets[index];
|
||||
const originalSize = node.size ? [...node.size] : null;
|
||||
const values = [...new Set(suggestions)];
|
||||
let lastValue = original.value ?? "";
|
||||
let widget;
|
||||
const choices = (value) => [...new Set([...values, value]), CUSTOM];
|
||||
const sync = () => {
|
||||
if (widget.value !== CUSTOM) lastValue = widget.value ?? "";
|
||||
widget.options.values = choices(lastValue);
|
||||
};
|
||||
|
||||
const apply = (value, ...args) => {
|
||||
if (value == null) return; // Cancel leaves the previous value intact.
|
||||
lastValue = String(value);
|
||||
widget.value = lastValue;
|
||||
sync();
|
||||
original.callback?.call(widget, lastValue, ...args);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
};
|
||||
const onSelect = (value, ...args) => {
|
||||
if (value !== CUSTOM) {
|
||||
apply(value, ...args);
|
||||
return;
|
||||
}
|
||||
// Never serialize or submit the UI-only custom-entry label.
|
||||
widget.value = lastValue;
|
||||
if (typeof app?.canvas?.prompt === "function") {
|
||||
app.canvas.prompt(`Custom ${name}`, lastValue, (text) => apply(text, ...args), args.at(-1));
|
||||
} else {
|
||||
apply(globalThis.prompt?.(`Custom ${name}`, lastValue), ...args);
|
||||
}
|
||||
};
|
||||
|
||||
const tooltip = `${inputOptions.tooltip || original.tooltip || ""} Suggested values; choose '${CUSTOM}' to enter any other value.`.trim();
|
||||
widget = node.addWidget("combo", name, lastValue, onSelect, {
|
||||
...original.options,
|
||||
values: choices(lastValue),
|
||||
tooltip,
|
||||
});
|
||||
widget.tooltip = tooltip;
|
||||
widget._falSyncSuggestions = sync;
|
||||
widget.value = lastValue;
|
||||
// addWidget appends. Move it into the old slot so positional workflow values
|
||||
// and every following widget keep their existing meaning.
|
||||
const appendedIndex = node.widgets.indexOf(widget);
|
||||
node.widgets.splice(appendedIndex, 1);
|
||||
node.widgets.splice(index, 1, widget);
|
||||
if (original.label != null) widget.label = original.label;
|
||||
if (original.serializeValue) widget.serializeValue = original.serializeValue.bind(widget);
|
||||
original.onRemove?.();
|
||||
if (originalSize) node.setSize?.(originalSize);
|
||||
}
|
||||
|
||||
export function setupSuggestedWidgets(nodeType, nodeData, app) {
|
||||
if (!nodeData?.name?.startsWith("FalAPI_")) return;
|
||||
const fields = Object.entries({ ...nodeData.input?.required, ...nodeData.input?.optional })
|
||||
.filter(([, spec]) => spec?.[0] === "STRING" && Array.isArray(spec?.[1]?.fal_suggestions))
|
||||
.map(([name, spec]) => [name, spec[1]]);
|
||||
if (!fields.length) return;
|
||||
const originalCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
const result = originalCreated?.apply(this, args);
|
||||
for (const [name, inputOptions] of fields) {
|
||||
try {
|
||||
replaceStringWidget(this, name, inputOptions, app);
|
||||
} catch (error) {
|
||||
console.debug(`[fal] suggestion widget failed for ${name}`, error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const originalConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (...args) {
|
||||
const result = originalConfigure?.apply(this, args);
|
||||
// Configuration loads the old positional values after node creation.
|
||||
for (const widget of this.widgets || []) widget._falSyncSuggestions?.();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user