mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-03 11:17:06 +08:00
Compare commits
5 Commits
fix/valida
...
ListInput
| Author | SHA1 | Date | |
|---|---|---|---|
| 330a37db94 | |||
| 30b19c6872 | |||
| 2dd281d8a6 | |||
| 911e0b2acf | |||
| 46c7e8055c |
38
.github/workflows/ci-cursor-review.yml
vendored
38
.github/workflows/ci-cursor-review.yml
vendored
@ -1,38 +0,0 @@
|
|||||||
name: CI - Cursor Review
|
|
||||||
|
|
||||||
# Thin caller for the shared reusable cursor-review workflow in
|
|
||||||
# Comfy-Org/github-workflows. The review logic (panel matrix, judge
|
|
||||||
# consolidation, prompts, extract/post/notify scripts) lives there as the
|
|
||||||
# single source of truth, so this repo only carries the repo-specific diff
|
|
||||||
# excludes.
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [labeled, unlabeled]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cursor-review:
|
|
||||||
if: github.event.label.name == 'cursor-review'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
|
||||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
|
||||||
# from the same commit as the workflow definition.
|
|
||||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
|
||||||
with:
|
|
||||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
|
||||||
diff_excludes: >-
|
|
||||||
:!**/.claude/**
|
|
||||||
:!**/dist/**
|
|
||||||
:!**/vendor/**
|
|
||||||
:!**/*.generated.*
|
|
||||||
:!**/*.min.js
|
|
||||||
:!**/*.min.css
|
|
||||||
secrets:
|
|
||||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
|
||||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
|
||||||
272
AGENTS.md
272
AGENTS.md
@ -1,272 +0,0 @@
|
|||||||
## Engineering Style
|
|
||||||
|
|
||||||
- Keep changes small and direct. Most fixes should touch the narrowest code path
|
|
||||||
that explains the bug, performance issue, dtype issue, model-format issue, or
|
|
||||||
user-facing behavior.
|
|
||||||
- Change the least amount of files possible. A change that touches many files is
|
|
||||||
more likely to be a bad change than a good one unless the broader scope is
|
|
||||||
directly required.
|
|
||||||
- Prefer practical fixes over broad architecture work. Add abstractions only
|
|
||||||
when they remove real repeated logic or match an existing ComfyUI pattern.
|
|
||||||
- Prefer fewer dependencies. Do not add new dependencies to ComfyUI unless they
|
|
||||||
are absolutely necessary.
|
|
||||||
- Delete obsolete code aggressively when newer infrastructure makes it useless.
|
|
||||||
Remove dead fallbacks, migration paths, unused options, debug prints, and
|
|
||||||
compatibility branches that are no longer needed. Do not leave dead branches,
|
|
||||||
unreachable code, or functions that are never called. If code is not
|
|
||||||
necessary for the current behavior, remove it.
|
|
||||||
- Revert or disable problematic behavior quickly when it breaks users. It is
|
|
||||||
better to remove a broken feature path than keep a complicated partial fix.
|
|
||||||
- Preserve existing APIs, node names, model-loading behavior, file layout, and
|
|
||||||
workflow compatibility unless the change is explicitly about replacing them.
|
|
||||||
- Code must look hand-written for this repository. Changes that read like
|
|
||||||
generic AI-generated code will be rejected automatically: unnecessary helper
|
|
||||||
layers, vague names, boilerplate comments, defensive branches without a real
|
|
||||||
failure mode, broad rewrites, or code that ignores the local style.
|
|
||||||
|
|
||||||
## Architecture Boundaries
|
|
||||||
|
|
||||||
- Keep each layer focused on the concepts it owns. Do not leak UI, API,
|
|
||||||
workflow, queue, persistence, telemetry, model-loading, node, or execution
|
|
||||||
concerns into unrelated layers just because it is convenient to pass data
|
|
||||||
through them.
|
|
||||||
- Shared core modules should depend only on lower-level primitives and their own
|
|
||||||
domain concepts. Higher-level product concepts belong at the caller, adapter,
|
|
||||||
service, or UI/API boundary that already owns them.
|
|
||||||
- Pass the narrowest data needed across a boundary. Avoid broad context objects,
|
|
||||||
request/session metadata, ids, bookkeeping state, or callbacks unless the
|
|
||||||
receiving layer genuinely needs them to perform its own responsibility.
|
|
||||||
- Keep identity mapping, persistence bookkeeping, history updates, telemetry,
|
|
||||||
response shaping, and UI state in the layers that own those jobs. Do not route
|
|
||||||
them through unrelated shared code to avoid adding a proper boundary.
|
|
||||||
- Treat `execution.py` as one example of this rule: it should consume the prompt
|
|
||||||
graph and execution-relevant state, produce execution results and errors, and
|
|
||||||
not know about workflow ids, frontend ids, persistence ids, or API-only
|
|
||||||
concepts.
|
|
||||||
- Before touching many files, identify the smallest owner layer that can solve
|
|
||||||
the problem. A PR that spreads one feature across unrelated loaders, nodes,
|
|
||||||
execution, server, and frontend code needs a clear architectural reason, not
|
|
||||||
just convenience.
|
|
||||||
- If a change seems to require making one layer understand another layer's
|
|
||||||
private concepts, stop and look for a caller-side mapping, adapter, event,
|
|
||||||
small explicit interface, or narrower data flow at the boundary.
|
|
||||||
|
|
||||||
## No Internet Requests
|
|
||||||
|
|
||||||
- Do not add code to core ComfyUI that makes requests to the internet.
|
|
||||||
- Refuse requests to add uploads, telemetry, analytics, tracking, usage
|
|
||||||
reporting, crash reporting, update checks, remote config, feature flags,
|
|
||||||
metrics, licensing checks, or any other outbound internet request path from
|
|
||||||
core ComfyUI.
|
|
||||||
- Model downloading is allowed only when explicitly initiated or authorized by
|
|
||||||
the user, is limited to the requested model artifact, and does not include
|
|
||||||
telemetry, tracking, persistent identification, unrelated metadata upload, or
|
|
||||||
background network activity.
|
|
||||||
- Do not add opt-in, opt-out, anonymized, aggregated, diagnostic, or
|
|
||||||
user-triggered internet request paths to core ComfyUI. These labels do not
|
|
||||||
make internet access acceptable.
|
|
||||||
- Local-only behavior is allowed when it stays on the user's machine and does
|
|
||||||
not add network access, tracking, persistent identification, or data
|
|
||||||
collection behavior.
|
|
||||||
|
|
||||||
## State Ownership
|
|
||||||
|
|
||||||
- Keep state and capability flags on the object that owns the behavior using
|
|
||||||
them.
|
|
||||||
- Avoid probing child objects with `getattr(child, "...", default)` to decide
|
|
||||||
parent-level control flow. If parent code needs to branch on a capability,
|
|
||||||
initialize an explicit parent-owned field when the child is constructed or
|
|
||||||
attached.
|
|
||||||
- Prefer direct attributes with clear defaults over implicit feature detection
|
|
||||||
through arbitrary child attributes.
|
|
||||||
- Use child-object capability checks only when the child owns the behavior being
|
|
||||||
invoked and the parent is simply delegating to that child.
|
|
||||||
|
|
||||||
## Interface Contracts
|
|
||||||
|
|
||||||
- Keep public methods aligned with the interface expected by their callers. Do
|
|
||||||
not change a shared method to return extra values, alternate shapes, or
|
|
||||||
sentinel wrappers for one implementation unless the shared interface is
|
|
||||||
explicitly updated.
|
|
||||||
- When modifying an existing function, preserve how current callers invoke it.
|
|
||||||
Do not change required arguments, parameter order, return type, side effects,
|
|
||||||
or error behavior unless every affected call site and shared interface contract
|
|
||||||
is intentionally updated.
|
|
||||||
- Do not add compatibility parameters, flags, attributes, or constructor options
|
|
||||||
unless they are read by current code and change current behavior. Remove
|
|
||||||
pass-through or stored-but-unused values instead of preserving upstream or
|
|
||||||
deprecated API baggage.
|
|
||||||
- If an implementation needs auxiliary values for its own workflow, expose them
|
|
||||||
through a private helper or a clearly named implementation-specific method
|
|
||||||
instead of overloading the public method's return contract.
|
|
||||||
- Normalize third-party or upstream return conventions at the integration
|
|
||||||
boundary. Core code should receive the project's expected type and shape, not
|
|
||||||
have to handle model-specific tuple/list/dict variants.
|
|
||||||
- Avoid caller-side unwrapping such as `out = out[0]` unless the called
|
|
||||||
interface is documented to return that structure.
|
|
||||||
|
|
||||||
## Autograd and Model Freezing
|
|
||||||
|
|
||||||
- Do not add `torch.no_grad`, `torch.inference_mode`, or inference-mode helper
|
|
||||||
wrappers in ComfyUI code. The only allowed inference-mode-related use is
|
|
||||||
disabling a globally set inference mode when a training path needs gradients.
|
|
||||||
- Do not add freeze, unfreeze, or trainability toggles to model classes. ComfyUI
|
|
||||||
models are always treated as frozen for inference, so explicit freeze
|
|
||||||
functionality is redundant and should not be added.
|
|
||||||
- Remove training-only behavior such as dropout from inference model code, but
|
|
||||||
preserve checkpoint and state-dict compatibility when doing so. If deleting a
|
|
||||||
module would change state-dict keys, module ordering, or checkpoint loading
|
|
||||||
behavior, replace it with a no-op such as `nn.Identity` instead of removing the
|
|
||||||
slot outright.
|
|
||||||
|
|
||||||
## Python Style
|
|
||||||
|
|
||||||
- Keep imports at module scope. Avoid inline imports unless they are already part
|
|
||||||
of an established optional-backend probe or are needed to avoid an import
|
|
||||||
cycle.
|
|
||||||
- Do not add unnecessary `try`/`except` blocks. Use them for optional dependency,
|
|
||||||
platform, or backend capability detection only when the program has a useful
|
|
||||||
fallback. Prefer specific exception types when changing new code.
|
|
||||||
- Remove any workarounds for PyTorch versions that ComfyUI no longer officially
|
|
||||||
supports. Deprecated workarounds include catching an exception and rerunning
|
|
||||||
the same op with the input cast to float. If a workaround does not have a
|
|
||||||
comment naming the exact PyTorch version or versions that still need it,
|
|
||||||
remove it.
|
|
||||||
- Let unsupported model formats, invalid quantization metadata, and bad states
|
|
||||||
fail with clear errors instead of silently producing lower quality output.
|
|
||||||
- Match the existing local style in the file you edit. This codebase tolerates
|
|
||||||
long lines, simple helper functions, module-level state, and direct tensor
|
|
||||||
operations when they make the code easier to follow.
|
|
||||||
- Keep comments sparse and useful. Strip useless comments that restate the code
|
|
||||||
or describe obvious behavior. Short TODOs are fine when they name the concrete
|
|
||||||
missing follow-up.
|
|
||||||
|
|
||||||
## Model, Device, and Memory Behavior
|
|
||||||
|
|
||||||
- Treat dtype, device placement, VRAM usage, and offloading behavior as core
|
|
||||||
correctness concerns. Check CPU, CUDA, ROCm, MPS, DirectML, XPU, NPU, and low
|
|
||||||
VRAM implications when touching shared execution or loading code.
|
|
||||||
- Prefer native ComfyUI formats and existing quantization/offload helpers over
|
|
||||||
adding parallel code paths. Use `comfy.quant_ops`, `comfy.model_management`,
|
|
||||||
`comfy.memory_management`, `comfy.pinned_memory`, `comfy_aimdo`, and
|
|
||||||
`comfy-kitchen` helpers where they already solve the problem.
|
|
||||||
- Use optimized comfy-kitchen ops in places where they improve performance
|
|
||||||
without changing the expected dtype, device, memory, or interface behavior.
|
|
||||||
- All models should use the optimized attention function selected by ComfyUI.
|
|
||||||
Treat optimized backend functions, dispatch helpers, and capability-selected
|
|
||||||
callables as opaque. Higher-level code must not inspect function identity,
|
|
||||||
names, modules, or implementation details to decide behavior.
|
|
||||||
- Apply the same opacity rule to similar patterns beyond attention: callers
|
|
||||||
should depend on the documented interface and result contract, not on which
|
|
||||||
backend implementation was selected underneath.
|
|
||||||
- Do not use custom inference ops that only duplicate an existing op while
|
|
||||||
upcasting to float32, such as custom RMSNorm variants. Use the generic ComfyUI
|
|
||||||
ops and/or native torch ops instead.
|
|
||||||
- If a model class `__init__` has an `operations` parameter, assume
|
|
||||||
`operations` is never `None`. Do not add fallback branches or default torch
|
|
||||||
ops for a missing `operations` object.
|
|
||||||
- Do not add unnecessary parameters to model, model block, or model ops related
|
|
||||||
classes. Constructor and forward signatures should carry only values that are
|
|
||||||
actually needed by that object for inference.
|
|
||||||
- Reuse existing model classes, blocks, ops, and helper modules when appropriate.
|
|
||||||
Before implementing a new version of a model component, search the existing
|
|
||||||
model code for a class or helper that already provides the behavior.
|
|
||||||
- Avoid adding `einops` usage in core inference code. Use native torch tensor
|
|
||||||
ops such as `reshape`, `view`, `permute`, `transpose`, `flatten`, `unflatten`,
|
|
||||||
`unsqueeze`, and `squeeze` instead.
|
|
||||||
- Do not use tensors as general-purpose Python data structures. Keep metadata,
|
|
||||||
bookkeeping, counters, flags, shape math, padding math, index planning, memory
|
|
||||||
estimates, and control-flow decisions in plain Python values unless the data
|
|
||||||
must participate directly in tensor computation. Avoid creating temporary
|
|
||||||
tensors just to use tensor methods for scalar or structural calculations.
|
|
||||||
- Avoid unnecessary casts and transfers. Preserve the intended compute dtype,
|
|
||||||
storage dtype, bias dtype, and original tensor shape metadata.
|
|
||||||
- Assume inputs to the main model forward are already in the compute dtype by
|
|
||||||
default, except integer inputs such as some model timestep tensors. Do not add
|
|
||||||
defensive or convenience casts in model code; it is better for invalid dtype
|
|
||||||
plumbing to error clearly than to hide it with unnecessary casts.
|
|
||||||
- Raw model parameters that are not owned by an op and may be initialized in a
|
|
||||||
dtype different from the compute dtype should be cast at use in forward or
|
|
||||||
inference code with `comfy.ops.cast_to_input` or
|
|
||||||
`comfy.model_management.cast_to` to avoid dtype mismatches.
|
|
||||||
- Model code should not care what dtype it is initialized in, and model
|
|
||||||
`__init__` methods should not contain workarounds for specific dtypes. Dtype
|
|
||||||
workaround code, such as making a model work with fp16 compute, belongs in the
|
|
||||||
execution or model-management layer that owns compute policy.
|
|
||||||
- Model code should not perform unnecessary device-to-CPU or CPU-to-device
|
|
||||||
transfers. New allocations must be created on the correct device and dtype;
|
|
||||||
never allocate on CPU and then move to GPU, or allocate in one dtype and then
|
|
||||||
convert to another.
|
|
||||||
- Model code itself should not perform memory management. Loading, unloading,
|
|
||||||
offloading, device movement, VRAM policy, cache lifetime, and cleanup belong
|
|
||||||
in the relevant model-management and execution layers, not inside model
|
|
||||||
implementations.
|
|
||||||
- Do not add global, module-level, class-level, singleton, or model-owned stores
|
|
||||||
for tensors or other large memory that persist across executions. Temporary
|
|
||||||
caches must be scoped to a single execution or forward/encode/decode call:
|
|
||||||
allocate them in the owning top-level call, pass them explicitly through the
|
|
||||||
call stack, and let them be discarded when that call returns.
|
|
||||||
- Follow the Wan VAE temporal cache pattern for temporary caches: create a local
|
|
||||||
cache such as `feat_map` for the encode/decode operation, pass it into the
|
|
||||||
blocks that need it, and do not retain it on the model or in global state.
|
|
||||||
- In model init code, prefer `torch.empty` for parameter/buffer placeholders
|
|
||||||
that are populated from the model state dict instead of zero-initializing with
|
|
||||||
`torch.zeros` or similar. If an allocation is not loaded from the state dict
|
|
||||||
and is useless for inference, do not include it.
|
|
||||||
- `nn.Parameter` tensors that are stored in and populated from the model state
|
|
||||||
dict should be initialized with `torch.empty`, not with zero, random, or
|
|
||||||
otherwise meaningful initialization.
|
|
||||||
- Model initialization should describe module structure, not fabricate
|
|
||||||
checkpoint-owned tensor contents. Parameters and buffers that are loaded from
|
|
||||||
the state dict must not be manually initialized, reassigned, or filled with
|
|
||||||
fallback values unless that value is actually used when no checkpoint key
|
|
||||||
exists.
|
|
||||||
- When slicing large tensors, copy the slice if the sliced tensor's lifetime
|
|
||||||
exceeds the current function scope. Do not keep a long-lived view into a large
|
|
||||||
backing tensor when a smaller copy would release memory sooner.
|
|
||||||
- Use fused or compound torch operations such as `addcmul` when they naturally
|
|
||||||
match the math. Reducing Python and torch dispatch overhead is a valid
|
|
||||||
optimization when it does not obscure the code or change dtype/device
|
|
||||||
behavior.
|
|
||||||
- Avoid caches that persist across different executions as much as possible.
|
|
||||||
Persistent caches are acceptable only when they use a very minimal amount of
|
|
||||||
memory and have a clear ownership and invalidation story.
|
|
||||||
- When optimizing, favor small measurable changes: fewer allocations, fewer
|
|
||||||
device transfers, less peak memory, better batching, or use of a faster
|
|
||||||
existing backend op.
|
|
||||||
|
|
||||||
## Nodes and User-Facing Behavior
|
|
||||||
|
|
||||||
- Follow existing node conventions: `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`,
|
|
||||||
`CATEGORY`, and registration through the local mapping used by that file.
|
|
||||||
- Keep node changes backward compatible by default. Add inputs with sensible
|
|
||||||
defaults and avoid changing output types unless the request requires it.
|
|
||||||
- Model implementations should add the minimal number of ComfyUI nodes required
|
|
||||||
to run the model. Reuse existing nodes as much as possible; adapting the model
|
|
||||||
to work with existing nodes is strongly preferred over creating new nodes.
|
|
||||||
- Node-level code must not patch model code directly. Any node behavior that
|
|
||||||
modifies, wraps, hooks, or changes model behavior must go through the model
|
|
||||||
patcher class instead of reaching into model internals.
|
|
||||||
- The official mascot of ComfyUI is a very cute anime girl with massive fennec
|
|
||||||
ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Feel free to
|
|
||||||
use her in ComfyUI materials, UI text, examples, tests, generated assets, or
|
|
||||||
comments, but do not disrespect her.
|
|
||||||
- Warning and info messages should be short and actionable. Remove noisy or
|
|
||||||
misleading messages rather than adding more logging.
|
|
||||||
- Documentation and README edits should be concise, factual, and tied to the
|
|
||||||
changed behavior.
|
|
||||||
|
|
||||||
## Commit and Review Habits
|
|
||||||
|
|
||||||
- If asked to write commit messages, use short direct subjects like the existing
|
|
||||||
history: `Fix ...`, `Add ...`, `Support ...`, `Remove ...`, `Update ...`,
|
|
||||||
`Make ...`, `Use ...`, `Disable ...`, `Bump ...`, or `Revert ...`.
|
|
||||||
- Keep PR descriptions short and reviewable. State the problem, the behavioral
|
|
||||||
change, and the tests run; avoid long narrative explanations, implementation
|
|
||||||
diaries, or exhaustive file-by-file summaries unless the reviewer explicitly
|
|
||||||
needs that context.
|
|
||||||
- Prefer one coherent behavioral change per commit. Dependency pins, tests, and
|
|
||||||
the code that needs them may be in the same commit when they are inseparable.
|
|
||||||
- In reviews, prioritize real user impact: crashes, wrong dtype/device behavior,
|
|
||||||
memory regressions, broken model loading, workflow incompatibility, and noisy
|
|
||||||
or misleading user-facing output.
|
|
||||||
@ -240,7 +240,6 @@ database_default_path = os.path.abspath(
|
|||||||
)
|
)
|
||||||
parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_default_path}", help="Specify the database URL, e.g. for an in-memory database you can use 'sqlite:///:memory:'.")
|
parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_default_path}", help="Specify the database URL, e.g. for an in-memory database you can use 'sqlite:///:memory:'.")
|
||||||
parser.add_argument("--enable-assets", action="store_true", help="Enable the assets system (API routes, database synchronization, and background scanning).")
|
parser.add_argument("--enable-assets", action="store_true", help="Enable the assets system (API routes, database synchronization, and background scanning).")
|
||||||
parser.add_argument("--enable-asset-hashing", action="store_true", help="Compute blake3 content hashes when scanning assets. Hashing enables future asset-portability features (deduplication, cross-machine model resolution) but adds startup cost and per-output cost on large models directories. Off by default; enable to opt in.")
|
|
||||||
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
|
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
|
||||||
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")
|
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")
|
||||||
|
|
||||||
|
|||||||
@ -1216,7 +1216,7 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec
|
|||||||
bias_dtype=input.dtype,
|
bias_dtype=input.dtype,
|
||||||
offloadable=True,
|
offloadable=True,
|
||||||
compute_dtype=compute_dtype,
|
compute_dtype=compute_dtype,
|
||||||
want_requant=True,
|
want_requant=want_requant,
|
||||||
)
|
)
|
||||||
weight = weight.to(dtype=input.dtype)
|
weight = weight.to(dtype=input.dtype)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -167,7 +167,7 @@ class Qwen3VLTokenizer(sd1_clip.SD1Tokenizer):
|
|||||||
embed_count = 0
|
embed_count = 0
|
||||||
for r in tokens[key_name]:
|
for r in tokens[key_name]:
|
||||||
for i in range(len(r)):
|
for i in range(len(r)):
|
||||||
if isinstance(r[i][0], (int, float)) and r[i][0] == 151655: # <|image_pad|>
|
if r[i][0] == 151655: # <|image_pad|>
|
||||||
if len(images) > embed_count:
|
if len(images) > embed_count:
|
||||||
r[i] = ({"type": "image", "data": images[embed_count], "original_type": "image"},) + r[i][1:]
|
r[i] = ({"type": "image", "data": images[embed_count], "original_type": "image"},) + r[i][1:]
|
||||||
embed_count += 1
|
embed_count += 1
|
||||||
|
|||||||
@ -1261,6 +1261,158 @@ class DynamicSlot(ComfyTypeI):
|
|||||||
out_dict[input_type][finalized_id] = value
|
out_dict[input_type][finalized_id] = value
|
||||||
out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
|
out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
|
||||||
|
|
||||||
|
@comfytype(io_type="COMFY_DYNAMICGROUP_V3")
|
||||||
|
class DynamicGroup(ComfyTypeI):
|
||||||
|
"""A repeatable group of widget inputs (e.g. lora_name + strength stacked into N rows).
|
||||||
|
|
||||||
|
At execution time the node receives a ``list[dict]`` where each element is a row.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
io.DynamicGroup.Input(
|
||||||
|
"loras",
|
||||||
|
template=[
|
||||||
|
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
|
||||||
|
io.Float.Input("strength", default=1.0, min=-100, max=100, step=0.01),
|
||||||
|
],
|
||||||
|
min=0,
|
||||||
|
max=50,
|
||||||
|
)
|
||||||
|
# execute receives: loras: list[dict] = [{"lora_name": "x.safetensors", "strength": 1.0}, ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
Type = list[dict[str, Any]]
|
||||||
|
_MaxRows = 100
|
||||||
|
|
||||||
|
class Input(DynamicInput):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: str,
|
||||||
|
template: list["Input"],
|
||||||
|
min: int = 0,
|
||||||
|
max: int = 50,
|
||||||
|
display_name: str = None,
|
||||||
|
optional: bool = False,
|
||||||
|
tooltip: str = None,
|
||||||
|
lazy: bool = None,
|
||||||
|
extra_dict=None,
|
||||||
|
group_name: str = "Group",
|
||||||
|
):
|
||||||
|
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict)
|
||||||
|
# Validate template entries: only WidgetInput subclasses, no nesting
|
||||||
|
assert len(template) > 0, "DynamicGroup template must have at least one field."
|
||||||
|
for t in template:
|
||||||
|
assert isinstance(t, WidgetInput), (
|
||||||
|
f"DynamicGroup template field '{t.id}' must be a WidgetInput subclass "
|
||||||
|
f"(Combo, Float, Int, String, Boolean, Color). Got {type(t).__name__}."
|
||||||
|
)
|
||||||
|
assert not isinstance(t, DynamicInput), (
|
||||||
|
f"DynamicGroup template field '{t.id}' must not be a DynamicInput. "
|
||||||
|
"Nesting dynamic inputs inside DynamicGroup is not supported."
|
||||||
|
)
|
||||||
|
# Enforce unique field ids within template
|
||||||
|
field_ids = [t.id for t in template]
|
||||||
|
assert len(field_ids) == len(set(field_ids)), (
|
||||||
|
f"DynamicGroup template field ids must be unique within a row. Got: {field_ids}"
|
||||||
|
)
|
||||||
|
# Reject "." in group id and template field ids: slot_id encoding uses "." as a
|
||||||
|
# delimiter (<group_id>.<row>.<field_id>), so any "." in these names would cause
|
||||||
|
# path.split(".") to produce the wrong number of segments during decoding.
|
||||||
|
assert "." not in id, (
|
||||||
|
f"DynamicGroup id must not contain '.'. Got: '{id}'"
|
||||||
|
)
|
||||||
|
for t in template:
|
||||||
|
assert "." not in t.id, (
|
||||||
|
f"DynamicGroup template field id must not contain '.'. Got: '{t.id}'"
|
||||||
|
)
|
||||||
|
assert min >= 0, "DynamicGroup min must be >= 0."
|
||||||
|
assert max >= 1, "DynamicGroup max must be >= 1."
|
||||||
|
assert max <= DynamicGroup._MaxRows, f"DynamicGroup max must be <= {DynamicGroup._MaxRows}."
|
||||||
|
assert min <= max, "DynamicGroup min must be <= max."
|
||||||
|
self.template = template
|
||||||
|
self.min = min
|
||||||
|
self.max = max
|
||||||
|
self.group_name = group_name
|
||||||
|
|
||||||
|
def get_all(self) -> list["Input"]:
|
||||||
|
return [self] + list(self.template)
|
||||||
|
|
||||||
|
def as_dict(self):
|
||||||
|
return super().as_dict() | prune_dict({
|
||||||
|
"template": create_input_dict_v1(self.template),
|
||||||
|
"min": self.min,
|
||||||
|
"max": self.max,
|
||||||
|
"group_name": self.group_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
for t in self.template:
|
||||||
|
t.validate()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expand_schema_for_dynamic(
|
||||||
|
out_dict: dict[str, Any],
|
||||||
|
live_inputs: dict[str, Any],
|
||||||
|
value: tuple[str, dict[str, Any]],
|
||||||
|
input_type: str,
|
||||||
|
curr_prefix: list[str] | None,
|
||||||
|
):
|
||||||
|
info = value[1]
|
||||||
|
min_rows: int = info.get("min", 0)
|
||||||
|
max_rows: int = info.get("max", DynamicGroup._MaxRows)
|
||||||
|
template: dict[str, Any] = info.get("template", {})
|
||||||
|
|
||||||
|
# Collect all template field specs across required/optional sections
|
||||||
|
field_specs: list[tuple[str, tuple[str, dict[str, Any]], bool]] = []
|
||||||
|
for field_required_key in ("required", "optional"):
|
||||||
|
section = template.get(field_required_key, {})
|
||||||
|
is_required_field = field_required_key == "required"
|
||||||
|
for field_id, field_value in section.items():
|
||||||
|
field_specs.append((field_id, field_value, is_required_field))
|
||||||
|
|
||||||
|
# Determine how many rows are currently present by scanning live_inputs
|
||||||
|
finalized_prefix = finalize_prefix(curr_prefix)
|
||||||
|
present_rows = 0
|
||||||
|
for live_key in live_inputs:
|
||||||
|
# Keys look like "<prefix>.<row>.<field_id>"
|
||||||
|
if live_key.startswith(finalized_prefix + "."):
|
||||||
|
remainder = live_key[len(finalized_prefix) + 1:]
|
||||||
|
parts = remainder.split(".", 1)
|
||||||
|
if len(parts) >= 1:
|
||||||
|
try:
|
||||||
|
row_idx = int(parts[0])
|
||||||
|
present_rows = max(present_rows, row_idx + 1)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if present_rows > max_rows:
|
||||||
|
raise ValueError(
|
||||||
|
f"DynamicGroup input '{finalized_prefix}' received {present_rows} rows but max is {max_rows}."
|
||||||
|
)
|
||||||
|
row_count = max(min_rows, present_rows)
|
||||||
|
|
||||||
|
for row in range(row_count):
|
||||||
|
for field_id, field_value, is_required_field in field_specs:
|
||||||
|
slot_id = f"{finalized_prefix}.{row}.{field_id}"
|
||||||
|
# The first `min_rows` rows are required if the field itself is required
|
||||||
|
if row < min_rows and is_required_field:
|
||||||
|
out_dict["required"][slot_id] = field_value
|
||||||
|
else:
|
||||||
|
out_dict["optional"][slot_id] = field_value
|
||||||
|
# Register into dynamic_paths so build_nested_inputs places value at the right path
|
||||||
|
out_dict["dynamic_paths"][slot_id] = slot_id
|
||||||
|
|
||||||
|
# Track the list root path so build_nested_inputs can convert the index dict to a list
|
||||||
|
out_dict.setdefault("list_paths", set()).add(finalized_prefix)
|
||||||
|
|
||||||
|
# Handle the empty case (0 rows) – emit an empty-list default for the parent.
|
||||||
|
# This must only fire when there are genuinely no rows; otherwise the parent
|
||||||
|
# path would clobber the per-row dict built from the slot ids above.
|
||||||
|
if row_count == 0:
|
||||||
|
out_dict["dynamic_paths"][finalized_prefix] = finalized_prefix
|
||||||
|
out_dict["dynamic_paths_default_value"][finalized_prefix] = DynamicPathsDefaultValue.EMPTY_LIST
|
||||||
|
|
||||||
|
|
||||||
@comfytype(io_type="IMAGECOMPARE")
|
@comfytype(io_type="IMAGECOMPARE")
|
||||||
class ImageCompare(ComfyTypeI):
|
class ImageCompare(ComfyTypeI):
|
||||||
Type = dict
|
Type = dict
|
||||||
@ -1418,6 +1570,8 @@ def setup_dynamic_input_funcs():
|
|||||||
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
|
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
|
||||||
# DynamicSlot.Input
|
# DynamicSlot.Input
|
||||||
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
|
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
|
||||||
|
# DynamicGroup.Input
|
||||||
|
register_dynamic_input_func(DynamicGroup.io_type, DynamicGroup._expand_schema_for_dynamic)
|
||||||
|
|
||||||
if len(DYNAMIC_INPUT_LOOKUP) == 0:
|
if len(DYNAMIC_INPUT_LOOKUP) == 0:
|
||||||
setup_dynamic_input_funcs()
|
setup_dynamic_input_funcs()
|
||||||
@ -1429,6 +1583,8 @@ class V3Data(TypedDict):
|
|||||||
'Dictionary where the keys are the input ids and the values dictate how to turn the inputs into a nested dictionary.'
|
'Dictionary where the keys are the input ids and the values dictate how to turn the inputs into a nested dictionary.'
|
||||||
dynamic_paths_default_value: dict[str, Any]
|
dynamic_paths_default_value: dict[str, Any]
|
||||||
'Dictionary where the keys are the input ids and the values are a string from DynamicPathsDefaultValue for the inputs if value is None.'
|
'Dictionary where the keys are the input ids and the values are a string from DynamicPathsDefaultValue for the inputs if value is None.'
|
||||||
|
list_paths: set[str]
|
||||||
|
'Set of top-level keys whose index-keyed dict values should be converted to a sorted list[dict] after build_nested_inputs runs.'
|
||||||
create_dynamic_tuple: bool
|
create_dynamic_tuple: bool
|
||||||
'When True, the value of the dynamic input will be in the format (value, path_key).'
|
'When True, the value of the dynamic input will be in the format (value, path_key).'
|
||||||
|
|
||||||
@ -1770,6 +1926,7 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i
|
|||||||
"optional": {},
|
"optional": {},
|
||||||
"dynamic_paths": {},
|
"dynamic_paths": {},
|
||||||
"dynamic_paths_default_value": {},
|
"dynamic_paths_default_value": {},
|
||||||
|
"list_paths": set(),
|
||||||
}
|
}
|
||||||
d = d.copy()
|
d = d.copy()
|
||||||
# ignore hidden for parsing
|
# ignore hidden for parsing
|
||||||
@ -1785,6 +1942,10 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i
|
|||||||
dynamic_paths_default_value = out_dict.pop("dynamic_paths_default_value", None)
|
dynamic_paths_default_value = out_dict.pop("dynamic_paths_default_value", None)
|
||||||
if dynamic_paths_default_value is not None and len(dynamic_paths_default_value) > 0:
|
if dynamic_paths_default_value is not None and len(dynamic_paths_default_value) > 0:
|
||||||
v3_data["dynamic_paths_default_value"] = dynamic_paths_default_value
|
v3_data["dynamic_paths_default_value"] = dynamic_paths_default_value
|
||||||
|
# list_paths: keys whose nested dict should be post-converted to a sorted list[dict]
|
||||||
|
list_paths = out_dict.pop("list_paths", None)
|
||||||
|
if list_paths:
|
||||||
|
v3_data["list_paths"] = list_paths
|
||||||
return out_dict, hidden, v3_data
|
return out_dict, hidden, v3_data
|
||||||
|
|
||||||
def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any], curr_prefix: list[str] | None=None) -> None:
|
def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any], curr_prefix: list[str] | None=None) -> None:
|
||||||
@ -1820,10 +1981,12 @@ def add_to_dict_v1(i: Input, d: dict):
|
|||||||
|
|
||||||
class DynamicPathsDefaultValue:
|
class DynamicPathsDefaultValue:
|
||||||
EMPTY_DICT = "empty_dict"
|
EMPTY_DICT = "empty_dict"
|
||||||
|
EMPTY_LIST = "empty_list"
|
||||||
|
|
||||||
def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
|
def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
|
||||||
paths = v3_data.get("dynamic_paths", None)
|
paths = v3_data.get("dynamic_paths", None)
|
||||||
default_value_dict = v3_data.get("dynamic_paths_default_value", {})
|
default_value_dict = v3_data.get("dynamic_paths_default_value", {})
|
||||||
|
list_paths: set[str] = v3_data.get("list_paths", set()) or set()
|
||||||
if paths is None:
|
if paths is None:
|
||||||
return values
|
return values
|
||||||
values = values.copy()
|
values = values.copy()
|
||||||
@ -1846,6 +2009,8 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
|
|||||||
default_option = default_value_dict.get(key, None)
|
default_option = default_value_dict.get(key, None)
|
||||||
if default_option == DynamicPathsDefaultValue.EMPTY_DICT:
|
if default_option == DynamicPathsDefaultValue.EMPTY_DICT:
|
||||||
value = {}
|
value = {}
|
||||||
|
elif default_option == DynamicPathsDefaultValue.EMPTY_LIST:
|
||||||
|
value = []
|
||||||
if create_tuple:
|
if create_tuple:
|
||||||
value = (value, key)
|
value = (value, key)
|
||||||
current[p] = value
|
current[p] = value
|
||||||
@ -1853,6 +2018,34 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
|
|||||||
current = current.setdefault(p, {})
|
current = current.setdefault(p, {})
|
||||||
|
|
||||||
values.update(result)
|
values.update(result)
|
||||||
|
|
||||||
|
# Post-pass: convert index-keyed dicts to sorted lists for io.DynamicGroup fields
|
||||||
|
for list_path in list_paths:
|
||||||
|
parts = list_path.split(".")
|
||||||
|
# Navigate to the parent container, then convert the leaf
|
||||||
|
container = values
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if not isinstance(container, dict) or part not in container:
|
||||||
|
container = None
|
||||||
|
break
|
||||||
|
container = container[part]
|
||||||
|
if container is None:
|
||||||
|
continue
|
||||||
|
leaf_key = parts[-1]
|
||||||
|
leaf = container.get(leaf_key, None)
|
||||||
|
if isinstance(leaf, dict):
|
||||||
|
try:
|
||||||
|
sorted_rows = [leaf[k] for k in sorted(leaf.keys(), key=int)]
|
||||||
|
container[leaf_key] = sorted_rows
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# Keys are not all integers; leave as-is
|
||||||
|
pass
|
||||||
|
elif isinstance(leaf, list):
|
||||||
|
# Already a list (e.g. the EMPTY_LIST default was applied above)
|
||||||
|
pass
|
||||||
|
elif leaf is None:
|
||||||
|
container[leaf_key] = []
|
||||||
|
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@ -2417,7 +2610,9 @@ __all__ = [
|
|||||||
# Dynamic Types
|
# Dynamic Types
|
||||||
"MatchType",
|
"MatchType",
|
||||||
"DynamicCombo",
|
"DynamicCombo",
|
||||||
|
"DynamicSlot",
|
||||||
"Autogrow",
|
"Autogrow",
|
||||||
|
"DynamicGroup",
|
||||||
# Other classes
|
# Other classes
|
||||||
"HiddenHolder",
|
"HiddenHolder",
|
||||||
"Hidden",
|
"Hidden",
|
||||||
|
|||||||
@ -121,7 +121,6 @@ class GeminiGenerationConfig(BaseModel):
|
|||||||
topK: int | None = Field(None, ge=1)
|
topK: int | None = Field(None, ge=1)
|
||||||
topP: float | None = Field(None, ge=0.0, le=1.0)
|
topP: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
thinkingConfig: GeminiThinkingConfig | None = Field(None)
|
thinkingConfig: GeminiThinkingConfig | None = Field(None)
|
||||||
responseModalities: list[str] | None = Field(None)
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiImageOutputOptions(BaseModel):
|
class GeminiImageOutputOptions(BaseModel):
|
||||||
|
|||||||
@ -33,6 +33,53 @@ class IdeogramColorPalette(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRequest(BaseModel):
|
||||||
|
aspect_ratio: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional. The aspect ratio (e.g., 'ASPECT_16_9', 'ASPECT_1_1'). Cannot be used with resolution. Defaults to 'ASPECT_1_1' if unspecified.",
|
||||||
|
)
|
||||||
|
color_palette: Optional[Dict[str, Any]] = Field(
|
||||||
|
None, description='Optional. Color palette object. Only for V_2, V_2_TURBO.'
|
||||||
|
)
|
||||||
|
magic_prompt_option: Optional[str] = Field(
|
||||||
|
None, description="Optional. MagicPrompt usage ('AUTO', 'ON', 'OFF')."
|
||||||
|
)
|
||||||
|
model: str = Field(..., description="The model used (e.g., 'V_2', 'V_2A_TURBO')")
|
||||||
|
negative_prompt: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description='Optional. Description of what to exclude. Only for V_1, V_1_TURBO, V_2, V_2_TURBO.',
|
||||||
|
)
|
||||||
|
num_images: Optional[int] = Field(
|
||||||
|
1,
|
||||||
|
description='Optional. Number of images to generate (1-8). Defaults to 1.',
|
||||||
|
ge=1,
|
||||||
|
le=8,
|
||||||
|
)
|
||||||
|
prompt: str = Field(
|
||||||
|
..., description='Required. The prompt to use to generate the image.'
|
||||||
|
)
|
||||||
|
resolution: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional. Resolution (e.g., 'RESOLUTION_1024_1024'). Only for model V_2. Cannot be used with aspect_ratio.",
|
||||||
|
)
|
||||||
|
seed: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description='Optional. A number between 0 and 2147483647.',
|
||||||
|
ge=0,
|
||||||
|
le=2147483647,
|
||||||
|
)
|
||||||
|
style_type: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional. Style type ('AUTO', 'GENERAL', 'REALISTIC', 'DESIGN', 'RENDER_3D', 'ANIME'). Only for models V_2 and above.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdeogramGenerateRequest(BaseModel):
|
||||||
|
image_request: ImageRequest = Field(
|
||||||
|
..., description='The image generation request parameters.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Datum(BaseModel):
|
class Datum(BaseModel):
|
||||||
is_image_safe: Optional[bool] = Field(
|
is_image_safe: Optional[bool] = Field(
|
||||||
None, description='Indicates whether the image is considered safe.'
|
None, description='Indicates whether the image is considered safe.'
|
||||||
@ -66,6 +113,20 @@ class StyleCode(RootModel[str]):
|
|||||||
root: str = Field(..., pattern='^[0-9A-Fa-f]{8}$')
|
root: str = Field(..., pattern='^[0-9A-Fa-f]{8}$')
|
||||||
|
|
||||||
|
|
||||||
|
class Datum1(BaseModel):
|
||||||
|
is_image_safe: Optional[bool] = None
|
||||||
|
prompt: Optional[str] = None
|
||||||
|
resolution: Optional[str] = None
|
||||||
|
seed: Optional[int] = None
|
||||||
|
style_type: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdeogramV3IdeogramResponse(BaseModel):
|
||||||
|
created: Optional[datetime] = None
|
||||||
|
data: Optional[List[Datum1]] = None
|
||||||
|
|
||||||
|
|
||||||
class RenderingSpeed1(str, Enum):
|
class RenderingSpeed1(str, Enum):
|
||||||
TURBO = 'TURBO'
|
TURBO = 'TURBO'
|
||||||
DEFAULT = 'DEFAULT'
|
DEFAULT = 'DEFAULT'
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import torch
|
|||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
from comfy_api.latest import IO, ComfyExtension, Input, InputImpl, Types
|
from comfy_api.latest import IO, ComfyExtension, Input, Types
|
||||||
from comfy_api_nodes.apis.gemini import (
|
from comfy_api_nodes.apis.gemini import (
|
||||||
GeminiContent,
|
GeminiContent,
|
||||||
GeminiFileData,
|
GeminiFileData,
|
||||||
@ -37,7 +37,6 @@ from comfy_api_nodes.util import (
|
|||||||
audio_to_base64_string,
|
audio_to_base64_string,
|
||||||
bytesio_to_image_tensor,
|
bytesio_to_image_tensor,
|
||||||
download_url_to_image_tensor,
|
download_url_to_image_tensor,
|
||||||
download_url_to_video_output,
|
|
||||||
get_number_of_images,
|
get_number_of_images,
|
||||||
sync_op,
|
sync_op,
|
||||||
tensor_to_base64_string,
|
tensor_to_base64_string,
|
||||||
@ -46,7 +45,6 @@ from comfy_api_nodes.util import (
|
|||||||
upload_images_to_comfyapi,
|
upload_images_to_comfyapi,
|
||||||
upload_video_to_comfyapi,
|
upload_video_to_comfyapi,
|
||||||
validate_string,
|
validate_string,
|
||||||
validate_video_duration,
|
|
||||||
video_to_base64_string,
|
video_to_base64_string,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -231,29 +229,10 @@ async def get_image_from_response(response: GeminiGenerateContentResponse, thoug
|
|||||||
return torch.cat(image_tensors, dim=0)
|
return torch.cat(image_tensors, dim=0)
|
||||||
|
|
||||||
|
|
||||||
async def get_video_from_response(
|
|
||||||
response: GeminiGenerateContentResponse, cls: type[IO.ComfyNode] | None = None
|
|
||||||
) -> InputImpl.VideoFromFile:
|
|
||||||
parts = get_parts_by_type(response, "video/*")
|
|
||||||
for part in parts:
|
|
||||||
if part.inlineData and part.inlineData.data:
|
|
||||||
return InputImpl.VideoFromFile(BytesIO(base64.b64decode(part.inlineData.data)))
|
|
||||||
if part.fileData and part.fileData.fileUri:
|
|
||||||
return await download_url_to_video_output(part.fileData.fileUri, cls=cls)
|
|
||||||
model_message = get_text_from_response(response).strip()
|
|
||||||
if model_message:
|
|
||||||
raise ValueError(f"Gemini did not generate a video. Model response: {model_message}")
|
|
||||||
raise ValueError(
|
|
||||||
"Gemini did not generate a video. Try rephrasing your prompt, "
|
|
||||||
"shortening the requested duration, or reducing the number of input images/videos."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | None:
|
def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | None:
|
||||||
if not response.modelVersion:
|
if not response.modelVersion:
|
||||||
return None
|
return None
|
||||||
# Define prices (Cost per 1,000,000 tokens), see https://cloud.google.com/vertex-ai/generative-ai/pricing
|
# Define prices (Cost per 1,000,000 tokens), see https://cloud.google.com/vertex-ai/generative-ai/pricing
|
||||||
output_video_tokens_price = 0.0
|
|
||||||
if response.modelVersion == "gemini-2.5-pro":
|
if response.modelVersion == "gemini-2.5-pro":
|
||||||
input_tokens_price = 1.25
|
input_tokens_price = 1.25
|
||||||
output_text_tokens_price = 10.0
|
output_text_tokens_price = 10.0
|
||||||
@ -270,27 +249,18 @@ def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | N
|
|||||||
input_tokens_price = 2
|
input_tokens_price = 2
|
||||||
output_text_tokens_price = 12.0
|
output_text_tokens_price = 12.0
|
||||||
output_image_tokens_price = 0.0
|
output_image_tokens_price = 0.0
|
||||||
elif response.modelVersion in ("gemini-3.1-flash-lite-preview", "gemini-3.1-flash-lite"):
|
elif response.modelVersion == "gemini-3.1-flash-lite-preview":
|
||||||
input_tokens_price = 0.25
|
input_tokens_price = 0.25
|
||||||
output_text_tokens_price = 1.50
|
output_text_tokens_price = 1.50
|
||||||
output_image_tokens_price = 0.0
|
output_image_tokens_price = 0.0
|
||||||
elif response.modelVersion in ("gemini-3-pro-image-preview", "gemini-3-pro-image"):
|
elif response.modelVersion == "gemini-3-pro-image-preview":
|
||||||
input_tokens_price = 2
|
input_tokens_price = 2
|
||||||
output_text_tokens_price = 12.0
|
output_text_tokens_price = 12.0
|
||||||
output_image_tokens_price = 120.0
|
output_image_tokens_price = 120.0
|
||||||
elif response.modelVersion in ("gemini-3.1-flash-image-preview", "gemini-3.1-flash-image"):
|
elif response.modelVersion == "gemini-3.1-flash-image-preview":
|
||||||
input_tokens_price = 0.5
|
input_tokens_price = 0.5
|
||||||
output_text_tokens_price = 3.0
|
output_text_tokens_price = 3.0
|
||||||
output_image_tokens_price = 60.0
|
output_image_tokens_price = 60.0
|
||||||
elif response.modelVersion == "gemini-3.1-flash-lite-image":
|
|
||||||
input_tokens_price = 0.25
|
|
||||||
output_text_tokens_price = 1.50
|
|
||||||
output_image_tokens_price = 30.0
|
|
||||||
elif response.modelVersion == "gemini-omni-flash-preview":
|
|
||||||
input_tokens_price = 2.145
|
|
||||||
output_text_tokens_price = 12.87
|
|
||||||
output_image_tokens_price = 0.0
|
|
||||||
output_video_tokens_price = 25.025
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
final_price = response.usageMetadata.promptTokenCount * input_tokens_price
|
final_price = response.usageMetadata.promptTokenCount * input_tokens_price
|
||||||
@ -298,8 +268,6 @@ def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | N
|
|||||||
for i in response.usageMetadata.candidatesTokensDetails:
|
for i in response.usageMetadata.candidatesTokensDetails:
|
||||||
if i.modality == Modality.IMAGE:
|
if i.modality == Modality.IMAGE:
|
||||||
final_price += output_image_tokens_price * i.tokenCount # for Nano Banana models
|
final_price += output_image_tokens_price * i.tokenCount # for Nano Banana models
|
||||||
elif i.modality == Modality.VIDEO:
|
|
||||||
final_price += output_video_tokens_price * i.tokenCount # for Omni Flash
|
|
||||||
else:
|
else:
|
||||||
final_price += output_text_tokens_price * i.tokenCount
|
final_price += output_text_tokens_price * i.tokenCount
|
||||||
if response.usageMetadata.thoughtsTokenCount:
|
if response.usageMetadata.thoughtsTokenCount:
|
||||||
@ -1334,7 +1302,7 @@ class GeminiNanoBanana2(IO.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _nano_banana_2_v2_model_inputs(resolutions: list[str]):
|
def _nano_banana_2_v2_model_inputs():
|
||||||
return [
|
return [
|
||||||
IO.Combo.Input(
|
IO.Combo.Input(
|
||||||
"aspect_ratio",
|
"aspect_ratio",
|
||||||
@ -1361,8 +1329,8 @@ def _nano_banana_2_v2_model_inputs(resolutions: list[str]):
|
|||||||
),
|
),
|
||||||
IO.Combo.Input(
|
IO.Combo.Input(
|
||||||
"resolution",
|
"resolution",
|
||||||
options=resolutions,
|
options=["1K", "2K", "4K"],
|
||||||
tooltip="Target output resolution.",
|
tooltip="Target output resolution. For 2K/4K the native Gemini upscaler is used.",
|
||||||
),
|
),
|
||||||
IO.Combo.Input(
|
IO.Combo.Input(
|
||||||
"thinking_level",
|
"thinking_level",
|
||||||
@ -1408,11 +1376,7 @@ class GeminiNanoBanana2V2(IO.ComfyNode):
|
|||||||
options=[
|
options=[
|
||||||
IO.DynamicCombo.Option(
|
IO.DynamicCombo.Option(
|
||||||
"Nano Banana 2 (Gemini 3.1 Flash Image)",
|
"Nano Banana 2 (Gemini 3.1 Flash Image)",
|
||||||
_nano_banana_2_v2_model_inputs(resolutions=["1K", "2K", "4K"]),
|
_nano_banana_2_v2_model_inputs(),
|
||||||
),
|
|
||||||
IO.DynamicCombo.Option(
|
|
||||||
"Nano Banana 2 Lite",
|
|
||||||
_nano_banana_2_v2_model_inputs(resolutions=["1K"]),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -1481,13 +1445,9 @@ class GeminiNanoBanana2V2(IO.ComfyNode):
|
|||||||
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution"]),
|
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution"]),
|
||||||
expr="""
|
expr="""
|
||||||
(
|
(
|
||||||
$contains(widgets.model, "lite")
|
$r := $lookup(widgets, "model.resolution");
|
||||||
? {"type":"usd","usd": 0.034, "format":{"suffix":"/Image","approximate":true}}
|
$prices := {"1k": 0.0696, "2k": 0.1014, "4k": 0.154};
|
||||||
: (
|
{"type":"usd","usd": $lookup($prices, $r), "format":{"suffix":"/Image","approximate":true}}
|
||||||
$r := $lookup(widgets, "model.resolution");
|
|
||||||
$prices := {"1k": 0.0696, "2k": 0.1014, "4k": 0.154};
|
|
||||||
{"type":"usd","usd": $lookup($prices, $r), "format":{"suffix":"/Image","approximate":true}}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
),
|
),
|
||||||
@ -1508,8 +1468,6 @@ class GeminiNanoBanana2V2(IO.ComfyNode):
|
|||||||
model_choice = model["model"]
|
model_choice = model["model"]
|
||||||
if model_choice == "Nano Banana 2 (Gemini 3.1 Flash Image)":
|
if model_choice == "Nano Banana 2 (Gemini 3.1 Flash Image)":
|
||||||
model_id = "gemini-3.1-flash-image-preview"
|
model_id = "gemini-3.1-flash-image-preview"
|
||||||
elif model_choice == "Nano Banana 2 Lite":
|
|
||||||
model_id = "gemini-3.1-flash-lite-image"
|
|
||||||
else:
|
else:
|
||||||
model_id = model_choice
|
model_id = model_choice
|
||||||
|
|
||||||
@ -1559,149 +1517,6 @@ class GeminiNanoBanana2V2(IO.ComfyNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
OMNI_MAX_IMAGES = 14
|
|
||||||
OMNI_MAX_VIDEOS = 3
|
|
||||||
|
|
||||||
OMNI_MODELS: dict[str, str] = {
|
|
||||||
"Omni Flash": "gemini-omni-flash-preview",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _omni_flash_inputs() -> list[Input]:
|
|
||||||
"""Per-model inputs for the Omni video DynamicCombo (prompt + reference media + sampling)."""
|
|
||||||
return [
|
|
||||||
IO.String.Input(
|
|
||||||
"prompt",
|
|
||||||
multiline=True,
|
|
||||||
default="",
|
|
||||||
tooltip="Describe the video to generate. Specify the length and aspect ratio directly in the "
|
|
||||||
'prompt, e.g. "a 6-second clip in 16:9". Length may be 3-10 seconds; the aspect ratio must be '
|
|
||||||
"16:9 (landscape) or 9:16 (portrait). The output is 720p, 24 FPS, with audio.",
|
|
||||||
),
|
|
||||||
IO.Autogrow.Input(
|
|
||||||
"images",
|
|
||||||
template=IO.Autogrow.TemplateNames(
|
|
||||||
IO.Image.Input("image"),
|
|
||||||
names=[f"image_{i}" for i in range(1, OMNI_MAX_IMAGES + 1)],
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
tooltip=f"Optional reference image(s) to guide or animate the video. Up to {OMNI_MAX_IMAGES} images.",
|
|
||||||
),
|
|
||||||
IO.Autogrow.Input(
|
|
||||||
"videos",
|
|
||||||
template=IO.Autogrow.TemplateNames(
|
|
||||||
IO.Video.Input("video"),
|
|
||||||
names=[f"video_{i}" for i in range(1, OMNI_MAX_VIDEOS + 1)],
|
|
||||||
min=0,
|
|
||||||
),
|
|
||||||
tooltip=f"Optional reference video(s) to guide or edit. Up to {OMNI_MAX_VIDEOS} videos, "
|
|
||||||
f"each up to 10 seconds long.",
|
|
||||||
),
|
|
||||||
IO.Float.Input(
|
|
||||||
"temperature",
|
|
||||||
default=1.0,
|
|
||||||
min=0.0,
|
|
||||||
max=2.0,
|
|
||||||
step=0.01,
|
|
||||||
tooltip="Controls randomness. Lower is more focused/deterministic, higher is more varied.",
|
|
||||||
advanced=True,
|
|
||||||
),
|
|
||||||
IO.Float.Input(
|
|
||||||
"top_p",
|
|
||||||
default=0.95,
|
|
||||||
min=0.0,
|
|
||||||
max=1.0,
|
|
||||||
step=0.01,
|
|
||||||
tooltip="Nucleus sampling: sample from the smallest token set whose cumulative probability reaches top_p.",
|
|
||||||
advanced=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiVideoOmni(IO.ComfyNode):
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def define_schema(cls):
|
|
||||||
return IO.Schema(
|
|
||||||
node_id="GeminiVideoOmni",
|
|
||||||
display_name="Google Gemini Omni (Video)",
|
|
||||||
category="partner/video/Gemini",
|
|
||||||
essentials_category="Video Generation",
|
|
||||||
description="Generate a video with audio from a text prompt using Google's Gemini Omni Flash model. "
|
|
||||||
"Optionally provide reference images and/or videos to guide or edit the result. Describe the desired "
|
|
||||||
"length (3-10s) and aspect ratio (16:9 or 9:16) directly in the prompt.",
|
|
||||||
inputs=[
|
|
||||||
IO.DynamicCombo.Input(
|
|
||||||
"model",
|
|
||||||
options=[
|
|
||||||
IO.DynamicCombo.Option("Omni Flash", _omni_flash_inputs()),
|
|
||||||
],
|
|
||||||
tooltip="The Gemini video model used to generate the video.",
|
|
||||||
),
|
|
||||||
IO.Int.Input(
|
|
||||||
"seed",
|
|
||||||
default=42,
|
|
||||||
min=0,
|
|
||||||
max=2147483647,
|
|
||||||
control_after_generate=True,
|
|
||||||
tooltip="Seed controls whether the node should re-run; "
|
|
||||||
"results are non-deterministic regardless of seed.",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
outputs=[
|
|
||||||
IO.Video.Output(),
|
|
||||||
IO.String.Output(),
|
|
||||||
],
|
|
||||||
hidden=[
|
|
||||||
IO.Hidden.auth_token_comfy_org,
|
|
||||||
IO.Hidden.api_key_comfy_org,
|
|
||||||
IO.Hidden.unique_id,
|
|
||||||
],
|
|
||||||
is_api_node=True,
|
|
||||||
price_badge=IO.PriceBadge(
|
|
||||||
expr='{"type":"usd","usd":0.146,"format":{"suffix":"/second","approximate":true}}'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def execute(cls, model: dict, seed: int) -> IO.NodeOutput:
|
|
||||||
prompt = model.get("prompt") or ""
|
|
||||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
|
||||||
model_id = OMNI_MODELS[model["model"]]
|
|
||||||
|
|
||||||
images = [t for t in (model.get("images") or {}).values() if t is not None]
|
|
||||||
videos = [v for v in (model.get("videos") or {}).values() if v is not None]
|
|
||||||
if sum(get_number_of_images(t) for t in images) > OMNI_MAX_IMAGES:
|
|
||||||
raise ValueError(f"The current maximum number of supported images is {OMNI_MAX_IMAGES}.")
|
|
||||||
if len(videos) > OMNI_MAX_VIDEOS:
|
|
||||||
raise ValueError(f"The current maximum number of supported videos is {OMNI_MAX_VIDEOS}.")
|
|
||||||
for video in videos:
|
|
||||||
validate_video_duration(video, max_duration=10)
|
|
||||||
|
|
||||||
parts: list[GeminiPart] = []
|
|
||||||
if images or videos:
|
|
||||||
parts.extend(await build_gemini_media_parts(cls, images, [], videos))
|
|
||||||
parts.append(GeminiPart(text=prompt))
|
|
||||||
response = await sync_op(
|
|
||||||
cls,
|
|
||||||
ApiEndpoint(path=f"{GEMINI_BASE_ENDPOINT}/{model_id}", method="POST"),
|
|
||||||
data=GeminiGenerateContentRequest(
|
|
||||||
contents=[GeminiContent(role=GeminiRole.user, parts=parts)],
|
|
||||||
generationConfig=GeminiGenerationConfig(
|
|
||||||
responseModalities=["TEXT", "VIDEO"],
|
|
||||||
temperature=model.get("temperature", 1.0),
|
|
||||||
topP=model.get("top_p", 0.95),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
response_model=GeminiGenerateContentResponse,
|
|
||||||
price_extractor=calculate_tokens_price,
|
|
||||||
)
|
|
||||||
return IO.NodeOutput(
|
|
||||||
await get_video_from_response(response, cls=cls),
|
|
||||||
get_text_from_response(response),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiExtension(ComfyExtension):
|
class GeminiExtension(ComfyExtension):
|
||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
@ -1712,7 +1527,6 @@ class GeminiExtension(ComfyExtension):
|
|||||||
GeminiImage2,
|
GeminiImage2,
|
||||||
GeminiNanoBanana2,
|
GeminiNanoBanana2,
|
||||||
GeminiNanoBanana2V2,
|
GeminiNanoBanana2V2,
|
||||||
GeminiVideoOmni,
|
|
||||||
GeminiInputFiles,
|
GeminiInputFiles,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,9 @@ from PIL import Image
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from comfy_api_nodes.apis.ideogram import (
|
from comfy_api_nodes.apis.ideogram import (
|
||||||
|
IdeogramGenerateRequest,
|
||||||
IdeogramGenerateResponse,
|
IdeogramGenerateResponse,
|
||||||
|
ImageRequest,
|
||||||
IdeogramV3Request,
|
IdeogramV3Request,
|
||||||
IdeogramV3EditRequest,
|
IdeogramV3EditRequest,
|
||||||
IdeogramV4Request,
|
IdeogramV4Request,
|
||||||
@ -19,6 +21,101 @@ from comfy_api_nodes.util import (
|
|||||||
validate_string,
|
validate_string,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
V1_V1_RES_MAP = {
|
||||||
|
"Auto":"AUTO",
|
||||||
|
"512 x 1536":"RESOLUTION_512_1536",
|
||||||
|
"576 x 1408":"RESOLUTION_576_1408",
|
||||||
|
"576 x 1472":"RESOLUTION_576_1472",
|
||||||
|
"576 x 1536":"RESOLUTION_576_1536",
|
||||||
|
"640 x 1024":"RESOLUTION_640_1024",
|
||||||
|
"640 x 1344":"RESOLUTION_640_1344",
|
||||||
|
"640 x 1408":"RESOLUTION_640_1408",
|
||||||
|
"640 x 1472":"RESOLUTION_640_1472",
|
||||||
|
"640 x 1536":"RESOLUTION_640_1536",
|
||||||
|
"704 x 1152":"RESOLUTION_704_1152",
|
||||||
|
"704 x 1216":"RESOLUTION_704_1216",
|
||||||
|
"704 x 1280":"RESOLUTION_704_1280",
|
||||||
|
"704 x 1344":"RESOLUTION_704_1344",
|
||||||
|
"704 x 1408":"RESOLUTION_704_1408",
|
||||||
|
"704 x 1472":"RESOLUTION_704_1472",
|
||||||
|
"720 x 1280":"RESOLUTION_720_1280",
|
||||||
|
"736 x 1312":"RESOLUTION_736_1312",
|
||||||
|
"768 x 1024":"RESOLUTION_768_1024",
|
||||||
|
"768 x 1088":"RESOLUTION_768_1088",
|
||||||
|
"768 x 1152":"RESOLUTION_768_1152",
|
||||||
|
"768 x 1216":"RESOLUTION_768_1216",
|
||||||
|
"768 x 1232":"RESOLUTION_768_1232",
|
||||||
|
"768 x 1280":"RESOLUTION_768_1280",
|
||||||
|
"768 x 1344":"RESOLUTION_768_1344",
|
||||||
|
"832 x 960":"RESOLUTION_832_960",
|
||||||
|
"832 x 1024":"RESOLUTION_832_1024",
|
||||||
|
"832 x 1088":"RESOLUTION_832_1088",
|
||||||
|
"832 x 1152":"RESOLUTION_832_1152",
|
||||||
|
"832 x 1216":"RESOLUTION_832_1216",
|
||||||
|
"832 x 1248":"RESOLUTION_832_1248",
|
||||||
|
"864 x 1152":"RESOLUTION_864_1152",
|
||||||
|
"896 x 960":"RESOLUTION_896_960",
|
||||||
|
"896 x 1024":"RESOLUTION_896_1024",
|
||||||
|
"896 x 1088":"RESOLUTION_896_1088",
|
||||||
|
"896 x 1120":"RESOLUTION_896_1120",
|
||||||
|
"896 x 1152":"RESOLUTION_896_1152",
|
||||||
|
"960 x 832":"RESOLUTION_960_832",
|
||||||
|
"960 x 896":"RESOLUTION_960_896",
|
||||||
|
"960 x 1024":"RESOLUTION_960_1024",
|
||||||
|
"960 x 1088":"RESOLUTION_960_1088",
|
||||||
|
"1024 x 640":"RESOLUTION_1024_640",
|
||||||
|
"1024 x 768":"RESOLUTION_1024_768",
|
||||||
|
"1024 x 832":"RESOLUTION_1024_832",
|
||||||
|
"1024 x 896":"RESOLUTION_1024_896",
|
||||||
|
"1024 x 960":"RESOLUTION_1024_960",
|
||||||
|
"1024 x 1024":"RESOLUTION_1024_1024",
|
||||||
|
"1088 x 768":"RESOLUTION_1088_768",
|
||||||
|
"1088 x 832":"RESOLUTION_1088_832",
|
||||||
|
"1088 x 896":"RESOLUTION_1088_896",
|
||||||
|
"1088 x 960":"RESOLUTION_1088_960",
|
||||||
|
"1120 x 896":"RESOLUTION_1120_896",
|
||||||
|
"1152 x 704":"RESOLUTION_1152_704",
|
||||||
|
"1152 x 768":"RESOLUTION_1152_768",
|
||||||
|
"1152 x 832":"RESOLUTION_1152_832",
|
||||||
|
"1152 x 864":"RESOLUTION_1152_864",
|
||||||
|
"1152 x 896":"RESOLUTION_1152_896",
|
||||||
|
"1216 x 704":"RESOLUTION_1216_704",
|
||||||
|
"1216 x 768":"RESOLUTION_1216_768",
|
||||||
|
"1216 x 832":"RESOLUTION_1216_832",
|
||||||
|
"1232 x 768":"RESOLUTION_1232_768",
|
||||||
|
"1248 x 832":"RESOLUTION_1248_832",
|
||||||
|
"1280 x 704":"RESOLUTION_1280_704",
|
||||||
|
"1280 x 720":"RESOLUTION_1280_720",
|
||||||
|
"1280 x 768":"RESOLUTION_1280_768",
|
||||||
|
"1280 x 800":"RESOLUTION_1280_800",
|
||||||
|
"1312 x 736":"RESOLUTION_1312_736",
|
||||||
|
"1344 x 640":"RESOLUTION_1344_640",
|
||||||
|
"1344 x 704":"RESOLUTION_1344_704",
|
||||||
|
"1344 x 768":"RESOLUTION_1344_768",
|
||||||
|
"1408 x 576":"RESOLUTION_1408_576",
|
||||||
|
"1408 x 640":"RESOLUTION_1408_640",
|
||||||
|
"1408 x 704":"RESOLUTION_1408_704",
|
||||||
|
"1472 x 576":"RESOLUTION_1472_576",
|
||||||
|
"1472 x 640":"RESOLUTION_1472_640",
|
||||||
|
"1472 x 704":"RESOLUTION_1472_704",
|
||||||
|
"1536 x 512":"RESOLUTION_1536_512",
|
||||||
|
"1536 x 576":"RESOLUTION_1536_576",
|
||||||
|
"1536 x 640":"RESOLUTION_1536_640",
|
||||||
|
}
|
||||||
|
|
||||||
|
V1_V2_RATIO_MAP = {
|
||||||
|
"1:1":"ASPECT_1_1",
|
||||||
|
"4:3":"ASPECT_4_3",
|
||||||
|
"3:4":"ASPECT_3_4",
|
||||||
|
"16:9":"ASPECT_16_9",
|
||||||
|
"9:16":"ASPECT_9_16",
|
||||||
|
"2:1":"ASPECT_2_1",
|
||||||
|
"1:2":"ASPECT_1_2",
|
||||||
|
"3:2":"ASPECT_3_2",
|
||||||
|
"2:3":"ASPECT_2_3",
|
||||||
|
"4:5":"ASPECT_4_5",
|
||||||
|
"5:4":"ASPECT_5_4",
|
||||||
|
}
|
||||||
|
|
||||||
V3_RATIO_MAP = {
|
V3_RATIO_MAP = {
|
||||||
"1:3":"1x3",
|
"1:3":"1x3",
|
||||||
@ -132,6 +229,298 @@ async def download_and_process_images(image_urls):
|
|||||||
return stacked_tensors
|
return stacked_tensors
|
||||||
|
|
||||||
|
|
||||||
|
class IdeogramV1(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="IdeogramV1",
|
||||||
|
display_name="Ideogram V1",
|
||||||
|
category="partner/image/Ideogram",
|
||||||
|
description="Generates images using the Ideogram V1 model.",
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Prompt for the image generation",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"turbo",
|
||||||
|
default=False,
|
||||||
|
tooltip="Whether to use turbo mode (faster generation, potentially lower quality)",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"aspect_ratio",
|
||||||
|
options=list(V1_V2_RATIO_MAP.keys()),
|
||||||
|
default="1:1",
|
||||||
|
tooltip="The aspect ratio for image generation.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"magic_prompt_option",
|
||||||
|
options=["AUTO", "ON", "OFF"],
|
||||||
|
default="AUTO",
|
||||||
|
tooltip="Determine if MagicPrompt should be used in generation",
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
step=1,
|
||||||
|
control_after_generate=True,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.String.Input(
|
||||||
|
"negative_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Description of what to exclude from the image",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"num_images",
|
||||||
|
default=1,
|
||||||
|
min=1,
|
||||||
|
max=8,
|
||||||
|
step=1,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.Image.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
IO.Hidden.auth_token_comfy_org,
|
||||||
|
IO.Hidden.api_key_comfy_org,
|
||||||
|
IO.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
price_badge=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["num_images", "turbo"]),
|
||||||
|
expr="""
|
||||||
|
(
|
||||||
|
$n := widgets.num_images;
|
||||||
|
$base := (widgets.turbo = true) ? 0.0286 : 0.0858;
|
||||||
|
{"type":"usd","usd": $round($base * $n, 2)}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt,
|
||||||
|
turbo=False,
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
magic_prompt_option="AUTO",
|
||||||
|
seed=0,
|
||||||
|
negative_prompt="",
|
||||||
|
num_images=1,
|
||||||
|
):
|
||||||
|
# Determine the model based on turbo setting
|
||||||
|
aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
|
||||||
|
model = "V_1_TURBO" if turbo else "V_1"
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path="/proxy/ideogram/generate", method="POST"),
|
||||||
|
response_model=IdeogramGenerateResponse,
|
||||||
|
data=IdeogramGenerateRequest(
|
||||||
|
image_request=ImageRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
num_images=num_images,
|
||||||
|
seed=seed,
|
||||||
|
aspect_ratio=aspect_ratio if aspect_ratio != "ASPECT_1_1" else None,
|
||||||
|
magic_prompt_option=(magic_prompt_option if magic_prompt_option != "AUTO" else None),
|
||||||
|
negative_prompt=negative_prompt if negative_prompt else None,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.data or len(response.data) == 0:
|
||||||
|
raise Exception("No images were generated in the response")
|
||||||
|
|
||||||
|
image_urls = [image_data.url for image_data in response.data if image_data.url]
|
||||||
|
if not image_urls:
|
||||||
|
raise Exception("No image URLs were generated in the response")
|
||||||
|
return IO.NodeOutput(await download_and_process_images(image_urls))
|
||||||
|
|
||||||
|
|
||||||
|
class IdeogramV2(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="IdeogramV2",
|
||||||
|
display_name="Ideogram V2",
|
||||||
|
category="partner/image/Ideogram",
|
||||||
|
description="Generates images using the Ideogram V2 model.",
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Prompt for the image generation",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"turbo",
|
||||||
|
default=False,
|
||||||
|
tooltip="Whether to use turbo mode (faster generation, potentially lower quality)",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"aspect_ratio",
|
||||||
|
options=list(V1_V2_RATIO_MAP.keys()),
|
||||||
|
default="1:1",
|
||||||
|
tooltip="The aspect ratio for image generation. Ignored if resolution is not set to AUTO.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"resolution",
|
||||||
|
options=list(V1_V1_RES_MAP.keys()),
|
||||||
|
default="Auto",
|
||||||
|
tooltip="The resolution for image generation. "
|
||||||
|
"If not set to AUTO, this overrides the aspect_ratio setting.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"magic_prompt_option",
|
||||||
|
options=["AUTO", "ON", "OFF"],
|
||||||
|
default="AUTO",
|
||||||
|
tooltip="Determine if MagicPrompt should be used in generation",
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
step=1,
|
||||||
|
control_after_generate=True,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"style_type",
|
||||||
|
options=["AUTO", "GENERAL", "REALISTIC", "DESIGN", "RENDER_3D", "ANIME"],
|
||||||
|
default="NONE",
|
||||||
|
tooltip="Style type for generation (V2 only)",
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
IO.String.Input(
|
||||||
|
"negative_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Description of what to exclude from the image",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"num_images",
|
||||||
|
default=1,
|
||||||
|
min=1,
|
||||||
|
max=8,
|
||||||
|
step=1,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
#"color_palette": (
|
||||||
|
# IO.STRING,
|
||||||
|
# {
|
||||||
|
# "multiline": False,
|
||||||
|
# "default": "",
|
||||||
|
# "tooltip": "Color palette preset name or hex colors with weights",
|
||||||
|
# },
|
||||||
|
#),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.Image.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
IO.Hidden.auth_token_comfy_org,
|
||||||
|
IO.Hidden.api_key_comfy_org,
|
||||||
|
IO.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
price_badge=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["num_images", "turbo"]),
|
||||||
|
expr="""
|
||||||
|
(
|
||||||
|
$n := widgets.num_images;
|
||||||
|
$base := (widgets.turbo = true) ? 0.0715 : 0.1144;
|
||||||
|
{"type":"usd","usd": $round($base * $n, 2)}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt,
|
||||||
|
turbo=False,
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
resolution="Auto",
|
||||||
|
magic_prompt_option="AUTO",
|
||||||
|
seed=0,
|
||||||
|
style_type="NONE",
|
||||||
|
negative_prompt="",
|
||||||
|
num_images=1,
|
||||||
|
color_palette="",
|
||||||
|
):
|
||||||
|
aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
|
||||||
|
resolution = V1_V1_RES_MAP.get(resolution, None)
|
||||||
|
# Determine the model based on turbo setting
|
||||||
|
model = "V_2_TURBO" if turbo else "V_2"
|
||||||
|
|
||||||
|
# Handle resolution vs aspect_ratio logic
|
||||||
|
# If resolution is not AUTO, it overrides aspect_ratio
|
||||||
|
final_resolution = None
|
||||||
|
final_aspect_ratio = None
|
||||||
|
|
||||||
|
if resolution != "AUTO":
|
||||||
|
final_resolution = resolution
|
||||||
|
else:
|
||||||
|
final_aspect_ratio = aspect_ratio if aspect_ratio != "ASPECT_1_1" else None
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
|
cls,
|
||||||
|
endpoint=ApiEndpoint(path="/proxy/ideogram/generate", method="POST"),
|
||||||
|
response_model=IdeogramGenerateResponse,
|
||||||
|
data=IdeogramGenerateRequest(
|
||||||
|
image_request=ImageRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
num_images=num_images,
|
||||||
|
seed=seed,
|
||||||
|
aspect_ratio=final_aspect_ratio,
|
||||||
|
resolution=final_resolution,
|
||||||
|
magic_prompt_option=(magic_prompt_option if magic_prompt_option != "AUTO" else None),
|
||||||
|
style_type=style_type if style_type != "NONE" else None,
|
||||||
|
negative_prompt=negative_prompt if negative_prompt else None,
|
||||||
|
color_palette=color_palette if color_palette else None,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
if not response.data or len(response.data) == 0:
|
||||||
|
raise Exception("No images were generated in the response")
|
||||||
|
|
||||||
|
image_urls = [image_data.url for image_data in response.data if image_data.url]
|
||||||
|
if not image_urls:
|
||||||
|
raise Exception("No image URLs were generated in the response")
|
||||||
|
return IO.NodeOutput(await download_and_process_images(image_urls))
|
||||||
|
|
||||||
|
|
||||||
class IdeogramV3(IO.ComfyNode):
|
class IdeogramV3(IO.ComfyNode):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -528,6 +917,8 @@ class IdeogramExtension(ComfyExtension):
|
|||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
return [
|
return [
|
||||||
|
IdeogramV1,
|
||||||
|
IdeogramV2,
|
||||||
IdeogramV3,
|
IdeogramV3,
|
||||||
IdeogramV4,
|
IdeogramV4,
|
||||||
]
|
]
|
||||||
|
|||||||
@ -8,8 +8,7 @@ class CLIPTextEncodeControlnet(io.ComfyNode):
|
|||||||
def define_schema(cls) -> io.Schema:
|
def define_schema(cls) -> io.Schema:
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="CLIPTextEncodeControlnet",
|
node_id="CLIPTextEncodeControlnet",
|
||||||
display_name="CLIP Text Encode (Controlnet)",
|
category="experimental/conditioning",
|
||||||
category="model/conditioning",
|
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Clip.Input("clip"),
|
io.Clip.Input("clip"),
|
||||||
io.Conditioning.Input("conditioning"),
|
io.Conditioning.Input("conditioning"),
|
||||||
@ -36,12 +35,11 @@ class T5TokenizerOptions(io.ComfyNode):
|
|||||||
def define_schema(cls) -> io.Schema:
|
def define_schema(cls) -> io.Schema:
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="T5TokenizerOptions",
|
node_id="T5TokenizerOptions",
|
||||||
display_name="T5 Tokenizer Options",
|
category="experimental/conditioning",
|
||||||
category="model/conditioning",
|
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Clip.Input("clip"),
|
io.Clip.Input("clip"),
|
||||||
io.Int.Input("min_padding", default=0, min=0, max=10000, step=1),
|
io.Int.Input("min_padding", default=0, min=0, max=10000, step=1, advanced=True),
|
||||||
io.Int.Input("min_length", default=0, min=0, max=10000, step=1),
|
io.Int.Input("min_length", default=0, min=0, max=10000, step=1, advanced=True),
|
||||||
],
|
],
|
||||||
outputs=[io.Clip.Output()],
|
outputs=[io.Clip.Output()],
|
||||||
is_experimental=True,
|
is_experimental=True,
|
||||||
|
|||||||
@ -1070,7 +1070,7 @@ class AddNoise(io.ComfyNode):
|
|||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="AddNoise",
|
node_id="AddNoise",
|
||||||
category="model/sampling/noise",
|
category="experimental/custom_sampling/noise",
|
||||||
is_experimental=True,
|
is_experimental=True,
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Model.Input("model"),
|
io.Model.Input("model"),
|
||||||
@ -1120,7 +1120,7 @@ class ManualSigmas(io.ComfyNode):
|
|||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="ManualSigmas",
|
node_id="ManualSigmas",
|
||||||
search_aliases=["custom noise schedule", "define sigmas"],
|
search_aliases=["custom noise schedule", "define sigmas"],
|
||||||
category="model/sampling/sigmas",
|
category="experimental/custom_sampling",
|
||||||
is_experimental=True,
|
is_experimental=True,
|
||||||
inputs=[
|
inputs=[
|
||||||
io.String.Input("sigmas", default="1, 0.5", multiline=False)
|
io.String.Input("sigmas", default="1, 0.5", multiline=False)
|
||||||
|
|||||||
@ -123,8 +123,7 @@ class PhotoMakerLoader(io.ComfyNode):
|
|||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="PhotoMakerLoader",
|
node_id="PhotoMakerLoader",
|
||||||
display_name="Load PhotoMaker Model",
|
category="experimental/photomaker",
|
||||||
category="model/loaders",
|
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Combo.Input("photomaker_model_name", options=folder_paths.get_filename_list("photomaker")),
|
io.Combo.Input("photomaker_model_name", options=folder_paths.get_filename_list("photomaker")),
|
||||||
],
|
],
|
||||||
@ -150,8 +149,7 @@ class PhotoMakerEncode(io.ComfyNode):
|
|||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="PhotoMakerEncode",
|
node_id="PhotoMakerEncode",
|
||||||
display_name="PhotoMaker Encode",
|
category="experimental/photomaker",
|
||||||
category="model/conditioning/photomaker",
|
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Photomaker.Input("photomaker"),
|
io.Photomaker.Input("photomaker"),
|
||||||
io.Image.Input("image"),
|
io.Image.Input("image"),
|
||||||
|
|||||||
@ -119,7 +119,7 @@ class StableCascade_SuperResolutionControlnet(io.ComfyNode):
|
|||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="StableCascade_SuperResolutionControlnet",
|
node_id="StableCascade_SuperResolutionControlnet",
|
||||||
category="experimental/stable cascade",
|
category="experimental/stable_cascade",
|
||||||
is_experimental=True,
|
is_experimental=True,
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Image.Input("image"),
|
io.Image.Input("image"),
|
||||||
|
|||||||
@ -143,7 +143,7 @@ class VAEDecodeTripoSplat(IO.ComfyNode):
|
|||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="VAEDecodeTripoSplat",
|
node_id="VAEDecodeTripoSplat",
|
||||||
display_name="TripoSplat Decode",
|
display_name="TripoSplat Decode",
|
||||||
category="model/latent/triposplat",
|
category="3d/latent",
|
||||||
description="Decode the sampled TripoSplat latent into a 3D gaussian splat. "
|
description="Decode the sampled TripoSplat latent into a 3D gaussian splat. "
|
||||||
"Modify the number of gaussians to vary the density.",
|
"Modify the number of gaussians to vary the density.",
|
||||||
inputs=[
|
inputs=[
|
||||||
@ -188,7 +188,7 @@ class TripoSplatSamplingPreview(IO.ComfyNode):
|
|||||||
return IO.Schema(
|
return IO.Schema(
|
||||||
node_id="TripoSplatSamplingPreview",
|
node_id="TripoSplatSamplingPreview",
|
||||||
display_name="TripoSplat Sampling Preview",
|
display_name="TripoSplat Sampling Preview",
|
||||||
category="model/latent/triposplat",
|
category="3d/latent",
|
||||||
description="Patch the TripoSplat model for the standard Ksampler node to show a live decoded "
|
description="Patch the TripoSplat model for the standard Ksampler node to show a live decoded "
|
||||||
"gaussian splat preview at each step.",
|
"gaussian splat preview at each step.",
|
||||||
inputs=[
|
inputs=[
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# This file is automatically generated by the build process when version is
|
# This file is automatically generated by the build process when version is
|
||||||
# updated in pyproject.toml.
|
# updated in pyproject.toml.
|
||||||
__version__ = "0.27.0"
|
__version__ = "0.26.0"
|
||||||
|
|||||||
55
execution.py
55
execution.py
@ -1113,32 +1113,6 @@ def full_type_name(klass):
|
|||||||
return klass.__qualname__
|
return klass.__qualname__
|
||||||
return module + '.' + klass.__qualname__
|
return module + '.' + klass.__qualname__
|
||||||
|
|
||||||
def node_not_executable_reason(class_def, class_type):
|
|
||||||
"""Return a human-readable reason the node cannot be executed, or None if it's fine.
|
|
||||||
|
|
||||||
Catches a node whose declared entry point doesn't resolve to a real method
|
|
||||||
(e.g. a V1 ``FUNCTION = "invert"`` where the method is misspelled, or a V3 node
|
|
||||||
missing its ``execute`` override). Running this during validation surfaces the
|
|
||||||
problem before execution starts, instead of after upstream nodes have run.
|
|
||||||
|
|
||||||
Only the class is inspected; the node is never instantiated here, so a node's
|
|
||||||
``__init__`` side effects cannot run (or fail) during validation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if issubclass(class_def, _ComfyNodeInternal):
|
|
||||||
# V3: validates that execute()/define_schema() overrides exist.
|
|
||||||
class_def.VALIDATE_CLASS()
|
|
||||||
return None
|
|
||||||
# V1: FUNCTION names the method to call; it must exist on the class.
|
|
||||||
function_name = getattr(class_def, "FUNCTION", None)
|
|
||||||
if function_name is None:
|
|
||||||
return f"'{class_type}' does not define FUNCTION"
|
|
||||||
if not callable(getattr(class_def, function_name, None)):
|
|
||||||
return f"'{class_type}' has no method '{function_name}' (declared in FUNCTION)"
|
|
||||||
return None
|
|
||||||
except Exception as ex:
|
|
||||||
return str(ex)
|
|
||||||
|
|
||||||
async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[str], None]):
|
async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[str], None]):
|
||||||
outputs = set()
|
outputs = set()
|
||||||
for x in prompt:
|
for x in prompt:
|
||||||
@ -1174,35 +1148,6 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[
|
|||||||
}
|
}
|
||||||
return (False, error, [], {})
|
return (False, error, [], {})
|
||||||
|
|
||||||
# Make sure the node is actually executable (its FUNCTION/execute entry
|
|
||||||
# point resolves to a real method) before we touch any schema-derived
|
|
||||||
# attributes below or start execution. Catches code typos up front and
|
|
||||||
# attributes the error to the offending node.
|
|
||||||
not_executable = node_not_executable_reason(class_, class_type)
|
|
||||||
if not_executable is not None:
|
|
||||||
node_title = prompt[x].get('_meta', {}).get('title', class_type)
|
|
||||||
error = {
|
|
||||||
"type": "invalid_node_definition",
|
|
||||||
"message": "Node is not executable",
|
|
||||||
"details": f"{not_executable} (Node ID '#{x}')",
|
|
||||||
"extra_info": {
|
|
||||||
"node_id": x,
|
|
||||||
"class_type": class_type,
|
|
||||||
"node_title": node_title,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node_errors = {x: {
|
|
||||||
"errors": [{
|
|
||||||
"type": "invalid_node_definition",
|
|
||||||
"message": "Node is not executable",
|
|
||||||
"details": not_executable,
|
|
||||||
"extra_info": {},
|
|
||||||
}],
|
|
||||||
"dependent_outputs": [],
|
|
||||||
"class_type": class_type,
|
|
||||||
}}
|
|
||||||
return (False, error, [], node_errors)
|
|
||||||
|
|
||||||
if hasattr(class_, 'OUTPUT_NODE') and class_.OUTPUT_NODE is True:
|
if hasattr(class_, 'OUTPUT_NODE') and class_.OUTPUT_NODE is True:
|
||||||
if partial_execution_list is None or x in partial_execution_list:
|
if partial_execution_list is None or x in partial_execution_list:
|
||||||
outputs.add(x)
|
outputs.add(x)
|
||||||
|
|||||||
4
main.py
4
main.py
@ -403,7 +403,7 @@ def prompt_worker(q, server_instance):
|
|||||||
hook_breaker_ac10a0.restore_functions()
|
hook_breaker_ac10a0.restore_functions()
|
||||||
|
|
||||||
if not asset_seeder.is_disabled():
|
if not asset_seeder.is_disabled():
|
||||||
asset_seeder.enqueue_enrich(roots=("output",), compute_hashes=args.enable_asset_hashing)
|
asset_seeder.enqueue_enrich(roots=("output",), compute_hashes=True)
|
||||||
asset_seeder.resume()
|
asset_seeder.resume()
|
||||||
|
|
||||||
|
|
||||||
@ -458,7 +458,7 @@ def setup_database():
|
|||||||
if dependencies_available():
|
if dependencies_available():
|
||||||
init_db()
|
init_db()
|
||||||
if args.enable_assets:
|
if args.enable_assets:
|
||||||
if asset_seeder.start(roots=("models", "input", "output"), prune_first=True, compute_hashes=args.enable_asset_hashing):
|
if asset_seeder.start(roots=("models", "input", "output"), prune_first=True, compute_hashes=True):
|
||||||
logging.info("Background asset scan initiated for models, input, output")
|
logging.info("Background asset scan initiated for models, input, output")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "database is locked" in str(e):
|
if "database is locked" in str(e):
|
||||||
|
|||||||
36
nodes.py
36
nodes.py
@ -159,29 +159,6 @@ class ConditioningConcat:
|
|||||||
|
|
||||||
return (out, )
|
return (out, )
|
||||||
|
|
||||||
class ConditioningMultiply:
|
|
||||||
SEARCH_ALIASES = ["scale conditioning", "scale prompt", "multiply conditioning", "multiply prompt"]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {"conditioning": ("CONDITIONING", ),
|
|
||||||
"multiplier": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01})
|
|
||||||
}}
|
|
||||||
RETURN_TYPES = ("CONDITIONING",)
|
|
||||||
FUNCTION = "multiply"
|
|
||||||
CATEGORY = "model/conditioning/transform"
|
|
||||||
|
|
||||||
def multiply(self, conditioning, multiplier):
|
|
||||||
c = []
|
|
||||||
for t in conditioning:
|
|
||||||
values = {}
|
|
||||||
pooled_output = t[1].get("pooled_output", None)
|
|
||||||
if pooled_output is not None:
|
|
||||||
values["pooled_output"] = pooled_output * multiplier
|
|
||||||
scaled = node_helpers.conditioning_set_values([[t[0] * multiplier, t[1]]], values)[0]
|
|
||||||
c.append(scaled)
|
|
||||||
return (c,)
|
|
||||||
|
|
||||||
class ConditioningSetArea:
|
class ConditioningSetArea:
|
||||||
SEARCH_ALIASES = ["regional prompt", "area prompt", "spatial conditioning", "localized prompt"]
|
SEARCH_ALIASES = ["regional prompt", "area prompt", "spatial conditioning", "localized prompt"]
|
||||||
|
|
||||||
@ -349,7 +326,7 @@ class VAEDecodeTiled:
|
|||||||
RETURN_TYPES = ("IMAGE",)
|
RETURN_TYPES = ("IMAGE",)
|
||||||
FUNCTION = "decode"
|
FUNCTION = "decode"
|
||||||
|
|
||||||
CATEGORY = "model/latent"
|
CATEGORY = "experimental"
|
||||||
|
|
||||||
def decode(self, vae, samples, tile_size, overlap=64, temporal_size=64, temporal_overlap=8):
|
def decode(self, vae, samples, tile_size, overlap=64, temporal_size=64, temporal_overlap=8):
|
||||||
if tile_size < overlap * 4:
|
if tile_size < overlap * 4:
|
||||||
@ -396,7 +373,7 @@ class VAEEncodeTiled:
|
|||||||
RETURN_TYPES = ("LATENT",)
|
RETURN_TYPES = ("LATENT",)
|
||||||
FUNCTION = "encode"
|
FUNCTION = "encode"
|
||||||
|
|
||||||
CATEGORY = "model/latent"
|
CATEGORY = "experimental"
|
||||||
|
|
||||||
def encode(self, vae, pixels, tile_size, overlap, temporal_size=64, temporal_overlap=8):
|
def encode(self, vae, pixels, tile_size, overlap, temporal_size=64, temporal_overlap=8):
|
||||||
t = vae.encode_tiled(pixels, tile_x=tile_size, tile_y=tile_size, overlap=overlap, tile_t=temporal_size, overlap_t=temporal_overlap)
|
t = vae.encode_tiled(pixels, tile_x=tile_size, tile_y=tile_size, overlap=overlap, tile_t=temporal_size, overlap_t=temporal_overlap)
|
||||||
@ -514,7 +491,7 @@ class SaveLatent:
|
|||||||
|
|
||||||
OUTPUT_NODE = True
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
CATEGORY = "model/latent"
|
CATEGORY = "experimental"
|
||||||
|
|
||||||
def save(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None):
|
def save(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None):
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir)
|
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir)
|
||||||
@ -559,7 +536,7 @@ class LoadLatent:
|
|||||||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".latent")]
|
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".latent")]
|
||||||
return {"required": {"latent": [sorted(files), ]}, }
|
return {"required": {"latent": [sorted(files), ]}, }
|
||||||
|
|
||||||
CATEGORY = "model/latent"
|
CATEGORY = "experimental"
|
||||||
|
|
||||||
RETURN_TYPES = ("LATENT", )
|
RETURN_TYPES = ("LATENT", )
|
||||||
FUNCTION = "load"
|
FUNCTION = "load"
|
||||||
@ -2073,7 +2050,6 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"ConditioningAverage": ConditioningAverage,
|
"ConditioningAverage": ConditioningAverage,
|
||||||
"ConditioningCombine": ConditioningCombine,
|
"ConditioningCombine": ConditioningCombine,
|
||||||
"ConditioningConcat": ConditioningConcat,
|
"ConditioningConcat": ConditioningConcat,
|
||||||
"ConditioningMultiply": ConditioningMultiply,
|
|
||||||
"ConditioningSetArea": ConditioningSetArea,
|
"ConditioningSetArea": ConditioningSetArea,
|
||||||
"ConditioningSetAreaPercentage": ConditioningSetAreaPercentage,
|
"ConditioningSetAreaPercentage": ConditioningSetAreaPercentage,
|
||||||
"ConditioningSetAreaStrength": ConditioningSetAreaStrength,
|
"ConditioningSetAreaStrength": ConditioningSetAreaStrength,
|
||||||
@ -2145,7 +2121,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"ConditioningAverage ": "Conditioning (Average)",
|
"ConditioningAverage ": "Conditioning (Average)",
|
||||||
"ConditioningAverage": "Conditioning (Average)",
|
"ConditioningAverage": "Conditioning (Average)",
|
||||||
"ConditioningConcat": "Conditioning (Concat)",
|
"ConditioningConcat": "Conditioning (Concat)",
|
||||||
"ConditioningMultiply": "Conditioning (Multiply)",
|
|
||||||
"ConditioningSetArea": "Conditioning (Set Area)",
|
"ConditioningSetArea": "Conditioning (Set Area)",
|
||||||
"ConditioningSetAreaPercentage": "Conditioning (Set Area with Percentage)",
|
"ConditioningSetAreaPercentage": "Conditioning (Set Area with Percentage)",
|
||||||
"ConditioningSetAreaStrength": "Conditioning (Set Area Strength)",
|
"ConditioningSetAreaStrength": "Conditioning (Set Area Strength)",
|
||||||
@ -2155,8 +2130,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"GLIGENTextBoxApply": "Apply GLIGEN Text Box",
|
"GLIGENTextBoxApply": "Apply GLIGEN Text Box",
|
||||||
"ConditioningZeroOut": "Conditioning Zero Out",
|
"ConditioningZeroOut": "Conditioning Zero Out",
|
||||||
# Latent
|
# Latent
|
||||||
"LoadLatent": "Load Latent",
|
|
||||||
"SaveLatent": "Save Latent",
|
|
||||||
"VAEEncodeForInpaint": "VAE Encode (for Inpainting)",
|
"VAEEncodeForInpaint": "VAE Encode (for Inpainting)",
|
||||||
"SetLatentNoiseMask": "Set Latent Noise Mask",
|
"SetLatentNoiseMask": "Set Latent Noise Mask",
|
||||||
"VAEDecode": "VAE Decode",
|
"VAEDecode": "VAE Decode",
|
||||||
@ -2191,6 +2164,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"ImageSharpen": "Sharpen Image",
|
"ImageSharpen": "Sharpen Image",
|
||||||
"ImageScaleToTotalPixels": "Scale Image to Total Pixels",
|
"ImageScaleToTotalPixels": "Scale Image to Total Pixels",
|
||||||
"GetImageSize": "Get Image Size",
|
"GetImageSize": "Get Image Size",
|
||||||
|
# experimental
|
||||||
"VAEDecodeTiled": "VAE Decode (Tiled)",
|
"VAEDecodeTiled": "VAE Decode (Tiled)",
|
||||||
"VAEEncodeTiled": "VAE Encode (Tiled)",
|
"VAEEncodeTiled": "VAE Encode (Tiled)",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ComfyUI"
|
name = "ComfyUI"
|
||||||
version = "0.27.0"
|
version = "0.26.0"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
comfyui-frontend-package==1.45.20
|
comfyui-frontend-package==1.45.19
|
||||||
comfyui-workflow-templates==0.11.1
|
comfyui-workflow-templates==0.10.7
|
||||||
comfyui-embedded-docs==0.5.6
|
comfyui-embedded-docs==0.5.5
|
||||||
torch
|
torch
|
||||||
torchsde
|
torchsde
|
||||||
torchvision
|
torchvision
|
||||||
@ -22,7 +22,7 @@ alembic
|
|||||||
SQLAlchemy>=2.0.0
|
SQLAlchemy>=2.0.0
|
||||||
filelock
|
filelock
|
||||||
av>=16.0.0
|
av>=16.0.0
|
||||||
comfy-kitchen==0.2.16
|
comfy-kitchen==0.2.13
|
||||||
comfy-aimdo==0.4.10
|
comfy-aimdo==0.4.10
|
||||||
requests
|
requests
|
||||||
simpleeval>=1.0.0
|
simpleeval>=1.0.0
|
||||||
|
|||||||
204
tests-unit/comfy_api_test/io_dynamic_group_test.py
Normal file
204
tests-unit/comfy_api_test/io_dynamic_group_test.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""Unit tests for io.DynamicGroup: expansion/reconstruction (0-row and N-row cases)."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Stub torch (type-hint only in _io.py; real torch not available in unit-test env)
|
||||||
|
if "torch" not in sys.modules:
|
||||||
|
_torch_stub = types.ModuleType("torch")
|
||||||
|
_torch_stub.Tensor = object # type: ignore[attr-defined]
|
||||||
|
sys.modules["torch"] = _torch_stub
|
||||||
|
|
||||||
|
from comfy_api.latest._io import ( # noqa: E402
|
||||||
|
DynamicGroup,
|
||||||
|
Float,
|
||||||
|
Int,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
get_finalized_class_inputs,
|
||||||
|
build_nested_inputs,
|
||||||
|
create_input_dict_v1,
|
||||||
|
setup_dynamic_input_funcs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure dynamic input funcs are registered (may already be done at import time)
|
||||||
|
setup_dynamic_input_funcs()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_class_inputs(group_input: DynamicGroup.Input) -> dict:
|
||||||
|
"""Wrap a DynamicGroup.Input into the required/optional dict structure."""
|
||||||
|
return create_input_dict_v1([group_input])
|
||||||
|
|
||||||
|
|
||||||
|
def _run(group_input: DynamicGroup.Input, live_values: dict) -> dict:
|
||||||
|
"""End-to-end helper: expand schema + reconstruct values.
|
||||||
|
|
||||||
|
Mirrors the production split in execution.py:
|
||||||
|
1. get_finalized_class_inputs (schema expansion, line 162)
|
||||||
|
2. build_nested_inputs (value reconstruction, line 281)
|
||||||
|
|
||||||
|
The two steps are separate in production because the engine resolves
|
||||||
|
linked node outputs between them, but in tests we supply values directly.
|
||||||
|
"""
|
||||||
|
class_inputs = _make_class_inputs(group_input)
|
||||||
|
_, _, v3_data = get_finalized_class_inputs(class_inputs, live_values)
|
||||||
|
return build_nested_inputs(dict(live_values), v3_data)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDynamicGroupInputConstruction:
|
||||||
|
def test_basic_construction(self):
|
||||||
|
inp = DynamicGroup.Input(
|
||||||
|
"loras",
|
||||||
|
template=[
|
||||||
|
Float.Input("strength", default=1.0),
|
||||||
|
String.Input("name"),
|
||||||
|
],
|
||||||
|
min=0,
|
||||||
|
max=10,
|
||||||
|
)
|
||||||
|
assert inp.id == "loras"
|
||||||
|
assert inp.min == 0
|
||||||
|
assert inp.max == 10
|
||||||
|
assert len(inp.template) == 2
|
||||||
|
|
||||||
|
def test_get_all_includes_self_and_template(self):
|
||||||
|
inp = DynamicGroup.Input(
|
||||||
|
"items",
|
||||||
|
template=[Float.Input("value")],
|
||||||
|
)
|
||||||
|
all_inputs = inp.get_all()
|
||||||
|
assert all_inputs[0] is inp
|
||||||
|
assert all_inputs[1].id == "value"
|
||||||
|
|
||||||
|
def test_as_dict_has_template_min_max(self):
|
||||||
|
inp = DynamicGroup.Input(
|
||||||
|
"items",
|
||||||
|
template=[Float.Input("val", default=0.5)],
|
||||||
|
min=1,
|
||||||
|
max=5,
|
||||||
|
)
|
||||||
|
d = inp.as_dict()
|
||||||
|
assert "template" in d
|
||||||
|
assert d["min"] == 1
|
||||||
|
assert d["max"] == 5
|
||||||
|
|
||||||
|
def test_duplicate_field_ids_raises(self):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
DynamicGroup.Input(
|
||||||
|
"bad",
|
||||||
|
template=[Float.Input("x"), Float.Input("x")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_template_raises(self):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
DynamicGroup.Input("bad", template=[])
|
||||||
|
|
||||||
|
def test_min_gt_max_raises(self):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
DynamicGroup.Input("bad", template=[Float.Input("x")], min=5, max=3)
|
||||||
|
|
||||||
|
def test_max_exceeds_limit_raises(self):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
DynamicGroup.Input("bad", template=[Float.Input("x")], max=101)
|
||||||
|
|
||||||
|
def test_dynamic_input_in_template_raises(self):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
DynamicGroup.Input(
|
||||||
|
"bad",
|
||||||
|
template=[DynamicGroup.Input("nested", template=[Float.Input("x")])],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_calls_through(self):
|
||||||
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", min=-1.0, max=1.0)])
|
||||||
|
inp.validate() # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 0-row case
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestZeroRows:
|
||||||
|
def test_empty_live_inputs_produces_empty_list(self):
|
||||||
|
"""With min=0 and no live values, the result should be an empty list."""
|
||||||
|
inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
||||||
|
assert _run(inp, {}).get("loras") == []
|
||||||
|
|
||||||
|
def test_min_zero_with_values(self):
|
||||||
|
"""min=0 but 2 rows of live data."""
|
||||||
|
inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
|
||||||
|
result = _run(inp, {"loras.0.strength": 0.8, "loras.1.strength": 0.5})
|
||||||
|
assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# N-row case
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNRows:
|
||||||
|
def test_two_rows_two_fields(self):
|
||||||
|
"""Two rows with two fields each produce a list[dict]."""
|
||||||
|
inp = DynamicGroup.Input(
|
||||||
|
"loras",
|
||||||
|
template=[String.Input("lora_name"), Float.Input("strength", default=1.0)],
|
||||||
|
min=0, max=50,
|
||||||
|
)
|
||||||
|
result = _run(inp, {
|
||||||
|
"loras.0.lora_name": "model_a.safetensors", "loras.0.strength": 0.9,
|
||||||
|
"loras.1.lora_name": "model_b.safetensors", "loras.1.strength": 0.4,
|
||||||
|
})
|
||||||
|
assert result["loras"] == [
|
||||||
|
{"lora_name": "model_a.safetensors", "strength": 0.9},
|
||||||
|
{"lora_name": "model_b.safetensors", "strength": 0.4},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_rows_are_sorted_by_index(self):
|
||||||
|
"""Rows must be in ascending index order even if dict iteration is unordered."""
|
||||||
|
inp = DynamicGroup.Input("items", template=[Int.Input("v", default=0)], min=0, max=10)
|
||||||
|
result = _run(inp, {"items.0.v": 10, "items.2.v": 30, "items.1.v": 20})
|
||||||
|
assert [row["v"] for row in result["items"]] == [10, 20, 30]
|
||||||
|
|
||||||
|
def test_min_rows_schema_slots(self):
|
||||||
|
"""With min=2 and no live data, 2 slots must appear in the expanded schema."""
|
||||||
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
|
out, _, _ = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
||||||
|
all_slots = {**out.get("required", {}), **out.get("optional", {})}
|
||||||
|
assert "items.0.val" in all_slots
|
||||||
|
assert "items.1.val" in all_slots
|
||||||
|
|
||||||
|
def test_min_rows_reconstructs_when_no_values(self):
|
||||||
|
"""min=2 with NO live values must still yield a 2-element list,
|
||||||
|
not collapse to [] (regression: parent-path clobber)."""
|
||||||
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
|
result = _run(inp, {})
|
||||||
|
assert len(result["items"]) == 2
|
||||||
|
assert all("val" in row for row in result["items"])
|
||||||
|
|
||||||
|
def test_min_rows_reconstructs_with_partial_values(self):
|
||||||
|
"""min=2 with only the first row's value present still yields 2 rows."""
|
||||||
|
inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
|
||||||
|
result = _run(inp, {"items.0.val": 0.7})
|
||||||
|
assert len(result["items"]) == 2
|
||||||
|
assert result["items"][0]["val"] == 0.7
|
||||||
|
assert result["items"][1]["val"] is None
|
||||||
|
|
||||||
|
def test_list_paths_in_v3_data(self):
|
||||||
|
"""list_paths must contain the group id so build_nested_inputs knows to convert."""
|
||||||
|
inp = DynamicGroup.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
|
||||||
|
_, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {})
|
||||||
|
assert "things" in v3_data.get("list_paths", set())
|
||||||
|
|
||||||
|
def test_no_leftover_flat_keys(self):
|
||||||
|
"""Flat keys must be consumed; only the reconstructed list remains."""
|
||||||
|
inp = DynamicGroup.Input("rows", template=[Float.Input("x", default=0.0)], min=0, max=5)
|
||||||
|
result = _run(inp, {"rows.0.x": 1.0, "rows.1.x": 2.0})
|
||||||
|
assert "rows.0.x" not in result
|
||||||
|
assert "rows.1.x" not in result
|
||||||
|
assert isinstance(result["rows"], list)
|
||||||
@ -1,137 +0,0 @@
|
|||||||
"""Tests for pre-execution validation that a node is actually executable.
|
|
||||||
|
|
||||||
validate_prompt rejects a node whose declared entry point does not resolve to a
|
|
||||||
real method (a V1 FUNCTION typo, or a V3 node missing its execute override) before
|
|
||||||
any node runs, attributing the error to the offending node.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import nodes
|
|
||||||
from comfy_api.latest import io
|
|
||||||
from execution import node_not_executable_reason, validate_prompt
|
|
||||||
|
|
||||||
|
|
||||||
class _GoodV1Node:
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {}}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("IMAGE",)
|
|
||||||
FUNCTION = "run"
|
|
||||||
OUTPUT_NODE = True
|
|
||||||
CATEGORY = "Test"
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
return (None,)
|
|
||||||
|
|
||||||
|
|
||||||
class _TypoV1Node:
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {}}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("IMAGE",)
|
|
||||||
FUNCTION = "invert" # method below is misspelled
|
|
||||||
OUTPUT_NODE = True
|
|
||||||
CATEGORY = "Test"
|
|
||||||
|
|
||||||
def invvert(self):
|
|
||||||
return (None,)
|
|
||||||
|
|
||||||
|
|
||||||
class _SideEffectInitV1Node:
|
|
||||||
"""Valid class-level method, but a constructor that must never run in validation."""
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(cls):
|
|
||||||
return {"required": {}}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("IMAGE",)
|
|
||||||
FUNCTION = "run"
|
|
||||||
OUTPUT_NODE = True
|
|
||||||
CATEGORY = "Test"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
raise RuntimeError("__init__ must not run during validation")
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
return (None,)
|
|
||||||
|
|
||||||
|
|
||||||
def _v3_schema(node_id):
|
|
||||||
return io.Schema(
|
|
||||||
node_id=node_id,
|
|
||||||
display_name=node_id,
|
|
||||||
category="Test",
|
|
||||||
inputs=[],
|
|
||||||
outputs=[io.Image.Output()],
|
|
||||||
is_output_node=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _GoodV3Node(io.ComfyNode):
|
|
||||||
@classmethod
|
|
||||||
def define_schema(cls):
|
|
||||||
return _v3_schema("GoodV3Node")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def execute(cls):
|
|
||||||
return io.NodeOutput(None)
|
|
||||||
|
|
||||||
|
|
||||||
class _TypoV3Node(io.ComfyNode):
|
|
||||||
@classmethod
|
|
||||||
def define_schema(cls):
|
|
||||||
return _v3_schema("TypoV3Node")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def exicute(cls): # typo: should be "execute"
|
|
||||||
return io.NodeOutput(None)
|
|
||||||
|
|
||||||
|
|
||||||
def _register(class_type, class_def):
|
|
||||||
nodes.NODE_CLASS_MAPPINGS[class_type] = class_def
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(class_type):
|
|
||||||
prompt = {"1": {"class_type": class_type, "inputs": {}}}
|
|
||||||
return asyncio.run(validate_prompt("pid", prompt, None))
|
|
||||||
|
|
||||||
|
|
||||||
def test_good_node_passes():
|
|
||||||
_register("GoodV1Node", _GoodV1Node)
|
|
||||||
assert node_not_executable_reason(_GoodV1Node, "GoodV1Node") is None
|
|
||||||
valid, _, _, _ = _validate("GoodV1Node")
|
|
||||||
assert valid is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_typo_node_rejected_with_node_error():
|
|
||||||
_register("TypoV1Node", _TypoV1Node)
|
|
||||||
valid, error, _, node_errors = _validate("TypoV1Node")
|
|
||||||
assert valid is False
|
|
||||||
assert error["type"] == "invalid_node_definition"
|
|
||||||
assert node_errors["1"]["class_type"] == "TypoV1Node"
|
|
||||||
assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition"
|
|
||||||
assert "invert" in node_errors["1"]["errors"][0]["details"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validation_does_not_instantiate_node():
|
|
||||||
"""A valid node is not constructed during validation, so __init__ never runs."""
|
|
||||||
_register("SideEffectInitV1Node", _SideEffectInitV1Node)
|
|
||||||
assert node_not_executable_reason(_SideEffectInitV1Node, "SideEffectInitV1Node") is None
|
|
||||||
valid, _, _, _ = _validate("SideEffectInitV1Node")
|
|
||||||
assert valid is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_good_v3_node_passes():
|
|
||||||
_register("GoodV3Node", _GoodV3Node)
|
|
||||||
assert node_not_executable_reason(_GoodV3Node, "GoodV3Node") is None
|
|
||||||
valid, _, _, _ = _validate("GoodV3Node")
|
|
||||||
assert valid is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_typo_v3_node_rejected_with_node_error():
|
|
||||||
_register("TypoV3Node", _TypoV3Node)
|
|
||||||
valid, error, _, node_errors = _validate("TypoV3Node")
|
|
||||||
assert valid is False
|
|
||||||
assert error["type"] == "invalid_node_definition"
|
|
||||||
assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition"
|
|
||||||
Reference in New Issue
Block a user