Merge branch 'master' into glary/fix-image-invert-alpha

This commit is contained in:
Alexis Rolland
2026-09-06 19:32:59 -07:00
committed by GitHub
201 changed files with 38582 additions and 2640 deletions
+6 -3
View File
@@ -35,9 +35,12 @@ jobs:
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if ! commit_authors=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)'); then
echo "Failed to fetch pull request commits" >&2
exit 1
fi
others=$(printf '%s\n' "$commit_authors" | sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
+30
View File
@@ -0,0 +1,30 @@
name: Notify on Merge
on:
push:
branches:
- master
jobs:
notify:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI'
steps:
- name: Notify downstream
env:
DISPATCH_TOKEN: ${{ secrets.SYNC_DISPATCH_TOKEN }}
TARGET_REPO: ${{ secrets.SYNC_TARGET_REPO }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${DISPATCH_TOKEN:-}" ] || [ -z "${TARGET_REPO:-}" ]; then
echo "::notice::SYNC_DISPATCH_TOKEN/SYNC_TARGET_REPO not set; skipping downstream notify."
exit 0
fi
PAYLOAD="$(jq -n --arg sha "$COMMIT_SHA" \
'{ event_type: "upstream-push", client_payload: { sha: $sha } }')"
curl -fsSL --connect-timeout 10 --max-time 60 -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
"https://api.github.com/repos/${TARGET_REPO}/dispatches" \
-d "$PAYLOAD"
+1
View File
@@ -119,6 +119,7 @@ jobs:
grep comfy ../ComfyUI/requirements.txt > ./requirements_comfyui.txt
./python.exe -s -m pip install -r requirements_comfyui.txt
./python.exe -s -m pip uninstall -y comfyui-workflow-templates-media-image comfyui-workflow-templates-media-video comfyui-workflow-templates-media-other
rm requirements_comfyui.txt
sed -i '1i../ComfyUI' ./python3${{ inputs.python_minor }}._pth
+12 -12
View File
@@ -2,18 +2,18 @@
# Results are reported as checkmarks on the commits, as well as onto https://ci.comfy.org/
name: Full Comfy CI Workflow Runs
on:
push:
branches:
- master
- release/**
paths-ignore:
- 'app/**'
- 'input/**'
- 'output/**'
- 'notebooks/**'
- 'script_examples/**'
- '.github/**'
- 'web/**'
# push:
# branches:
# - master
# - release/**
# paths-ignore:
# - 'app/**'
# - 'input/**'
# - 'output/**'
# - 'notebooks/**'
# - 'script_examples/**'
# - '.github/**'
# - 'web/**'
workflow_dispatch:
jobs:
+25
View File
@@ -297,10 +297,35 @@
- 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 condition-dependent model work would otherwise repeat on every denoising
step and preprocessing it once materially improves performance, expose a
model preprocessing method and call it from `BaseModel.extra_conds`, following
patterns such as LTXAV and Anima. Pass the result through normal conditioning;
do not add model-owned caches, sampler-option caches, or cache-management
wrappers for this work.
- When optimizing, favor small measurable changes: fewer allocations, fewer
device transfers, less peak memory, better batching, or use of a faster
existing backend op.
## User Input Tolerance
- Prefer completing a workflow with the user's supplied values over rejecting
them because they fall outside recommended, UI-advertised, or quality-oriented
limits. If the downstream implementation can consume an input, pass it
through unchanged even when the result may be poor. For example, do not reject
or truncate additional reference images merely because a node advertises a
smaller recommended maximum.
- Do not add validation errors solely to prevent degraded, nonsensical, or
low-quality model output. A bad result is preferable to failing an otherwise
executable workflow.
- Resize, pad, clamp, normalize, or otherwise adapt user input only when passing
it through unchanged would make the existing model or underlying operation
fail. Make the smallest adjustment needed to keep execution running; do not
add a model-level validation failure merely to justify changing the input.
- This permissive policy does not override security boundaries such as path
containment, or integrity checks required to load model formats and
checkpoints safely.
## Nodes and User-Facing Behavior
- Follow existing node conventions: `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`,
+2 -4
View File
@@ -50,10 +50,6 @@ ComfyUI is the AI creation engine for visual professionals who demand control ov
- The easiest way to get started.
- Available on Windows & macOS.
#### [Windows Portable Package](#installing)
- Get the latest commits and completely portable.
- Available on Windows.
#### [Manual Install](#manual-install-windows-linux)
Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
@@ -83,6 +79,8 @@ See what ComfyUI can do with the [newer template workflows](https://comfy.org/wo
- Runs fully offline: core does not download anything unless you request it. Use `--disable-api-nodes` to disable the optional paid [Comfy API nodes](https://docs.comfy.org/tutorials/api-nodes/overview) and force all built-in functionality to stay offline.
- Extend ComfyUI with custom nodes
- Configure additional model locations with [`extra_model_paths.yaml`](extra_model_paths.yaml.example).
- Support for saving and loading high bit depth images and videos: 16 bit PNG images, 32 bit EXR, 10 bit AVIF are supported and more.
- Support for saving and loading HDR videos and images in various formats.
## Release Process
+54 -33
View File
@@ -2,6 +2,7 @@ import asyncio
import functools
import json
import logging
import mimetypes
import os
import urllib.parse
import uuid
@@ -32,6 +33,7 @@ from app.assets.services import (
create_from_hash,
delete_asset_reference,
get_asset_detail,
get_preview_file_paths,
list_assets_page,
list_tags,
remove_tags,
@@ -40,7 +42,7 @@ from app.assets.services import (
upload_from_temp_path,
)
from app.assets.services.cursor import InvalidCursorError
from app.assets.services.path_utils import compute_display_name
from app.assets.services.path_utils import compute_asset_response_paths
from app.assets.services.tagging import list_tag_histogram
ROUTES = web.RouteTableDef()
@@ -207,44 +209,62 @@ def _validate_sort_field(requested: str | None) -> str:
return "created_at"
def _build_preview_url_from_view(tags: list[str], user_metadata: dict[str, Any] | None) -> str | None:
"""Build a /api/view preview URL from asset tags and user_metadata filename."""
if not user_metadata:
# What a client can render from the bytes themselves; anything else needs a nominated preview.
PREVIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
# models is deliberately absent: /api/view has no directory type for it.
VIEWABLE_NAMESPACES = frozenset({"input", "output", "temp"})
def _has_previewable_content(asset: schemas.AssetData | None, file_path: str | None) -> bool:
if asset is None:
return False
# Resolved from the path, not the caller-editable name, so a rename cannot change what previews.
raw = asset.mime_type or mimetypes.guess_type(file_path or "")[0] or ""
return raw.split(";", 1)[0].strip().lower().startswith(PREVIEWABLE_MIME_PREFIXES)
def _build_view_url(file_path: str | None) -> str | None:
# /api/view is a FileResponse: byte-range seeking, no user header, no access write.
if not file_path:
return None
filename = user_metadata.get("filename")
if not filename:
paths = compute_asset_response_paths(file_path)
if not paths:
return None
logical_path, relative_path = paths
namespace = logical_path.split("/", 1)[0]
if namespace not in VIEWABLE_NAMESPACES or not relative_path:
return None
if "input" in tags:
view_type = "input"
elif "output" in tags:
view_type = "output"
else:
return None
subfolder = ""
if "/" in filename:
subfolder, filename = filename.rsplit("/", 1)
encoded_filename = urllib.parse.quote(filename, safe="")
url = f"/api/view?type={view_type}&filename={encoded_filename}"
subfolder, _, filename = relative_path.rpartition("/")
url = f"/api/view?type={namespace}&filename={urllib.parse.quote(filename, safe='')}"
if subfolder:
url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
return url
def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset:
"""Build an Asset response from a service result."""
def _resolve_preview_paths(
results: "list[schemas.AssetDetailResult] | list[schemas.AssetSummaryData]",
) -> dict[str, str]:
# A miss means no live preview - that is what keeps a soft-deleted one quiet.
preview_ids = {r.ref.preview_id for r in results if r.ref.preview_id}
return get_preview_file_paths(sorted(preview_ids))
def _build_asset_response(
result: schemas.AssetDetailResult | schemas.UploadResult,
preview_paths: dict[str, str],
) -> schemas_out.Asset:
if result.ref.preview_id:
preview_detail = get_asset_detail(result.ref.preview_id)
if preview_detail:
preview_url = _build_preview_url_from_view(preview_detail.tags, preview_detail.ref.user_metadata)
else:
preview_url = None
# A nominated preview is one whatever it holds, so no media check here.
preview_url = _build_view_url(preview_paths.get(result.ref.preview_id))
elif _has_previewable_content(result.asset, result.ref.file_path):
preview_url = _build_view_url(result.ref.file_path)
else:
preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
preview_url = None
if result.ref.file_path:
display_name = compute_display_name(result.ref.file_path)
paths = compute_asset_response_paths(result.ref.file_path)
display_name = paths[1] if paths else None
# In-root loader path (model category dropped): what model loaders consume.
loader_path = result.ref.loader_path
else:
@@ -324,7 +344,8 @@ async def list_assets_route(request: web.Request) -> web.Response:
except InvalidCursorError as e:
return _build_error_response(400, "INVALID_CURSOR", str(e))
summaries = [_build_asset_response(item) for item in result.items]
preview_paths = _resolve_preview_paths(result.items)
summaries = [_build_asset_response(item, preview_paths) for item in result.items]
# has_more semantics differ by mode:
# - cursor mode: a non-empty next_cursor means there are more results.
@@ -363,7 +384,7 @@ async def get_asset_route(request: web.Request) -> web.Response:
{"id": reference_id},
)
payload = _build_asset_response(result)
payload = _build_asset_response(result, _resolve_preview_paths([result]))
except ValueError as e:
return _build_error_response(
404, "ASSET_NOT_FOUND", str(e), {"id": reference_id}
@@ -494,7 +515,7 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response:
404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
)
asset = _build_asset_response(result)
asset = _build_asset_response(result, _resolve_preview_paths([result]))
payload_out = schemas_out.AssetCreated(
**asset.model_dump(),
created_new=result.created_new,
@@ -585,7 +606,7 @@ async def upload_asset(request: web.Request) -> web.Response:
logging.exception("upload_asset failed for owner_id=%s", owner_id)
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
asset = _build_asset_response(result)
asset = _build_asset_response(result, _resolve_preview_paths([result]))
payload_out = schemas_out.AssetCreated(
**asset.model_dump(),
created_new=result.created_new,
@@ -615,7 +636,7 @@ async def update_asset_route(request: web.Request) -> web.Response:
owner_id=USER_MANAGER.get_request_user_id(request),
preview_id=body.preview_id,
)
payload = _build_asset_response(result)
payload = _build_asset_response(result, _resolve_preview_paths([result]))
except PermissionError as pe:
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
except ValueError as ve:
+2
View File
@@ -28,6 +28,7 @@ from app.assets.database.queries.asset_reference import (
get_reference_by_id,
get_reference_with_owner_check,
get_reference_ids_by_ids,
get_reference_paths_by_ids,
get_references_by_paths_and_asset_ids,
get_references_for_prefixes,
get_unenriched_references,
@@ -101,6 +102,7 @@ __all__ = [
"get_reference_by_id",
"get_reference_with_owner_check",
"get_reference_ids_by_ids",
"get_reference_paths_by_ids",
"get_reference_tags",
"get_references_by_paths_and_asset_ids",
"get_references_for_prefixes",
@@ -1064,6 +1064,27 @@ def get_references_by_paths_and_asset_ids(
return winners
def get_reference_paths_by_ids(
session: Session,
reference_ids: list[str],
) -> dict[str, str]:
"""Map reference id -> file_path for live, file-backed references."""
if not reference_ids:
return {}
paths: dict[str, str] = {}
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
rows = session.execute(
select(AssetReference.id, AssetReference.file_path).where(
AssetReference.id.in_(chunk),
AssetReference.file_path.is_not(None),
AssetReference.deleted_at.is_(None),
)
)
paths.update({rid: fp for rid, fp in rows})
return paths
def get_reference_ids_by_ids(
session: Session,
reference_ids: list[str],
+38 -9
View File
@@ -57,10 +57,11 @@ class _AssetAccumulator(TypedDict):
refs: list[_RefInfo]
# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing.
RootType = Literal["models", "input", "output"]
def get_prefixes_for_root(root: RootType) -> list[str]:
def get_scan_prefixes_for_root(root: RootType) -> list[str]:
if root == "models":
bases: list[str] = []
for _bucket, paths, _exts in get_comfy_models_folders():
@@ -73,10 +74,15 @@ def get_prefixes_for_root(root: RootType) -> list[str]:
return []
def get_all_known_prefixes() -> list[str]:
"""Get all known asset prefixes across all root types."""
all_roots: tuple[RootType, ...] = ("models", "input", "output")
return [p for root in all_roots for p in get_prefixes_for_root(root)]
def get_owned_prefixes() -> list[str]:
"""Every directory an asset may live in; references outside these are marked missing."""
scan_roots: tuple[RootType, ...] = ("models", "input", "output")
prefixes = [p for root in scan_roots for p in get_scan_prefixes_for_root(root)]
return prefixes + get_temp_prefixes()
def get_temp_prefixes() -> list[str]:
return [os.path.abspath(folder_paths.get_temp_directory())]
def collect_models_files() -> list[str]:
@@ -107,7 +113,21 @@ def sync_references_with_filesystem(
collect_existing_paths: bool = False,
update_missing_tags: bool = False,
) -> set[str] | None:
"""Reconcile asset references with filesystem for a root.
return sync_prefixes_with_filesystem(
session,
get_scan_prefixes_for_root(root),
collect_existing_paths=collect_existing_paths,
update_missing_tags=update_missing_tags,
)
def sync_prefixes_with_filesystem(
session,
prefixes: list[str],
collect_existing_paths: bool = False,
update_missing_tags: bool = False,
) -> set[str] | None:
"""Reconcile asset references with filesystem under the given prefixes.
- Toggle needs_verify per reference using mtime/size stat check
- For hashed assets with at least one stat-unchanged ref: delete stale missing refs
@@ -117,14 +137,13 @@ def sync_references_with_filesystem(
Args:
session: Database session
root: Root type to scan
prefixes: Absolute directory prefixes whose references to reconcile
collect_existing_paths: If True, return set of surviving file paths
update_missing_tags: If True, update 'missing' tags based on file status
Returns:
Set of surviving absolute paths if collect_existing_paths=True, else None
"""
prefixes = get_prefixes_for_root(root)
if not prefixes:
return set() if collect_existing_paths else None
@@ -251,6 +270,16 @@ def sync_root_safely(root: RootType) -> set[str]:
return set()
def sync_temp_references_safely() -> None:
"""Retire temp references whose file is gone; temp is never scanned, so nothing else stats them."""
try:
with create_session() as sess:
sync_prefixes_with_filesystem(sess, get_temp_prefixes())
sess.commit()
except Exception as e:
logging.exception("temp reference sync failed: %s", e)
def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
"""Mark references as missing when outside the given prefixes.
@@ -384,7 +413,7 @@ def get_unenriched_assets_for_roots(
"""
prefixes: list[str] = []
for root in roots:
prefixes.extend(get_prefixes_for_root(root))
prefixes.extend(get_scan_prefixes_for_root(root))
if not prefixes:
return []
+7 -5
View File
@@ -15,12 +15,13 @@ from app.assets.scanner import (
build_asset_specs,
collect_paths_for_roots,
enrich_assets_batch,
get_all_known_prefixes,
get_prefixes_for_root,
get_owned_prefixes,
get_scan_prefixes_for_root,
get_unenriched_assets_for_roots,
insert_asset_specs,
mark_missing_outside_prefixes_safely,
sync_root_safely,
sync_temp_references_safely,
)
from app.database.db import dependencies_available
@@ -413,7 +414,7 @@ class _AssetSeeder:
)
return 0
all_prefixes = get_all_known_prefixes()
all_prefixes = get_owned_prefixes()
marked = mark_missing_outside_prefixes_safely(all_prefixes)
if marked > 0:
logging.info("Marked %d references as missing", marked)
@@ -523,7 +524,7 @@ class _AssetSeeder:
os.path.abspath(folder_paths.models_dir),
)
else:
prefixes = get_prefixes_for_root(root)
prefixes = get_scan_prefixes_for_root(root)
if prefixes:
logging.info("Asset scan [%s] directories: %s", root, prefixes)
@@ -548,10 +549,11 @@ class _AssetSeeder:
return
if self._prune_first:
all_prefixes = get_all_known_prefixes()
all_prefixes = get_owned_prefixes()
marked = mark_missing_outside_prefixes_safely(all_prefixes)
if marked > 0:
logging.info("Marked %d refs as missing before scan", marked)
sync_temp_references_safely()
if self._check_pause_and_cancel():
logging.info("Asset scan cancelled after pruning phase")
+2
View File
@@ -4,6 +4,7 @@ from app.assets.services.asset_management import (
get_asset_by_hash,
get_asset_detail,
list_assets_page,
get_preview_file_paths,
resolve_asset_for_download,
set_asset_preview,
update_asset_metadata,
@@ -83,6 +84,7 @@ __all__ = [
"list_tags",
"cleanup_unreferenced_assets",
"remove_tags",
"get_preview_file_paths",
"resolve_asset_for_download",
"set_asset_preview",
"update_asset_metadata",
+9
View File
@@ -21,6 +21,7 @@ from app.assets.database.queries import (
reference_exists_for_asset_id,
delete_reference_by_id,
fetch_reference_and_asset,
get_reference_paths_by_ids,
soft_delete_reference_by_id,
fetch_reference_asset_and_tags,
get_asset_by_hash as queries_get_asset_by_hash,
@@ -424,6 +425,14 @@ def resolve_hash_to_path(
)
def get_preview_file_paths(preview_ids: list[str]) -> dict[str, str]:
"""Map preview reference id -> file_path, in one query for the whole page."""
if not preview_ids:
return {}
with create_session() as session:
return get_reference_paths_by_ids(session, reference_ids=preview_ids)
def resolve_asset_for_download(
reference_id: str,
owner_id: str = "",
+53 -5
View File
@@ -4,7 +4,7 @@ import shutil
from app.logger import log_startup_warning
from utils.install_util import get_missing_requirements_message
from filelock import FileLock, Timeout
from comfy.cli_args import args
from comfy.cli_args import args, database_default_path
_DB_AVAILABLE = False
Session = None
@@ -57,19 +57,66 @@ def get_alembic_config():
config = Config(config_path)
config.set_main_option("script_location", scripts_path)
config.set_main_option("sqlalchemy.url", args.database_url)
config.set_main_option("sqlalchemy.url", get_database_url())
return config
def get_database_url():
if args.database_url is not None:
return args.database_url
import folder_paths
db_path = os.path.join(folder_paths.get_user_directory(), "comfyui.db")
return f"sqlite:///{db_path}"
def get_legacy_default_db_path():
return database_default_path
def get_db_path():
url = args.database_url
url = get_database_url()
if url.startswith("sqlite:///"):
return url.split("///")[1]
return url.split("///", 1)[1]
else:
raise ValueError(f"Unsupported database URL '{url}'.")
def copy_legacy_default_db(db_path):
if args.database_url is not None:
return
legacy_db_path = get_legacy_default_db_path()
if legacy_db_path is None:
return
if os.path.abspath(legacy_db_path) == os.path.abspath(db_path):
return
if os.path.exists(db_path) or not os.path.exists(legacy_db_path):
return
backup_path = legacy_db_path + ".bak"
if os.path.exists(backup_path):
return
os.replace(legacy_db_path, backup_path)
shutil.copy(backup_path, db_path)
logging.info(
f"Renamed legacy database '{legacy_db_path}' to '{backup_path}' and copied it to '{db_path}'"
)
def prepare_file_db_path(db_path):
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
copy_legacy_default_db(db_path)
_db_lock = None
def _acquire_file_lock(db_path):
@@ -97,7 +144,7 @@ def _is_memory_db(db_url):
def init_db():
db_url = args.database_url
db_url = get_database_url()
logging.debug(f"Database URL: {db_url}")
if _is_memory_db(db_url):
@@ -134,6 +181,7 @@ def _init_memory_db(db_url):
def _init_file_db(db_url):
"""Initialize a file-backed SQLite database using Alembic migrations."""
db_path = get_db_path()
prepare_file_db_path(db_path)
db_exists = os.path.exists(db_path)
config = get_alembic_config()
+8 -6
View File
@@ -277,15 +277,17 @@ comfyui-workflow-templates is not installed.
return None
asset_map: Dict[str, str] = {}
try:
for entry in template_entries:
for asset in entry.assets:
for entry in template_entries:
for asset in entry.assets:
try:
asset_map[asset.filename] = get_asset_path(
entry.template_id, asset.filename
)
except Exception as exc:
logging.error(f"Failed to resolve template asset paths: {exc}")
return None
except FileNotFoundError:
continue
except Exception as exc:
logging.error(f"Failed to resolve template asset paths: {exc}")
return None
if not asset_map:
logging.error("No workflow template assets found. Did the packages install correctly?")
@@ -1,6 +1,6 @@
{
"revision": 0,
"last_node_id": 176,
"last_node_id": 177,
"last_link_id": 0,
"nodes": [
{
@@ -164,8 +164,8 @@
"version": 1,
"state": {
"lastGroupId": 8,
"lastNodeId": 176,
"lastLinkId": 380,
"lastNodeId": 177,
"lastLinkId": 381,
"lastRerouteId": 0
},
"revision": 0,
@@ -715,6 +715,88 @@
1
]
},
{
"id": 177,
"type": "LatentCut",
"pos": [
830,
-70
],
"size": [
270,
170
],
"flags": {},
"order": 14,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 142
},
{
"localized_name": "dim",
"name": "dim",
"type": "COMBO",
"widget": {
"name": "dim"
},
"link": null
},
{
"localized_name": "index",
"name": "index",
"type": "INT",
"widget": {
"name": "index"
},
"link": null
},
{
"localized_name": "amount",
"name": "amount",
"type": "INT",
"widget": {
"name": "amount"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
381
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.5.1",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.7"
},
"Node name for S&R": "LatentCut",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"t",
1,
16384
]
},
{
"id": 76,
"type": "LatentCutToBatch",
@@ -734,7 +816,7 @@
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 142
"link": 381
},
{
"localized_name": "dim",
@@ -1434,7 +1516,7 @@
"id": 142,
"origin_id": 3,
"origin_slot": 0,
"target_id": 76,
"target_id": 177,
"target_slot": 0,
"type": "LATENT"
},
@@ -1581,6 +1663,14 @@
"target_id": 39,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 381,
"origin_id": 177,
"origin_slot": 0,
"target_id": 76,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {
+7 -2
View File
@@ -74,7 +74,7 @@ parser.add_argument("--temp-directory", type=str, default=None, help="Set the Co
parser.add_argument("--input-directory", type=str, default=None, help="Set the ComfyUI input directory. Overrides --base-directory.")
parser.add_argument("--auto-launch", action="store_true", help="Automatically launch ComfyUI in the default browser.")
parser.add_argument("--disable-auto-launch", action="store_true", help="Disable auto launching the browser.")
parser.add_argument("--cuda-device", type=str, default=None, metavar="DEVICE_ID", help="Set the ids of cuda devices this instance will use, as a comma-separated list (e.g. '0' or '0,1'). All other devices will not be visible.")
parser.add_argument("--cuda-device", type=str, default=None, metavar="DEVICE_ID", help="Set the ids of cuda devices this instance will use, as a comma-separated list (e.g. '0' or '0,1'), or 'all' to leave all currently visible devices available. All other devices will not be visible.")
parser.add_argument("--default-device", type=int, default=None, metavar="DEFAULT_DEVICE_ID", help="Set the id of the default device, all other devices will stay visible.")
cm_group = parser.add_mutually_exclusive_group()
cm_group.add_argument("--cuda-malloc", action="store_true", help="Enable cudaMallocAsync (enabled by default for torch 2.0 and up).")
@@ -181,6 +181,8 @@ parser.add_argument("--disable-dynamic-vram", action="store_true", help="Disable
parser.add_argument("--enable-dynamic-vram", action="store_true", help="Enable dynamic VRAM on systems where it's not enabled by default.")
parser.add_argument("--fast-disk", action="store_true", help="Prefer disk-backed dynamic loading and offload over unpinned RAM. Can be faster for users with fast NVME disks.")
parser.add_argument("--disable-cuda-graphs", action="store_true", help="Disable CUDA graphs.")
parser.add_argument("--disable-comfy-compiler", action="store_true", help="Disable the Comfy model compiler, including its CUDA graph subfeature.")
parser.add_argument("--assert-graph-breaks", action="store_true", help="Fail on Comfy model compiler graph breaks.")
parser.add_argument("--force-non-blocking", action="store_true", help="Force ComfyUI to use non-blocking operations for all applicable tensors. This may improve performance on some non-Nvidia systems but can cause issues with some workflows.")
@@ -268,7 +270,7 @@ parser.add_argument(
database_default_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "user", "comfyui.db")
)
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=None, help="Specify the database URL, e.g. for an in-memory database you can use 'sqlite:///:memory:'. Defaults to 'comfyui.db' in the effective user directory.")
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")
@@ -291,6 +293,9 @@ if args.windows_standalone_build:
if args.disable_auto_launch:
args.auto_launch = False
if args.disable_comfy_compiler:
args.disable_cuda_graphs = True
if args.force_fp16:
args.fp16_unet = True
+12
View File
@@ -10,6 +10,7 @@ import comfy.utils
import comfy.clip_model
import comfy.image_encoders.dino2
import comfy.image_encoders.dino3
from comfy.image_encoders.naf import NAF
class Output:
def __getitem__(self, key):
@@ -53,6 +54,7 @@ class ClipVisionModel():
self.model.eval()
self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device)
self.naf = None
def load_sd(self, sd):
return self.model.load_state_dict(sd, strict=False, assign=self.patcher.is_dynamic())
@@ -141,6 +143,8 @@ def load_clipvision_from_sd(sd, prefix="", convert_keys=False):
json_config = os.path.join(os.path.join(os.path.dirname(os.path.realpath(__file__)), "image_encoders"), "dino2_large.json")
elif 'layer.0.mlp.gate_proj.weight' in sd and 'layer.31.norm1.weight' in sd: # Dinov3 ViT-H/16+ (SwiGLU gated MLP, 32 layers)
json_config = comfy.image_encoders.dino3.DINOV3_VITH_CONFIG
elif 'layer.23.attention.o_proj.bias' in sd: # dinov3 large (24 layers)
json_config = os.path.join(os.path.join(os.path.dirname(os.path.realpath(__file__)), "image_encoders"), "dino3_large.json")
else:
return None
@@ -153,6 +157,14 @@ def load_clipvision_from_sd(sd, prefix="", convert_keys=False):
for k in keys:
if k not in u:
sd.pop(k)
# NAF feature upsampler bundled into the DINOv3 file under the `naf.` prefix.
naf_keys = [k for k in sd if k.startswith("naf.")]
if naf_keys:
naf_sd = {k[len("naf."):]: sd.pop(k) for k in naf_keys}
naf = NAF(operations=comfy.ops.manual_cast).eval()
naf.load_state_dict(naf_sd)
naf.to(comfy.model_management.text_encoder_dtype(clip.load_device))
clip.naf = comfy.model_patcher.CoreModelPatcher(naf, load_device=clip.load_device, offload_device=comfy.model_management.text_encoder_offload_device())
return clip
def load(ckpt_path):
+17 -4
View File
@@ -156,10 +156,11 @@ class DINOv3ViTRopePositionEmbedding(nn.Module):
class DINOv3ViTEmbeddings(nn.Module):
def __init__(self, hidden_size, num_register_tokens, num_channels, patch_size, dtype, device, operations):
def __init__(self, hidden_size, num_register_tokens, num_channels, patch_size, dtype, device, operations, use_mask_token=True):
super().__init__()
self.cls_token = nn.Parameter(torch.empty(1, 1, hidden_size, device=device, dtype=dtype))
self.mask_token = nn.Parameter(torch.empty(1, 1, hidden_size, device=device, dtype=dtype))
self.mask_token = nn.Parameter(torch.empty(1, 1, hidden_size, device=device, dtype=dtype)) if use_mask_token else None
self.register_tokens = nn.Parameter(torch.empty(1, num_register_tokens, hidden_size, device=device, dtype=dtype))
self.patch_embeddings = operations.Conv2d(
num_channels, hidden_size, kernel_size=patch_size, stride=patch_size, device=device, dtype=dtype
@@ -212,7 +213,7 @@ class DINOv3ViTLayer(nn.Module):
class DINOv3ViTModel(nn.Module):
def __init__(self, config, dtype, device, operations):
def __init__(self, config, dtype, device, operations, use_mask_token=True):
super().__init__()
num_hidden_layers = config["num_hidden_layers"]
hidden_size = config["hidden_size"]
@@ -228,7 +229,7 @@ class DINOv3ViTModel(nn.Module):
self.embeddings = DINOv3ViTEmbeddings(
hidden_size, num_register_tokens, num_channels=num_channels, patch_size=patch_size,
dtype=dtype, device=device, operations=operations
dtype=dtype, device=device, operations=operations, use_mask_token=use_mask_token
)
self.rope_embeddings = DINOv3ViTRopePositionEmbedding(
rope_theta, hidden_size, num_attention_heads, patch_size=patch_size, dtype=dtype, device=device
@@ -240,6 +241,10 @@ class DINOv3ViTModel(nn.Module):
for _ in range(num_hidden_layers)])
self.norm = operations.LayerNorm(hidden_size, eps=layer_norm_eps, dtype=dtype, device=device)
self.patch_size = patch_size
self.embed_dim = self.embed_dims = hidden_size
self.num_prefix_tokens = 1 + num_register_tokens # cls + register
def get_input_embeddings(self):
return self.embeddings.patch_embeddings
@@ -257,3 +262,11 @@ class DINOv3ViTModel(nn.Module):
sequence_output = norm(hidden_states)
pooled_output = sequence_output[:, 0, :]
return sequence_output, None, pooled_output, None
def forward_features(self, pixel_values, **kwargs):
sequence_output = self.forward(pixel_values, **kwargs)[0]
b = pixel_values.shape[0]
h = pixel_values.shape[-2] // self.patch_size
w = pixel_values.shape[-1] // self.patch_size
patches = sequence_output[:, self.num_prefix_tokens:, :]
return patches.reshape(b, h, w, self.embed_dim).permute(0, 3, 1, 2).contiguous()
+23
View File
@@ -0,0 +1,23 @@
{
"model_type": "dinov3",
"hidden_size": 1024,
"image_size": 224,
"initializer_range": 0.02,
"intermediate_size": 4096,
"key_bias": false,
"layer_norm_eps": 1e-05,
"mlp_bias": true,
"num_attention_heads": 16,
"num_channels": 3,
"num_hidden_layers": 24,
"num_register_tokens": 4,
"patch_size": 16,
"pos_embed_rescale": 2.0,
"proj_bias": true,
"query_bias": true,
"rope_theta": 100.0,
"use_gated_mlp": false,
"value_bias": true,
"image_mean": [0.485, 0.456, 0.406],
"image_std": [0.229, 0.224, 0.225]
}
+282
View File
@@ -0,0 +1,282 @@
"""NAF (Neighborhood Attention Filtering) feature upsampler.
Vendored from valeoai/NAF (Apache-2.0):
https://github.com/valeoai/NAF src/model/naf.py + src/layers/{convolutions,attentions,rope}.py
Used by Pixal3D's shape/texture conditioning to produce
the 2x-upsampled half of the 2048-channel proj feature map.
"""
import math
from typing import Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
import comfy.ops
# Pure-torch neighborhood attention (replaces natten.na2d / na2d_qk + na2d_av).
def upsample_lr_slice(src_lr: torch.Tensor, lr_dh: int, lr_dw: int,
hr_h_range: Tuple[int, int], hr_w_range: Tuple[int, int]) -> torch.Tensor:
"""Slice a LR-layout tensor [B, h_lr, w_lr, n, C], permute to BCHW, and
nearest-exact upsample only the region covering [hr_h_range, hr_w_range].
Returns BCHW at hr_h_end-hr_h_start x hr_w_end-hr_w_start (no padding for
out-of-bounds regions)."""
B = src_lr.shape[0]
n = src_lr.shape[-2]
C = src_lr.shape[-1]
h_hr_start, h_hr_end = hr_h_range
w_hr_start, w_hr_end = hr_w_range
# LR positions covering [h_hr_start, h_hr_end). Nearest-exact maps HR p → p // D.
lr_h_start = h_hr_start // lr_dh
lr_h_end = (h_hr_end - 1) // lr_dh + 1
lr_w_start = w_hr_start // lr_dw
lr_w_end = (w_hr_end - 1) // lr_dw + 1
lr_slice = src_lr[:, lr_h_start:lr_h_end, lr_w_start:lr_w_end]
lh, lw = lr_slice.shape[1], lr_slice.shape[2]
lr_bcd = lr_slice.permute(0, 3, 4, 1, 2).reshape(B * n, C, lh, lw).contiguous()
up = F.interpolate(lr_bcd, scale_factor=(lr_dh, lr_dw), mode="nearest-exact")
offset_h = h_hr_start - lr_h_start * lr_dh
offset_w = w_hr_start - lr_w_start * lr_dw
return up[:, :, offset_h:offset_h + (h_hr_end - h_hr_start),
offset_w:offset_w + (w_hr_end - w_hr_start)]
def na2d_pure(
q: torch.Tensor, # [B, H, W, n_heads, d_qk] at HR.
k_lr: torch.Tensor, # [B, h_lr, w_lr, n_heads, d_qk] at LR
v_lr: torch.Tensor, # [B, h_lr, w_lr, n_heads, d_v] at LR
kernel_size: Tuple[int, int], # (Kh, Kw) attention window.
dilation: Tuple[int, int], # (Dh, Dw) stride within the unrolled K/V grid; also the LR→HR upsample factor.
scale: float, # 1 / sqrt(d_qk) scaling for the Q·K scores.
tile: int = 128, # Spatial tile size (output positions per tile)
v_chunk: int = 64, # Sub-divide d_v into chunks of this size when computing attn·V. None disables chunking.
output: torch.Tensor = None, # Pre-allocated [B, n_heads, d_v, H, W] buffer (may be on CPU).
) -> torch.Tensor: # [B, n_heads, d_v, H, W] (caller views as BCHW).
"""Neighborhood attention in pure torch via F.unfold + per-tile slicing.
K and V are passed at LR resolution and upsampled (nearest-exact) per-tile only
for the slice the unfold needs. Avoids the [B, n*d, H, W] HR allocations for K
(512 MB) and V (2 GB) at tex_1024 fp16. Spatial tiling bounds the per-tile
F.unfold blob; `v_chunk` further slices d_v so attn·V is computed in C-sized
chunks (attn is reused, computed once from Q/K).
"""
B, H, W, n, d_qk = q.shape
d_v = v_lr.shape[-1]
Kh, Kw = kernel_size
Dh, Dw = dilation
pad_h, pad_w = (Kh // 2) * Dh, (Kw // 2) * Dw
out = output if output is not None else torch.empty((B, n, d_v, H, W), device=q.device, dtype=q.dtype)
th = min(tile, H) if tile else H
tw = min(tile, W) if tile else W
chunk = v_chunk if (v_chunk and v_chunk < d_v) else d_v
for h0 in range(0, H, th):
for w0 in range(0, W, tw):
h1, w1 = min(h0 + th, H), min(w0 + tw, W)
t_h, t_w = h1 - h0, w1 - w0
# Padded HR region the unfold needs (kernel span = (K-1)*D + 1).
h_src_start = max(0, h0 - pad_h)
h_src_end = min(H, h1 + pad_h)
w_src_start = max(0, w0 - pad_w)
w_src_end = min(W, w1 + pad_w)
pad_top = max(0, pad_h - h0)
pad_bot = max(0, (h1 + pad_h) - H)
pad_lft = max(0, pad_w - w0)
pad_rgt = max(0, (w1 + pad_w) - W)
# Upsample only the tile region from k_lr / v_lr.
k_tile = upsample_lr_slice(k_lr, Dh, Dw,
(h_src_start, h_src_end),
(w_src_start, w_src_end))
v_tile = upsample_lr_slice(v_lr, Dh, Dw,
(h_src_start, h_src_end),
(w_src_start, w_src_end))
if pad_top or pad_bot or pad_lft or pad_rgt:
k_tile = F.pad(k_tile, [pad_lft, pad_rgt, pad_top, pad_bot])
v_tile = F.pad(v_tile, [pad_lft, pad_rgt, pad_top, pad_bot])
# Q·K → attention weights (small: KK=81 per output position).
KK = Kh * Kw
k_w = F.unfold(k_tile, kernel_size=(Kh, Kw), dilation=(Dh, Dw), padding=0)
k_w = k_w.view(B, n, d_qk, KK, t_h * t_w).permute(0, 1, 4, 3, 2) # [B, n, t, KK, d_qk]
# q is [B, H, W, n, d_qk]; per-tile slice + permute -> [B, n, t_h*t_w, 1, d_qk].
q_tile = q[:, h0:h1, w0:w1].permute(0, 3, 1, 2, 4).reshape(B, n, t_h * t_w, 1, d_qk)
scores = torch.matmul(q_tile, k_w.transpose(-1, -2)) * scale
attn = scores.softmax(dim=-1)
del k_w, scores, q_tile, k_tile
# attn · V, chunked over d_v.
for c0 in range(0, d_v, chunk):
c1 = min(c0 + chunk, d_v)
v_w = F.unfold(v_tile[:, c0:c1], kernel_size=(Kh, Kw),dilation=(Dh, Dw), padding=0) # [B*n, (c1-c0)*KK, t]
v_w = v_w.view(B, n, c1 - c0, KK, t_h * t_w).permute(0, 1, 4, 3, 2)
out_chunk = torch.matmul(attn, v_w).squeeze(-2) # [B, n, t, c1-c0]
out_chunk = out_chunk.view(B, n, t_h, t_w, c1 - c0).permute(0, 1, 4, 2, 3)
out[:, :, c0:c1, h0:h1, w0:w1] = out_chunk
del v_w, out_chunk
del attn, v_tile
return out # [B, n, d_v, H, W] — sole caller (CrossAttention) views it as BCHW directly.
class CrossAttention(nn.Module):
"""Window-restricted cross-attention. No learnable parameters; the model's
capacity lives entirely in the ImageEncoder convs."""
def __init__(self, dim: int, num_heads: int, kernel_size: Tuple[int, int] = (9, 9)):
super().__init__()
assert dim % num_heads == 0, "dim must be divisible by num_heads"
self.num_heads = num_heads
self.kernel_size = kernel_size
self.scale = (dim // num_heads) ** -0.5
@staticmethod
def _split_heads_lr(x: torch.Tensor, num_heads: int) -> torch.Tensor:
"""[B, n*d, h, w] -> [B, h, w, n, d] at the input resolution (no upsample)."""
B, C, H, W = x.shape
return x.view(B, num_heads, C // num_heads, H, W).permute(0, 3, 4, 1, 2).contiguous()
def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor,
output=None) -> torch.Tensor:
hq, wq = q.shape[-2:]
hk, wk = k.shape[-2:]
dilation = (hq // hk, wq // wk)
B, C, _, _ = q.shape
q = q.view(B, self.num_heads, C // self.num_heads, hq, wq).permute(0, 3, 4, 1, 2).contiguous()
k_lr = self._split_heads_lr(k, self.num_heads).to(q.dtype)
v_lr = self._split_heads_lr(v, self.num_heads).to(q.dtype)
out_buf = output.view(B, self.num_heads, v.shape[1] // self.num_heads, hq, wq) if output is not None else None
out = na2d_pure(q, k_lr, v_lr, self.kernel_size, dilation, self.scale, output=out_buf)
return out.view(B, -1, hq, wq)
# RoPE positional embedding
def rope_rotate_half(x: torch.Tensor) -> torch.Tensor:
x1, x2 = x.chunk(2, dim=-1)
return torch.cat([-x2, x1], dim=-1)
class RoPE(nn.Module):
def __init__(self, embed_dim: int, num_heads: int, base: float = 100.0):
super().__init__()
assert embed_dim % (4 * num_heads) == 0
self.num_heads = num_heads
self.D_head = embed_dim // num_heads
self.base = base
self.register_buffer("periods", torch.empty(self.D_head // 4), persistent=True) # loaded from the checkpoint
def _cos_sin(self, H: int, W: int, x: torch.Tensor):
"""cos/sin depend only on (H, W, dtype) and the checkpoint-fixed periods; recomputed per forward."""
periods = comfy.ops.cast_to_input(self.periods, x)
coords_h = torch.arange(0.5, H, device=x.device, dtype=torch.float32) / H
coords_w = torch.arange(0.5, W, device=x.device, dtype=torch.float32) / W
coords = torch.stack(torch.meshgrid(coords_h, coords_w, indexing="ij"), dim=-1) # [H, W, 2]
coords = coords.flatten(0, 1) * 2.0 - 1.0 # [HW, 2]
angles = 2 * math.pi * coords[:, :, None] / periods.to(coords.dtype)[None, None, :] # [HW, 2, D//4]
angles = angles.flatten(1, 2).tile(2) # [HW, D]
cos = torch.cos(angles).to(x.dtype)
sin = torch.sin(angles).to(x.dtype)
return cos, sin
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: [B, n*D_head, H, W]
B, C, H, W = x.shape
n = self.num_heads
D = C // n
x = x.view(B, n, D, H, W).permute(0, 1, 3, 4, 2).reshape(B, n, H * W, D)
cos, sin = self._cos_sin(H, W, x)
x = (x * cos) + (rope_rotate_half(x) * sin)
x = x.view(B, n, H, W, D).permute(0, 1, 4, 2, 3).reshape(B, n * D, H, W)
return x
# Image encoder
class EncBlock(nn.Module):
def __init__(self, channels: int, kernel_size: int, operations, num_groups: int = 8):
super().__init__()
self.norm1 = operations.GroupNorm(num_groups=num_groups, num_channels=channels)
self.conv1 = operations.Conv2d(channels, channels, kernel_size=kernel_size,
padding=kernel_size // 2, padding_mode="reflect", bias=True)
self.norm2 = operations.GroupNorm(num_groups=num_groups, num_channels=channels)
self.conv2 = operations.Conv2d(channels, channels, kernel_size=kernel_size,
padding=kernel_size // 2, padding_mode="reflect", bias=True)
self.activation_fn = nn.SiLU()
def forward(self, x):
x = self.norm1(x)
x = self.activation_fn(x)
x = self.conv1(x)
x = self.norm2(x)
x = self.activation_fn(x)
x = self.conv2(x)
return x # no skip connection
def _encoder(in_dim: int, hidden_dim: int, operations, kernel_size: int = 1, ks_res: int = 1, num_layers: int = 2) -> nn.Sequential:
return nn.Sequential(
operations.Conv2d(in_dim, hidden_dim, kernel_size=kernel_size, padding=kernel_size // 2, padding_mode="reflect", bias=True),
*[EncBlock(hidden_dim, kernel_size=ks_res, operations=operations) for _ in range(num_layers)],
)
class ImageEncoder(nn.Module):
"""Two parallel conv stacks (1x1 + 3x3) producing dim/2 channels each, then concat,
spatial average-pool to target size, RoPE-embed positions."""
def __init__(self, operations, in_channels: int = 3, out_channels: int = 256,
heads_rope: int = 4, rope_base: float = 100.0, img_layers: int = 2):
super().__init__()
half = out_channels // 2
self.encoder = _encoder(in_channels, half, operations=operations, kernel_size=1, ks_res=1, num_layers=img_layers)
self.sem_encoder = _encoder(in_channels, half, operations=operations, kernel_size=3, ks_res=3, num_layers=img_layers)
self.rope = RoPE(embed_dim=out_channels, num_heads=heads_rope, base=rope_base)
def forward(self, x: torch.Tensor, output_size: Tuple[int, int]) -> torch.Tensor:
# Avoid running the conv stacks on >4× the target resolution.
out_h, out_w = output_size
if x.shape[-2] > 4 * out_h or x.shape[-1] > 4 * out_w:
x = F.interpolate(x, size=(min(x.shape[-2], 4 * out_h),
min(x.shape[-1], 4 * out_w)),
mode="bilinear", align_corners=False)
x = torch.cat([self.encoder(x), self.sem_encoder(x)], dim=1)
x = F.adaptive_avg_pool2d(x, output_size=output_size)
x = self.rope(x)
return x
class NAF(nn.Module):
"""NAF feature upsampler."""
def __init__(
self, operations,
dim: int = 256, # internal channel dimension of the ImageEncoder
heads_attn: int = 4, # attention heads in the windowed cross-attn
heads_rope: int = 4, # heads for RoPE position encoding (must divide dim)
kernel_size: int = 9, # square kernel for the neighborhood attention window
rope_base: float = 100.0, # base for RoPE frequency periods
img_layers: int = 2, # number of EncBlocks in each conv stack
):
super().__init__()
self.image_encoder = ImageEncoder(operations=operations, in_channels=3, out_channels=dim, heads_rope=heads_rope, rope_base=rope_base, img_layers=img_layers)
self.upsampler = CrossAttention(dim=dim, num_heads=heads_attn, kernel_size=(kernel_size, kernel_size))
def forward(
self,
image: torch.Tensor, # [B, 3, H_img, W_img] in [0, 1].
features: torch.Tensor, # [B, C, H_feat, W_feat] low-resolution features (any C).
output_size: Tuple[int, int], # (H_out, W_out) target spatial resolution for the upsampled features.
output=None,
) -> torch.Tensor: # [B, C, H_out, W_out] upsampled features.
"""Upsample low-res feature map to output_size, guided by the image."""
q = self.image_encoder(image, output_size=output_size)
k = F.adaptive_avg_pool2d(q, output_size=features.shape[-2:])
return self.upsampler(q, k, features, output=output)
+68
View File
@@ -416,6 +416,74 @@ def linear_multistep_coeff(order, t, i, j):
return integrate.quad(fn, t[i], t[i + 1], epsrel=1e-4)[0]
def _sample_cfgpp_history(model, x, sigmas, extra_args=None, callback=None, disable=None, history_weight=0.5, zero_weight=None, zero_order=1, uncond_history_weight=0.0):
"""CFG++ Euler with variable-step AB2 history and optional sigma-zero extrapolation."""
extra_args = {} if extra_args is None else extra_args
model_sampling = model.inner_model.model_patcher.get_model_object("model_sampling")
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
s_in = x.new_ones([x.shape[0]])
sigmas_cpu = sigmas.detach().cpu().numpy()
derivatives = []
denoised_history = []
old_uncond_d = None
uncond_denoised = None
def post_cfg_function(args):
nonlocal uncond_denoised
uncond_denoised = args["uncond_denoised"] if args["uncond"] is not None else args["cond_denoised"]
return args["denoised"]
model_options = extra_args.get("model_options", {}).copy()
extra_args["model_options"] = comfy.model_patcher.set_model_options_post_cfg_function(model_options, post_cfg_function)
for i in trange(len(sigmas) - 1, disable=disable):
denoised = model(x, sigmas[i] * s_in, **extra_args)
if callback is not None:
callback({'x': x, 'i': i, 'sigma': sigmas[i], 'sigma_hat': sigmas[i], 'denoised': denoised})
alpha_s = sigmas[i] * lambda_fn(sigmas[i]).exp()
alpha_t = sigmas[i + 1] * lambda_fn(sigmas[i + 1]).exp() if sigmas[i + 1] != 0 else sigmas[i + 1].new_ones([])
current_uncond_d = to_d(x, sigmas[i], alpha_s * uncond_denoised)
uncond_d = current_uncond_d
dt = sigmas[i + 1] - sigmas[i]
if i > 0 and uncond_history_weight:
step_ratio = dt / (sigmas[i] - sigmas[i - 1])
uncond_d = uncond_d + uncond_history_weight * step_ratio * (current_uncond_d - old_uncond_d)
euler_step = alpha_t * denoised + sigmas[i + 1] * uncond_d - x
d = euler_step / dt
derivatives.append(d)
if len(derivatives) > 2:
derivatives.pop(0)
if len(derivatives) == 1:
step = euler_step
else:
coeffs = [linear_multistep_coeff(2, sigmas_cpu, i, j) for j in range(2)]
history_step = sum(coeff * derivative for coeff, derivative in zip(coeffs, reversed(derivatives)))
step = torch.lerp(euler_step, history_step, history_weight)
x = x + step
if sigmas[i + 1] == 0 and zero_weight is not None and denoised_history:
if zero_order == 2 and len(denoised_history) > 1:
sigma_0, sigma_1, sigma_2 = sigmas[i - 2], sigmas[i - 1], sigmas[i]
weight_0 = sigma_1 * sigma_2 / ((sigma_0 - sigma_1) * (sigma_0 - sigma_2))
weight_1 = sigma_0 * sigma_2 / ((sigma_1 - sigma_0) * (sigma_1 - sigma_2))
weight_2 = sigma_0 * sigma_1 / ((sigma_2 - sigma_0) * (sigma_2 - sigma_1))
zero_prediction = weight_0 * denoised_history[-2] + weight_1 * denoised_history[-1] + weight_2 * denoised
else:
denoised_slope = (denoised - denoised_history[-1]) / (sigmas[i] - sigmas[i - 1])
zero_prediction = denoised - sigmas[i] * denoised_slope
x = torch.lerp(x, zero_prediction, zero_weight)
denoised_history.append(denoised)
if len(denoised_history) > 2:
denoised_history.pop(0)
old_uncond_d = current_uncond_d
return x
def sample_cfgpp_ud10_ab(model, x, sigmas, extra_args=None, callback=None, disable=None):
return _sample_cfgpp_history(model, x, sigmas, extra_args=extra_args, callback=callback, disable=disable, history_weight=0.25, zero_weight=1.0, uncond_history_weight=0.1)
@torch.no_grad()
def sample_lms(model, x, sigmas, extra_args=None, callback=None, disable=None, order=4):
extra_args = {} if extra_args is None else extra_args
+65
View File
@@ -1,4 +1,5 @@
import torch
import comfy.nested_tensor
class LatentFormat:
scale_factor = 1.0
@@ -17,6 +18,9 @@ class LatentFormat:
def process_out(self, latent):
return latent / self.scale_factor
def fix_empty_latent(self, latent):
return latent
class SD15(LatentFormat):
def __init__(self, scale_factor=0.18215):
self.scale_factor = scale_factor
@@ -249,6 +253,53 @@ class TripoSplat(LatentFormat):
def process_out(self, latent):
return latent
class Trellis2(LatentFormat):
latent_channels = 32
class Trellis2SLAT(Trellis2):
# Sparse structured latent: per-token feats [N, 32]. process_out denormalizes
# the decoded feats (latent * std + mean); subclasses carry each space's stats.
latents_mean = None
latents_std = None
def process_in(self, latent):
mean = self.latents_mean.to(latent.device, latent.dtype)
std = self.latents_std.to(latent.device, latent.dtype)
return (latent - mean) / std
def process_out(self, latent):
mean = self.latents_mean.to(latent.device, latent.dtype)
std = self.latents_std.to(latent.device, latent.dtype)
return latent * std + mean
class Trellis2ShapeSLAT(Trellis2SLAT):
latents_mean = torch.tensor([
0.781296, 0.018091, -0.495192, -0.558457, 1.060530, 0.093252, 1.518149, -0.933218,
-0.732996, 2.604095, -0.118341, -2.143904, 0.495076, -2.179512, -2.130751, -0.996944,
0.261421, -2.217463, 1.260067, -0.150213, 3.790713, 1.481266, -1.046058, -1.523667,
-0.059621, 2.220780, 1.621212, 0.877230, 0.567247, -3.175944, -3.186688, 1.578665
])[None]
latents_std = torch.tensor([
5.972266, 4.706852, 5.445010, 5.209927, 5.320220, 4.547237, 5.020802, 5.444004,
5.226681, 5.683095, 4.831436, 5.286469, 5.652043, 5.367606, 5.525084, 4.730578,
4.805265, 5.124013, 5.530808, 5.619001, 5.103930, 5.417670, 5.269677, 5.547194,
5.634698, 5.235274, 6.110351, 5.511298, 6.237273, 4.879207, 5.347008, 5.405691
])[None]
class Trellis2TexSLAT(Trellis2SLAT):
latents_mean = torch.tensor([
3.501659, 2.212398, 2.226094, 0.251093, -0.026248, -0.687364, 0.439898, -0.928075,
0.029398, -0.339596, -0.869527, 1.038479, -0.972385, 0.126042, -1.129303, 0.455149,
-1.209521, 2.069067, 0.544735, 2.569128, -0.323407, 2.293000, -1.925608, -1.217717,
1.213905, 0.971588, -0.023631, 0.106750, 2.021786, 0.250524, -0.662387, -0.768862
])[None]
latents_std = torch.tensor([
2.665652, 2.743913, 2.765121, 2.595319, 3.037293, 2.291316, 2.144656, 2.911822,
2.969419, 2.501689, 2.154811, 3.163343, 2.621215, 2.381943, 3.186697, 3.021588,
2.295916, 3.234985, 3.233086, 2.260140, 2.874801, 2.810596, 3.292720, 2.674999,
2.680878, 2.372054, 2.451546, 2.353556, 2.995195, 2.379849, 2.786195, 2.775190
])[None]
class Mochi(LatentFormat):
latent_channels = 12
latent_dimensions = 3
@@ -573,6 +624,7 @@ class MiniMaxH3Video(LatentFormat):
spacial_downscale_ratio = 16
temporal_downscale_ratio = 4
scale_factor = 1.0
taesd_decoder_name = "taeh3"
latent_rgb_factors = [
[-0.018555, 0.024344, -0.017536],
@@ -606,6 +658,19 @@ class MiniMaxH3AV(MiniMaxH3Video):
# max channels across the two streams (video 24, audio 32) so per-stream slices keep both streams whole
latent_channels = 32
def fix_empty_latent(self, latent):
video_latent_channels = MiniMaxH3Video.latent_channels
audio_latent_channels = 32
audio_channels = 2
frames_per_token = (1, 4, 4, 4, 4)
audio_frame_rescale = 5.0 / 3.0
video = latent[:, :video_latent_channels].clone()
frame_count = sum(frames_per_token[i % len(frames_per_token)] for i in range(video.shape[2]))
audio_t = round(frame_count * audio_frame_rescale)
audio = latent.new_zeros((latent.shape[0], audio_latent_channels, audio_channels, audio_t))
return comfy.nested_tensor.NestedTensor((video, audio))
class HunyuanVideo(LatentFormat):
latent_channels = 16
latent_dimensions = 3
+8 -2
View File
@@ -938,8 +938,11 @@ class LTXAVModel(LTXVModel):
stg_self_attn_blocks = transformer_options.get("stg_self_attn_blocks", ())
# Process transformer blocks
comfy.model_prefetch.malloc_graph_begin(self, vx.device)
for i, block in enumerate(self.transformer_blocks):
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, vx.device, block)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, vx.device, block, malloc_scope="block"
)
block_transformer_options = transformer_options
if i in stg_self_attn_blocks:
block_transformer_options = {**transformer_options, "stg_skip_self_attn": True}
@@ -1015,7 +1018,10 @@ class LTXAVModel(LTXVModel):
a_prompt_timestep=a_prompt_timestep,
)
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, vx.device, None)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, vx.device, None, malloc_scope="block"
)
comfy.model_prefetch.malloc_graph_end()
return [vx, ax]
+85
View File
@@ -0,0 +1,85 @@
"""MiniMax H3 Fun ControlNet-Union model patch."""
import torch
import torch.nn as nn
import comfy.ldm.common_dit
from .model import DiTBlock, patchify_video
class ControlDiTBlock(DiTBlock):
def __init__(self, hidden, heads, head_dim, ffn, t_dim, eps, qk_eps, first_block=False,
apply_silu=True, adaln_dtype=None, dtype=None, device=None, operations=None):
super().__init__(hidden, heads, head_dim, ffn, t_dim, eps, qk_eps, apply_silu=apply_silu,
adaln_dtype=adaln_dtype, dtype=dtype, device=device, operations=operations)
if first_block:
self.before_proj = operations.Linear(hidden, hidden, bias=True, dtype=dtype, device=device)
self.after_proj = operations.Linear(hidden, hidden, bias=True, dtype=dtype, device=device)
class MiniMaxH3FunControl(torch.nn.Module):
def __init__(self, control_in_dim=49, injection_layers=(0, 10, 20, 30, 40), hidden_size=5376,
num_attention_heads=56, attention_head_dim=128, ffn_hidden_size=14336,
time_embed_dim=2688, patch_size=(1, 2, 2), norm_eps=1e-5, qk_norm_eps=1e-5,
use_adaln_curves=False, dtype=None, device=None, operations=None):
super().__init__()
self.dtype = dtype
self.patch_size = tuple(patch_size)
self.injection_layers = tuple(injection_layers)
if not self.injection_layers or self.injection_layers[0] != 0:
raise ValueError("MiniMax H3 Fun control injection layers must start at layer 0")
if self.injection_layers != tuple(sorted(set(self.injection_layers))):
raise ValueError("MiniMax H3 Fun control injection layers must be unique and increasing")
self.control_in_dim = control_in_dim
patch_dim = control_in_dim * self.patch_size[0] * self.patch_size[1] * self.patch_size[2]
self.control_proj_in = operations.Linear(patch_dim, hidden_size, bias=True, dtype=torch.float32, device=device)
self.control_blocks = nn.ModuleList([
ControlDiTBlock(hidden_size, num_attention_heads, attention_head_dim, ffn_hidden_size,
time_embed_dim, norm_eps, qk_norm_eps, first_block=(i == 0),
apply_silu=not use_adaln_curves,
adaln_dtype=torch.float32 if use_adaln_curves else dtype,
dtype=dtype, device=device, operations=operations)
for i in range(len(self.injection_layers))])
def init_stream(self, h, control_latent, layout, t_emb):
adaln_in = self.control_blocks[0].adaln_proj.linear.in_features
if t_emb.shape[-1] != adaln_in:
raise RuntimeError(
"MiniMax H3 controlnet adaln width {} does not match the base model's timestep embedding width {}: "
"the controlnet and base checkpoint use different adaln forms (curve basis vs full), "
"convert the controlnet to match the base model.".format(adaln_in, t_emb.shape[-1]))
patch_dim = self.control_in_dim * self.patch_size[0] * self.patch_size[1] * self.patch_size[2]
control_latent = comfy.ldm.common_dit.pad_to_patch_size(control_latent.to(torch.float32), self.patch_size)
target_rows = patchify_video(control_latent, self.patch_size)
if target_rows.shape[1] < patch_dim:
target_rows = torch.nn.functional.pad(target_rows, (0, patch_dim - target_rows.shape[1]))
elif target_rows.shape[1] > patch_dim:
raise ValueError("MiniMax H3 control input has {} columns but the model patch expects {}".format(target_rows.shape[1], patch_dim))
# keyframe/reference conditioning rows get a zero control row
img_update = layout.img_update.to(h.device)
rows = torch.zeros(img_update.shape[0], patch_dim, dtype=torch.float32, device=h.device)
rows[img_update] = target_rows
c = h.clone()
c[layout.img_pos.to(h.device)] = self.control_proj_in(rows).to(h.dtype)
return self.control_blocks[0].before_proj(c).add_(h)
def step(self, index, c, t_emb, mod_segments, rope_freqs, transformer_options):
block = self.control_blocks[index]
c = DiTBlock.forward(block, c, t_emb, mod_segments, rope_freqs, transformer_options=transformer_options)
return c, block.after_proj(c)
def is_minimax_h3_fun_state_dict(state_dict):
required = (
"control_proj_in.weight",
"control_blocks.0.adaln_proj.linear.weight",
"control_blocks.0.after_proj.weight",
"control_blocks.0.before_proj.weight",
"control_blocks.0.attn.qkv_proj.weight",
"control_blocks.0.attn.q_norm.weight",
"control_blocks.0.mlp.fc1.weight",
)
return all(key in state_dict for key in required)
+141 -33
View File
@@ -74,6 +74,17 @@ def _axis_from_sqrt_area(dim, patch, sqrt_area):
return (torch.arange(n, dtype=torch.float64) * (ratio / n) + (1.0 - ratio) / 2.0) * 32.0
def mask_row_values(mask, latent_t, lat_h, lat_w):
# [T, H, W] denoise mask (1 = generate) -> per-2x2-patch-row float in [0, 1],
# None when every row fully generates
m = torch.nn.functional.pad(mask, (0, lat_w - mask.shape[-1], 0, lat_h - mask.shape[-2]), mode="replicate")
m = m.reshape(latent_t, lat_h // 2, 2, lat_w // 2, 2).amax(dim=(2, 4))
values = m.reshape(-1)
if bool((values >= 1.0 - 1e-3).all()):
return None
return values
def _frame_grid(h, w):
# area-normalized (h, w) coordinates of one latent frame's 2x2-patch rows
area = math.sqrt(h * w)
@@ -145,7 +156,7 @@ def rope_rotation_table(angles, dtype):
class Attention(nn.Module):
def __init__(self, hidden, heads, head_dim, eps, dtype=None, device=None, operations=None):
def __init__(self, hidden, heads, head_dim, eps, gate_compress=False, dtype=None, device=None, operations=None):
super().__init__()
self.heads = heads
self.head_dim = head_dim
@@ -154,6 +165,10 @@ class Attention(nn.Module):
self.q_norm = operations.RMSNorm(head_dim, eps=eps, dtype=dtype, device=device)
self.k_norm = operations.RMSNorm(head_dim, eps=eps, dtype=dtype, device=device)
self.out_proj = operations.Linear(inner, hidden, bias=False, dtype=dtype, device=device)
self.to_gate_compress = None
if gate_compress:
# VSA gate, unused by the dense forward; consumed by sparse attention patches
self.to_gate_compress = operations.Linear(hidden, inner, bias=False, dtype=dtype, device=device)
def forward(self, x, rope_freqs=None, transformer_options={}):
s = x.shape[0]
@@ -177,7 +192,7 @@ class Attention(nn.Module):
else:
q = self.q_norm(q.view(s, self.heads, self.head_dim))
k = self.k_norm(k.view(s, self.heads, self.head_dim))
v = v.clone()
q = AttentionTensorContainer(q.transpose(0, 1).unsqueeze(0))
k = AttentionTensorContainer(k.transpose(0, 1).unsqueeze(0))
v = AttentionTensorContainer(v.transpose(0, 1).unsqueeze(0))
@@ -212,17 +227,22 @@ class AdalnProj(nn.Module):
return x.chunk(self.expand, dim=-1)
def _mod_row(vecs, row, dtype):
# row is a mod-row index, or a per-token LongTensor of mod-row indices
return vecs[row].to(dtype)
def _mod_scale_shift(h, shift, scale, segments):
# segments: [(start, stop, mod_row)] covering h contiguously.
for a, b, row in segments:
h[a:b].mul_(1.0 + scale[row].to(h.dtype)).add_(shift[row].to(h.dtype))
h[a:b].mul_(1.0 + _mod_row(scale, row, h.dtype)).add_(_mod_row(shift, row, h.dtype))
return h
def _mod_gate(x, gate, other, segments):
# other is the fresh attn/mlp output: accumulate the gated residual into the stream in place, one fused kernel per segment
for a, b, row in segments:
x[a:b].addcmul_(other[a:b], gate[row].to(x.dtype))
x[a:b].addcmul_(other[a:b], _mod_row(gate, row, x.dtype))
return x
@@ -257,20 +277,22 @@ class TokenRefiner(nn.Module):
class DiTBlock(nn.Module):
def __init__(self, hidden, heads, head_dim, ffn, t_dim, eps, qk_eps,
apply_silu=True, adaln_dtype=None, dtype=None, device=None, operations=None):
apply_silu=True, adaln_dtype=None, gate_compress=False, dtype=None, device=None, operations=None):
super().__init__()
self.norm1 = operations.RMSNorm(hidden, eps=eps, dtype=dtype, device=device)
self.norm2 = operations.RMSNorm(hidden, eps=eps, dtype=dtype, device=device)
self.attn = Attention(hidden, heads, head_dim, qk_eps, dtype=dtype, device=device, operations=operations)
self.attn = Attention(hidden, heads, head_dim, qk_eps, gate_compress=gate_compress,
dtype=dtype, device=device, operations=operations)
self.mlp = MLP(hidden, ffn, dtype=dtype, device=device, operations=operations)
self.adaln_proj = AdalnProj(t_dim, hidden, 6, 3, apply_silu=apply_silu,
dtype=adaln_dtype if adaln_dtype is not None else dtype,
device=device, operations=operations)
def forward(self, x, t_emb, mod_segments, rope_freqs, transformer_options={}):
def forward(self, x, t_emb, mod_segments, rope_freqs, transformer_options={}, attention=None):
attention = self.attn if attention is None else attention
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(t_emb)
h = _mod_scale_shift(self.norm1(x), shift_msa, scale_msa, mod_segments)
x = _mod_gate(x, gate_msa, self.attn(h, rope_freqs=rope_freqs, transformer_options=transformer_options), mod_segments)
x = _mod_gate(x, gate_msa, attention(h, rope_freqs=rope_freqs, transformer_options=transformer_options), mod_segments)
h = _mod_scale_shift(self.norm2(x), shift_mlp, scale_mlp, mod_segments)
return _mod_gate(x, gate_mlp, self.mlp(h), mod_segments)
@@ -287,14 +309,42 @@ class FinalLayer(nn.Module):
self.video_out = operations.Linear(hidden, video_dim, bias=True, dtype=torch.float32, device=device)
self.audio_out = operations.Linear(hidden, audio_dim, bias=True, dtype=torch.float32, device=device)
def forward(self, x, t_emb, video_seg, audio_seg):
# video_seg / audio_seg: (start, stop, timestep_row) of the target streams
def forward(self, x, t_emb, video_seg, audio_seg, sigma, sample_sigmas, shifts):
# video_seg / audio_seg: (start, stop, row) of the target streams, where row
# is a mod-row index or a per-token blend (see _mod_row)
shift, scale = self.adaln_proj(t_emb)
va, vb, vrow = video_seg
aa, ab, arow = audio_seg
hv = (self.norm(x[va:vb]) * (1.0 + scale[vrow]) + shift[vrow]).to(torch.float32)
ha = (self.norm(x[aa:ab]) * (1.0 + scale[arow]) + shift[arow]).to(torch.float32)
return self.video_out(hv), self.audio_out(ha)
def mod(seg):
a, b, row = seg
return (self.norm(x[a:b]) * (1.0 + _mod_row(scale, row, scale.dtype)) + _mod_row(shift, row, shift.dtype)).to(torch.float32)
n = self.video_out.weight.shape[0] // self.video_out.out_features
if n == 1:
return self.video_out(mod(video_seg)), self.audio_out(mod(audio_seg))
# PDD head bank: row block 0 is a full head, later blocks are offsets from it;
# a step consumes the dt-weighted mean of the heads it spans.
if sample_sigmas is None:
raise ValueError("MiniMax H3 PDD heads need the sampler's sigma schedule")
i = int((sample_sigmas - sigma).abs().argmin())
sigma_next = sample_sigmas[min(i + 1, sample_sigmas.shape[0] - 1)]
start, stop = (round(float(1.0 - time_shift_sigma(s, shifts[0], 1.0)) * n) for s in (sigma, sigma_next))
start = min(start, n - 1)
stop = max(stop, start + 1)
return (_pdd_head(self.video_out, mod(video_seg), n, start, stop, shifts[0]),
_pdd_head(self.audio_out, mod(audio_seg), n, start, stop, shifts[1]))
def _pdd_head(head, h, n, start, stop, flow_shift):
grid = torch.linspace(1.0, 0.0, n + 1, dtype=torch.float64)
dt = (1.0 - flow_shift * grid / (1.0 + (flow_shift - 1.0) * grid)).diff()[start:stop]
w = (dt / dt.sum()).to(h)
with comfy.ops.CastBiasWeightContext(head, h, offloadable=True) as (weight, bias):
rows = weight.reshape(n, -1, weight.shape[1])
brows = bias.reshape(n, -1)
first = max(start, 1)
return nn.functional.linear(h, rows[0] + torch.einsum("n,noi->oi", w[first - start:], rows[first:stop]),
brows[0] + torch.einsum("n,no->o", w[first - start:], brows[first:stop]))
class PackedLayout:
@@ -427,7 +477,7 @@ class MiniMaxH3Model(nn.Module):
timestep_input_dim=256, time_embed_hidden_size=5376, time_embed_dim=2688,
rope_inv_freq_len=16, norm_eps=1e-5, qk_norm_eps=1e-5, final_norm_eps=1e-5,
sigma_shift_video=12.0, sigma_shift_audio=3.0,
adaln_curve_grid=None,
adaln_curve_grid=None, gate_compress=False,
image_model=None, dtype=None, device=None, operations=None, **kwargs):
super().__init__()
self.dtype = dtype
@@ -458,7 +508,8 @@ class MiniMaxH3Model(nn.Module):
final_norm_eps, dtype=dtype, device=device, operations=operations)
self.blocks = nn.ModuleList([
DiTBlock(hidden_size, num_attention_heads, attention_head_dim, ffn_hidden_size,
time_embed_dim, norm_eps, qk_norm_eps, **curve, dtype=dtype, device=device, operations=operations)
time_embed_dim, norm_eps, qk_norm_eps, **curve, gate_compress=gate_compress,
dtype=dtype, device=device, operations=operations)
for _ in range(num_layers)])
self.final_layer = FinalLayer(hidden_size, time_embed_dim, video_patch_dim, audio_latents_dim,
final_norm_eps, **curve, dtype=dtype, device=device, operations=operations)
@@ -506,7 +557,7 @@ class MiniMaxH3Model(nn.Module):
rows.append(r.to(device))
return torch.cat(rows, dim=0) if rows else None
def forward(self, x, timestep, context, transformer_options={}, minimax_payload=None, **kwargs):
def forward(self, x, timestep, context, transformer_options={}, minimax_payload=None, denoise_mask=None, audio_denoise_mask=None, **kwargs):
# the sampler carries the audio as (sigma_v / sigma_a) * x_audio; undo it outside
# the wrappers so they and the network see the stream's own latent and velocity
scale = float((minimax_payload or {}).get("audio_scale", 1.0))
@@ -519,11 +570,23 @@ class MiniMaxH3Model(nn.Module):
carry = (sigma_a / sigma_v).to(audio_src.dtype)
x = [x[0], audio_src * carry]
out = comfy.patcher_extension.WrapperExecutor.new_class_executor(
compile_allocations = comfy.model_prefetch.malloc_graph_enabled(x[0].device)
if compile_allocations:
out = [torch.empty_like(x[0]), torch.empty_like(x[1])]
comfy.model_prefetch.malloc_graph_begin(self, x[0].device)
graph_out = comfy.patcher_extension.WrapperExecutor.new_class_executor(
self._forward,
self,
comfy.patcher_extension.get_all_wrappers(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, transformer_options)
).execute(x, timestep, context, transformer_options, minimax_payload=minimax_payload, **kwargs)
).execute(x, timestep, context, transformer_options, minimax_payload=minimax_payload,
denoise_mask=denoise_mask, audio_denoise_mask=audio_denoise_mask, **kwargs)
if compile_allocations:
out[0].copy_(graph_out[0])
out[1].copy_(graph_out[1])
del graph_out
comfy.model_prefetch.malloc_graph_end()
else:
out = graph_out
if scale != 1.0:
# d/d(sigma_v) of the carried variable
@@ -531,7 +594,7 @@ class MiniMaxH3Model(nn.Module):
+ (1.0 + (scale - 1.0) * sigma_a).to(out[1].dtype) * out[1])
return out
def _forward(self, x, timestep, context, transformer_options={}, minimax_payload=None, **kwargs):
def _forward(self, x, timestep, context, transformer_options={}, minimax_payload=None, denoise_mask=None, audio_denoise_mask=None, **kwargs):
video_x, audio_x = x[0], x[1]
orig_t, orig_h, orig_w = video_x.shape[2], video_x.shape[3], video_x.shape[4]
video_x = comfy.ldm.common_dit.pad_to_patch_size(video_x, self.patch_size)
@@ -551,6 +614,8 @@ class MiniMaxH3Model(nn.Module):
keyframes=payload.get("keyframes"),
refs=payload.get("refs"))
transformer_options["minimax_h3_layout"] = layout # segment spans for attention patches
# model_base passes model_sampling.timestep(sigma) = sigma * 1000
shift_v = float(transformer_options.get("minimax_h3_sigma_shift_video", self.sigma_shift_video))
shift_a = float(transformer_options.get("minimax_h3_sigma_shift_audio", self.sigma_shift_audio))
@@ -561,16 +626,47 @@ class MiniMaxH3Model(nn.Module):
# distinct timesteps are known analytically: text/pad follow video, cond rows pin near 1
vis_aug = float(payload.get("visual_cond_noise_aug", VISUAL_COND_TIMESTEP))
aud_aug = float(payload.get("audio_cond_noise_aug", AUDIO_COND_TIMESTEP))
has_vis_cond = any(k in ("cond", "ref_img") for _, _, k in layout.segments)
has_aud_cond = any(k in ("cond_audio", "ref_audio") for _, _, k in layout.segments)
seg_t = {"text": t_v, "video": t_v, "audio": t_a,
"cond": max(t_v, vis_aug), "ref_img": max(t_v, vis_aug),
"cond_audio": max(t_a, aud_aug), "ref_audio": max(t_a, aud_aug)}
unique_t = sorted({t_v, t_a} | ({seg_t["cond"]} if has_vis_cond else set())
| ({seg_t["ref_audio"]} if has_aud_cond else set()))
# masked rows run at their own strength: mask value m puts a row at sigma = m * sigma_stream,
# so its label is 1 - m * sigma, clamped at the cond timestep for fully preserved rows
t_pin_v = max(t_v, VISUAL_COND_TIMESTEP)
t_pin_a = max(t_a, AUDIO_COND_TIMESTEP)
video_rows_t = None
audio_rows_t = None
if denoise_mask is not None:
m = mask_row_values(denoise_mask[0, 0].to(torch.float32), latent_t, lat_h, lat_w)
if m is not None:
rows_t = (1.0 - m * sigma_v.to(m.device)).clamp(max=t_pin_v)
if rows_t.unique().numel() == 1:
seg_t["video"] = float(rows_t[0])
else:
video_rows_t = rows_t
if audio_denoise_mask is not None:
m = audio_denoise_mask[0, 0].to(torch.float32).reshape(-1)
if not bool((m >= 1.0 - 1e-3).all()):
sigma_a = 1.0 - t_a
rows_t = (1.0 - m * sigma_a).clamp(max=t_pin_a)
if rows_t.unique().numel() == 1:
seg_t["audio"] = float(rows_t[0])
else:
audio_rows_t = rows_t
unique_t = sorted({t_v, t_a} | {seg_t[k] for _, _, k in layout.segments}
| (set(video_rows_t.unique().tolist()) if video_rows_t is not None else set())
| (set(audio_rows_t.unique().tolist()) if audio_rows_t is not None else set()))
t_row = {t: i for i, t in enumerate(unique_t)}
seg_tag = {"text": 1, "video": 0, "audio": 2, "cond": 0, "ref_img": 0, "cond_audio": 2, "ref_audio": 2}
def rows_to_mod_index(rows_t, tag):
# per-row timestep values -> per-row mod-row indices into the t_emb table
levels = rows_t.unique()
base = torch.tensor([t_row[v] * 3 + tag for v in levels.tolist()],
dtype=torch.long, device=rows_t.device)
return base[torch.searchsorted(levels, rows_t)]
text_tags = payload.get("text_token_tags")
mod_segments = []
for a, b, kind in layout.segments:
@@ -583,6 +679,10 @@ class MiniMaxH3Model(nn.Module):
if i == b - a or tags[i] != tags[run_start]:
mod_segments.append((a + run_start, a + i, row_base + int(tags[run_start])))
run_start = i
elif kind == "video" and video_rows_t is not None:
mod_segments.append((a, b, rows_to_mod_index(video_rows_t, seg_tag[kind])))
elif kind == "audio" and audio_rows_t is not None:
mod_segments.append((a, b, rows_to_mod_index(audio_rows_t, seg_tag[kind])))
else:
mod_segments.append((a, b, row_base + seg_tag[kind]))
@@ -644,24 +744,32 @@ class MiniMaxH3Model(nn.Module):
blocks_replace = patches_replace.get("dit", {})
prefetch_queue = comfy.model_prefetch.make_prefetch_queue(list(self.blocks), device, transformer_options)
for i, block in enumerate(self.blocks):
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, device, block)
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, device, block, malloc_scope="block")
transformer_options["block_index"] = i
if ("double_block", i) in blocks_replace:
def block_wrap(args):
return {"img": block(args["img"], args["t_emb"], args["mod_segments"], args["rope_freqs"],
transformer_options=args["transformer_options"])}
transformer_options=args["transformer_options"], attention=args.get("attention"))}
h = blocks_replace[("double_block", i)](
{"img": h, "t_emb": t_emb, "mod_segments": mod_segments, "rope_freqs": rope_freqs,
"transformer_options": transformer_options},
"layout": layout, "transformer_options": transformer_options},
{"original_block": block_wrap})["img"]
else:
h = block(h, t_emb, mod_segments, rope_freqs, transformer_options=transformer_options)
if prefetch_queue is not None:
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, device, None)
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, device, None, malloc_scope="block")
# target streams are single contiguous segments (audio then video, last two)
video_seg = next((a, b, t_row[seg_t["video"]]) for a, b, k in layout.segments if k == "video")
audio_seg = next((a, b, t_row[seg_t["audio"]]) for a, b, k in layout.segments if k == "audio")
v, a = self.final_layer(h, t_emb, video_seg, audio_seg)
va, vb, _ = next(s for s in layout.segments if s[2] == "video")
aa, ab, _ = next(s for s in layout.segments if s[2] == "audio")
if video_rows_t is not None:
video_seg = (va, vb, rows_to_mod_index(video_rows_t, 0) // 3)
else:
video_seg = (va, vb, t_row[seg_t["video"]])
if audio_rows_t is not None:
audio_seg = (aa, ab, rows_to_mod_index(audio_rows_t, 0) // 3)
else:
audio_seg = (aa, ab, t_row[seg_t["audio"]])
v, a = self.final_layer(h, t_emb, video_seg, audio_seg, sigma_v, transformer_options.get("sample_sigmas"), (shift_v, shift_a))
video_out = unpatchify_video(v, latent_t, lat_h // 2, lat_w // 2, self.latents_dim, self.patch_size)
video_out = video_out[:, :, :orig_t, :orig_h, :orig_w]
+25 -13
View File
@@ -251,8 +251,13 @@ class MiniMaxMusic3AR(nn.Module):
decode_limit = min(int(max_audio_frames), MAX_AUDIO_FRAMES)
past = self.model.init_kv_cache(2, prompt_tokens + decode_limit + 1, device, execution_dtype)
output = self.model(None, embeds=text_embeds, past_key_values=past, dtype=execution_dtype)
last_hidden = output[0][:, -1]
last_hidden = output[0][:, -1].clone()
past = output[2]
del output
vbar = getattr(self, "dynamic_vbars", {}).get(device)
if vbar is not None:
comfy.model_management.reset_cast_buffers()
vbar.set_watermark(vbar.max_size)
generator = torch.Generator(device=device).manual_seed(derive_seed(seed, "ar"))
decoder = self.model.audio_decoder
@@ -263,13 +268,12 @@ class MiniMaxMusic3AR(nn.Module):
"codes": torch.empty((last_hidden.shape[0], self.num_codebooks), dtype=torch.long, device=device),
"depth_hidden": torch.empty((1, last_hidden.shape[-1] * (self.num_codebooks - 1)), dtype=execution_dtype, device=device),
}
decoder._comfy_cross_step_state = depth_io
comfy.model_management._register_cross_step(decoder)
hidden_frames = []
pending_code = None
stop_token = None
pending_event = None
pending_hidden = None
pending_hidden = torch.empty(last_hidden.shape[-1] * self.num_codebooks, dtype=execution_dtype, device=device)
pending_hidden_valid = False
progress = comfy.utils.ProgressBar(decode_limit)
cuda_device = torch.device(device).type == "cuda"
vocab_mask = None
@@ -284,14 +288,16 @@ class MiniMaxMusic3AR(nn.Module):
if pending_event is not None:
pending_event.synchronize()
if int(pending_code.item()) == stop_token:
pending_hidden = None
pending_hidden_valid = False
break
if pending_hidden is not None:
hidden_frames.append(pending_hidden)
if pending_hidden_valid:
hidden_frames.append(pending_hidden.clone())
progress.update_absolute(len(hidden_frames))
if len(hidden_frames) >= decode_limit:
break
if frame_index:
comfy.model_prefetch.malloc_graph_begin(self, device)
c0, code_or_stop, stop_token = self._sample_c0(last_hidden, cfg_scale, top_k, generator, vocab_mask)
if pending_code is None:
pending_code = torch.empty_like(code_or_stop, device="cpu", pin_memory=cuda_device)
@@ -318,25 +324,31 @@ class MiniMaxMusic3AR(nn.Module):
[[decoder, self.model.audio_extra_embedding]], device, {"prefetch_dynamic_vbars": True}
)
comfy.model_prefetch.prefetch_queue_pop(
depth_queue, device, decoder, execution_dtype, core=depth_core, enable_graph=True, generator=generator
depth_queue, device, decoder, execution_dtype, core=depth_core, enable_graph=True,
generator=generator, malloc_scope="depth"
)
comfy.model_prefetch.prefetch_queue_pop(
depth_queue, device, None, malloc_scope="depth"
)
comfy.model_prefetch.prefetch_queue_pop(depth_queue, device, None)
feedback_codes = depth_io["codes"]
depth_hidden = depth_io["depth_hidden"]
frame_hidden = torch.cat((last_hidden[:1].detach(), depth_hidden), dim=-1)
if frame_index > 0:
pending_hidden = frame_hidden[0].clone()
pending_hidden.copy_(frame_hidden[0])
pending_hidden_valid = True
feedback = self._embed_audio_frame(feedback_codes, execution_dtype)
output = self.model(None, embeds=feedback, past_key_values=past, dtype=execution_dtype)
last_hidden = output[0][:, -1]
last_hidden.copy_(output[0][:, -1])
past = output[2]
del output, feedback, frame_hidden, depth_hidden, feedback_codes, c0_embed, c0, code_or_stop
comfy.model_prefetch.malloc_graph_end()
if pending_hidden is not None and len(hidden_frames) < decode_limit:
if pending_hidden_valid and len(hidden_frames) < decode_limit:
if pending_event is not None:
pending_event.synchronize()
if int(pending_code.item()) != stop_token:
hidden_frames.append(pending_hidden)
hidden_frames.append(pending_hidden.clone())
if not hidden_frames:
raise ValueError("MiniMax Music3 generated zero audio frames")
+4
View File
@@ -603,6 +603,8 @@ def _comfy_kitchen_int8_inputs(q, k, v, heads, mask, skip_reshape, enable_gqa):
@wrap_attn
def attention_comfy_kitchen_int8(q, k, v, heads, mask=None, attn_precision=None, skip_reshape=False, skip_output_reshape=False, **kwargs):
if kwargs.get("low_precision_attention", True) is False and q.dtype == torch.float32:
return attention_pytorch(q, k, v, heads, mask=mask, attn_precision=attn_precision, skip_reshape=skip_reshape, skip_output_reshape=skip_output_reshape, **kwargs)
q, k, v, mask, b, dim_head = _comfy_kitchen_int8_inputs(
q, k, v, heads, mask, skip_reshape, kwargs.get("enable_gqa", False)
)
@@ -622,6 +624,8 @@ def _attention_comfy_kitchen_int8_containers(q, k, v, heads, mask=None, attn_pre
q = q.take()
k = k.take()
v = v.take()
if kwargs.get("low_precision_attention", True) is False and q.dtype == torch.float32:
return attention_pytorch(q, k, v, heads, mask=mask, attn_precision=attn_precision, skip_reshape=skip_reshape, skip_output_reshape=skip_output_reshape, **kwargs)
q, k, v, mask, b, dim_head = _comfy_kitchen_int8_inputs(
q, k, v, heads, mask, skip_reshape, kwargs.get("enable_gqa", False)
)
+2 -3
View File
@@ -69,7 +69,7 @@ class MoGeModelV1(nn.Module):
resize = ((num_tokens * 14 ** 2) / (H * W)) ** 0.5
rh, rw = int(H * resize), int(W * resize)
x = F.interpolate(image, (rh, rw), mode="bicubic", align_corners=False, antialias=True)
x = (x - self.image_mean) / self.image_std
x = (x - comfy.ops.cast_to_input(self.image_mean, x, copy=False)) / comfy.ops.cast_to_input(self.image_std, x, copy=False)
x14 = F.interpolate(x, (rh // 14 * 14, rw // 14 * 14), mode="bilinear", align_corners=False, antialias=True)
n_layers = len(self.backbone.encoder.layer)
@@ -268,7 +268,6 @@ class MoGeModel:
"""Loaded MoGe model + ComfyUI memory management."""
def __init__(self, state_dict: dict):
# text encoder dtype closest match
self.load_device = comfy.model_management.text_encoder_device()
offload_device = comfy.model_management.text_encoder_offload_device()
self.dtype = comfy.model_management.text_encoder_dtype(self.load_device)
@@ -287,7 +286,7 @@ class MoGeModel:
) -> Dict[str, torch.Tensor]:
"""Run a single MoGe forward + post-process pass. image is (B, 3, H, W) in [0, 1]."""
comfy.model_management.load_model_gpu(self.patcher)
image = image.to(device=self.load_device, dtype=self.dtype)
image = image.to(device=self.load_device, dtype=torch.float32)
H, W = image.shape[-2:]
aspect_ratio = W / H
+1 -1
View File
@@ -136,7 +136,7 @@ class DINOv2Encoder(nn.Module):
def forward(self, image: torch.Tensor, token_rows: int, token_cols: int,
return_class_token: bool = False) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
image_14 = F.interpolate(image, (token_rows * 14, token_cols * 14), mode="bilinear", align_corners=False, antialias=True)
image_14 = (image_14 - self.image_mean) / self.image_std
image_14 = (image_14 - comfy.ops.cast_to_input(self.image_mean, image_14, copy=False)) / comfy.ops.cast_to_input(self.image_std, image_14, copy=False)
feats = self.backbone.get_intermediate_layers(image_14, self.intermediate_layers, apply_norm=True)
x = torch.stack([
proj(feat.permute(0, 2, 1).unflatten(2, (token_rows, token_cols)).contiguous())
+1 -1
View File
@@ -31,7 +31,7 @@ class ConvBNAct(nn.Module):
super().__init__()
self.conv = operations.Conv2d(ic, oc, k, s, (k - 1) // 2, groups=groups, bias=False, device=device, dtype=dtype)
self.bn = nn.BatchNorm2d(oc, device=device, dtype=dtype)
self.bn = operations.BatchNorm2d(oc, device=device, dtype=dtype)
self.act = nn.ReLU() if use_act else nn.Identity()
def forward(self, x):
+6 -2
View File
@@ -423,11 +423,15 @@ class SAM3Detector(nn.Module):
points=None, boxes=None):
"""Shared detection: geometry encoding, transformer, scoring, segmentation."""
B = features[0].shape[0]
# Scalp for encoder (use top-level feature), but keep all levels for segmentation head
seg_features = features
# Scalp the segmentation head inputs together with the encoder:
# SAM3 (non-multiplex) has 4 FPN levels but scalp=1 keeps only 3. Passing the
# 4th level (the smallest one) to SegmentationHead makes it replace that level
# with a spatially-wrong crop of encoder_visual, which biases all mask logits
# negative and produces empty masks. SAM3.1 (multiplex, scalp=0) is unaffected.
if self.scalp > 0:
features = features[:-self.scalp]
positions = positions[:-self.scalp]
seg_features = features
enc_feat, enc_pos = features[-1], positions[-1]
_, _, H, W = enc_feat.shape
img_flat = enc_feat.flatten(2).permute(0, 2, 1)
+344
View File
@@ -0,0 +1,344 @@
from typing import Optional
import torch
import torch.nn as nn
from comfy.ops import cast_to_input
from ..utils import euler_to_rotmat, rot6d_to_rotmat, rotmat_to_euler, unitquat_to_rotmat
from .mhr_utils import compact_cont_to_model_params_body, compact_cont_to_model_params_hand, mhr_param_hand_idxs
from ..model.transformer import MLP
class MHRHead(nn.Module):
def __init__(self, input_dim: int, mhr_rig, mlp_depth: int = 1, mlp_channel_div_factor: int = 8, enable_hand_model=False,
device=None, dtype=None, operations=None):
super().__init__()
# Store the shared MHRRig as a non-registered Python attribute
object.__setattr__(self, "mhr", mhr_rig)
self.num_shape_comps = 45
self.num_scale_comps = 28
self.num_hand_comps = 54
self.num_face_comps = 72
self.enable_hand_model = enable_hand_model
self.body_cont_dim = 260
self.npose = (
6 # Global Rotation
+ self.body_cont_dim # then body
+ self.num_shape_comps
+ self.num_scale_comps
+ self.num_hand_comps * 2
+ self.num_face_comps
)
self.proj = MLP(
input_dim=input_dim,
hidden_dim=input_dim // mlp_channel_div_factor,
output_dim=self.npose,
num_layers=mlp_depth,
device=device, dtype=dtype, operations=operations,
)
# MHR Parameters
self.num_hand_scale_comps = self.num_scale_comps - 18
self.num_hand_pose_comps = self.num_hand_comps
# Buffers populated by load_state_dict from the safetensors
def _p(*shape, dtype=torch.float32):
return nn.Parameter(torch.empty(*shape, dtype=dtype, device=device), requires_grad=False)
self.joint_rotation = _p(127, 3, 3)
self.scale_mean = _p(68)
self.scale_comps = _p(28, 68)
self.register_buffer("faces", torch.empty(36874, 3, dtype=torch.int64, device=device))
self._faces_np = None
self.hand_pose_mean = _p(54)
self.hand_pose_comps = _p(54, 54)
self.register_buffer("hand_joint_idxs_left", torch.empty(27, dtype=torch.int64, device=device))
self.register_buffer("hand_joint_idxs_right", torch.empty(27, dtype=torch.int64, device=device))
self.keypoint_mapping = _p(308, 18439 + 127)
# Some special buffers for the hand-version
self.right_wrist_coords = _p(3)
self.root_coords = _p(3)
self.local_to_world_wrist = _p(3, 3)
self.register_buffer("nonhand_param_idxs", torch.empty(145, dtype=torch.int64, device=device))
if not enable_hand_model:
self.register_buffer("face_region_rgb", torch.empty(18439, 3, dtype=torch.float32, device=device))
def canonical_vertices(self):
"""Return the T-pose vertices for the mean shape (scaled to meters).
Runs MHR with zero pose / shape / scale / expression so the returned
mesh is the canonical rest pose fixed per-model
"""
device = self.scale_mean.device
dtype = self.scale_mean.dtype
B = 1
global_trans = torch.zeros(B, 3, device=device, dtype=dtype)
global_rot = torch.zeros(B, 3, device=device, dtype=dtype)
body_pose = torch.zeros(B, 130, device=device, dtype=dtype)
hand_pose = torch.zeros(B, self.num_hand_comps * 2, device=device, dtype=dtype)
scale = torch.zeros(B, self.num_scale_comps, device=device, dtype=dtype)
shape = torch.zeros(B, self.num_shape_comps, device=device, dtype=dtype)
expr = torch.zeros(B, self.num_face_comps, device=device, dtype=dtype)
verts = self.mhr_forward(
global_trans=global_trans,
global_rot=global_rot,
body_pose_params=body_pose,
hand_pose_params=hand_pose,
scale_params=scale,
shape_params=shape,
expr_params=expr,
) # single-tensor shape (1, N_v, 3) in meters
return verts[0]
def faces_np(self):
"""Static topology — cached so the per-layer pose_output doesn't force a D2H sync."""
if self._faces_np is None:
self._faces_np = self.faces.cpu().numpy()
return self._faces_np
def replace_hands_in_pose(self, full_pose_params, hand_pose_params):
assert full_pose_params.shape[1] == 136
# This drops in the hand poses from hand_pose_params (PCA 6D) into full_pose_params.
# Split into left and right hands
left_hand_params, right_hand_params = torch.split(
hand_pose_params,
[self.num_hand_pose_comps, self.num_hand_pose_comps],
dim=1,
)
# Change from cont to model params
left_hand_params_model_params = compact_cont_to_model_params_hand(
cast_to_input(self.hand_pose_mean, left_hand_params, copy=False)
+ torch.einsum("da,ab->db", left_hand_params, cast_to_input(self.hand_pose_comps, left_hand_params, copy=False))
)
right_hand_params_model_params = compact_cont_to_model_params_hand(
cast_to_input(self.hand_pose_mean, right_hand_params, copy=False)
+ torch.einsum("da,ab->db", right_hand_params, cast_to_input(self.hand_pose_comps, right_hand_params, copy=False))
)
# Drop it in
full_pose_params[:, self.hand_joint_idxs_left.to(full_pose_params.device)] = left_hand_params_model_params
full_pose_params[:, self.hand_joint_idxs_right.to(full_pose_params.device)] = right_hand_params_model_params
return full_pose_params # B x 207
def mhr_forward(
self,
global_trans,
global_rot,
body_pose_params,
hand_pose_params,
scale_params,
shape_params,
expr_params=None,
return_keypoints=False,
return_joint_coords=False,
return_model_params=False,
return_joint_rotations=False,
):
# Align everything to the static buffers
dt = self.scale_mean.dtype
global_trans = global_trans.to(dt)
global_rot = global_rot.to(dt)
body_pose_params = body_pose_params.to(dt)
if hand_pose_params is not None:
hand_pose_params = hand_pose_params.to(dt)
scale_params = scale_params.to(dt)
shape_params = shape_params.to(dt)
if expr_params is not None:
expr_params = expr_params.to(dt)
if self.enable_hand_model:
# Transfer wrist-centric predictions to the body.
global_rot_ori = global_rot.clone()
global_trans_ori = global_trans.clone()
global_rot = rotmat_to_euler(
"xyz",
euler_to_rotmat("xyz", global_rot_ori) @ cast_to_input(self.local_to_world_wrist, global_rot_ori, copy=False),
)
right_wrist_coords = cast_to_input(self.right_wrist_coords, global_rot, copy=False)
root_coords = cast_to_input(self.root_coords, global_rot, copy=False)
global_trans = (
-(
euler_to_rotmat("xyz", global_rot)
@ (right_wrist_coords - root_coords)
+ root_coords
)
+ global_trans_ori
)
body_pose_params = body_pose_params[..., :130]
# Convert from scale and shape params to actual scales and vertices
# Add singleton batches in case...
if len(scale_params.shape) == 1:
scale_params = scale_params[None]
if len(shape_params.shape) == 1:
shape_params = shape_params[None]
# Convert scale...
scale_mean = cast_to_input(self.scale_mean, scale_params, copy=False)
scale_comps = cast_to_input(self.scale_comps, scale_params, copy=False)
scales = scale_mean[None, :] + scale_params @ scale_comps
# Now, figure out the pose.
## 10 here is because it's more stable to optimize global translation in meters.
full_pose_params = torch.cat([global_trans * 10, global_rot, body_pose_params], dim=1) # B x 127
## Put in hands
if hand_pose_params is not None:
full_pose_params = self.replace_hands_in_pose(
full_pose_params, hand_pose_params
)
model_params = torch.cat([full_pose_params, scales], dim=1)
if self.enable_hand_model:
# Zero out non-hand parameters
model_params[:, self.nonhand_param_idxs.to(model_params.device)] = 0
curr_skinned_verts, curr_skel_state = self.mhr(
shape_params, model_params, expr_params
)
curr_joint_coords, curr_joint_quats, _ = torch.split(
curr_skel_state, [3, 4, 1], dim=2
)
curr_skinned_verts = curr_skinned_verts / 100
curr_joint_coords = curr_joint_coords / 100
curr_joint_rots = unitquat_to_rotmat(curr_joint_quats)
# Prepare returns
to_return = [curr_skinned_verts]
if return_keypoints:
# Get sapiens 308 keypoints
model_vert_joints = torch.cat(
[curr_skinned_verts, curr_joint_coords], dim=1
) # B x (num_verts + 127) x 3
kp_map = cast_to_input(self.keypoint_mapping, model_vert_joints, copy=False)
model_keypoints_pred = (
(kp_map @ model_vert_joints.permute(1, 0, 2).flatten(1, 2))
.reshape(-1, model_vert_joints.shape[0], 3)
.permute(1, 0, 2)
)
if self.enable_hand_model:
# Zero out everything except for the right hand
model_keypoints_pred[:, :21] = 0
model_keypoints_pred[:, 42:] = 0
to_return = to_return + [model_keypoints_pred]
if return_joint_coords:
to_return = to_return + [curr_joint_coords]
if return_model_params:
to_return = to_return + [model_params]
if return_joint_rotations:
to_return = to_return + [curr_joint_rots]
if isinstance(to_return, list) and len(to_return) == 1:
return to_return[0]
else:
return tuple(to_return)
def forward(self, x: torch.Tensor, init_estimate: Optional[torch.Tensor] = None, intermediate: bool = False):
"""
Args:
x: pose token with shape [B, C], usually C=DECODER.DIM
init_estimate: [B, self.npose]
intermediate: when True, the caller only needs the keypoints/pose
outputs needed by the per-layer keypoint-token update path
vertex output is suppressed so `camera_project` skips the
18439-vertex perspective projection on intermediate decoder
layers. The final layer must call with intermediate=False.
"""
batch_size = x.shape[0]
pred = self.proj(x)
if init_estimate is not None:
pred = pred + init_estimate
# From pred, we want to pull out individual predictions.
## First, get globals
### Global rotation is first 6.
count = 6
global_rot_6d = pred[:, :count]
global_rot_rotmat = rot6d_to_rotmat(global_rot_6d) # B x 3 x 3
global_rot_euler = rotmat_to_euler("ZYX", global_rot_rotmat) # B x 3
global_trans = torch.zeros_like(global_rot_euler)
## Next, get body pose.
### Hold onto raw, continuous version for iterative correction.
pred_pose_cont = pred[:, count : count + self.body_cont_dim]
count += self.body_cont_dim
### Convert to eulers (and trans)
pred_pose_euler = compact_cont_to_model_params_body(pred_pose_cont)
### Zero-out hands
pred_pose_euler[:, mhr_param_hand_idxs] = 0
### Zero-out jaw
pred_pose_euler[:, -3:] = 0
## Get remaining parameters
pred_shape = pred[:, count : count + self.num_shape_comps]
count += self.num_shape_comps
pred_scale = pred[:, count : count + self.num_scale_comps]
count += self.num_scale_comps
pred_hand = pred[:, count : count + self.num_hand_comps * 2]
count += self.num_hand_comps * 2
pred_face = pred[:, count : count + self.num_face_comps] * 0
count += self.num_face_comps
# Run everything through mhr
output = self.mhr_forward(
global_trans=global_trans,
global_rot=global_rot_euler,
body_pose_params=pred_pose_euler,
hand_pose_params=pred_hand,
scale_params=pred_scale,
shape_params=pred_shape,
expr_params=pred_face,
return_keypoints=True,
return_joint_coords=True,
return_model_params=True,
return_joint_rotations=True,
)
# Some existing code to get joints and fix camera system
verts, j3d, jcoords, mhr_model_params, joint_global_rots = output
j3d = j3d[:, :70] # 308 --> 70 keypoints
# Intermediate decoder layers only consume pred_keypoints_3d via the
# keypoint-token update path; suppress verts so camera_project skips
# the 18439-vertex perspective projection.
if intermediate:
verts = None
if verts is not None:
verts[..., [1, 2]] *= -1 # Camera system difference
j3d[..., [1, 2]] *= -1 # Camera system difference
if jcoords is not None:
jcoords[..., [1, 2]] *= -1
# Head-MLP outputs are promoted to fp32 here so the external
# pose_output["mhr"] contract has a stable dtype regardless of what
# the head ran at (fp16/bf16 for speed). MHR-derived outputs are
# already fp32 from MHR's math layers.
output = {
"pred_pose_raw": torch.cat([global_rot_6d, pred_pose_cont], dim=1).float(),
"pred_pose_rotmat": None,
"global_rot": global_rot_euler.float(),
"body_pose": pred_pose_euler.float(),
"shape": pred_shape.float(),
"scale": pred_scale.float(),
"hand": pred_hand.float(),
"face": pred_face.float(),
"pred_keypoints_3d": j3d.reshape(batch_size, -1, 3),
"pred_vertices": verts.reshape(batch_size, -1, 3) if verts is not None else None,
"pred_joint_coords": jcoords.reshape(batch_size, -1, 3) if jcoords is not None else None,
"joint_global_rots": joint_global_rots,
"mhr_model_params": mhr_model_params,
}
return output
+246
View File
@@ -0,0 +1,246 @@
# Adapted from facebookresearch/MHR (Apache 2.0):
# https://github.com/facebookresearch/MHR/blob/main/mhr/mhr.py
# Skinning ops follow facebookincubator/momentum (Apache 2.0) — formulas
# verbatim from the upstream mhr_model.pt
# (pymomentum.{skel_state,quaternion,backend.skel_state_backend}).
# Original Copyright (c) Meta Platforms, Inc. and affiliates.
import torch
import torch.nn as nn
import torch.nn.functional as F
from comfy.ops import cast_to_input
from .mhr_utils import batch6DFromXYZ
_LN2 = 0.6931471824645996
# Half-angle cos/sin are computed on the
# whole (..., 3) at once and concatenated to [cr, cp, cy, sr, sp, sy]; _EQ_I then
# picks the three factors of each term, reproducing:
# x = sr*cp*cy - cr*sp*sy z = cr*cp*sy - sr*sp*cy
# y = cr*sp*cy + sr*cp*sy w = cr*cp*cy + sr*sp*sy
def _euler_xyz_to_quat(angles):
"""(roll, pitch, yaw) -> quaternion (x, y, z, w). Matches pymomentum.quaternion.euler_xyz_to_quaternion."""
half = angles * 0.5
c = torch.cos(half)
s = torch.sin(half)
cr, cp, cy = c.unbind(-1)
sr, sp, sy = s.unbind(-1)
return torch.stack([
sr * cp * cy - cr * sp * sy,
cr * sp * cy + sr * cp * sy,
cr * cp * sy - sr * sp * cy,
cr * cp * cy + sr * sp * sy,
], dim=-1)
# Hamilton product as gather + 3 adds. Each output component is a 4-term sum;
# _QM_P1/_QM_P2 pick the operands and _QM_S the signs, reproducing:
# x = w1*x2 + x1*w2 + y1*z2 - z1*y2
# y = w1*y2 - x1*z2 + y1*w2 + z1*x2
# z = w1*z2 + x1*y2 - y1*x2 + z1*w2
# w = w1*w2 - x1*x2 - y1*y2 - z1*z2
def _quat_multiply(q1, q2):
x1, y1, z1, w1 = q1.unbind(-1)
x2, y2, z2, w2 = q2.unbind(-1)
return torch.stack([
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
], dim=-1)
def _quat_rotate(q, v):
"""Rotate v by unit quaternion q (xyzw). v + 2 * (axis x v * w + axis x (axis x v))."""
axis = q[..., :3]
r = q[..., 3:4]
av = torch.cross(axis, v, dim=-1)
aav = torch.cross(axis, av, dim=-1)
return v + 2.0 * (av * r + aav)
def _skel_multiply(s1, s2):
"""Compose two skel states (..., 8). Returns parent ∘ child.
Mirrors pymomentum.skel_state.multiply: both quaternions are renormalized
before composition. With many FK levels the previously-normalized quats
drift in ULPs; upstream renormalizes defensively, so we do too to stay
bit-close to its outputs.
"""
t1, sc1 = s1[..., :3], s1[..., 7:8]
t2, sc2 = s2[..., :3], s2[..., 7:8]
q1 = F.normalize(s1[..., 3:7], p=2, dim=-1, eps=1e-12)
q2 = F.normalize(s2[..., 3:7], p=2, dim=-1, eps=1e-12)
t_res = t1 + sc1 * _quat_rotate(q1, t2)
q_res = _quat_multiply(q1, q2)
s_res = sc1 * sc2
return torch.cat([t_res, q_res, s_res], dim=-1)
def _skel_transform_points(skel_state, points):
"""Apply skel_state (..., 8) to points (..., 3): t + q * (s * points).
Assumes the quaternion in skel_state is already unit-norm. Callers that
can't guarantee that should normalize first.
"""
t = skel_state[..., :3]
q = skel_state[..., 3:7]
s = skel_state[..., 7:8]
return t + _quat_rotate(q, s * points)
def _global_skel_state_from_local(local, pmi_levels):
"""FK walk in fp64 (matches upstream's use_double_precision=True path).
`pmi_levels` is a precomputed list of (source_idx, target_idx) tensor pairs,
one per BFS level. Avoids per-call torch.split + tolist() sync.
"""
orig_dtype = local.dtype
g = local.to(torch.float64).clone()
for source, target in pmi_levels:
parent = g.index_select(-2, target)
child = g.index_select(-2, source)
g.index_copy_(-2, source, _skel_multiply(parent, child))
return g.to(orig_dtype)
class MHRRig(nn.Module):
"""Plain-PyTorch reimplementation of Meta's MHR rig.
All math runs in fp32 (FK upcast to fp64 internally, matching upstream's
use_double_precision=True backend) regardless of the host model's dtype.
"""
NUM_VERTS = 18439
NUM_JOINTS = 127
NUM_LBS_TRIPLETS = 51337
NUM_IDENTITY = 45
NUM_EXPR = 72
PARAM_TRANSFORM_IN = 249 # = model_parameters(204) + identity_coeffs(45)
PARAM_TRANSFORM_OUT = 889 # = NUM_JOINTS * 7
POSE_CORR_IN = 750 # = (NUM_JOINTS - 2) * 6
POSE_CORR_HIDDEN = 3000
POSE_CORR_SPARSE_NNZ = 53136
def __init__(self, device=None):
super().__init__()
# All buffers are populated by load_state_dict from the `mhr.*` keys
def _p(*shape, dtype=torch.float32):
return nn.Parameter(torch.empty(*shape, dtype=dtype, device=device), requires_grad=False)
def _b(name, *shape, dtype):
self.register_buffer(name, torch.empty(*shape, dtype=dtype, device=device))
self.base_shape = _p(self.NUM_VERTS, 3)
self.identity_basis = _p(self.NUM_IDENTITY, self.NUM_VERTS, 3)
self.expr_basis = _p(self.NUM_EXPR, self.NUM_VERTS, 3)
self.param_transform = _p(self.PARAM_TRANSFORM_OUT, self.PARAM_TRANSFORM_IN)
self.skel_joint_translation_offsets = _p(self.NUM_JOINTS, 3)
self.skel_joint_prerotations = _p(self.NUM_JOINTS, 4)
_b("skel_joint_parents", self.NUM_JOINTS, dtype=torch.int32)
_b("skel_pmi", 2, 266, dtype=torch.int64)
_b("skel_pmi_buffer_sizes", 4, dtype=torch.int64)
self.lbs_inverse_bind_pose = _p(self.NUM_JOINTS, 8)
self.lbs_skin_weights = _p(self.NUM_LBS_TRIPLETS)
_b("lbs_skin_indices", self.NUM_LBS_TRIPLETS, dtype=torch.int32)
_b("lbs_vert_indices", self.NUM_LBS_TRIPLETS, dtype=torch.int64)
_b("pose_corr_sparse_indices", 2, self.POSE_CORR_SPARSE_NNZ, dtype=torch.int64)
self.pose_corr_sparse_weight = _p(self.POSE_CORR_SPARSE_NNZ)
_b("pose_corr_sparse_shape", 2, dtype=torch.int64)
self.pose_corr_weight = _p(self.NUM_VERTS * 3, self.POSE_CORR_HIDDEN)
self.pose_corr_bias = None
self._pmi_sizes = None
self._pose_corr_shape = None
self.register_load_state_dict_post_hook(self._set_pmi_sizes)
def _set_pmi_sizes(self, module, incompatible_keys):
self._pmi_sizes = tuple(self.skel_pmi_buffer_sizes.tolist())
self._pose_corr_shape = tuple(self.pose_corr_sparse_shape.tolist())
def forward(self, identity_coeffs, model_parameters, expr_coeffs, apply_correctives: bool = True):
dtype = self.base_shape.dtype
identity_coeffs = identity_coeffs.to(dtype)
model_parameters = model_parameters.to(dtype)
expr_coeffs = expr_coeffs.to(dtype)
B = identity_coeffs.shape[0]
base_shape = cast_to_input(self.base_shape, identity_coeffs, copy=False)
identity_basis = cast_to_input(self.identity_basis, identity_coeffs, copy=False)
identity_rest = base_shape + torch.einsum("nvd,bn->bvd", identity_basis, identity_coeffs)
cat_in = torch.cat([model_parameters, torch.zeros_like(identity_coeffs)], dim=1)
joint_parameters = torch.einsum("dn,bn->bd", cast_to_input(self.param_transform, cat_in, copy=False), cat_in)
jp = joint_parameters.view(B, self.NUM_JOINTS, 7)
local_t = jp[..., :3] + cast_to_input(self.skel_joint_translation_offsets, jp, copy=False).unsqueeze(0)
local_q = _euler_xyz_to_quat(jp[..., 3:6])
local_q = _quat_multiply(cast_to_input(self.skel_joint_prerotations, local_q, copy=False).unsqueeze(0), local_q)
local_s = torch.exp(jp[..., 6:7] * _LN2)
local_state = torch.cat([local_t, local_q, local_s], dim=-1)
skel_state = _global_skel_state_from_local(local_state, self._pmi_levels(local_state.device))
face_expr = torch.einsum("nvd,bn->bvd", cast_to_input(self.expr_basis, expr_coeffs, copy=False), expr_coeffs)
unposed = identity_rest + face_expr
if apply_correctives:
unposed = unposed + self._pose_correctives(joint_parameters)
verts = self._skin(skel_state, unposed)
return verts, skel_state
def _pose_correctives(self, joint_parameters):
B = joint_parameters.shape[0]
jp = joint_parameters.view(B, self.NUM_JOINTS, 7)
# Joints [2:] only — root and one more skipped. Take Euler XYZ (cols 3:6).
feat = batch6DFromXYZ(jp[:, 2:, 3:6], return_9D=False) # (B, 125, 6)
feat[..., 0] -= 1.0
feat[..., 4] -= 1.0
feat = feat.flatten(1, 2) # (B, 750)
h = (self._sparse_w(feat) @ feat.T).T # (B, 3000)
h = F.relu(h)
out = F.linear(h, cast_to_input(self.pose_corr_weight, h, copy=False), self.pose_corr_bias) # (B, 55317)
return out.view(B, self.NUM_VERTS, 3)
def _pmi_levels(self, device):
if self._pmi_sizes is None:
raise RuntimeError("MHR rig weights have not been loaded")
pmi = self.skel_pmi.to(device=device)
return [(part[0], part[1]) for part in torch.split(pmi, self._pmi_sizes, dim=1)]
def _sparse_w(self, ref):
if self._pose_corr_shape is None:
raise RuntimeError("MHR rig weights have not been loaded")
w = cast_to_input(self.pose_corr_sparse_weight, ref, copy=False)
# PyTorch 2.12 warns unless invariant checking is explicitly scoped.
with torch.sparse.check_sparse_tensor_invariants():
return torch.sparse_coo_tensor(
self.pose_corr_sparse_indices.to(device=ref.device),
w,
self._pose_corr_shape,
).coalesce()
def _skin(self, skel_state, rest_verts):
B = skel_state.shape[0]
ibp = cast_to_input(self.lbs_inverse_bind_pose, skel_state, copy=False).unsqueeze(0).expand(B, self.NUM_JOINTS, 8)
joint_xform = _skel_multiply(skel_state, ibp)
norm_q = F.normalize(joint_xform[..., 3:7], p=2, dim=-1, eps=1e-12)
joint_xform = torch.cat([joint_xform[..., :3], norm_q, joint_xform[..., 7:8]], dim=-1)
sk_idx = self.lbs_skin_indices.to(device=rest_verts.device, dtype=torch.long)
v_idx = self.lbs_vert_indices.to(device=rest_verts.device)
w = cast_to_input(self.lbs_skin_weights, rest_verts, copy=False)
per_triplet_xform = joint_xform.index_select(-2, sk_idx) # (B, 51337, 8)
per_triplet_rest = rest_verts.index_select(-2, v_idx) # (B, 51337, 3)
contrib = _skel_transform_points(per_triplet_xform, per_triplet_rest) * w.unsqueeze(0).unsqueeze(-1)
out = torch.zeros(B, self.NUM_VERTS, 3, dtype=rest_verts.dtype, device=rest_verts.device)
out.index_add_(-2, v_idx, contrib)
return out
+215
View File
@@ -0,0 +1,215 @@
# MHR (Meta Human Rig) parameter packing/unpacking. The 6D-rotation helpers
# (batch6DFromXYZ, batchXYZfrom6D) are the continuity
# representation from Zhou et al., "On the Continuity of Rotation
# Representations in Neural Networks" (CVPR 2019, https://arxiv.org/abs/1812.07035),
# implementations from papagina/RotationContinuity:
# https://github.com/papagina/RotationContinuity/blob/758b0ce5/shapenet/code/tools.py
# The compact_cont_to_model_params_* functions are MHR-rig-specific glue.
import torch
import torch.nn.functional as F
def rotation_angle_difference(A: torch.Tensor, B: torch.Tensor) -> torch.Tensor:
"""
Compute the angle difference (magnitude) between two batches of SO(3) rotation matrices.
Args:
A: Tensor of shape (*, 3, 3), batch of rotation matrices.
B: Tensor of shape (*, 3, 3), batch of rotation matrices.
Returns:
Tensor of shape (*,), angle differences in radians.
"""
# Compute relative rotation matrix
R_rel = torch.matmul(A, B.transpose(-2, -1)) # (B, 3, 3)
# Compute trace of relative rotation
trace = R_rel[..., 0, 0] + R_rel[..., 1, 1] + R_rel[..., 2, 2] # (B,)
# Compute angle using the trace formula
cos_theta = (trace - 1) / 2
# Clamp for numerical stability
cos_theta_clamped = torch.clamp(cos_theta, -1.0, 1.0)
# Compute angle difference
angle = torch.acos(cos_theta_clamped)
return angle
def fix_wrist_euler(
wrist_xzy, limits_x=(-2.2, 1.0), limits_z=(-2.2, 1.5), limits_y=(-1.2, 1.5)
):
"""
wrist_xzy: B x 2 x 3 (X, Z, Y angles)
Returns: Fixed angles within joint limits
"""
x, z, y = wrist_xzy[..., 0], wrist_xzy[..., 1], wrist_xzy[..., 2]
x_alt = torch.atan2(torch.sin(x + torch.pi), torch.cos(x + torch.pi))
z_alt = torch.atan2(torch.sin(-(z + torch.pi)), torch.cos(-(z + torch.pi)))
y_alt = torch.atan2(torch.sin(y + torch.pi), torch.cos(y + torch.pi))
# Calculate L2 violation distance
def calc_violation(val, limits):
below = torch.clamp(limits[0] - val, min=0.0)
above = torch.clamp(val - limits[1], min=0.0)
return below**2 + above**2
violation_orig = (
calc_violation(x, limits_x)
+ calc_violation(z, limits_z)
+ calc_violation(y, limits_y)
)
violation_alt = (
calc_violation(x_alt, limits_x)
+ calc_violation(z_alt, limits_z)
+ calc_violation(y_alt, limits_y)
)
# Use alternative where it has lower L2 violation
use_alt = violation_alt < violation_orig
# Stack alternative and apply mask
wrist_xzy_alt = torch.stack([x_alt, z_alt, y_alt], dim=-1)
result = torch.where(use_alt.unsqueeze(-1), wrist_xzy_alt, wrist_xzy)
return result
# https://github.com/papagina/RotationContinuity/blob/758b0ce551c06372cab7022d4c0bdf331c89c696/shapenet/code/tools.py
def batch6DFromXYZ(r, return_9D=False):
"""
Generate a matrix representing a rotation defined by a XYZ-Euler
rotation.
Args:
r: ... x 3 rotation vectors
Returns:
... x 6
"""
rc = torch.cos(r)
rs = torch.sin(r)
cx = rc[..., 0]
cy = rc[..., 1]
cz = rc[..., 2]
sx = rs[..., 0]
sy = rs[..., 1]
sz = rs[..., 2]
result = torch.empty(list(r.shape[:-1]) + [3, 3], dtype=r.dtype, device=r.device)
result[..., 0, 0] = cy * cz
result[..., 0, 1] = -cx * sz + sx * sy * cz
result[..., 0, 2] = sx * sz + cx * sy * cz
result[..., 1, 0] = cy * sz
result[..., 1, 1] = cx * cz + sx * sy * sz
result[..., 1, 2] = -sx * cz + cx * sy * sz
result[..., 2, 0] = -sy
result[..., 2, 1] = sx * cy
result[..., 2, 2] = cx * cy
if not return_9D:
return torch.cat([result[..., :, 0], result[..., :, 1]], dim=-1)
else:
return result
# https://github.com/papagina/RotationContinuity/blob/758b0ce551c06372cab7022d4c0bdf331c89c696/shapenet/code/tools.py#L82
def batchXYZfrom6D(poses):
# Args: poses: ... x 6, where "6" is the combined first and second columns
# First, get the rotaiton matrix
x_raw = poses[..., :3]
y_raw = poses[..., 3:]
x = F.normalize(x_raw, dim=-1)
z = torch.cross(x, y_raw, dim=-1)
z = F.normalize(z, dim=-1)
y = torch.cross(z, x, dim=-1)
matrix = torch.stack([x, y, z], dim=-1) # ... x 3 x 3
# Now get it into euler
# https://github.com/papagina/RotationContinuity/blob/758b0ce551c06372cab7022d4c0bdf331c89c696/shapenet/code/tools.py#L412
sy = torch.sqrt(
matrix[..., 0, 0] * matrix[..., 0, 0] + matrix[..., 1, 0] * matrix[..., 1, 0]
)
singular = sy < 1e-6
singular = singular.float()
x = torch.atan2(matrix[..., 2, 1], matrix[..., 2, 2])
y = torch.atan2(-matrix[..., 2, 0], sy)
z = torch.atan2(matrix[..., 1, 0], matrix[..., 0, 0])
xs = torch.atan2(-matrix[..., 1, 2], matrix[..., 1, 1])
ys = torch.atan2(-matrix[..., 2, 0], sy)
zs = matrix[..., 1, 0] * 0
out_euler = torch.zeros_like(matrix[..., 0])
out_euler[..., 0] = x * (1 - singular) + xs * singular
out_euler[..., 1] = y * (1 - singular) + ys * singular
out_euler[..., 2] = z * (1 - singular) + zs * singular
return out_euler
_HAND_DOFS = (3, 1, 1, 3, 1, 1, 3, 1, 1, 3, 1, 1, 2, 3, 1, 1)
_HAND_CONT_3DOF_MASK = [k == 3 for k in _HAND_DOFS for _ in range(2 * k)]
_HAND_CONT_1DOF_MASK = [k in (1, 2) for k in _HAND_DOFS for _ in range(2 * k)]
_HAND_MODEL_3DOF_MASK = [k == 3 for k in _HAND_DOFS for _ in range(k)]
_HAND_MODEL_1DOF_MASK = [k in (1, 2) for k in _HAND_DOFS for _ in range(k)]
def compact_cont_to_model_params_hand(hand_cont):
# These are ordered by joint, not model params ^^
# Convert hand_cont to eulers
## First for 3DoFs
hand_cont_threedofs = hand_cont[..., _HAND_CONT_3DOF_MASK].unflatten(-1, (-1, 6))
hand_model_params_threedofs = batchXYZfrom6D(hand_cont_threedofs).flatten(-2, -1)
## Next for 1DoFs
hand_cont_onedofs = hand_cont[..., _HAND_CONT_1DOF_MASK].unflatten(
-1, (-1, 2)
) # (sincos)
hand_model_params_onedofs = torch.atan2(
hand_cont_onedofs[..., -2], hand_cont_onedofs[..., -1]
)
# Finally, assemble into a 27-dim vector, ordered by joint, then XYZ.
hand_model_params = torch.zeros(*hand_cont.shape[:-1], 27, dtype=hand_cont.dtype, device=hand_cont.device)
hand_model_params[..., _HAND_MODEL_3DOF_MASK] = hand_model_params_threedofs
hand_model_params[..., _HAND_MODEL_1DOF_MASK] = hand_model_params_onedofs
return hand_model_params
# fmt: off
_BODY_3DOF_IDXS = ((0, 2, 4), (6, 8, 10), (12, 13, 14), (15, 16, 17), (18, 19, 20), (21, 22, 23), (24, 25, 26), (27, 28, 29), (34, 35, 36), (37, 38, 39), (44, 45, 46), (53, 54, 55), (64, 65, 66), (85, 69, 73), (86, 70, 79), (87, 71, 82), (88, 72, 76), (91, 92, 93), (112, 96, 100), (113, 97, 106), (114, 98, 109), (115, 99, 103), (130, 131, 132))
_BODY_1DOF_ROT_IDXS = (1, 3, 5, 7, 9, 11, 30, 31, 32, 33, 40, 41, 42, 43, 47, 48, 49, 50, 51, 52, 56, 57, 58, 59, 60, 61, 62, 63, 67, 68, 74, 75, 77, 78, 80, 81, 83, 84, 89, 90, 94, 95, 101, 102, 104, 105, 107, 108, 110, 111, 116, 117, 118, 119, 120, 121, 122, 123)
_BODY_1DOF_TRANS_IDXS = (124, 125, 126, 127, 128, 129)
_BODY_3DOF_FLAT_IDXS = tuple(i for group in _BODY_3DOF_IDXS for i in group)
# fmt: on
def compact_cont_to_model_params_body(body_pose_cont):
num_3dof_angles = len(_BODY_3DOF_IDXS) * 3
num_1dof_angles = len(_BODY_1DOF_ROT_IDXS)
# Get subsets
body_cont_3dofs = body_pose_cont[..., : 2 * num_3dof_angles]
body_cont_1dofs = body_pose_cont[..., 2 * num_3dof_angles : 2 * num_3dof_angles + 2 * num_1dof_angles]
body_cont_trans = body_pose_cont[..., 2 * num_3dof_angles + 2 * num_1dof_angles :]
# Convert conts to model params
## First for 3dofs
body_cont_3dofs = body_cont_3dofs.unflatten(-1, (-1, 6))
body_params_3dofs = batchXYZfrom6D(body_cont_3dofs).flatten(-2, -1)
## Next for 1dofs
body_cont_1dofs = body_cont_1dofs.unflatten(-1, (-1, 2)) # (sincos)
body_params_1dofs = torch.atan2(body_cont_1dofs[..., -2], body_cont_1dofs[..., -1])
## Nothing to do for trans
body_params_trans = body_cont_trans
# Put them together
body_pose_params = torch.zeros(*body_pose_cont.shape[:-1], 133, dtype=body_pose_cont.dtype, device=body_pose_cont.device)
body_pose_params[..., list(_BODY_3DOF_FLAT_IDXS)] = body_params_3dofs
body_pose_params[..., list(_BODY_1DOF_ROT_IDXS)] = body_params_1dofs
body_pose_params[..., list(_BODY_1DOF_TRANS_IDXS)] = body_params_trans
return body_pose_params
# Hand indices into the 133-dim body-pose vector.
mhr_param_hand_idxs = list(range(62, 116))
@@ -0,0 +1,139 @@
import math
import torch
import torch.nn.functional as F
from comfy.ldm.cascade.common import LayerNorm2d_op
from torch import nn
from typing import List, Optional, Tuple, Union
from ..utils import perspective_projection
from .transformer import MLP
class CameraEncoder(nn.Module):
def __init__(self, embed_dim: int, patch_size: int = 14, device=None, dtype=None, operations=None):
super().__init__()
self.patch_size = patch_size
self.embed_dim = embed_dim
self.camera = FourierPositionEncoding(n=3, num_bands=16, max_resolution=64)
self.conv = operations.Conv2d(embed_dim + 99, embed_dim, kernel_size=1, bias=False, device=device, dtype=dtype)
self.norm = LayerNorm2d_op(operations)(embed_dim, device=device, dtype=dtype)
def forward(self, img_embeddings: torch.Tensor, rays: torch.Tensor):
B, D, _h, _w = img_embeddings.shape
scale = 1 / self.patch_size
rays = F.interpolate(rays, scale_factor=(scale, scale), mode="bilinear", align_corners=False, antialias=True)
rays = rays.permute(0, 2, 3, 1).contiguous() # [b, h, w, 2]
rays = torch.cat([rays, torch.ones_like(rays[..., :1])], dim=-1)
rays_embeddings = self.camera(pos=rays.reshape(B, -1, 3)) # (bs, N, 99): rays fourier embedding
rays_embeddings = rays_embeddings.reshape(B, _h, _w, -1).permute(0, 3, 1, 2).contiguous()
z = torch.cat([img_embeddings, rays_embeddings], dim=1)
return self.norm(self.conv(z))
class FourierPositionEncoding(nn.Module):
"""Sin/cos Fourier features for ray positions"""
def __init__(self, n: int, num_bands: int, max_resolution: int):
super().__init__()
self.num_bands = num_bands
self.max_resolution = [max_resolution] * n
def forward(self, pos: torch.Tensor):
fourier_pos_enc = _generate_fourier_features(pos, num_bands=self.num_bands, max_resolution=self.max_resolution)
return fourier_pos_enc
def _generate_fourier_features(pos: torch.Tensor, num_bands: int, max_resolution: List[int], min_freq: float = 1.0):
b, n = pos.shape[:2]
freq_bands = torch.stack([torch.linspace(start=min_freq, end=res / 2, steps=num_bands, device=pos.device, dtype=pos.dtype) for res in max_resolution], dim=0)
per_pos_features = pos.unsqueeze(-1) * freq_bands.unsqueeze(0).unsqueeze(0)
per_pos_features = per_pos_features.reshape(b, n, -1)
# Sin-Cos
per_pos_features = torch.cat([torch.sin(math.pi * per_pos_features), torch.cos(math.pi * per_pos_features)], dim=-1)
# Concat with initial pos
per_pos_features = torch.cat([pos, per_pos_features], dim=-1)
return per_pos_features
class PerspectiveHead(nn.Module):
"""
Predict camera translation (s, tx, ty) and perform full-perspective 2D reprojection (CLIFF/CameraHMR setup).
"""
def __init__(self, input_dim: int, img_size: Union[int, Tuple[int, int]], # model input size (W, H)
mlp_depth: int = 1, mlp_channel_div_factor: int = 8, default_scale_factor: float = 1.0,
device=None, dtype=None, operations=None
):
super().__init__()
# Metadata to compute 3D skeleton and 2D reprojection
self.img_size = img_size if isinstance(img_size, tuple) else (img_size, img_size)
self.ncam = 3 # (s, tx, ty)
self.default_scale_factor = default_scale_factor
self.proj = MLP(
input_dim=input_dim,
hidden_dim=input_dim // mlp_channel_div_factor,
output_dim=self.ncam,
num_layers=mlp_depth,
device=device,
dtype=dtype,
operations=operations,
)
def forward(self, x: torch.Tensor, init_estimate: Optional[torch.Tensor] = None):
"""
Args:
x: pose token with shape [B, C], usually C=DECODER.DIM
init_estimate: [B, self.ncam]
"""
pred_cam = self.proj(x)
if init_estimate is not None:
pred_cam = pred_cam + init_estimate
return pred_cam
def perspective_projection(
self,
points_3d: torch.Tensor,
pred_cam: torch.Tensor,
bbox_center: torch.Tensor, # [N, 2], in original image space (w, h)
bbox_size: torch.Tensor, # [N,], in original image space
cam_int: torch.Tensor, # [B, 3, 3]
):
batch_size = points_3d.shape[0]
pred_cam = pred_cam.clone()
pred_cam[..., [0, 2]] *= -1 # Camera system difference
# Compute camera translation: (scale, x, y) --> (x, y, depth)
# depth ~= f / s, Note that f is in the NDC space
s, tx, ty = pred_cam[:, 0], pred_cam[:, 1], pred_cam[:, 2]
bs = bbox_size * s * self.default_scale_factor + 1e-8
focal_length = cam_int[:, 0, 0]
tz = 2 * focal_length / bs
cx = 2 * (bbox_center[:, 0] - cam_int[:, 0, 2]) / bs
cy = 2 * (bbox_center[:, 1] - cam_int[:, 1, 2]) / bs
pred_cam_t = torch.stack([tx + cx, ty + cy, tz], dim=-1)
# Compute camera translation
j3d_cam = points_3d + pred_cam_t.unsqueeze(1)
# Projection to the image plane, note that the projection output is in original image space now.
j2d = perspective_projection(j3d_cam, cam_int)
return {
"pred_keypoints_2d": j2d.reshape(batch_size, -1, 2),
"pred_keypoints_2d_depth": j3d_cam.reshape(batch_size, -1, 3)[:, :, 2],
"pred_cam_t": pred_cam_t, "focal_length": focal_length,
}
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
"""SAM 3D Body prompt pipeline: encode (keypoint, mask) prompts and run them
through a cross-attention transformer decoder over (token, image) pairs.
Both adapted from the SAM-style prompt path (Meta, Apache 2.0):
https://github.com/facebookresearch/segment-anything
"""
from typing import Optional, Tuple
import torch
import torch.nn as nn
from comfy.ldm.cascade.common import LayerNorm2d_op
from comfy.ops import cast_to_input
from comfy.ldm.sam3.sam import PositionEmbeddingRandom
from .transformer import TransformerDecoderLayer
class PromptEncoder(nn.Module):
def __init__(
self,
embed_dim: int,
num_body_joints: int,
device=None,
dtype=None,
operations=None,
) -> None:
"""
Encodes prompts for input to SAM's mask decoder.
"""
super().__init__()
self.embed_dim = embed_dim
self.num_body_joints = num_body_joints
# Keypoint prompts
self.pe_layer = PositionEmbeddingRandom(embed_dim // 2)
self.point_embeddings = nn.ModuleList(
[operations.Embedding(1, embed_dim, device=device, dtype=dtype) for _ in range(self.num_body_joints)]
)
self.not_a_point_embed = operations.Embedding(1, embed_dim, device=device, dtype=dtype)
self.invalid_point_embed = operations.Embedding(1, embed_dim, device=device, dtype=dtype)
# Mask prompt: 5-stage 2x2 strided conv downscaling to embed_dim.
LN2d = LayerNorm2d_op(operations)
mask_in_chans = 256
self.mask_downscaling = nn.Sequential(
operations.Conv2d(1, mask_in_chans // 64, kernel_size=2, stride=2, device=device, dtype=dtype),
LN2d(mask_in_chans // 64, device=device, dtype=dtype),
nn.GELU(),
operations.Conv2d(mask_in_chans // 64, mask_in_chans // 16, kernel_size=2, stride=2, device=device, dtype=dtype),
LN2d(mask_in_chans // 16, device=device, dtype=dtype),
nn.GELU(),
operations.Conv2d(mask_in_chans // 16, mask_in_chans // 4, kernel_size=2, stride=2, device=device, dtype=dtype),
LN2d(mask_in_chans // 4, device=device, dtype=dtype),
nn.GELU(),
operations.Conv2d(mask_in_chans // 4, mask_in_chans, kernel_size=2, stride=2, device=device, dtype=dtype),
LN2d(mask_in_chans, device=device, dtype=dtype),
nn.GELU(),
operations.Conv2d(mask_in_chans, embed_dim, kernel_size=1, device=device, dtype=dtype),
)
# Trained values for the gating conv and no_mask_embed are loaded from the state dict
self.no_mask_embed = operations.Embedding(1, embed_dim, device=device, dtype=dtype)
def get_dense_pe(self, size: Tuple[int, int]) -> torch.Tensor:
"""Positional encoding over the image-embedding grid; (1, C, H, W)."""
return self.pe_layer(size)
def _embed_keypoints(self, points: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
"""
Embeds point prompts.
Assuming points have been normalized to [0, 1].
Output shape [B, N, C], mask shape [B, N]
"""
assert points.min() >= 0 and points.max() <= 1
# PE compute in fp32 for precision (sin/cos of large coords), then cast back to the embedding weight dtype
weight_dtype = self.invalid_point_embed.weight.dtype
point_embedding = self.pe_layer._encode(points.to(torch.float)).to(weight_dtype)
# One gather over the stacked joint table.
joint_w = cast_to_input(torch.cat([e.weight for e in self.point_embeddings], dim=0), point_embedding, copy=False)
idx = labels.long().clamp(0, self.num_body_joints - 1)
is_joint = ((labels >= 0) & (labels < self.num_body_joints)).unsqueeze(-1)
point_embedding = point_embedding + joint_w[idx] * is_joint.to(point_embedding.dtype)
# -2/-1 zero the PE first, so the embedding replaces it outright.
invalid_w = cast_to_input(self.invalid_point_embed.weight, point_embedding, copy=False)
not_a_point_w = cast_to_input(self.not_a_point_embed.weight, point_embedding, copy=False)
point_embedding = torch.where((labels == -2).unsqueeze(-1), invalid_w, point_embedding)
point_embedding = torch.where((labels == -1).unsqueeze(-1), not_a_point_w, point_embedding)
point_mask = labels > -2
return point_embedding, point_mask
def _get_batch_size(self, keypoints: Optional[torch.Tensor], boxes: Optional[torch.Tensor], masks: Optional[torch.Tensor]) -> int:
if keypoints is not None:
return keypoints.shape[0]
elif boxes is not None:
return boxes.shape[0]
elif masks is not None:
return masks.shape[0]
else:
return 1
def forward(
self,
keypoints: Optional[torch.Tensor],
boxes: Optional[torch.Tensor] = None,
masks: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Embeds different types of prompts, returning both sparse and dense
embeddings.
Arguments:
keypoints (torchTensor or none): point coordinates and labels to embed.
boxes (torch.Tensor or none): boxes to embed
masks (torch.Tensor or none): masks to embed
Returns:
torch.Tensor: sparse embeddings for the points and boxes, with shape
BxNx(embed_dim), where N is determined by the number of input points
and boxes.
torch.Tensor: dense embeddings for the masks, in the shape
Bx(embed_dim)x(embed_H)x(embed_W)
"""
bs = self._get_batch_size(keypoints, boxes, masks)
ref = keypoints if keypoints is not None else boxes if boxes is not None else masks
device = ref.device if ref is not None else self.point_embeddings[0].weight.device
weight_dtype = self.invalid_point_embed.weight.dtype
sparse_embeddings = torch.empty((bs, 0, self.embed_dim), device=device, dtype=weight_dtype)
sparse_masks = torch.empty((bs, 0), device=device)
if keypoints is not None:
coords = keypoints[:, :, :2]
labels = keypoints[:, :, -1]
point_embeddings, point_mask = self._embed_keypoints(coords, labels)
sparse_embeddings = torch.cat([sparse_embeddings, point_embeddings], dim=1)
sparse_masks = torch.cat([sparse_masks, point_mask], dim=1)
return sparse_embeddings, sparse_masks
def get_mask_embeddings(self, masks: torch.Tensor, bs: int = 1, size: Tuple[int, int] = (16, 16)) -> torch.Tensor:
"""Embeds mask inputs. Caller casts both outputs to its working dtype."""
no_mask_embeddings = cast_to_input(self.no_mask_embed.weight, masks).reshape(1, -1, 1, 1).expand(bs, -1, size[0], size[1])
mask_embeddings = self.mask_downscaling(masks)
return mask_embeddings, no_mask_embeddings
class PromptableDecoder(nn.Module):
"""Cross-attention transformer decoder over (token, image) pairs."""
def __init__(
self,
dims: int,
context_dims: int,
depth: int,
num_heads: int = 8,
head_dims: int = 64,
mlp_dims: int = 1024,
repeat_pe: bool = False,
do_interm_preds: bool = False,
keypoint_token_update: bool = False,
device=None, dtype=None, operations=None,
):
super().__init__()
self.layers = nn.ModuleList(
TransformerDecoderLayer(
token_dims=dims,
context_dims=context_dims,
num_heads=num_heads,
head_dims=head_dims,
mlp_dims=mlp_dims,
repeat_pe=repeat_pe,
skip_first_pe=(i == 0),
device=device,
dtype=dtype,
operations=operations,
)
for i in range(depth)
)
self.norm_final = operations.LayerNorm(dims, eps=1e-6, device=device, dtype=dtype)
self.do_interm_preds = do_interm_preds
self.keypoint_token_update = keypoint_token_update
def forward(
self,
token_embedding: torch.Tensor,
image_embedding: torch.Tensor,
token_augment: Optional[torch.Tensor] = None,
image_augment: Optional[torch.Tensor] = None,
token_mask: Optional[torch.Tensor] = None,
token_to_pose_output_fn=None,
keypoint_token_update_fn=None,
hand_embeddings=None,
hand_augment=None,
):
"""
Args:
token_embedding: [B, N, C]
image_embedding: [B, C, H, W] -- flattened to [B, HW, C] inline
"""
# Channels-last for the transformer.
image_embedding = image_embedding.flatten(2).permute(0, 2, 1)
if image_augment is not None:
image_augment = image_augment.flatten(2).permute(0, 2, 1)
if hand_embeddings is not None:
hand_embeddings = hand_embeddings.flatten(2).permute(0, 2, 1)
hand_augment = hand_augment.flatten(2).permute(0, 2, 1)
if len(hand_augment) == 1:
# inflate batch dimension
assert len(hand_augment.shape) == 3
hand_augment = hand_augment.repeat(len(hand_embeddings), 1, 1)
all_pose_outputs = [] if self.do_interm_preds else None
if self.do_interm_preds:
assert token_to_pose_output_fn is not None
layer_idx = 0
for layer_idx, layer in enumerate(self.layers):
if hand_embeddings is None:
token_embedding, image_embedding = layer(
token_embedding, image_embedding,
token_augment, image_augment, token_mask,
)
else:
token_embedding, image_embedding = layer(
token_embedding,
torch.cat([image_embedding, hand_embeddings], dim=1),
token_augment,
torch.cat([image_augment, hand_augment], dim=1),
token_mask,
)
image_embedding = image_embedding[:, : image_augment.shape[1]]
if self.do_interm_preds and layer_idx < len(self.layers) - 1:
curr = token_to_pose_output_fn(
self.norm_final(token_embedding),
prev_pose_output=all_pose_outputs[-1] if all_pose_outputs else None,
layer_idx=layer_idx,
)
all_pose_outputs.append(curr)
if self.keypoint_token_update:
assert keypoint_token_update_fn is not None
token_embedding, token_augment, _, _ = keypoint_token_update_fn(
token_embedding, token_augment, curr, layer_idx,
)
out = self.norm_final(token_embedding)
if self.do_interm_preds:
curr = token_to_pose_output_fn(
out,
prev_pose_output=all_pose_outputs[-1] if all_pose_outputs else None,
layer_idx=layer_idx,
)
all_pose_outputs.append(curr)
return out, all_pose_outputs
return out
+104
View File
@@ -0,0 +1,104 @@
from typing import Optional
import torch
import torch.nn as nn
from comfy.ldm.modules.attention import optimized_attention
class MLP(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim, num_layers, act_layer=nn.ReLU, device=None, dtype=None, operations=None):
super().__init__()
dims = [input_dim] + [hidden_dim] * (num_layers - 1) + [output_dim]
self.layers = nn.ModuleList(
operations.Linear(dims[i], dims[i + 1], device=device, dtype=dtype)
for i in range(num_layers)
)
self.act = act_layer()
def forward(self, x):
for i, layer in enumerate(self.layers):
x = self.act(layer(x)) if i < len(self.layers) - 1 else layer(x)
return x
class Attention(nn.Module):
def __init__(self, embed_dims, num_heads, query_dims=None, key_dims=None, value_dims=None, qkv_bias=True, proj_bias=True,
device=None, dtype=None, operations=None):
super().__init__()
self.query_dims = query_dims or embed_dims
self.key_dims = key_dims or embed_dims
self.value_dims = value_dims or embed_dims
self.embed_dims = embed_dims
self.num_heads = num_heads
self.head_dims = embed_dims // num_heads
lin = lambda i, o, b: operations.Linear(i, o, bias=b, device=device, dtype=dtype)
self.q_proj = lin(self.query_dims, embed_dims, qkv_bias)
self.k_proj = lin(self.key_dims, embed_dims, qkv_bias)
self.v_proj = lin(self.value_dims, embed_dims, qkv_bias)
self.proj = lin(embed_dims, self.query_dims, proj_bias)
def _split(self, x: torch.Tensor) -> torch.Tensor:
b, n, _ = x.shape
return x.reshape(b, n, self.num_heads, self.head_dims).transpose(1, 2)
def forward(self, q, k, v, attn_mask: Optional[torch.Tensor] = None):
q, k, v = self._split(self.q_proj(q)), self._split(self.k_proj(k)), self._split(self.v_proj(v))
x = optimized_attention(q, k, v, self.num_heads, mask=attn_mask, skip_reshape=True, low_precision_attention=False)
return self.proj(x)
class TransformerDecoderLayer(nn.Module):
def __init__(self, token_dims, context_dims, num_heads=8, head_dims=64, mlp_dims=1024,
repeat_pe=False, skip_first_pe=False, device=None, dtype=None, operations=None):
super().__init__()
self.repeat_pe = repeat_pe
self.skip_first_pe = skip_first_pe
ln = lambda d: operations.LayerNorm(d, eps=1e-6, device=device, dtype=dtype)
attn_dim = num_heads * head_dims
attn_kwargs = dict(embed_dims=attn_dim, num_heads=num_heads, device=device, dtype=dtype, operations=operations)
if repeat_pe:
self.ln_pe_1, self.ln_pe_2 = ln(token_dims), ln(context_dims)
self.ln1 = ln(token_dims)
self.self_attn = Attention(query_dims=token_dims, key_dims=token_dims, value_dims=token_dims, **attn_kwargs)
self.ln2_1, self.ln2_2 = ln(token_dims), ln(context_dims)
self.cross_attn = Attention(query_dims=token_dims, key_dims=context_dims, value_dims=context_dims, **attn_kwargs)
self.ln3 = ln(token_dims)
self.ffn = MLP(token_dims, mlp_dims, token_dims, num_layers=2, act_layer=nn.GELU, device=device, dtype=dtype, operations=operations)
def forward(self, x, context, x_pe=None, context_pe=None, x_mask=None):
"""x: [B, N_tokens, C], context: [B, N_ctx, C], x_mask: [B, N_tokens] or None."""
# LaPE-style PE re-norm per layer.
if self.repeat_pe and context_pe is not None:
x_pe = self.ln_pe_1(x_pe)
context_pe = self.ln_pe_2(context_pe)
# Self-attn over tokens.
if self.repeat_pe and not self.skip_first_pe and x_pe is not None:
q = k = self.ln1(x) + x_pe
v = self.ln1(x)
else:
q = k = v = self.ln1(x)
attn_mask = None
if x_mask is not None:
attn_mask = x_mask[:, :, None] @ x_mask[:, None, :]
attn_mask.diagonal(dim1=1, dim2=2).fill_(1) # avoid all-invalid rows -> nan
attn_mask = attn_mask > 0
x = x + self.self_attn(q, k, v, attn_mask=attn_mask)
# Cross-attn: tokens attend to image context.
if self.repeat_pe and context_pe is not None:
q = self.ln2_1(x) + x_pe
k = self.ln2_2(context) + context_pe
v = self.ln2_2(context)
else:
q = self.ln2_1(x)
k = v = self.ln2_2(context)
x = x + self.cross_attn(q, k, v)
x = x + self.ffn(self.ln3(x))
return x, context
+339
View File
@@ -0,0 +1,339 @@
# The bbox/affine math (xyxy<->cs, get_warp_matrices) is the standard
# top-down pose-estimation crop pipeline from MMPose (Apache 2.0):
# https://github.com/open-mmlab/mmpose — same algorithm as UDP (CVPR 2020).
from typing import Dict, Tuple
import torch
import torch.nn.functional as F
# Bbox + affine math
# All `output_size` / image-shape tuples in this block are (H, W) to match
# the torch.Size convention used everywhere else in the codebase.
def bbox_xyxy2cs(bbox, padding: float) -> Tuple[torch.Tensor, torch.Tensor]:
"""xyxy bbox -> (center, scale) with optional padding multiplier."""
bbox = torch.as_tensor(bbox, dtype=torch.float32)
dim = bbox.dim()
if dim == 1:
bbox = bbox.unsqueeze(0)
x1, y1, x2, y2 = bbox[:, 0:1], bbox[:, 1:2], bbox[:, 2:3], bbox[:, 3:4]
center = torch.cat([x1 + x2, y1 + y2], dim=1) * 0.5
scale = torch.cat([x2 - x1, y2 - y1], dim=1) * padding
if dim == 1:
return center[0], scale[0]
return center, scale
def fix_aspect_ratio(bbox_scale, aspect_ratio: float) -> torch.Tensor:
"""Pad whichever side is too narrow to hit `aspect_ratio` (w/h)."""
bbox_scale = torch.as_tensor(bbox_scale, dtype=torch.float32)
dim = bbox_scale.dim()
if dim == 1:
bbox_scale = bbox_scale.unsqueeze(0)
w, h = bbox_scale[:, 0:1], bbox_scale[:, 1:2]
out = torch.where(
w > h * aspect_ratio,
torch.cat([w, w / aspect_ratio], dim=1),
torch.cat([h * aspect_ratio, h], dim=1),
)
return out[0] if dim == 1 else out
def get_warp_matrices(centers, scales, output_size: Tuple[int, int]) -> torch.Tensor:
"""Batched 2x3 affine matrices mapping each (center, scale) bbox region to
the output box. `output_size` is (H_out, W_out). With rot=0 the MMPose
3-point fit reduces to a closed-form isotropic scale + translate.
"""
centers = torch.as_tensor(centers, dtype=torch.float32)
scales = torch.as_tensor(scales, dtype=torch.float32)
if centers.dim() == 1:
centers = centers.unsqueeze(0)
scales = scales.unsqueeze(0)
n = centers.shape[0]
src_w = scales[:, 0]
dst_h = float(output_size[0])
dst_w = float(output_size[1])
# With rot=0 the warp is just scale + translate (uniform x/y scale based
# on src_w/dst_w). The closed form drops out of MMPose's 3-point solve.
s = dst_w / src_w # (N,)
mats = torch.zeros((n, 2, 3), dtype=centers.dtype, device=centers.device)
mats[:, 0, 0] = s
mats[:, 1, 1] = s
mats[:, 0, 2] = dst_w * 0.5 - s * centers[:, 0]
mats[:, 1, 2] = dst_h * 0.5 - s * centers[:, 1]
return mats # (N, 2, 3)
def warp_affine_batched(
src_t: torch.Tensor, # (N, C, H_src, W_src) float
mats: torch.Tensor, # (N, 2, 3) float
output_size: Tuple[int, int] # (H_out, W_out)
) -> torch.Tensor:
"""Apply N forward (src->dst) 2x3 affine warps to N source images in one
grid_sample call. Kept generic over arbitrary affines (not specialized to
the scale+translate produced by `get_warp_matrices`) so callers can pass
rotated/sheared affines; the per-crop 3x3 invert is O(N) of trivial work."""
H_out, W_out = int(output_size[0]), int(output_size[1])
N, _, H_src, W_src = src_t.shape
device = src_t.device
# Invert each forward affine; grid_sample needs dst->src.
mats_t = mats.to(device=device, dtype=torch.float32)
bottom = mats_t.new_tensor([0.0, 0.0, 1.0]).expand(N, 1, 3)
mats_3 = torch.cat([mats_t, bottom], dim=1) # (N, 3, 3)
mats_inv = torch.linalg.inv(mats_3)[:, :2, :] # (N, 2, 3)
# Output pixel-center grid (i+0.5, j+0.5).
ys, xs = torch.meshgrid(
torch.arange(H_out, dtype=torch.float32, device=device) + 0.5,
torch.arange(W_out, dtype=torch.float32, device=device) + 0.5,
indexing="ij",
)
homo = torch.stack([xs, ys, torch.ones_like(xs)], dim=-1) # (H_out, W_out, 3)
src_pos = torch.einsum("nkl,ijl->nijk", mats_inv, homo) # (N, H_out, W_out, 2)
# Normalize to [-1, 1] grid_sample coords (align_corners=False).
src_pos[..., 0] = src_pos[..., 0] / W_src * 2 - 1
src_pos[..., 1] = src_pos[..., 1] / H_src * 2 - 1
return F.grid_sample(src_t, src_pos, mode="bilinear", padding_mode="zeros", align_corners=False)
# Batch construction (one prediction over N person crops from a single image)
def prepare_batch(
img, # (H, W, 3) uint8 torch tensor or list of such tensors
boxes, # (N, 4) xyxy (numpy or torch)
input_size: Tuple[int, int], # (W, H) of the model crop
bbox_padding: float = 1.25, # xyxy->cs padding multiplier (1.25 body, 0.9 hand)
aspect_ratio: float = 0.75, # w/h of the crop (0.75 matches HMR2/Sapiens)
masks=None, # optional per-person masks
masks_score=None, # optional per-person mask scores
cam_int=None, # optional camera intrinsics
) -> Dict:
"""Build the batch dict the SAM3DBody forward expects, doing the N crops in one batched `grid_sample` call."""
is_multi_image = isinstance(img, list)
if is_multi_image:
assert len(img) == boxes.shape[0]
height, width = img[0].shape[:2]
else:
height, width = img.shape[:2]
n = int(boxes.shape[0])
assert n > 0, "prepare_batch needs at least one box"
W_out, H_out = int(input_size[0]), int(input_size[1])
# Per-box bbox math (cheap, vectorized, CPU).
centers, scales = bbox_xyxy2cs(boxes, padding=bbox_padding)
# Two passes: first hits the upstream bbox aspect (e.g. 0.75 HMR2/Sapiens
# convention), second pads further if the model crop's W_out/H_out differs
# from that. When they match (common case) the second call is a no-op.
scales = fix_aspect_ratio(scales, aspect_ratio)
scales = fix_aspect_ratio(scales, W_out / H_out)
mats = get_warp_matrices(centers, scales, (H_out, W_out)) # (N, 2, 3)
# Stack source images into a contiguous (N, 3, H, W) tensor on CPU.
if is_multi_image:
src_t = torch.stack(list(img), dim=0)
else:
src_t = img.unsqueeze(0).expand(n, -1, -1, -1)
src_t = src_t.permute(0, 3, 1, 2).contiguous().float() # (N, 3, H, W) in [0, 255]
warped_t = warp_affine_batched(src_t, mats, (H_out, W_out)) # (N, 3, H_out, W_out)
# Float warp -> floor (matches the legacy uint8 round-trip) -> /255.
img_t = torch.floor(warped_t).clamp_(0.0, 255.0) / 255.0 # (N, 3, H_out, W_out) in [0, 1]
# Masks: zero-init when missing, otherwise stack and warp through the same matrices.
boxes_t = torch.as_tensor(boxes, dtype=torch.float32)
if masks is None:
mask_t = torch.zeros((n, H_out, W_out), dtype=torch.float32)
mask_score_t = torch.zeros((n,), dtype=torch.float32)
else:
# masks is an array of N items, each (H, W) or (H, W, 1).
masks_t = torch.stack([torch.as_tensor(masks[i]) for i in range(n)], dim=0)
if masks_t.dim() == 4 and masks_t.shape[-1] == 1:
masks_t = masks_t[..., 0]
masks_src_t = masks_t.float().unsqueeze(1) # (N, 1, H, W) in [0, 255]
warped_masks = warp_affine_batched(masks_src_t, mats, (H_out, W_out))
mask_t = torch.floor(warped_masks.squeeze(1)).clamp_(0.0, 255.0)
if masks_score is not None:
mask_score_t = torch.as_tensor([masks_score[i] for i in range(n)], dtype=torch.float32)
else:
mask_score_t = torch.ones((n,), dtype=torch.float32)
img_size_t = torch.tensor([W_out, H_out], dtype=torch.float32).expand(n, 2).contiguous()
batch = {
"img": img_t.unsqueeze(0), # (1, N, 3, H_out, W_out)
"img_size": img_size_t.unsqueeze(0), # (1, N, 2)
"bbox_center": centers.unsqueeze(0), # (1, N, 2)
"bbox_scale": scales.unsqueeze(0), # (1, N, 2)
"bbox": boxes_t.unsqueeze(0), # (1, N, 4)
"affine_trans": mats.unsqueeze(0), # (1, N, 2, 3)
"mask": mask_t.unsqueeze(0).unsqueeze(2), # (1, N, 1, H_out, W_out)
"mask_score": mask_score_t.unsqueeze(0), # (1, N)
"person_valid": torch.ones((1, n), dtype=torch.float32),
}
if cam_int is not None:
batch["cam_int"] = cam_int.to(batch["img"])
else:
# Default intrinsics: focal = sqrt(W^2 + H^2), principal point = image center.
f = (height ** 2 + width ** 2) ** 0.5
batch["cam_int"] = torch.tensor(
[[[f, 0, width / 2.0], [0, f, height / 2.0], [0, 0, 1]]],
).to(batch["img"])
return batch
# Geometry utils
def rot6d_to_rotmat(
x: torch.Tensor # (B, 6) batch of 6-D rotation representations.
) -> torch.Tensor: # (B, 3, 3) rotation matrices.
"""6D continuous rotation rep (Zhou et al., CVPR 2019) -> 3x3 rotation matrix."""
x = x.reshape(-1, 2, 3).permute(0, 2, 1).contiguous()
a1, a2 = x[:, :, 0], x[:, :, 1]
b1 = F.normalize(a1)
b2 = F.normalize(a2 - torch.einsum("bi,bi->b", b1, a2).unsqueeze(-1) * b1)
b3 = torch.linalg.cross(b1, b2)
return torch.stack((b1, b2, b3), dim=-1)
def perspective_projection(
x: torch.Tensor, # (B, N, 3) 3D points in camera coords.
K: torch.Tensor # (B, 3, 3) camera intrinsics.
) -> torch.Tensor: # (B, N, 2) 2D image-plane projections.
"""Project 3D points (already in camera frame) through intrinsics K."""
y = x / x[:, :, -1].unsqueeze(-1) # perspective divide
y = torch.einsum("bij,bkj->bki", K, y) # apply intrinsics
return y[:, :, :2]
# Rotation conversions, behavior mirrors the roma library (https://github.com/naver/roma)
def _axis_rotmat(axis: str, angle: torch.Tensor) -> torch.Tensor:
"""Rotation matrices around a single coordinate axis. Shape (..., 3, 3)."""
cos = torch.cos(angle)
sin = torch.sin(angle)
one = torch.ones_like(angle)
zero = torch.zeros_like(angle)
if axis == "X":
flat = (one, zero, zero,
zero, cos, -sin,
zero, sin, cos)
elif axis == "Y":
flat = (cos, zero, sin,
zero, one, zero,
-sin, zero, cos)
elif axis == "Z":
flat = (cos, -sin, zero,
sin, cos, zero,
zero, zero, one)
else:
raise ValueError(f"Invalid axis {axis!r}; expected X/Y/Z.")
return torch.stack(flat, dim=-1).reshape(angle.shape + (3, 3))
def euler_to_rotmat(convention: str, angles: torch.Tensor) -> torch.Tensor:
"""Euler angles -> rotation matrix, matching roma's case-keyed convention."""
axes = convention.upper()
R0 = _axis_rotmat(axes[0], angles[..., 0])
R1 = _axis_rotmat(axes[1], angles[..., 1])
R2 = _axis_rotmat(axes[2], angles[..., 2])
if convention.islower():
return R2 @ R1 @ R0
return R0 @ R1 @ R2
def _index_from_letter(letter: str) -> int:
return {"X": 0, "Y": 1, "Z": 2}[letter]
def _angle_from_tan(
axis: str,
other_axis: str,
data: torch.Tensor,
horizontal: bool,
tait_bryan: bool,
) -> torch.Tensor:
"""Extract an outer Euler angle from a row/column of a rotation matrix.
Adapted from PyTorch3D's matrix_to_euler_angles helper.
"""
i1, i2 = {"X": (2, 1), "Y": (0, 2), "Z": (1, 0)}[axis]
if horizontal:
i2, i1 = i1, i2
even = (axis + other_axis) in ("XY", "YZ", "ZX")
if horizontal == even:
return torch.atan2(data[..., i1], data[..., i2])
if tait_bryan:
return torch.atan2(-data[..., i2], data[..., i1])
return torch.atan2(data[..., i2], -data[..., i1])
def _matrix_to_euler_intrinsic(matrix: torch.Tensor, convention: str) -> torch.Tensor:
"""Decompose a rotation matrix into intrinsic Euler angles (uppercase abc).
Adapted from PyTorch3D's matrix_to_euler_angles.
"""
i0 = _index_from_letter(convention[0])
i2 = _index_from_letter(convention[2])
tait_bryan = i0 != i2
if tait_bryan:
sign = -1.0 if (i0 - i2) in (-1, 2) else 1.0
central = torch.asin(matrix[..., i0, i2] * sign)
else:
central = torch.acos(matrix[..., i0, i0])
out = (
_angle_from_tan(convention[0], convention[1], matrix[..., i2], False, tait_bryan),
central,
_angle_from_tan(convention[2], convention[1], matrix[..., i0, :], True, tait_bryan),
)
return torch.stack(out, dim=-1)
def rotmat_to_euler(convention: str, matrix: torch.Tensor) -> torch.Tensor:
"""Rotation matrix -> Euler angles, inverse of :func:`euler_to_rotmat`.
PyTorch3D's matrix_to_euler_angles uses the convention R = R_a R_b R_c for
convention "abc"; that matches roma's UPPERCASE ordering directly. For
roma's lowercase, the matrix is reversed (R_c R_b R_a), so we decompose
with the reversed convention and flip the angles back to axis order.
"""
if matrix.shape[-2:] != (3, 3):
raise ValueError(f"Expected (..., 3, 3) rotation matrix, got {tuple(matrix.shape)}.")
if convention.isupper():
return _matrix_to_euler_intrinsic(matrix, convention)
decomposed = _matrix_to_euler_intrinsic(matrix, convention.upper()[::-1])
return decomposed.flip(-1)
def unitquat_to_rotmat(quat: torch.Tensor) -> torch.Tensor:
"""Unit quaternion (x, y, z, w) -> rotation matrix.
Matches roma.unitquat_to_rotmat (scalar-last). The quaternion is assumed to be normalized.
Args:
quat: (..., 4) unit quaternion.
Returns:
(..., 3, 3) rotation matrix.
"""
x, y, z, w = quat.unbind(dim=-1)
tx, ty, tz = 2 * x, 2 * y, 2 * z
twx, twy, twz = tx * w, ty * w, tz * w
txx, txy, txz = tx * x, ty * x, tz * x
tyy, tyz, tzz = ty * y, tz * y, tz * z
one = torch.ones_like(w)
flat = (
one - (tyy + tzz), txy - twz, txz + twy,
txy + twz, one - (txx + tzz), tyz - twx,
txz - twy, tyz + twx, one - (txx + tyy),
)
return torch.stack(flat, dim=-1).reshape(quat.shape[:-1] + (3, 3))
+1
View File
@@ -0,0 +1 @@
"""SenseNova U1.5 model implementation."""
+135
View File
@@ -0,0 +1,135 @@
import torch
IMAGE_CONTEXT_ID = 151669
IMAGE_START_ID = 151670
IMAGE_END_ID = 151671
IM_START_ID = 151644
IM_END_ID = 151645
USER_ID = 872
ASSISTANT_ID = 77091
NEWLINE_ID = 198
IMAGE_LABEL_ID = 1906
HYPHEN_ID = 12
DIGIT_ZERO_ID = 15
COLON_ID = 25
def preprocess_reference(image):
if image.ndim == 3:
image = image.unsqueeze(0)
image = image[:, :, :, :3].movedim(-1, 1).float()
if image.shape[1] == 0:
image = image.new_zeros((image.shape[0], 3, *image.shape[-2:]))
elif image.shape[1] < 3:
repeats = (3 + image.shape[1] - 1) // image.shape[1]
image = image.repeat(1, repeats, 1, 1)[:, :3]
mean = image.new_tensor((0.485, 0.456, 0.406)).view(1, 3, 1, 1)
std = image.new_tensor((0.229, 0.224, 0.225)).view(1, 3, 1, 1)
return (image - mean) / std
def split_reference_batches(images):
references = []
for image in images:
if image.ndim == 3:
image = image.unsqueeze(0)
references.extend(image[index : index + 1] for index in range(image.shape[0]))
return references
def preprocess_references(images):
return [preprocess_reference(image) for image in split_reference_batches(images)]
def _image_tokens(token_height, token_width):
return (
[IMAGE_START_ID]
+ [IMAGE_CONTEXT_ID] * (token_height * token_width)
+ [IMAGE_END_ID]
)
def _image_label_tokens(index):
digits = (DIGIT_ZERO_ID + int(digit) for digit in str(index + 1))
return (IMAGE_LABEL_ID, HYPHEN_ID, *digits, COLON_ID)
def conditioned_input_length(input_length, reference_grids, image_only=False):
image_token_count = sum(height * width for height, width in reference_grids)
if image_only:
return image_token_count + 9 + 2 * len(reference_grids)
label_count = (
sum(len(_image_label_tokens(index)) for index in range(len(reference_grids)))
if len(reference_grids) > 1
else 0
)
return input_length + image_token_count + 3 * len(reference_grids) + label_count
def condition_input_ids(input_ids, reference_grids, image_only=False):
image_blocks = [_image_tokens(height, width) for height, width in reference_grids]
if image_only:
values = (
[IM_START_ID, USER_ID, NEWLINE_ID]
+ [token for block in image_blocks for token in block]
+ [
IM_END_ID,
NEWLINE_ID,
IM_START_ID,
ASSISTANT_ID,
NEWLINE_ID,
IMAGE_START_ID,
]
)
return torch.tensor([values], dtype=torch.long, device=input_ids.device)
values = input_ids[0].tolist()
starts = [index for index, value in enumerate(values) if value == IM_START_ID]
insert_at = starts[1] + 3 if len(starts) > 1 else len(values)
inserted = []
for index, block in enumerate(image_blocks):
if len(image_blocks) > 1:
inserted.extend(_image_label_tokens(index))
inserted.extend(block)
inserted.append(NEWLINE_ID)
values[insert_at:insert_at] = inserted
return torch.tensor([values], dtype=torch.long, device=input_ids.device)
def thw_indexes(input_ids, reference_grids):
values = input_ids[0]
image_start_shift = torch.cat(
(
torch.zeros(1, dtype=torch.long, device=values.device),
(values == IMAGE_START_ID).long(),
)
)[:-1]
not_image = (values != IMAGE_CONTEXT_ID).long()
time_indexes = (image_start_shift + not_image).cumsum(0) - 1
height_indexes = torch.zeros_like(time_indexes)
width_indexes = torch.zeros_like(time_indexes)
selected = values == IMAGE_CONTEXT_ID
height_positions = []
width_positions = []
for token_height, token_width in reference_grids:
positions = torch.arange(
token_height * token_width, dtype=torch.long, device=values.device
)
height_positions.append(positions // token_width)
width_positions.append(positions % token_width)
if height_positions:
height_indexes[selected] = torch.cat(height_positions)
width_indexes[selected] = torch.cat(width_positions)
return torch.stack((time_indexes, height_indexes, width_indexes)).unsqueeze(0)
def block_causal_mask(time_indexes, dtype=torch.float32):
values = time_indexes[0, 0]
length = values.shape[0]
same_block = values[:, None] == values[None, :]
positions = torch.arange(length, device=values.device)
causal = positions[None, :] <= positions[:, None]
allowed = same_block | causal
mask = torch.zeros((1, 1, length, length), dtype=dtype, device=values.device)
return mask.masked_fill_(~allowed[None, None], float("-inf"))
+622
View File
@@ -0,0 +1,622 @@
import torch
import torch.nn as nn
import torch.nn.functional as F
import comfy.patcher_extension
import comfy.utils
from comfy.ldm.common_dit import pad_to_patch_size
from comfy.ldm.flux.math import apply_rope1
from comfy.ldm.modules.attention import optimized_attention
from comfy.ldm.modules.diffusionmodules.mmdit import TimestepEmbedder
from .sampling import resolution_noise_scale
HIDDEN_SIZE = 4096
INTERMEDIATE_SIZE = 12288
NUM_LAYERS = 42
NUM_HEADS = 32
NUM_KV_HEADS = 8
HEAD_DIM = 128
MERGED_PATCH_SIZE = 32
VOCAB_SIZE = 151936
def _pad_to_merged_patch_size(value):
height, width = value.shape[-2:]
height_pad = max(16 - height, 0)
width_pad = max(16 - width, 0)
if height_pad or width_pad:
value = F.pad(
value,
(0, width_pad, 0, height_pad),
mode="replicate" if height > 0 and width > 0 else "constant",
)
return pad_to_patch_size(value, (MERGED_PATCH_SIZE, MERGED_PATCH_SIZE))
def _generation_batch_size(total_batch, prefix_batch):
if prefix_batch < 1 or total_batch < 1 or total_batch % prefix_batch != 0:
raise ValueError(
"SenseNova generation batch must be a positive multiple of the prefix batch "
f"(generation={total_batch}, prefix={prefix_batch})"
)
return total_batch // prefix_batch
def _match_prefix_batch(total_batch, text_input_ids, prefix_indexes, prefix_mask):
prefix_batch = text_input_ids.shape[0]
if prefix_batch > 0 and total_batch % prefix_batch:
text_input_ids = comfy.utils.resize_to_batch_size(text_input_ids, total_batch)
if prefix_indexes is not None:
prefix_indexes = comfy.utils.resize_to_batch_size(
prefix_indexes, total_batch
)
if prefix_mask is not None:
prefix_mask = comfy.utils.resize_to_batch_size(prefix_mask, total_batch)
return text_input_ids, prefix_indexes, prefix_mask
def _expand_prefix_batch(value, generation_batch):
"""Repeat each guidance branch's prefix KV for its generated variants."""
if generation_batch == 1:
return value
prefix_batch = value.shape[0]
return (
value.unsqueeze(1)
.expand(prefix_batch, generation_batch, *value.shape[1:])
.reshape(prefix_batch * generation_batch, *value.shape[1:])
)
def _prepare_llm_rope(positions, dim, theta, device, dtype):
frequencies = theta ** (
-torch.arange(0, dim, 2, dtype=torch.float32, device=device) / dim
)
positions = positions.to(device=device, dtype=torch.float32)
if positions.ndim == 1:
positions = positions.unsqueeze(0)
angles = positions.unsqueeze(-1) * frequencies
embedding = torch.cat((angles, angles), dim=-1).unsqueeze(1)
return embedding.cos().to(dtype), embedding.sin().to(dtype)
def _prepare_mrope(indexes, device, dtype):
return (
_prepare_llm_rope(indexes[0], HEAD_DIM // 2, 5000000.0, device, dtype),
_prepare_llm_rope(indexes[1], HEAD_DIM // 4, 10000.0, device, dtype),
_prepare_llm_rope(indexes[2], HEAD_DIM // 4, 10000.0, device, dtype),
)
def _apply_llm_rope(query, key, rope):
cosine, sine = rope
def rotate_half(value):
first, second = value.chunk(2, dim=-1)
return torch.cat((-second, first), dim=-1)
# Keep this split-half RoPE on the reference PyTorch formula. The
# comfy-kitchen CUDA kernel is selected automatically on CUDA 13 builds;
# on Blackwell it can return finite but numerically incorrect values, which
# corrupts the generated image without raising an execution error.
return (
query * cosine + rotate_half(query) * sine,
key * cosine + rotate_half(key) * sine,
)
def _apply_interleaved_rope(value, positions, theta):
dim = value.shape[-1]
frequencies = theta ** (
-torch.arange(0, dim, 2, dtype=torch.float32, device=value.device) / dim
)
angles = (
positions.to(device=value.device, dtype=torch.float32).unsqueeze(-1)
* frequencies
)
cosine = angles.cos()
sine = angles.sin()
# comfy-kitchen acceleration backends use the canonical four-dimensional
# input and six-dimensional rotation layout. SenseNova's vision patches
# have no head axis, so add a singleton one instead of relying on the eager
# backend's more permissive rank handling.
rotation = torch.stack((cosine, -sine, sine, cosine), dim=-1).reshape(
1, 1, *angles.shape, 2, 2
)
return apply_rope1(value.float().unsqueeze(1), rotation).squeeze(1)
class VisionEmbeddings(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.patch_embedding = operations.Conv2d(
3, 1024, kernel_size=16, stride=16, device=device, dtype=dtype
)
self.dense_embedding = operations.Conv2d(
1024, HIDDEN_SIZE, kernel_size=2, stride=2, device=device, dtype=dtype
)
self.gelu = nn.GELU()
def forward(self, image):
patches = self.gelu(self.patch_embedding(image))
batch, channels, height, width = patches.shape
patches = patches.flatten(2).transpose(1, 2)
indexes = torch.arange(height * width, device=patches.device)
x_positions = indexes % width
y_positions = indexes // width
first = _apply_interleaved_rope(
patches[..., : channels // 2], x_positions, 10000.0
)
second = _apply_interleaved_rope(
patches[..., channels // 2 :], y_positions, 10000.0
)
patches = torch.cat((first, second), dim=-1).to(image.dtype)
patches = patches.transpose(1, 2).reshape(batch, channels, height, width)
patches = self.dense_embedding(patches)
return patches.flatten(2).transpose(1, 2)
class VisionModel(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.embeddings = VisionEmbeddings(
device=device, dtype=dtype, operations=operations
)
def forward(self, image):
return self.embeddings(image)
class MLP(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.gate_proj = operations.Linear(
HIDDEN_SIZE, INTERMEDIATE_SIZE, bias=False, device=device, dtype=dtype
)
self.up_proj = operations.Linear(
HIDDEN_SIZE, INTERMEDIATE_SIZE, bias=False, device=device, dtype=dtype
)
self.down_proj = operations.Linear(
INTERMEDIATE_SIZE, HIDDEN_SIZE, bias=False, device=device, dtype=dtype
)
def forward(self, hidden_states):
return self.down_proj(
F.silu(self.gate_proj(hidden_states)) * self.up_proj(hidden_states)
)
class Attention(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.q_proj = operations.Linear(
HIDDEN_SIZE, NUM_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.q_proj_mot_gen = operations.Linear(
HIDDEN_SIZE, NUM_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.k_proj = operations.Linear(
HIDDEN_SIZE, NUM_KV_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.k_proj_mot_gen = operations.Linear(
HIDDEN_SIZE, NUM_KV_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.v_proj = operations.Linear(
HIDDEN_SIZE, NUM_KV_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.v_proj_mot_gen = operations.Linear(
HIDDEN_SIZE, NUM_KV_HEADS * HEAD_DIM, bias=False, device=device, dtype=dtype
)
self.o_proj = operations.Linear(
NUM_HEADS * HEAD_DIM, HIDDEN_SIZE, bias=False, device=device, dtype=dtype
)
self.o_proj_mot_gen = operations.Linear(
NUM_HEADS * HEAD_DIM, HIDDEN_SIZE, bias=False, device=device, dtype=dtype
)
self.q_norm = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.q_norm_mot_gen = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.q_norm_hw = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.q_norm_hw_mot_gen = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.k_norm = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.k_norm_mot_gen = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.k_norm_hw = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
self.k_norm_hw_mot_gen = operations.RMSNorm(
HEAD_DIM // 2, eps=1e-6, device=device, dtype=dtype
)
def _project(self, hidden_states, rope, generation):
batch, length, _ = hidden_states.shape
if generation:
query = self.q_proj_mot_gen(hidden_states).view(
batch, length, NUM_HEADS, HEAD_DIM
)
key = self.k_proj_mot_gen(hidden_states).view(
batch, length, NUM_KV_HEADS, HEAD_DIM
)
value = (
self.v_proj_mot_gen(hidden_states)
.view(batch, length, NUM_KV_HEADS, HEAD_DIM)
.transpose(1, 2)
)
query_t, query_hw = query.chunk(2, dim=-1)
key_t, key_hw = key.chunk(2, dim=-1)
query_t = self.q_norm_mot_gen(query_t).transpose(1, 2)
query_hw = self.q_norm_hw_mot_gen(query_hw).transpose(1, 2)
key_t = self.k_norm_mot_gen(key_t).transpose(1, 2)
key_hw = self.k_norm_hw_mot_gen(key_hw).transpose(1, 2)
else:
query = self.q_proj(hidden_states).view(batch, length, NUM_HEADS, HEAD_DIM)
key = self.k_proj(hidden_states).view(batch, length, NUM_KV_HEADS, HEAD_DIM)
value = (
self.v_proj(hidden_states)
.view(batch, length, NUM_KV_HEADS, HEAD_DIM)
.transpose(1, 2)
)
query_t, query_hw = query.chunk(2, dim=-1)
key_t, key_hw = key.chunk(2, dim=-1)
query_t = self.q_norm(query_t).transpose(1, 2)
query_hw = self.q_norm_hw(query_hw).transpose(1, 2)
key_t = self.k_norm(key_t).transpose(1, 2)
key_hw = self.k_norm_hw(key_hw).transpose(1, 2)
query_h, query_w = query_hw.chunk(2, dim=-1)
key_h, key_w = key_hw.chunk(2, dim=-1)
query_t, key_t = _apply_llm_rope(query_t, key_t, rope[0])
query_h, key_h = _apply_llm_rope(query_h, key_h, rope[1])
query_w, key_w = _apply_llm_rope(query_w, key_w, rope[2])
query = torch.cat((query_t, query_h, query_w), dim=-1)
key = torch.cat((key_t, key_h, key_w), dim=-1)
return query, key, value
def forward_prefix(
self, hidden_states, rope, attention_mask, transformer_options
):
query, key, value = self._project(hidden_states, rope, False)
output = optimized_attention(
query,
key,
value,
NUM_HEADS,
mask=attention_mask,
skip_reshape=True,
transformer_options=transformer_options,
enable_gqa=True,
)
return self.o_proj(output), key, value
def forward_generation(
self, hidden_states, rope, prefix_key, prefix_value, transformer_options
):
query, key, value = self._project(hidden_states, rope, True)
key = torch.cat((prefix_key, key), dim=2)
value = torch.cat((prefix_value, value), dim=2)
output = optimized_attention(
query,
key,
value,
NUM_HEADS,
mask=None,
skip_reshape=True,
transformer_options=transformer_options,
enable_gqa=True,
)
return self.o_proj_mot_gen(output)
class DecoderLayer(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.self_attn = Attention(device=device, dtype=dtype, operations=operations)
self.mlp = MLP(device=device, dtype=dtype, operations=operations)
self.mlp_mot_gen = MLP(device=device, dtype=dtype, operations=operations)
self.input_layernorm = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
self.input_layernorm_mot_gen = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
self.post_attention_layernorm = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
self.post_attention_layernorm_mot_gen = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
def forward_prefix(self, prefix, prefix_rope, prefix_mask, transformer_options):
prefix_attention, prefix_key, prefix_value = self.self_attn.forward_prefix(
self.input_layernorm(prefix),
prefix_rope,
prefix_mask,
transformer_options,
)
prefix = prefix + prefix_attention
prefix = prefix + self.mlp(self.post_attention_layernorm(prefix))
return prefix, prefix_key, prefix_value
def forward_generation(
self, image, image_rope, prefix_key, prefix_value, transformer_options
):
image_attention = self.self_attn.forward_generation(
self.input_layernorm_mot_gen(image),
image_rope,
prefix_key,
prefix_value,
transformer_options,
)
image = image + image_attention
image = image + self.mlp_mot_gen(self.post_attention_layernorm_mot_gen(image))
return image
class LanguageBackbone(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.embed_tokens = operations.Embedding(
VOCAB_SIZE, HIDDEN_SIZE, padding_idx=151643, device=device, dtype=dtype
)
self.layers = nn.ModuleList(
DecoderLayer(device=device, dtype=dtype, operations=operations)
for _ in range(NUM_LAYERS)
)
self.norm = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
self.norm_mot_gen = operations.RMSNorm(
HIDDEN_SIZE, eps=1e-6, device=device, dtype=dtype
)
class LanguageModel(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.model = LanguageBackbone(device=device, dtype=dtype, operations=operations)
class ConvDecoder(nn.Module):
def __init__(self, device=None, dtype=None, operations=None):
super().__init__()
self.ps1 = nn.PixelShuffle(2)
self.conv1 = operations.Conv2d(
1024, 1024, kernel_size=3, padding=1, device=device, dtype=dtype
)
self.act1 = nn.GELU()
self.ps2 = nn.PixelShuffle(2)
self.conv2 = operations.Conv2d(
256, 192, kernel_size=3, padding=1, device=device, dtype=dtype
)
self.ps3 = nn.PixelShuffle(8)
def forward(self, hidden_states):
hidden_states = self.act1(self.conv1(self.ps1(hidden_states)))
return self.ps3(self.conv2(self.ps2(hidden_states)))
class SenseNovaU15(nn.Module):
def __init__(
self, image_model=None, dtype=None, device=None, operations=None, **kwargs
):
super().__init__()
self.dtype = dtype
self.vision_model = VisionModel(
device=device, dtype=dtype, operations=operations
)
self.language_model = LanguageModel(
device=device, dtype=dtype, operations=operations
)
self.fm_modules = nn.ModuleDict(
{
"vision_model_mot_gen": VisionModel(
device=device, dtype=dtype, operations=operations
),
"timestep_embedder": TimestepEmbedder(
HIDDEN_SIZE, device=device, dtype=dtype, operations=operations
),
"fm_head": ConvDecoder(
device=device, dtype=dtype, operations=operations
),
"noise_scale_embedder": TimestepEmbedder(
HIDDEN_SIZE, device=device, dtype=dtype, operations=operations
),
}
)
def forward(self, x, timesteps, context=None, transformer_options={}, **kwargs):
return comfy.patcher_extension.WrapperExecutor.new_class_executor(
self._forward,
self,
comfy.patcher_extension.get_all_wrappers(
comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, transformer_options
),
).execute(x, timesteps, context, transformer_options, **kwargs)
def _prepare_prefix(
self, text_input_ids, reference_images, prefix_indexes, prefix_mask
):
prefix = self.language_model.model.embed_tokens(text_input_ids)
if reference_images:
reference_embeds = [
self.vision_model(_pad_to_merged_patch_size(reference))
for reference in reference_images
]
selected = text_input_ids == 151669
prefix = prefix.clone()
prefix[selected] = torch.cat(reference_embeds, dim=1).reshape(
-1, HIDDEN_SIZE
)
prefix_length = text_input_ids.shape[1]
if prefix_indexes is None:
prefix_positions = torch.arange(
prefix_length, dtype=torch.long, device=prefix.device
)
zeros = torch.zeros_like(prefix_positions)
prefix_indexes = torch.stack((prefix_positions, zeros, zeros))
prefix_mask = torch.full(
(prefix_length, prefix_length),
float("-inf"),
dtype=prefix.dtype,
device=prefix.device,
).triu(1)
prefix_time = torch.full(
(prefix.shape[0],),
prefix_length,
dtype=torch.long,
device=prefix.device,
)
else:
prefix_indexes = prefix_indexes.transpose(0, 1)
prefix_time = prefix_indexes[0].amax(dim=-1) + 1
return prefix, prefix_indexes, prefix_mask, prefix_time
def preprocess_prefix(
self,
text_input_ids,
reference_images=None,
prefix_indexes=None,
prefix_mask=None,
):
prefix, prefix_indexes, prefix_mask, prefix_time = self._prepare_prefix(
text_input_ids, reference_images, prefix_indexes, prefix_mask
)
prefix_keys = []
prefix_values = []
prefix_rope = _prepare_mrope(prefix_indexes, prefix.device, prefix.dtype)
transformer_options = {}
for layer_index, layer in enumerate(self.language_model.model.layers):
transformer_options["block_index"] = layer_index
prefix, prefix_key, prefix_value = layer.forward_prefix(
prefix,
prefix_rope,
prefix_mask,
transformer_options,
)
prefix_keys.append(prefix_key)
prefix_values.append(prefix_value)
return prefix_keys, prefix_values, prefix_time
def _forward(
self,
x,
timesteps,
context=None,
transformer_options={},
text_input_ids=None,
reference_images=None,
prefix_indexes=None,
prefix_mask=None,
prefix_keys=None,
prefix_values=None,
prefix_time=None,
**kwargs,
):
if text_input_ids is None and prefix_keys is None:
raise ValueError("SenseNova-U1.5 requires text conditioning")
original_height, original_width = x.shape[-2:]
x = _pad_to_merged_patch_size(x)
batch, _, height, width = x.shape
if prefix_keys is None:
text_input_ids, prefix_indexes, prefix_mask = _match_prefix_batch(
batch, text_input_ids, prefix_indexes, prefix_mask
)
prefix_batch = text_input_ids.shape[0]
if reference_images:
reference_images = [
comfy.utils.resize_to_batch_size(reference, prefix_batch)
for reference in reference_images
]
else:
reference_images = None
else:
prefix_batch = prefix_keys[0].shape[0]
if prefix_batch > 0 and batch % prefix_batch:
prefix_keys = [
comfy.utils.resize_to_batch_size(value, batch)
for value in prefix_keys
]
prefix_values = [
comfy.utils.resize_to_batch_size(value, batch)
for value in prefix_values
]
prefix_time = comfy.utils.resize_to_batch_size(prefix_time, batch)
prefix_batch = batch
generation_batch = _generation_batch_size(batch, prefix_batch)
token_height = height // MERGED_PATCH_SIZE
token_width = width // MERGED_PATCH_SIZE
image_length = token_height * token_width
image = self.fm_modules["vision_model_mot_gen"](x)
time_embedding = self.fm_modules["timestep_embedder"](timesteps, image.dtype)
noise_scale = resolution_noise_scale(height, width) / 16.0
scale_timesteps = torch.full_like(timesteps, noise_scale)
time_embedding = time_embedding + self.fm_modules["noise_scale_embedder"](
scale_timesteps, image.dtype
)
image = image + time_embedding[:, None, :]
if prefix_keys is None:
prefix, prefix_indexes, prefix_mask, prefix_time = self._prepare_prefix(
text_input_ids, reference_images, prefix_indexes, prefix_mask
)
prefix_rope = _prepare_mrope(prefix_indexes, prefix.device, prefix.dtype)
image_time = prefix_time.repeat_interleave(generation_batch)
image_positions = torch.arange(image_length, dtype=torch.long, device=x.device)
image_indexes = torch.stack(
(
image_time[:, None].expand(batch, image_length),
(image_positions // token_width)[None].expand(batch, image_length),
(image_positions % token_width)[None].expand(batch, image_length),
)
)
image_rope = _prepare_mrope(image_indexes, image.device, image.dtype)
for layer_index, layer in enumerate(self.language_model.model.layers):
transformer_options["block_index"] = layer_index
if prefix_keys is None:
prefix, prefix_key, prefix_value = layer.forward_prefix(
prefix,
prefix_rope,
prefix_mask,
transformer_options,
)
else:
prefix_key = prefix_keys[layer_index]
prefix_value = prefix_values[layer_index]
generation_prefix_key = _expand_prefix_batch(prefix_key, generation_batch)
generation_prefix_value = _expand_prefix_batch(
prefix_value, generation_batch
)
image = layer.forward_generation(
image,
image_rope,
generation_prefix_key,
generation_prefix_value,
transformer_options,
)
image = self.language_model.model.norm_mot_gen(image)
image = image.view(batch, token_height, token_width, HIDDEN_SIZE).permute(
0, 3, 1, 2
)
predicted = self.fm_modules["fm_head"](image)
denominator = (1.0 - timesteps).clamp_min(0.02).view(batch, 1, 1, 1)
velocity = (x - predicted) / denominator
return velocity[..., :original_height, :original_width]
+69
View File
@@ -0,0 +1,69 @@
import math
import torch
import comfy.model_sampling
def time_snr_shift(shift, value):
if shift == 1.0:
return value
return shift * value / (1.0 + (shift - 1.0) * value)
def inverse_time_snr_shift(shift, value):
if shift == 1.0:
return value
return value / (shift - (shift - 1.0) * value)
def upstream_timesteps(steps, shift, device=None):
base = torch.linspace(0.0, 1.0, steps + 1, device=device)
return 1.0 - time_snr_shift(shift, 1.0 - base)
def upstream_sigmas(steps, shift, device=None):
return 1.0 - upstream_timesteps(steps, shift, device=device)
def resolution_noise_scale(
height, width, base_seq_len=64, noise_scale=1.0, maximum=16.0
):
token_height = math.ceil(height / 32)
token_width = math.ceil(width / 32)
scale = math.sqrt(token_height * token_width / base_seq_len) * noise_scale
return min(scale, maximum)
class SenseNovaModelSampling(
comfy.model_sampling.ModelSamplingDiscreteFlow, comfy.model_sampling.CONST
):
def set_parameters(self, shift=1.0, timesteps=1000, multiplier=1000):
self.shift = shift
self.multiplier = multiplier
base_timesteps = torch.linspace(multiplier, 0.0, timesteps + 1)
self.register_buffer("sigmas", self.sigma(base_timesteps))
def timestep(self, sigma):
base_sigma = inverse_time_snr_shift(self.shift, sigma)
return (1.0 - base_sigma) * self.multiplier
def sigma(self, timestep):
base_sigma = 1.0 - timestep / self.multiplier
return time_snr_shift(self.shift, base_sigma)
def percent_to_sigma(self, percent):
if percent <= 0.0:
return 1.0
if percent >= 1.0:
return 0.0
return float(time_snr_shift(self.shift, 1.0 - percent))
def noise_scaling(self, sigma, noise, latent_image, max_denoise=False):
sigma = comfy.model_sampling.reshape_sigma(sigma, noise.ndim)
scale = resolution_noise_scale(
latent_image.shape[-2],
latent_image.shape[-1],
noise_scale=self.noise_scale,
)
return sigma * (scale * noise) + (1.0 - sigma) * latent_image
+166
View File
@@ -0,0 +1,166 @@
from typing import Optional, Tuple
import torch
import comfy.model_management
def compute_kernel_offsets(Kw, Kh, Kd, Dw, Dh, Dd, device):
"""Kernel spatial offsets in the same order as the CUDA/Triton kernels."""
offsets = []
for vx in range(Kw):
for vy in range(Kh):
for vz in range(Kd):
offsets.append((vx * Dw, vy * Dh, vz * Dd))
return torch.tensor(offsets, device=device, dtype=torch.int32)
class TorchHashMap:
"""Sorted-array hashmap backed by torch.searchsorted."""
def __init__(self, keys: torch.Tensor, values: torch.Tensor):
self.sorted_keys, order = torch.sort(keys.to(torch.long))
self.sorted_vals = values[order]
self._n = self.sorted_keys.numel()
# Chunk size for lookup_flat, caps each transient to ~CHUNK rows.
_LOOKUP_CHUNK = 1 << 23 # 8M rows ≈ 64 MB per int64 temp
def lookup_flat(self, flat_keys: torch.Tensor) -> torch.Tensor:
N = flat_keys.shape[0]
out = torch.full((N,), -1, device=flat_keys.device, dtype=torch.int32)
if self._n == 0 or N == 0:
return out
for s in range(0, N, self._LOOKUP_CHUNK):
e = min(s + self._LOOKUP_CHUNK, N)
flat_chunk = flat_keys[s:e].to(torch.long)
idx = torch.searchsorted(self.sorted_keys, flat_chunk)
in_range = idx < self._n
idx.clamp_(max=self._n - 1) # reuse idx as the "safe" index
found = in_range & (self.sorted_keys[idx] == flat_chunk)
if found.any():
found_idx = found.nonzero(as_tuple=True)[0]
out[s + found_idx] = self.sorted_vals[idx[found_idx]].to(torch.int32)
return out
def build_submanifold_neighbor_map(
hashmap,
coords: torch.Tensor,
W, H, D,
Kw, Kh, Kd,
Dw, Dh, Dd,
):
# neighbor[i, v] = index of the voxel at voxel i's coord + kernel-offset v, or -1.
# Chunked over voxels so the [chunk, V, 3] candidate transient stays bounded.
device = coords.device
M = coords.shape[0]
offsets = compute_kernel_offsets(Kw, Kh, Kd, Dw, Dh, Dd, device) # [V, 3]
V = offsets.shape[0]
center = torch.tensor([(Kw // 2) * Dw, (Kh // 2) * Dh, (Kd // 2) * Dd], dtype=torch.int32, device=device)
WHD, HD = W * H * D, H * D
neighbor = torch.empty((M, V), dtype=torch.int32, device=device)
# ~V*40 bytes/voxel of transient (int64 cand + flat + masks); cap at ~0.5 GB.
chunk = max(1, min(M, int(0.5 * (1024 ** 3) / (V * 40))))
for s in range(0, M, chunk):
e = min(s + chunk, M)
b = coords[s:e, 0].long()
cand = coords[s:e, 1:4][:, None, :] + offsets[None, :, :] - center # [c, V, 3]
x, y, z = cand[..., 0], cand[..., 1], cand[..., 2]
in_bounds = (x >= 0) & (x < W) & (y >= 0) & (y < H) & (z >= 0) & (z < D) # [c, V]
flat = x.long().mul_(HD)
flat.add_(y.long().mul_(D)).add_(z).add_(b[:, None] * WHD)
flat.masked_fill_(~in_bounds, -1) # OOB -> guaranteed miss
neighbor[s:e] = hashmap.lookup_flat(flat.reshape(-1)).view(e - s, V)
return neighbor
def get_recommended_chunk_mem(
device=None,
safety_fraction: float = 0.2,
min_gb: float = 0.25,
max_gb: float = 0.5,
):
"""Pick a chunk-memory budget (in GB) for sparse conv batching."""
free_gb = comfy.model_management.get_free_memory(device) / (1024 ** 3)
return max(min_gb, min(free_gb * safety_fraction, max_gb))
def sparse_submanifold_conv3d(
feats: torch.Tensor,
coords: torch.Tensor,
shape: tuple,
weight: torch.Tensor,
bias: Optional[torch.Tensor],
neighbor_cache: Optional[torch.Tensor],
dilation: tuple,
cache_neighbor_map: bool = True,
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
if feats.shape[0] == 0:
Co = weight.shape[0]
return torch.empty((0, Co), device=feats.device, dtype=feats.dtype), None
W, H, D = shape
Co, Kw, Kh, Kd, Ci = weight.shape
V = Kw * Kh * Kd
device = feats.device
hashmap = None
if neighbor_cache is None:
b_stride = W * H * D
x_stride = H * D
y_stride = D
z_stride = 1
flat_keys = coords[:, 0].long() * b_stride
flat_keys.add_(coords[:, 1], alpha=x_stride)
flat_keys.add_(coords[:, 2], alpha=y_stride)
flat_keys.add_(coords[:, 3], alpha=z_stride)
vals = torch.arange(coords.shape[0], dtype=torch.int32, device=device)
hashmap = TorchHashMap(flat_keys, vals)
if cache_neighbor_map:
neighbor = build_submanifold_neighbor_map(
hashmap, coords, W, H, D, Kw, Kh, Kd,
dilation[0], dilation[1], dilation[2]
)
else:
neighbor = None
else:
neighbor = neighbor_cache
N_pts = feats.shape[0]
weight_T = weight.view(Co, V * Ci).T
output = torch.empty(N_pts, Co, device=device, dtype=feats.dtype)
# Chunk over voxels to bound the (chunk, V, Ci) gather.
max_chunk_mem_gb = get_recommended_chunk_mem(device)
mem_per_row = V * Ci * feats.element_size()
max_chunk_mem = max_chunk_mem_gb * (1024 ** 3)
chunk_size = max(1, int(max_chunk_mem / mem_per_row))
chunk_size = min(chunk_size, N_pts)
for start in range(0, N_pts, chunk_size):
end = min(start + chunk_size, N_pts)
actual_chunk = end - start
if neighbor is None:
neighbor_chunk = build_submanifold_neighbor_map(
hashmap, coords[start:end], W, H, D, Kw, Kh, Kd,
dilation[0], dilation[1], dilation[2]
)
else:
neighbor_chunk = neighbor[start:end]
chunk_idx = neighbor_chunk.clamp_min(0)
gathered = feats[chunk_idx] # (chunk, V, Ci)
gathered.masked_fill_(neighbor_chunk[:, :, None] < 0, 0)
gathered_flat = gathered.view(actual_chunk, V * Ci)
output[start:end] = torch.matmul(gathered_flat, weight_T) # (chunk, V*Ci) @ (V*Ci, Co)
if bias is not None:
output += bias.unsqueeze(0).to(output.dtype)
return output, neighbor
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -304,6 +304,13 @@ def model_lora_keys_unet(model, key_map={}):
key_map["lycoris_{}".format(key_lora.replace(".", "_"))] = k #SimpleTuner lycoris format
key_map["transformer.{}".format(key_lora)] = k #SimpleTuner regular format
if isinstance(model, comfy.model_base.HiDreamO1):
for k in sdk:
if k.startswith("diffusion_model."):
if k.endswith(".weight"):
key_lora = k[len("diffusion_model."):-len(".weight")]
key_map["model.{}".format(key_lora)] = k
if isinstance(model, comfy.model_base.ACEStep):
for k in sdk:
if k.startswith("diffusion_model.") and k.endswith(".weight"): #Official ACE step lora format
@@ -374,6 +381,12 @@ def model_lora_keys_unet(model, key_map={}):
key_lora = k[len("diffusion_model."):-len(".weight")]
key_map["{}".format(key_lora)] = k
if isinstance(model, comfy.model_base.MiniMaxH3):
for k in sdk:
if k.startswith("diffusion_model.") and k.endswith(".weight"):
key_lora = k[len("diffusion_model."):-len(".weight")]
key_map[key_lora] = k
return key_map
+213
View File
@@ -68,6 +68,7 @@ import comfy.ldm.ideogram4.model
import comfy.ldm.krea2.model
import comfy.ldm.kandinsky5.model
import comfy.ldm.anima.model
import comfy.ldm.trellis2.model
import comfy.ldm.ace.ace_step15
import comfy.ldm.cogvideo.model
import comfy.ldm.rt_detr.rtdetr_v4
@@ -75,6 +76,9 @@ import comfy.ldm.ernie.model
import comfy.ldm.sam3.detector
import comfy.ldm.hidream_o1.model
from comfy.ldm.hidream_o1.conditioning import build_extra_conds
import comfy.ldm.sensenova.conditioning
import comfy.ldm.sensenova.model
from comfy.ldm.sensenova.sampling import SenseNovaModelSampling, time_snr_shift
import comfy.ldm.depth_anything_3.model
import comfy.model_management
@@ -1920,6 +1924,23 @@ class WAN22(WAN21):
def scale_latent_inpaint(self, sigma, noise, latent_image, **kwargs):
return latent_image
class Trellis2(BaseModel):
def __init__(self, model_config, model_type=ModelType.FLOW, device=None, unet_model=comfy.ldm.trellis2.model.Trellis2):
super().__init__(model_config, model_type, device, unet_model)
def extra_conds(self, **kwargs):
out = super().extra_conds(**kwargs)
embeds = kwargs.get("embeds")
out["embeds"] = comfy.conds.CONDRegular(embeds)
# CONDConstant: shared across pos/neg
for k in ("trellis2_coords", "trellis2_coord_counts",
"trellis2_generation_mode", "trellis2_shape_slat",
"trellis2_proj_feats", "trellis2_model_frame"):
v = kwargs.get(k)
if v is not None:
out[k] = comfy.conds.CONDConstant(v)
return out
class WAN21_FlowRVS(WAN21):
def __init__(self, model_config, model_type=ModelType.IMG_TO_IMG_FLOW, image_to_video=False, device=None):
model_config.unet_config["model_type"] = "t2v"
@@ -2179,6 +2200,11 @@ class MiniMaxH3(BaseModel):
payload["seed"] = kwargs.get("seed", 0)
# same value process_latent_in/out used, so the model never undoes a scale that was not applied
payload["audio_scale"] = self.audio_scale()
denoise_mask = kwargs.get("denoise_mask", None)
if denoise_mask is not None:
out.update(self._denoise_mask_conds(denoise_mask, latent_shapes))
if cross_attn is not None and latent_shapes is not None and len(latent_shapes) > 1:
# packed layout built once per sampling run, h/w rounded up to the DiT's 2x2 patch
vs = latent_shapes[0]
@@ -2189,6 +2215,65 @@ class MiniMaxH3(BaseModel):
out['minimax_payload'] = comfy.conds.CONDConstant(payload)
return out
def _pool_masks_to_token_grid(self, masks):
# pool the per-pixel masks to the label grid with amax: video per 2x2 DiT patch, audio per latent frame
video_mask = masks[0]
h, w = video_mask.shape[-2:]
ph, pw = self.diffusion_model.patch_size[1:]
lead = video_mask.shape[:-2]
video_mask = torch.nn.functional.pad(video_mask.reshape((-1,) + video_mask.shape[-3:]), (0, -w % pw, 0, -h % ph), mode="replicate")
video_mask = video_mask.reshape(lead + video_mask.shape[-2:])
video_mask = video_mask.reshape(video_mask.shape[:-2] + (video_mask.shape[-2] // ph, ph, video_mask.shape[-1] // pw, pw)).amax(dim=(-3, -1))
pooled = [video_mask.repeat_interleave(ph, dim=-2).repeat_interleave(pw, dim=-1)[..., :h, :w]]
if len(masks) > 1:
audio_mask = masks[1].amax(dim=1, keepdim=True)
pooled.append(audio_mask.expand_as(masks[1]).contiguous())
return pooled
def _token_grid_masks(self, denoise_mask, latent_shapes):
masks = utils.unpack_latents(denoise_mask, latent_shapes)
return [torch.ceil(mask * 256.0) / 256.0 for mask in self._pool_masks_to_token_grid(masks)]
def _denoise_mask_values(self, denoise_mask, latent_shapes):
if latent_shapes is None or len(latent_shapes) < 2:
return {}
masks = self._token_grid_masks(denoise_mask, latent_shapes)
out = {}
if torch.amin(masks[0]).item() < 1.0 - 1e-3:
out['denoise_mask'] = masks[0][:1, :1].clone()
if torch.amin(masks[1]).item() < 1.0 - 1e-3:
out['audio_denoise_mask'] = masks[1][:1].amax(dim=1, keepdim=True)
return out
def _denoise_mask_conds(self, denoise_mask, latent_shapes):
return {name: comfy.conds.CONDRegular(value) for name, value in self._denoise_mask_values(denoise_mask, latent_shapes).items()}
def scale_latent_inpaint(self, sigma, noise, latent_image, x=None, denoise_mask=None, **kwargs):
# preserved regions run at the cond timestep, inject them at cond strength
shapes = self.latent_shapes
if shapes is None or len(shapes) < 2:
return super().scale_latent_inpaint(sigma=sigma, noise=noise, latent_image=latent_image, **kwargs)
cleans = utils.unpack_latents(latent_image, shapes)
noises = utils.unpack_latents(noise, shapes)
aug = comfy.ldm.minimax.model.VISUAL_COND_TIMESTEP # H3's video timestep is 0.999 by default
cleans[0] = aug * cleans[0] + (1.0 - aug) * noises[0]
scale = self.audio_scale()
if scale != 1.0:
# the sampler carries audio as (sigma_v / sigma_a) * x_audio and latent_image
# holds audio_scale * x_audio, so rescale for the model to see it clean
model_sampling = self.model_sampling
sigma_v = sigma.clamp(min=1e-6)
sigma_a = comfy.ldm.minimax.model.time_shift_sigma(sigma_v, model_sampling.shift, model_sampling.audio_shift)
factor = (sigma_v / sigma_a) / scale
cleans[1] = cleans[1] * factor.view(factor.shape[:1] + (1,) * (cleans[1].ndim - 1)).to(cleans[1].dtype)
injected = utils.pack_latents(cleans)[0]
if x is None or denoise_mask is None:
return injected
token_grid_mask = utils.pack_latents(self._token_grid_masks(denoise_mask, shapes))[0]
x_blend_weight = (token_grid_mask - denoise_mask) / (1.0 - denoise_mask).clamp(min=1e-6)
x_blend_weight = torch.where(denoise_mask < 1.0, x_blend_weight.clamp(0.0, 1.0), torch.zeros_like(x_blend_weight))
return injected + x_blend_weight.to(injected.dtype) * (x - injected)
class TripoSplat(BaseModel):
def __init__(self, model_config, model_type=ModelType.FLOW, device=None):
super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.triposplat.model.LatentSeqMMFlowModel)
@@ -2260,6 +2345,134 @@ class HiDreamO1(BaseModel):
out[k] = cls(v)
return out
class SenseNovaSharedRegular(comfy.conds.CONDRegular):
"""Keep the shared text/reference prefix at one copy per guidance branch."""
def process_cond(self, batch_size, **kwargs):
return self._copy_with(self.cond)
class SenseNovaSharedList(comfy.conds.CONDList):
def process_cond(self, batch_size, **kwargs):
return self._copy_with(self.cond)
class SenseNovaU15(BaseModel):
PATCH_SIZE = 32
def __init__(self, model_config, model_type=ModelType.FLOW, device=None):
super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.sensenova.model.SenseNovaU15)
self.model_sampling = SenseNovaModelSampling(model_config)
self.memory_usage_factor_conds = ("reference_images",)
def process_timestep(self, timestep, **kwargs):
base_timestep = timestep / self.model_sampling.multiplier
return 1.0 - time_snr_shift(self.model_sampling.shift, 1.0 - base_timestep)
def extra_conds(self, **kwargs):
out = super().extra_conds(**kwargs)
text_input_ids = kwargs.get("text_input_ids")
if text_input_ids is not None:
device = kwargs["device"]
reference_images = kwargs.get("reference_latents")
if reference_images is not None:
reference_images = comfy.ldm.sensenova.conditioning.preprocess_references(reference_images)
image_only = kwargs.get("prompt_type") == "negative"
indexes = None
prefix_mask = None
if reference_images:
reference_grids = [
(
max(1, math.ceil(image.shape[-2] / self.PATCH_SIZE)),
max(1, math.ceil(image.shape[-1] / self.PATCH_SIZE)),
)
for image in reference_images
]
text_input_ids = comfy.ldm.sensenova.conditioning.condition_input_ids(
text_input_ids,
reference_grids,
image_only=image_only,
)
indexes = comfy.ldm.sensenova.conditioning.thw_indexes(text_input_ids, reference_grids)
prefix_mask = comfy.ldm.sensenova.conditioning.block_causal_mask(
indexes, dtype=self.get_dtype_inference()
)
if kwargs.get("hooks") is None:
dtype = self.get_dtype_inference()
prefix_keys, prefix_values, prefix_time = (
self.diffusion_model.preprocess_prefix(
text_input_ids.to(device=device),
[
image.to(device=device, dtype=dtype)
for image in reference_images
]
if reference_images
else None,
indexes.to(device=device) if indexes is not None else None,
prefix_mask.to(device=device)
if prefix_mask is not None
else None,
)
)
out["prefix_keys"] = SenseNovaSharedList(prefix_keys)
out["prefix_values"] = SenseNovaSharedList(prefix_values)
out["prefix_time"] = SenseNovaSharedRegular(prefix_time)
else:
if reference_images:
out["prefix_indexes"] = SenseNovaSharedRegular(indexes)
out["prefix_mask"] = SenseNovaSharedRegular(prefix_mask)
out["reference_images"] = SenseNovaSharedList(reference_images)
out["text_input_ids"] = SenseNovaSharedRegular(text_input_ids)
return out
def extra_conds_shapes(self, **kwargs):
images = kwargs.get("reference_latents")
images = comfy.ldm.sensenova.conditioning.split_reference_batches(images) if images is not None else []
reference_grids = [
(
max(1, math.ceil(image.shape[-3] / self.PATCH_SIZE)),
max(1, math.ceil(image.shape[-2] / self.PATCH_SIZE)),
)
for image in images
]
reference_pixels = sum(
height * width * self.PATCH_SIZE**2
for height, width in reference_grids
)
out = {}
if reference_pixels:
out["reference_images"] = [1, 3, reference_pixels]
text_input_ids = kwargs.get("text_input_ids")
if text_input_ids is not None:
if reference_grids:
length = comfy.ldm.sensenova.conditioning.conditioned_input_length(
text_input_ids.shape[1],
reference_grids,
image_only=kwargs.get("prompt_type") == "negative",
)
else:
length = text_input_ids.shape[1]
out["prefix_mask"] = [1, 1, length, length]
if kwargs.get("hooks") is None:
prefix_shape = [
1,
comfy.ldm.sensenova.model.NUM_KV_HEADS,
comfy.ldm.sensenova.model.NUM_LAYERS
* length
* comfy.ldm.sensenova.model.HEAD_DIM,
]
out["prefix_keys"] = prefix_shape
out["prefix_values"] = prefix_shape
return out
def memory_required(self, input_shape, cond_shapes={}):
memory = super().memory_required(input_shape, cond_shapes)
dtype_size = comfy.model_management.dtype_size(self.get_dtype_inference())
return memory + sum(
math.prod(shape) * dtype_size
for key in ("prefix_mask", "prefix_keys", "prefix_values")
for shape in cond_shapes.get(key, ())
)
class Chroma(Flux):
def __init__(self, model_config, model_type=ModelType.FLUX, device=None, unet_model=comfy.ldm.chroma.model.Chroma):
super().__init__(model_config, model_type, device=device, unet_model=unet_model)
+39
View File
@@ -120,6 +120,27 @@ def detect_unet_config(state_dict, key_prefix, metadata=None):
unet_config['block_repeat'] = [[1, 1, 1, 1], [2, 2, 2, 2]]
return unet_config
shape_key = '{}img2shape.t_embedder.mlp.0.weight'.format(key_prefix)
tex_key = '{}shape2txt.t_embedder.mlp.0.weight'.format(key_prefix)
if shape_key in state_dict_keys or tex_key in state_dict_keys: # trellis2 / pixal3d
has_shape = shape_key in state_dict_keys
has_tex = tex_key in state_dict_keys
unet_config = {
"image_model": "trellis2",
"resolution": 32 if (metadata or {}).get("is_512") else 64,
"init_txt_model": has_tex,
"txt_only": has_tex and not has_shape,
}
# Per-submodel projection head (Pixal3D adds `proj_linear`; Trellis2 doesn't).
for sub, name, proj_in_channels in (("img2shape", "shape", 2048),
("shape2txt", "texture", 2048),
("structure_model", "structure", 1024)):
key = '{}{}.blocks.0.cross_attn.proj_linear.weight'.format(key_prefix, sub)
if key in state_dict_keys:
unet_config["image_attn_mode_{}".format(name)] = "proj"
unet_config["proj_in_channels_{}".format(name)] = proj_in_channels
return unet_config
if '{}transformer.rotary_pos_emb.inv_freq'.format(key_prefix) in state_dict_keys: #stable audio dit
unet_config = {}
unet_config["audio_model"] = "dit1.0"
@@ -391,6 +412,7 @@ def detect_unet_config(state_dict, key_prefix, metadata=None):
dit_config["time_embed_hidden_size"] = te.shape[0]
dit_config["time_embed_dim"] = state_dict['{}time_embedder.proj_out.weight'.format(key_prefix)].shape[0]
dit_config["rope_inv_freq_len"] = state_dict['{}rope.inv_freq'.format(key_prefix)].shape[0]
dit_config["gate_compress"] = '{}blocks.0.attn.to_gate_compress.weight'.format(key_prefix) in state_dict_keys # VSA-trained
if metadata is not None and "config" in metadata:
dit_config.update(json.loads(metadata["config"]).get("transformer", {}))
return dit_config
@@ -794,6 +816,16 @@ def detect_unet_config(state_dict, key_prefix, metadata=None):
if '{}t_embedder1.mlp.0.weight'.format(key_prefix) in state_dict_keys and '{}x_embedder.proj1.weight'.format(key_prefix) in state_dict_keys: # HiDream-O1
return {"image_model": "hidream_o1"}
vision_key = f"{key_prefix}fm_modules.vision_model_mot_gen.embeddings.patch_embedding.weight"
query_key = f"{key_prefix}language_model.model.layers.0.self_attn.q_proj_mot_gen.weight"
if (
vision_key in state_dict
and query_key in state_dict
and state_dict[vision_key].shape[0] == 1024
and state_dict[query_key].shape[0] == 4096
): # SenseNova U1.5
return {"image_model": "sensenova_u15"}
if '{}caption_projection.0.linear.weight'.format(key_prefix) in state_dict_keys: # HiDream
dit_config = {}
dit_config["image_model"] = "hidream"
@@ -1280,6 +1312,13 @@ def unet_prefix_from_state_dict(state_dict):
if any(k.startswith("detector.") for k in state_dict) and any(k.startswith("tracker.") for k in state_dict):
return ""
# SenseNova checkpoints store the diffusion and language backbones at top level.
if (
"fm_modules.vision_model_mot_gen.embeddings.patch_embedding.weight" in state_dict
and "language_model.model.layers.0.self_attn.q_proj_mot_gen.weight" in state_dict
):
return ""
candidates = ["model.diffusion_model.", #ldm/sgm models
"model.model.", #audio models
"net.", #cosmos
+18 -9
View File
@@ -30,6 +30,7 @@ import gc
import os
from contextlib import contextmanager, nullcontext
import comfy.memory_management
import comfy.system_memory
import comfy.utils
import comfy.quant_ops
import comfy_aimdo.host_buffer
@@ -318,7 +319,7 @@ def get_total_memory(dev=None, torch_total_too=False):
dev = get_torch_device()
if hasattr(dev, 'type') and (dev.type == 'cpu' or dev.type == 'mps'):
mem_total = psutil.virtual_memory().total
mem_total = comfy.system_memory.virtual_memory_total()
mem_total_torch = mem_total
else:
if directml_enabled:
@@ -361,8 +362,11 @@ def mac_version():
return None
total_vram = get_total_memory(get_torch_device()) / (1024 * 1024)
total_ram = psutil.virtual_memory().total / (1024 * 1024)
total_ram = comfy.system_memory.virtual_memory_total() / (1024 * 1024)
logging.info("Total VRAM {:0.0f} MB, total RAM {:0.0f} MB".format(total_vram, total_ram))
cgroup_ram_limit = comfy.system_memory.cgroup_memory_limit()
if cgroup_ram_limit is not None:
logging.info("RAM limited by cgroup to {:0.0f} MB (host has {:0.0f} MB)".format(cgroup_ram_limit / (1024 * 1024), psutil.virtual_memory().total / (1024 * 1024)))
try:
logging.info("pytorch version: {}".format(torch_version))
@@ -515,13 +519,13 @@ try:
if args.use_split_cross_attention == False and args.use_quad_cross_attention == False:
if aotriton_supported(): # AMD efficient attention implementation depends on aotriton.
if torch_version_numeric >= (2, 7): # works on 2.6 but doesn't actually seem to improve much
if any((a in arch) for a in ["gfx90a", "gfx942", "gfx950", "gfx1100", "gfx1101", "gfx1150", "gfx1151"]): # TODO: more arches, TODO: gfx950
if any((a in arch) for a in ["gfx90a", "gfx942", "gfx950", "gfx1100", "gfx1101", "gfx1150", "gfx1151", "gfx1170", "gfx1171"]): # TODO: more arches, TODO: gfx950
ENABLE_PYTORCH_ATTENTION = True
if rocm_version >= (7, 0):
if any((a in arch) for a in ["gfx1200", "gfx1201"]):
ENABLE_PYTORCH_ATTENTION = True
if torch_version_numeric >= (2, 7) and rocm_version >= (6, 4):
if any((a in arch) for a in ["gfx1200", "gfx1201", "gfx950"]): # TODO: more arches, "gfx942" gives error on pytorch nightly 2.10 1013 rocm7.0
if any((a in arch) for a in ["gfx1200", "gfx1201", "gfx950", "gfx1170", "gfx1171"]): # TODO: more arches, "gfx942" gives error on pytorch nightly 2.10 1013 rocm7.0
SUPPORT_FP8_OPS = True
except:
@@ -703,7 +707,7 @@ def should_free_pins_for_ram_pressure(shortfall):
return False
if not WINDOWS:
return True
if psutil.virtual_memory().available < WINDOWS_PIN_EVICTION_EMERGENCY_AVAILABLE:
if comfy.system_memory.virtual_memory_available() < WINDOWS_PIN_EVICTION_EMERGENCY_AVAILABLE:
return True
try:
return psutil.swap_memory().percent >= WINDOWS_PIN_EVICTION_SWAP_PERCENT
@@ -717,7 +721,7 @@ def ensure_pin_budget(size, evict_active=False, loaded=False):
if args.fast_disk:
shortfall = TOTAL_PINNED_MEMORY + size - MAX_PINNED_MEMORY
else:
shortfall = size + max(comfy.memory_management.RAM_CACHE_HEADROOM / 2, 2048 * 1024 ** 2) - psutil.virtual_memory().available
shortfall = size + max(comfy.memory_management.RAM_CACHE_HEADROOM / 2, 2048 * 1024 ** 2) - comfy.system_memory.virtual_memory_available()
if shortfall <= 0:
return True
@@ -1369,6 +1373,7 @@ LARGEST_CASTED_WEIGHT = (None, 0)
STREAM_AIMDO_CAST_BUFFERS = {}
LARGEST_AIMDO_CASTED_WEIGHT = (None, 0)
CROSS_STEP_STATE = weakref.WeakSet()
MALLOC_GRAPH_MODULES = weakref.WeakSet()
DEFAULT_AIMDO_CAST_BUFFER_RESERVATION_SIZE = 16 * 1024 ** 3
@@ -1454,6 +1459,9 @@ def reset_cast_buffers():
STREAM_CAST_BUFFERS.clear()
STREAM_AIMDO_CAST_BUFFERS.clear()
for module in MALLOC_GRAPH_MODULES:
del module._comfy_malloc_graph
MALLOC_GRAPH_MODULES.clear()
soft_empty_cache()
def get_offload_stream(device):
@@ -1584,7 +1592,8 @@ if not args.disable_pinned_memory:
if WINDOWS:
MAX_PINNED_MEMORY = ram * 0.40 # Windows limit is apparently 50%
else:
MAX_PINNED_MEMORY = max(ram * 0.40, min(ram * 0.90, ram - 4 * 1024 ** 3, ram + get_disk_swap_total() - 16 * 1024 ** 3))
swap = 0 if comfy.system_memory.cgroup_memory_limit() is not None else get_disk_swap_total()
MAX_PINNED_MEMORY = max(ram * 0.40, min(ram * 0.90, ram - 4 * 1024 ** 3, ram + swap - 16 * 1024 ** 3))
logging.info("Enabled pinned memory {}".format(MAX_PINNED_MEMORY // (1024 * 1024)))
PINNING_ALLOWED_TYPES = set(["Tensor", "Parameter", "QuantizedTensor"])
@@ -1751,7 +1760,7 @@ def get_free_memory(dev=None, torch_free_too=False):
dev = get_torch_device()
if hasattr(dev, 'type') and (dev.type == 'cpu' or dev.type == 'mps'):
mem_free_total = psutil.virtual_memory().available
mem_free_total = comfy.system_memory.virtual_memory_available()
mem_free_torch = mem_free_total
else:
if directml_enabled:
@@ -1999,7 +2008,7 @@ def supports_mxfp8_compute(device=None):
return True
def supports_fp64(device=None):
if is_device_mps(device):
if (device is not None and is_device_mps(device)) or mps_mode():
return False
if is_intel_xpu():
+4 -4
View File
@@ -1020,14 +1020,14 @@ class ModelPatcher:
m.bias_function = []
if weight_key in self.patches:
if force_patch_weights:
if force_patch_weights or comfy.lora.calculate_shape(self.patches[weight_key], m.weight, weight_key) != m.weight.shape:
self.patch_weight_to_device(weight_key)
else:
_, set_func, convert_func = get_key_weight(self.model, weight_key)
m.weight_function = [LowVramPatch(weight_key, self.patches, convert_func, set_func)]
patch_counter += 1
if bias_key in self.patches:
if force_patch_weights:
if force_patch_weights or comfy.lora.calculate_shape(self.patches[bias_key], m.bias, bias_key) != m.bias.shape:
self.patch_weight_to_device(bias_key)
else:
_, set_func, convert_func = get_key_weight(self.model, bias_key)
@@ -1217,14 +1217,14 @@ class ModelPatcher:
module_mem += move_weight_functions(m, device_to)
if lowvram_possible:
if weight_key in self.patches:
if force_patch_weights:
if force_patch_weights or comfy.lora.calculate_shape(self.patches[weight_key], m.weight, weight_key) != m.weight.shape:
self.patch_weight_to_device(weight_key)
else:
_, set_func, convert_func = get_key_weight(self.model, weight_key)
m.weight_function.append(LowVramPatch(weight_key, self.patches, convert_func, set_func))
patch_counter += 1
if bias_key in self.patches:
if force_patch_weights:
if force_patch_weights or comfy.lora.calculate_shape(self.patches[bias_key], m.bias, bias_key) != m.bias.shape:
self.patch_weight_to_device(bias_key)
else:
_, set_func, convert_func = get_key_weight(self.model, bias_key)
+91 -8
View File
@@ -1,7 +1,12 @@
import torch
import contextlib
import logging
import threading
import warnings
import weakref
import torch
import comfy_aimdo.malloc_graph
import comfy_aimdo.model_vbar
from comfy.cli_args import args
import comfy.memory_management
@@ -12,6 +17,52 @@ PREFETCH_QUEUES = []
GRAPH_MODULES = weakref.WeakSet()
GRAPH_WARMED_MODULES = weakref.WeakSet()
GRAPH_CAPTURE_STREAMS = {}
ACTIVE_MALLOC_GRAPHS = {}
MALLOC_GRAPH_BREAKS = 0
MALLOC_GRAPH_USED = False
def _malloc_graph_break():
global MALLOC_GRAPH_BREAKS
MALLOC_GRAPH_BREAKS += 1
logging.debug("Comfy model compiler graph break")
def malloc_graph_enabled(device):
return not args.disable_comfy_compiler and comfy.memory_management.aimdo_enabled and comfy.model_management.is_device_cuda(device)
@contextlib.contextmanager
def pause_malloc_graph(sync=False):
graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident())
if graph is not None:
graph.pause(sync=sync)
try:
yield
finally:
if graph is not None:
graph.resume(sync=sync)
def malloc_graph_begin(module, device):
global MALLOC_GRAPH_USED
if not malloc_graph_enabled(device):
return
graph = getattr(module, "_comfy_malloc_graph", None)
if graph is None:
graph = comfy_aimdo.malloc_graph.record(
comfy.model_management.current_stream(device), args.assert_graph_breaks
)
module._comfy_malloc_graph = graph
comfy.model_management.MALLOC_GRAPH_MODULES.add(module)
else:
graph.push()
ACTIVE_MALLOC_GRAPHS[threading.get_ident()] = graph
MALLOC_GRAPH_USED = True
def malloc_graph_end():
thread_id = threading.get_ident()
graph = ACTIVE_MALLOC_GRAPHS.get(thread_id)
if graph is not None:
if graph.pop():
_malloc_graph_break()
ACTIVE_MALLOC_GRAPHS.pop(thread_id)
def cleanup_prefetched_modules(module, comfy_modules):
for s in comfy_modules:
@@ -42,8 +93,13 @@ def _drop_graph(module):
del module._comfy_graph
def cleanup_prefetch_queues():
global PREFETCH_QUEUES, GRAPH_CAPTURE_STREAMS
global PREFETCH_QUEUES
global MALLOC_GRAPH_BREAKS
global MALLOC_GRAPH_USED
graph = ACTIVE_MALLOC_GRAPHS.pop(threading.get_ident(), None)
if graph is not None:
graph.abort()
for queue in PREFETCH_QUEUES:
for entry in queue:
if entry is None or not isinstance(entry, tuple):
@@ -57,11 +113,18 @@ def cleanup_prefetch_queues():
_drop_graph(module)
GRAPH_MODULES.clear()
GRAPH_WARMED_MODULES.clear()
GRAPH_CAPTURE_STREAMS = {}
if MALLOC_GRAPH_USED:
logging.info("Comfy model compiler graph breaks: %d", MALLOC_GRAPH_BREAKS)
MALLOC_GRAPH_BREAKS = 0
MALLOC_GRAPH_USED = False
def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_graph=False, generator=None):
enable_graph = enable_graph and not args.disable_cuda_graphs and comfy.model_management.is_device_cuda(device) and getattr(module, "_v_block", None) is not None
def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_graph=False, generator=None, malloc_scope=None):
malloc_graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident())
enable_graph = enable_graph and malloc_graph is not None and not args.disable_cuda_graphs and comfy.model_management.is_device_cuda(device) and getattr(module, "_v_block", None) is not None
if queue is None:
if malloc_graph is not None and malloc_scope is not None:
if malloc_graph.iterate(malloc_scope if module is not None else None):
_malloc_graph_break()
if core is not None:
core()
return
@@ -71,6 +134,13 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap
capture_stream = GRAPH_CAPTURE_STREAMS.get(device)
if capture_stream is None:
capture_stream = torch.cuda.Stream(device=device)
# Keep PyTorch's persistent BLAS workspaces outside the allocation graph.
malloc_graph.pause()
with torch.cuda.stream(capture_stream):
torch.cuda.current_blas_handle()
one = torch.empty((2, 2), device=device)
torch.addmm(one[0], one, one)
malloc_graph.resume()
GRAPH_CAPTURE_STREAMS[device] = capture_stream
signature = None
@@ -82,6 +152,10 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap
module._v_block_faulted = True
graph_hit = comfy_aimdo.model_vbar.vbar_signature_compare(signature, graph["signature"])
if malloc_graph is not None and malloc_scope is not None:
if malloc_graph.iterate(malloc_scope if module is not None and not graph_hit else None):
_malloc_graph_break()
consumed = queue.pop(0)
if consumed is not None:
offload_stream, prefetch_state = consumed
@@ -131,12 +205,21 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap
module._v_block_faulted = True
if signature is not None:
_drop_graph(module)
malloc_graph.pause()
graph = torch.cuda.CUDAGraph()
if generator is not None:
graph.register_generator_state(generator)
malloc_graph.resume()
# Capture-time VBAR eviction is safe after prior work completes.
comfy.model_management.synchronize()
capture_stream.wait_stream(comfy.model_management.current_stream(device))
with torch.cuda.graph(graph, stream=capture_stream, capture_error_mode="thread_local"):
core()
malloc_graph.pause(sync=True)
with malloc_graph.use_stream(capture_stream):
with torch.cuda.graph(graph, stream=capture_stream, capture_error_mode="thread_local"):
malloc_graph.resume()
core()
malloc_graph.pause()
malloc_graph.resume(sync=True)
comfy.model_management.current_stream(device).wait_stream(capture_stream)
graph.replay()
module._comfy_graph = {"graph": graph, "signature": signature}
@@ -146,7 +229,7 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap
core()
else:
capture_stream.wait_stream(comfy.model_management.current_stream(device))
with torch.cuda.stream(capture_stream):
with torch.cuda.stream(capture_stream), malloc_graph.use_stream(capture_stream):
core()
comfy.model_management.current_stream(device).wait_stream(capture_stream)
GRAPH_WARMED_MODULES.add(module)
+4 -1
View File
@@ -45,7 +45,7 @@ def prepare_empty_noise(latent_image):
def fix_empty_latent_channels(model, latent_image, downscale_ratio_spacial=None, downscale_ratio_temporal=None):
if latent_image.is_nested:
return latent_image
latent_format = model.get_model_object("latent_format") #Resize the empty latent image so it has the right number of channels
latent_format = model.get_model_object("latent_format")
is_empty = torch.count_nonzero(latent_image) == 0
if is_empty:
if latent_format.latent_channels != latent_image.shape[1]:
@@ -64,6 +64,9 @@ def fix_empty_latent_channels(model, latent_image, downscale_ratio_spacial=None,
new_t = max(1, round(latent_image.shape[2] * ratio))
latent_image = comfy.utils.repeat_to_batch_size(latent_image, new_t, dim=2)
if is_empty:
latent_image = latent_format.fix_empty_latent(latent_image)
return latent_image
def prepare_sampling(model, noise_shape, positive, negative, noise_mask):
+2 -2
View File
@@ -636,7 +636,7 @@ class KSamplerX0Inpaint:
if "denoise_mask_function" in model_options:
denoise_mask = model_options["denoise_mask_function"](sigma, denoise_mask, extra_options={"model": self.inner_model, "sigmas": self.sigmas})
latent_mask = 1. - denoise_mask
x = x * denoise_mask + self.inner_model.inner_model.scale_latent_inpaint(x=x, sigma=sigma, noise=self.noise, latent_image=self.latent_image) * latent_mask
x = x * denoise_mask + self.inner_model.inner_model.scale_latent_inpaint(x=x, sigma=sigma, noise=self.noise, latent_image=self.latent_image, denoise_mask=denoise_mask) * latent_mask
out = self.inner_model(x, sigma, model_options=model_options, seed=seed)
if denoise_mask is not None:
out = out * denoise_mask + self.latent_image * latent_mask
@@ -971,7 +971,7 @@ class Sampler:
KSAMPLER_NAMES = ["euler", "euler_cfg_pp", "euler_ancestral", "euler_ancestral_cfg_pp", "heun", "heunpp2", "exp_heun_2_x0", "exp_heun_2_x0_sde", "dpm_2", "dpm_2_ancestral",
"lms", "dpm_fast", "dpm_adaptive", "dpmpp_2s_ancestral", "dpmpp_2s_ancestral_cfg_pp", "dpmpp_sde", "dpmpp_sde_gpu",
"dpmpp_2m", "dpmpp_2m_cfg_pp", "dpmpp_2m_sde", "dpmpp_2m_sde_gpu", "dpmpp_2m_sde_heun", "dpmpp_2m_sde_heun_gpu", "dpmpp_3m_sde", "dpmpp_3m_sde_gpu", "ddpm", "lcm",
"ipndm", "ipndm_v", "deis", "res_multistep", "res_multistep_cfg_pp", "res_multistep_ancestral", "res_multistep_ancestral_cfg_pp",
"ipndm", "ipndm_v", "deis", "cfgpp_ud10_ab", "res_multistep", "res_multistep_cfg_pp", "res_multistep_ancestral", "res_multistep_ancestral_cfg_pp",
"gradient_estimation", "gradient_estimation_cfg_pp", "er_sde", "seeds_2", "seeds_3", "sa_solver", "sa_solver_pece"]
class KSAMPLER(Sampler):
+28 -1
View File
@@ -15,6 +15,7 @@ import comfy.ldm.lightricks.vae.na_diffusion_decoder
import comfy.ldm.lightricks.vae.audio_vae
import comfy.ldm.cosmos.vae
import comfy.ldm.wan.vae
import comfy.ldm.trellis2.vae
import comfy.ldm.wan.vae2_2
import comfy.ldm.hunyuan3d.vae
import comfy.ldm.seedvr.vae
@@ -575,6 +576,16 @@ class VAE:
self.first_stage_model = StageC_coder()
self.downscale_ratio = 32
self.latent_channels = 16
elif "shape_dec.blocks.1.16.to_subdiv.weight" in sd: # trellis2 shape vae (struct_dec + shape_dec)
self.working_dtypes = [torch.float16, torch.bfloat16, torch.float32]
self.memory_used_decode = lambda shape, dtype: (2500 * math.prod(shape[2:])) * model_management.dtype_size(dtype)
self.memory_used_encode = lambda shape, dtype: (2500 * math.prod(shape[2:])) * model_management.dtype_size(dtype)
self.first_stage_model = comfy.ldm.trellis2.vae.ShapeVae()
elif "txt_dec.blocks.3.4.conv2.weight" in sd: # trellis2 texture vae
self.working_dtypes = [torch.float16, torch.bfloat16, torch.float32]
self.memory_used_decode = lambda shape, dtype: (2500 * math.prod(shape[2:])) * model_management.dtype_size(dtype)
self.memory_used_encode = lambda shape, dtype: (2500 * math.prod(shape[2:])) * model_management.dtype_size(dtype)
self.first_stage_model = comfy.ldm.trellis2.vae.TextureVae()
elif "decoder.up_blocks.2.upsamplers.0.upscale_conv.weight" in sd: # seedvr2
self.first_stage_model = comfy.ldm.seedvr.vae.VideoAutoencoderKLWrapper()
self.latent_channels = comfy.ldm.seedvr.vae.SEEDVR2_LATENT_CHANNELS
@@ -907,7 +918,14 @@ class VAE:
self.upscale_index_formula = (4, 16, 16)
self.downscale_ratio = (lambda a: max(0, math.floor((a + 3) / 4)), 16, 16)
self.downscale_index_formula = (4, 16, 16)
if self.latent_channels in [48, 128]: # Wan 2.2 and LTX2
if self.latent_channels == 24 and sd["decoder.22.bias"].shape[0] == 12: # MiniMax H3
self.first_stage_model = comfy.taesd.taehv.TAEHV(latent_channels=self.latent_channels, latent_format=None)
self.process_input = self.process_output = lambda image: image
self.upscale_ratio = (lambda a: max(1, (a - 2) // 5 * 17 + 5), 16, 16)
self.downscale_ratio = (lambda a: max(1, (a - 1) // 17 * 5 + 2) if a > 1 else 1, 16, 16)
self.memory_used_encode = lambda shape, dtype: (400 * ((shape[-3] + 16) // 17) * shape[-2] * shape[-1] * model_management.dtype_size(dtype))
self.memory_used_decode = lambda shape, dtype: ((260 * 16 * 16 + shape[1] * shape[-3]) * shape[-2] * shape[-1] * model_management.dtype_size(dtype))
elif self.latent_channels in [48, 128]: # Wan 2.2 and LTX2
self.first_stage_model = comfy.taesd.taehv.TAEHV(latent_channels=self.latent_channels, latent_format=None) # taehv doesn't need scaling
self.process_input = self.process_output = lambda image: image
self.process_output = lambda image: image
@@ -1277,6 +1295,15 @@ class VAE:
pixel_samples = pixel_samples.to(self.output_device).movedim(1,-1)
return pixel_samples
def prepare_decode(self, sample_shape, memory_required=None):
"""For VAEs whose real decode entry point bypasses decode()"""
if memory_required is None:
memory_required = self.memory_used_decode(sample_shape, self.vae_dtype)
memory_required = max(1, int(memory_required))
model_management.load_models_gpu([self.patcher], memory_required=memory_required, force_full_load=self.disable_offload)
free_memory = self.patcher.get_free_memory(self.device)
return max(1, int(free_memory / memory_required))
def _tile_bounded_shape(self, shape, tile_x, tile_y, tile_t):
"""Clamp a latent shape to one tile for memory estimates: peak memory of a tiled decode is per-tile. Only caller-provided tile dims are clamped."""
s = list(shape)
+66
View File
@@ -37,6 +37,7 @@ import comfy.text_encoders.longcat_image
import comfy.text_encoders.ernie
import comfy.text_encoders.cogvideo
import comfy.text_encoders.hidream_o1
import comfy.text_encoders.sensenova
import comfy.text_encoders.pixeldit
from . import supported_models_base
@@ -1478,6 +1479,31 @@ class WAN22_T2V(WAN21_T2V):
out = model_base.WAN22(self, image_to_video=True, device=device)
return out
class Trellis2(supported_models_base.BASE):
unet_config = {
"image_model": "trellis2"
}
unet_extra_config = {"num_heads": 12}
sampling_settings = {
"shift": 3.0,
}
memory_usage_factor = 6
latent_format = latent_formats.Trellis2
vae_key_prefix = ["vae."]
clip_vision_prefix = "conditioner.main_image_encoder.model."
# this is only needed for the texture model
supported_inference_dtypes = [torch.bfloat16, torch.float32]
def get_model(self, state_dict, prefix="", device=None):
return model_base.Trellis2(self, device=device)
def clip_target(self, state_dict={}):
return None
class WAN21_FlowRVS(WAN21_T2V):
unet_config = {
"image_model": "wan2.1",
@@ -1695,6 +1721,44 @@ class HiDreamO1(supported_models_base.BASE):
comfy.text_encoders.hidream_o1.HiDreamO1TE,
)
class SenseNovaU15(supported_models_base.BASE):
unet_config = {
"image_model": "sensenova_u15",
}
sampling_settings = {
"shift": 3.0,
"noise_scale": 1.0,
}
latent_format = latent_formats.HiDreamO1Pixel
memory_usage_factor = 0.033
supported_inference_dtypes = [torch.bfloat16, torch.float32]
vae_key_prefix = ["vae."]
text_encoder_key_prefix = ["text_encoders."]
optimizations = {"fp8": False}
def get_model(self, state_dict, prefix="", device=None):
return model_base.SenseNovaU15(self, device=device)
def process_unet_state_dict(self, state_dict):
state_dict.pop("language_model.lm_head.weight", None)
return state_dict
def process_vae_state_dict(self, state_dict):
return {"pixel_space_vae": torch.tensor(1.0)}
def process_clip_state_dict(self, state_dict):
return {"_sensenova_te_sentinel": torch.zeros(1)}
def clip_target(self, state_dict={}):
return supported_models_base.ClipTarget(
comfy.text_encoders.sensenova.SenseNovaTokenizer,
comfy.text_encoders.sensenova.SenseNovaTextEncoder,
)
class Chroma(supported_models_base.BASE):
unet_config = {
"image_model": "chroma",
@@ -2512,6 +2576,7 @@ models = [
TripoSplat,
HiDream,
HiDreamO1,
SenseNovaU15,
Chroma,
SeedVR2,
ChromaRadiance,
@@ -2538,5 +2603,6 @@ models = [
CogVideoX_I2V,
CogVideoX_T2V,
SVD_img2vid,
Trellis2,
DepthAnything3,
]
+128
View File
@@ -0,0 +1,128 @@
import os
import sys
import psutil
CGROUP_V2_ROOT = "/sys/fs/cgroup"
CGROUP_V1_MEMORY_ROOT = "/sys/fs/cgroup/memory"
PROC_SELF_CGROUP = "/proc/self/cgroup"
_cgroup_dirs = None
def _read_text(path):
try:
with open(path, encoding="utf-8") as f:
return f.read().strip()
except (OSError, ValueError):
return None
def _read_int(path):
raw = _read_text(path)
if raw is None:
return None
try:
return int(raw)
except ValueError:
return None
def _read_stat(path, key):
raw = _read_text(path)
if raw is None:
return None
for line in raw.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] == key:
try:
return int(parts[1])
except ValueError:
return None
return None
def _lineage(root, path):
dirs = [root]
for part in path.split("/"):
if part:
dirs.append(os.path.join(dirs[-1], part))
return dirs[::-1]
def _own_cgroup_dirs():
raw = _read_text(PROC_SELF_CGROUP)
if raw is None:
return []
dirs = []
for line in raw.splitlines():
parts = line.split(":", 2)
if len(parts) != 3:
continue
controllers, path = parts[1], parts[2]
if controllers == "":
root = CGROUP_V2_ROOT
elif "memory" in controllers.split(","):
root = CGROUP_V1_MEMORY_ROOT
else:
continue
for directory in _lineage(root, path):
if directory not in dirs:
dirs.append(directory)
return dirs
def _cgroup_directories():
global _cgroup_dirs
if _cgroup_dirs is None:
_cgroup_dirs = _own_cgroup_dirs() or [CGROUP_V2_ROOT, CGROUP_V1_MEMORY_ROOT]
return _cgroup_dirs
def _limit_in(directory):
for name in ("memory.max", "memory.limit_in_bytes"):
value = _read_int(os.path.join(directory, name))
if value is not None and value > 0:
return value
return None
def _working_set_in(directory):
usage = _read_int(os.path.join(directory, "memory.current"))
key = "inactive_file"
if usage is None:
usage = _read_int(os.path.join(directory, "memory.usage_in_bytes"))
key = "total_inactive_file"
if usage is None:
return None
inactive_file = _read_stat(os.path.join(directory, "memory.stat"), key) or 0
return max(0, usage - inactive_file)
def _limited(host_total):
if not sys.platform.startswith("linux"):
return []
limited = []
for directory in _cgroup_directories():
limit = _limit_in(directory)
if limit is not None and limit < host_total:
limited.append((limit, directory))
return limited
def cgroup_memory_limit():
return min((limit for limit, _ in _limited(psutil.virtual_memory().total)), default=None)
def virtual_memory_total():
host = psutil.virtual_memory()
return min((limit for limit, _ in _limited(host.total)), default=host.total)
def virtual_memory_available():
host = psutil.virtual_memory()
available = host.available
for limit, directory in _limited(host.total):
used = _working_set_in(directory)
available = min(available, limit if used is None else limit - used)
return max(0, available)
+26 -1
View File
@@ -131,10 +131,11 @@ class TAEHV(nn.Module):
self.latent_channels = latent_channels
self.parallel = parallel
self.latent_format = latent_format
self.is_h3 = self.latent_channels == 24
self.show_progress_bar = show_progress_bar
self.process_in = latent_format().process_in if latent_format is not None else (lambda x: x)
self.process_out = latent_format().process_out if latent_format is not None else (lambda x: x)
if self.latent_channels in [48, 32]: # Wan 2.2 and HunyuanVideo1.5
if self.latent_channels in [48, 32, 24]: # Wan 2.2, HunyuanVideo1.5 and MiniMax H3
self.patch_size = 2
elif self.latent_channels == 128: # LTX2
self.patch_size, self.latent_channels, encoder_time_downscale, decoder_time_upscale = 4, 128, (True, True, True), (True, True, True)
@@ -176,6 +177,21 @@ class TAEHV(nn.Module):
def encode(self, x, **kwargs):
x = x.movedim(2, 1) # [B, C, T, H, W] -> [B, T, C, H, W]
if self.is_h3:
single_frame = x.shape[1] == 1
batch = x.shape[0]
x = torch.cat([x, x[:, -1:].expand(-1, -x.shape[1] % 17, -1, -1, -1)], dim=1)
x = F.pad(x.reshape(batch, -1, 17, *x.shape[2:]), (0, 0, 0, 0, 0, 0, 3, 0))
if self.parallel:
x = apply_model_with_memblocks(self.encoder, x.flatten(0, 1), True, self.show_progress_bar,
patch_size=self.patch_size)
x = x.reshape(batch, -1, *x.shape[2:])
else:
x = torch.cat([apply_model_with_memblocks(self.encoder, chunk, False, False,
patch_size=self.patch_size)
for chunk in tqdm(x.unbind(1), disable=not self.show_progress_bar)], dim=1)
x = x[:, :1] if single_frame else x[:, :-3]
return self.process_out(x.movedim(2, 1))
if x.shape[1] % self.t_downscale != 0:
# pad at end to multiple of t_downscale
n_pad = self.t_downscale - x.shape[1] % self.t_downscale
@@ -189,7 +205,16 @@ class TAEHV(nn.Module):
x = x.unsqueeze(0) if x.ndim == 4 else x # [T, C, H, W] -> [1, T, C, H, W]
x = x.movedim(1, 2) if x.shape[1] != self.latent_channels else x # [B, T, C, H, W] or [B, C, T, H, W]
x = self.process_in(x).movedim(2, 1) # [B, C, T, H, W] -> [B, T, C, H, W]
if self.is_h3:
single_frame = x.shape[1] == 1
x = apply_model_with_memblocks(self.decoder, x, self.parallel, self.show_progress_bar,
output_device=comfy.model_management.intermediate_device(),
patch_size=self.patch_size, decode=True)
if self.is_h3:
x.clamp_(0, 1)
if not single_frame:
chunk_frames = 5 * self.t_upscale
x = F.pad(x, (0, 0, 0, 0, 0, 0, 0, -x.shape[1] % chunk_frames))
x = x.unflatten(1, (-1, chunk_frames))[:, :, self.frames_to_trim:].flatten(1, 2)
return x[:, :-3 * self.t_upscale].movedim(2, 1)
return x[:, self.frames_to_trim:].movedim(2, 1)
+73 -37
View File
@@ -4,9 +4,29 @@ from comfy import sd1_clip
import torch
import math
import yaml
import comfy.ops
import comfy.utils
def _audio_logits(model, x, audio_start, audio_end, eos_token=None):
input = x[:, -1:]
module = model.embed_tokens
offload_stream = None
if module.comfy_cast_weights:
weight, _, offload_stream = comfy.ops.cast_bias_weight(module, input, offloadable=True)
else:
weight = module.weight.to(x)
logits = torch.nn.functional.linear(input, weight[audio_start:audio_end], None)[:, -1]
eos_logits = None
if eos_token is not None:
eos_logits = torch.nn.functional.linear(input, weight[eos_token:eos_token + 1], None)[:, -1]
comfy.ops.uncast_bias_weight(module, weight, None, offload_stream)
return logits, eos_logits
def sample_manual_loop_no_classes(
model,
ids=None,
@@ -34,48 +54,43 @@ def sample_manual_loop_no_classes(
execution_dtype = torch.float32
embeds, attention_mask, num_tokens, embeds_info = model.process_tokens(ids, device)
embeds = embeds.to(execution_dtype)
embeds_batch = embeds.shape[0]
output_audio_codes = []
past_key_values = []
output_audio_codes = torch.empty((max_new_tokens,), device=device, dtype=torch.long)
generated_tokens = 0
generator = torch.Generator(device=device)
generator.manual_seed(seed)
model_config = model.transformer.model.config
past_kv_shape = [embeds_batch, model_config.num_key_value_heads, embeds.shape[1] + min_tokens, model_config.head_dim]
for x in range(model_config.num_hidden_layers):
past_key_values.append((torch.empty(past_kv_shape, device=device, dtype=execution_dtype), torch.empty(past_kv_shape, device=device, dtype=execution_dtype), 0))
past_key_values = model.transformer.model.init_kv_cache(embeds_batch, embeds.shape[1] + max_new_tokens, device, execution_dtype)
fixed_kv = isinstance(past_key_values[0], comfy.text_encoders.llama.FixedKV)
progress_bar = comfy.utils.ProgressBar(max_new_tokens)
sampling_logits = None
for step in comfy.utils.model_trange(max_new_tokens, desc="LM sampling"):
outputs = model.transformer(None, attention_mask, embeds=embeds.to(execution_dtype), num_tokens=num_tokens, intermediate_output=None, dtype=execution_dtype, embeds_info=embeds_info, past_key_values=past_key_values)
next_token_logits = model.transformer.logits(outputs[0])[:, -1]
outputs = model.transformer(None, attention_mask, embeds=embeds, num_tokens=num_tokens, intermediate_output=None, dtype=execution_dtype, embeds_info=embeds_info, past_key_values=past_key_values)
past_key_values = outputs[2]
if cfg_scale != 1.0:
cond_logits = next_token_logits[0:1]
uncond_logits = next_token_logits[1:2]
cfg_logits = uncond_logits + cfg_scale * (cond_logits - uncond_logits)
else:
cfg_logits = next_token_logits[0:1]
use_eos_score = eos_token_id is not None and eos_token_id < audio_start_id and min_tokens < step
if use_eos_score:
eos_score = cfg_logits[:, eos_token_id].clone()
audio_logits, eos_logits = _audio_logits(model.transformer.model, outputs[0], audio_start_id, audio_end_id, eos_token_id if use_eos_score else None)
if cfg_scale != 1.0:
cfg_logits = audio_logits[1:2] + cfg_scale * (audio_logits[0:1] - audio_logits[1:2])
if use_eos_score:
cond_eos = eos_logits[0:1, 0]
uncond_eos = eos_logits[1:2, 0]
eos_score = uncond_eos + cfg_scale * (cond_eos - uncond_eos)
else:
cfg_logits = audio_logits[0:1]
if use_eos_score:
eos_score = eos_logits[0:1, 0]
remove_logit_value = torch.finfo(cfg_logits.dtype).min
# Only generate audio tokens
cfg_logits[:, :audio_start_id] = remove_logit_value
cfg_logits[:, audio_end_id:] = remove_logit_value
if use_eos_score:
cfg_logits[:, eos_token_id] = eos_score
cfg_logits = torch.cat((eos_score.unsqueeze(1), cfg_logits), dim=1)
if top_k is not None and top_k > 0:
top_k_vals, _ = torch.topk(cfg_logits, top_k)
min_val = top_k_vals[..., -1, None]
cfg_logits[cfg_logits < min_val] = remove_logit_value
top_k_values = torch.topk(cfg_logits, min(top_k, cfg_logits.shape[-1])).values
cfg_logits[cfg_logits < top_k_values[..., -1, None]] = remove_logit_value
if min_p is not None and min_p > 0:
probs = torch.softmax(cfg_logits, dim=-1)
@@ -89,28 +104,40 @@ def sample_manual_loop_no_classes(
sorted_indices_to_remove = cumulative_probs > top_p
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0
indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
indices_to_remove = torch.zeros_like(cfg_logits, dtype=torch.bool)
indices_to_remove.scatter_(1, sorted_indices, sorted_indices_to_remove)
cfg_logits[indices_to_remove] = remove_logit_value
if temperature > 0:
cfg_logits = cfg_logits / temperature
next_token = torch.multinomial(torch.softmax(cfg_logits, dim=-1), num_samples=1, generator=generator).squeeze(1)
if sampling_logits is None:
sampling_logits = cfg_logits.new_empty((cfg_logits.shape[0], model.transformer.model.vocab_size))
sampling_logits.fill_(remove_logit_value)
if use_eos_score:
sampling_logits[:, eos_token_id] = cfg_logits[:, 0]
cfg_logits = cfg_logits[:, 1:]
sampling_logits[:, audio_start_id:audio_end_id] = cfg_logits
next_token = torch.multinomial(torch.softmax(sampling_logits, dim=-1), num_samples=1, generator=generator).squeeze(1)
else:
next_token = torch.argmax(cfg_logits, dim=-1)
if use_eos_score:
next_token = torch.where(next_token == 0, eos_token_id, next_token + audio_start_id - 1)
else:
next_token += audio_start_id
token = next_token.item()
if token == eos_token_id:
if eos_token_id is not None and next_token.item() == eos_token_id:
break
embed, _, _, _ = model.process_tokens([[token]], device)
embeds = embed.repeat(embeds_batch, 1, 1)
attention_mask = torch.cat([attention_mask, torch.ones((embeds_batch, 1), device=device, dtype=attention_mask.dtype)], dim=1)
input_ids = next_token.repeat(embeds_batch).unsqueeze(1)
embeds = model.transformer.get_input_embeddings()(input_ids, out_dtype=execution_dtype)
if not fixed_kv:
attention_mask = torch.cat([attention_mask, torch.ones((embeds_batch, 1), device=device, dtype=attention_mask.dtype)], dim=1)
output_audio_codes.append(token - audio_start_id)
output_audio_codes[generated_tokens] = next_token[0] - audio_start_id
generated_tokens += 1
progress_bar.update_absolute(step)
return output_audio_codes
return output_audio_codes[:generated_tokens].tolist()
def generate_audio_codes(model, positive, negative, min_tokens=1, max_tokens=1024, seed=0, cfg_scale=2.0, temperature=0.85, top_p=0.9, top_k=0, min_p=0.000):
@@ -286,7 +313,10 @@ class ACE15TEModel(torch.nn.Module):
self.qwen3_06b = Qwen3_06BModel(device=device, dtype=dtype, model_options=model_options)
if model is not None:
setattr(self, self.lm_model, model(device=device, dtype=dtype_llama, model_options=model_options))
ar_model = getattr(self, self.lm_model)
ar_model.transformer.model.fixed_kv = True
ar_model.transformer.model.prefetch_dynamic_vbars = True
ar_model.transformer.model.graph_dynamic_vbar_blocks = True
self.dtypes = set([dtype, dtype_llama])
def encode_token_weights(self, token_weight_pairs):
@@ -319,6 +349,12 @@ class ACE15TEModel(torch.nn.Module):
if lm_model is not None:
lm_model.reset_clip_options()
def get_dynamic_vram__units(self):
if self.lm_model is None:
return ([], [])
model = getattr(self, self.lm_model)
return model.transformer.model.get_dynamic_vram__units()
def load_sd(self, sd):
if "model.layers.0.post_attention_layernorm.weight" in sd:
shape = sd["model.layers.0.post_attention_layernorm.weight"].shape
+26 -41
View File
@@ -226,7 +226,7 @@ class Gemma4Attention(nn.Module):
present_key_value = None
fixed_cache = past_key_value if isinstance(past_key_value, FixedKV) else None
if fixed_cache is not None:
if seq_length == 1:
if seq_length == 1 and fixed_cache.index > 0:
# CUDA-graphable decode: write at the device-side ring/linear position
fixed_cache.key.index_copy_(2, fixed_cache.position, xk)
fixed_cache.value.index_copy_(2, fixed_cache.position, xv)
@@ -516,9 +516,12 @@ class Gemma4Transformer(nn.Module):
fixed_kv = (past_key_values is not None and len(past_key_values) > 0
and isinstance(past_key_values[0], FixedKV))
decode = fixed_kv and seq_len == 1
# mirror the conditions under which prefetch_queue_pop can actually capture, so
# eager fallbacks keep the sliced decode path instead of the full-capacity one
enable_graph = (decode and mask is None and self.graph_dynamic_vbar_blocks
# Compiled decode needs fixed-capacity attention for a stable allocation trace;
# CUDA graph capture has additional prefetch and device requirements.
compiled_decode = decode and past_len > 0 and mask is None and self.graph_dynamic_vbar_blocks
if compiled_decode:
x = x.clone()
enable_graph = (compiled_decode
and prefetch_queue is not None
and hasattr(self.layers[0], "_v_block")
and not comfy.model_management.args.disable_cuda_graphs
@@ -536,39 +539,12 @@ class Gemma4Transformer(nn.Module):
for kv in past_key_values:
if isinstance(kv, FixedKV) and id(kv.position) not in decode_masks:
decode_masks[id(kv.position)] = _fixed_kv_decode_mask(mask, kv, min_val)
if enable_graph:
# static buffers + per-capacity attention biases: layer graphs replay against
# stable storage, refreshed eagerly each step
if compiled_decode:
capacities = tuple(sorted({kv.key.shape[2] for kv in past_key_values if isinstance(kv, FixedKV)}))
state_key = (x.shape, x.dtype, x.device, tuple(t.shape for t in freqs_cis), capacities,
None if per_layer_inputs is None else per_layer_inputs.shape)
state = getattr(self, "_comfy_cross_step_state", None)
if state is None or state["key"] != state_key:
state = {"key": state_key,
"x": torch.empty_like(x),
"freqs_cis": [torch.empty_like(t) for t in freqs_cis],
"bias": {c: torch.empty((1, 1, 1, c), dtype=x.dtype, device=x.device) for c in capacities},
"per_layer": None if per_layer_inputs is None else torch.empty_like(per_layer_inputs),
"bias_valid": -1}
self._comfy_cross_step_state = state
comfy.model_management._register_cross_step(self)
state["x"].copy_(x)
for source, target in zip(freqs_cis, state["freqs_cis"]):
target.copy_(source)
x = state["x"]
freqs_cis = state["freqs_cis"]
if per_layer_inputs is not None:
state["per_layer"].copy_(per_layer_inputs)
per_layer_inputs = state["per_layer"]
valid = past_len + 1
for capacity, bias in state["bias"].items():
if state["bias_valid"] != past_len:
bias.fill_(min_val)
bias[..., :min(valid, capacity)] = 0
elif past_len < capacity:
bias[..., past_len:valid] = 0
state["bias_valid"] = valid
decode_bias = state["bias"]
decode_bias = {capacity: torch.full((1, 1, 1, capacity), min_val, dtype=x.dtype, device=x.device) for capacity in capacities}
for capacity, bias in decode_bias.items():
bias[..., :min(valid, capacity)] = 0
intermediate = None
all_intermediate = None
@@ -604,7 +580,7 @@ class Gemma4Transformer(nn.Module):
if shared is not None:
layer_kwargs['shared_kv'] = shared
if enable_graph:
if compiled_decode:
bias_cache = layer_kwargs.get('shared_kv', past_kv)
layer_mask = decode_bias[bias_cache.key.shape[2]]
elif decode:
@@ -617,10 +593,17 @@ class Gemma4Transformer(nn.Module):
def core():
nonlocal x
x, current_kv, shareable_kv = layer(x=x, attention_mask=layer_mask, freqs_cis=freqs_cis, past_key_value=past_kv, **layer_kwargs)
output, current_kv, shareable_kv = layer(x=x, attention_mask=layer_mask, freqs_cis=freqs_cis, past_key_value=past_kv, **layer_kwargs)
if compiled_decode:
x.copy_(output)
else:
x = output
result.append((current_kv, shareable_kv))
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, layer, x.dtype, core=core, enable_graph=enable_graph)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, x.device, layer, x.dtype, core=core, enable_graph=enable_graph,
malloc_scope="block"
)
if result:
current_kv, shareable_kv = result[0]
@@ -639,8 +622,10 @@ class Gemma4Transformer(nn.Module):
if i == intermediate_output:
intermediate = x.clone()
if prefetch_queue is not None:
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, None)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, x.device, None,
malloc_scope="block"
)
if fixed_kv:
for kv in past_key_values:
@@ -706,7 +691,7 @@ class Gemma4Base(BaseLlama, BaseGenerate, torch.nn.Module):
tracker = trackers.get((cache_cls, length))
if tracker is None:
tracker = (torch.empty((1,), device=device, dtype=torch.int64),
torch.empty((batch,), device=device, dtype=torch.int32))
torch.zeros((batch,), device=device, dtype=torch.int32))
trackers[(cache_cls, length)] = tracker
# zero-init: decode attends full capacity with masked tails, 0*0 stays finite
key = torch.zeros((batch, kv_heads, length, head_dim), device=device, dtype=execution_dtype)
+56 -45
View File
@@ -26,8 +26,8 @@ class FixedKV:
seqlen: torch.Tensor
def prepare(self, num_tokens):
self.position.fill_(self.index)
self.seqlen.fill_(self.index + num_tokens)
self.position.copy_(self.seqlen)
self.seqlen.add_(num_tokens)
def advance(self, num_tokens):
self.index += num_tokens
@@ -571,17 +571,25 @@ class Attention(nn.Module):
xq = xq.transpose(1, 2)
xk = xk.transpose(1, 2)
xv = xv.transpose(1, 2)
if seq_length == 1:
if seq_length == 1 and fixed_cache.index > 0:
# CUDA-graphable decode path.
fixed_cache.key.index_copy_(1, fixed_cache.position, xk)
fixed_cache.value.index_copy_(1, fixed_cache.position, xv)
position = fixed_cache.position.view(batch_size, 1, 1, 1).expand_as(xk)
fixed_cache.key.scatter_(1, position, xk)
fixed_cache.value.scatter_(1, position, xv)
output = comfy_kitchen.flash_attention_decode(xq, fixed_cache.key, fixed_cache.value, fixed_cache.seqlen)
return self.o_proj(output.view(batch_size, seq_length, self.inner_size)), fixed_cache
fixed_cache.key[:, fixed_cache.index:fixed_cache.index + seq_length].copy_(xk)
fixed_cache.value[:, fixed_cache.index:fixed_cache.index + seq_length].copy_(xv)
xk = fixed_cache.key[:, :fixed_cache.index + seq_length]
xv = fixed_cache.value[:, :fixed_cache.index + seq_length]
if attention_mask is None or attention_mask.ndim < 4:
fixed_cache.key[:, :seq_length].copy_(xk)
fixed_cache.value[:, :seq_length].copy_(xv)
else:
valid = attention_mask[:, 0, -1, -seq_length:] == 0
indices = torch.arange(seq_length, device=xk.device).expand(batch_size, -1)
indices = indices.masked_fill(~valid, seq_length).sort(dim=1).values.clamp_max_(seq_length - 1)
indices = indices.view(batch_size, seq_length, 1, 1).expand_as(xk)
fixed_cache.key[:, :seq_length].copy_(xk.gather(1, indices))
fixed_cache.value[:, :seq_length].copy_(xv.gather(1, indices))
fixed_cache.seqlen.copy_(valid.sum(dim=1))
xq = xq.transpose(1, 2)
xk = xk.transpose(1, 2)
@@ -796,8 +804,8 @@ class Llama2_(nn.Module):
if fixed_kv:
key = torch.empty((batch, capacity, self.config.num_key_value_heads, self.config.head_dim), device=device, dtype=dtype)
value = torch.empty_like(key)
position = torch.empty((1,), device=device, dtype=torch.int64)
seqlen = torch.empty((batch,), device=device, dtype=torch.int32)
position = torch.empty((batch,), device=device, dtype=torch.int64)
seqlen = torch.zeros((batch,), device=device, dtype=torch.int32)
caches.append(FixedKV(key, value, 0, position, seqlen))
else:
key = torch.empty((batch, self.config.num_key_value_heads, capacity, self.config.head_dim), device=device, dtype=dtype)
@@ -824,6 +832,10 @@ class Llama2_(nn.Module):
past_len = 0
if past_key_values is not None and len(past_key_values) > 0:
past_len = self.get_past_len(past_key_values)
fixed_kv = past_key_values is not None and len(past_key_values) > 0 and isinstance(past_key_values[0], FixedKV)
fixed_kv_decode = fixed_kv and past_len > 0 and seq_len == 1
if fixed_kv_decode:
attention_mask = None
if position_ids is None:
position_ids = torch.arange(past_len, past_len + seq_len, device=x.device).unsqueeze(0)
@@ -844,32 +856,9 @@ class Llama2_(nn.Module):
optimized_attention = optimized_attention_for_device(x.device, mask=mask is not None, small_input=True)
fixed_kv = past_key_values is not None and len(past_key_values) > 0 and isinstance(past_key_values[0], FixedKV)
enable_graph = self.graph_dynamic_vbar_blocks and fixed_kv and seq_len == 1 and mask is None
enable_graph = self.graph_dynamic_vbar_blocks and fixed_kv_decode
if enable_graph:
freqs_cis_groups = freqs_cis if isinstance(freqs_cis, list) else [freqs_cis]
cross_step_state_key = [(x.shape, x.stride(), x.dtype, x.device)]
for group in freqs_cis_groups:
for tensor in group:
cross_step_state_key.append((tensor.shape, tensor.stride(), tensor.dtype, tensor.device))
cross_step_state_key = tuple(cross_step_state_key)
cross_step_state = getattr(self, "_comfy_cross_step_state", None)
if cross_step_state is None or cross_step_state["key"] != cross_step_state_key:
static_freqs_cis = []
for group in freqs_cis_groups:
static_freqs_cis.append(tuple(torch.empty_like(tensor) for tensor in group))
if not isinstance(freqs_cis, list):
static_freqs_cis = static_freqs_cis[0]
cross_step_state = {"key": cross_step_state_key, "x": torch.empty_like(x), "freqs_cis": static_freqs_cis}
self._comfy_cross_step_state = cross_step_state
comfy.model_management._register_cross_step(self)
cross_step_state["x"].copy_(x)
static_freqs_cis_groups = cross_step_state["freqs_cis"] if isinstance(freqs_cis, list) else [cross_step_state["freqs_cis"]]
for source_group, target_group in zip(freqs_cis_groups, static_freqs_cis_groups):
for source, target in zip(source_group, target_group):
target.copy_(source)
x = cross_step_state["x"]
freqs_cis = cross_step_state["freqs_cis"]
x = x.clone()
intermediate = None
all_intermediate = None
@@ -900,17 +889,24 @@ class Llama2_(nn.Module):
def core():
nonlocal x
x, current_kv = layer(
output, current_kv = layer(
x=x,
attention_mask=mask,
freqs_cis=freqs_cis,
optimized_attention=optimized_attention,
past_key_value=past_kv,
)
if enable_graph:
x.copy_(output)
else:
x = output
if next_key_values:
next_key_values[i] = current_kv
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, layer, x.dtype, core=core, enable_graph=enable_graph)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, x.device, layer, x.dtype, core=core, enable_graph=enable_graph,
malloc_scope="block"
)
if fixed_kv:
next_key_values[i].advance(seq_len)
@@ -921,8 +917,10 @@ class Llama2_(nn.Module):
if i == intermediate_output:
intermediate = x.clone()
if prefetch_queue is not None:
comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, None)
comfy.model_prefetch.prefetch_queue_pop(
prefetch_queue, x.device, None,
malloc_scope="block"
)
if self.norm is not None:
x = self.norm(x)
@@ -1030,9 +1028,19 @@ class BaseGenerate:
# MRoPE: prefill uses explicit 3D position_ids, decode continues from the last position
next_pos = int(position_ids[:, -1].max()) + 1 if position_ids is not None else None
compile_allocations = self.model.graph_dynamic_vbar_blocks and comfy.model_prefetch.malloc_graph_enabled(device)
decode_tokens = torch.empty((embeds.shape[0], 1), dtype=torch.long, device=device)
# Generation loop
current_input_ids = initial_input_ids
for step in tqdm(range(max_length), desc="Generating tokens"):
if step > 0:
if compile_allocations:
comfy.model_prefetch.malloc_graph_begin(self, device)
embeds = self.model.embed_tokens(decode_tokens).to(execution_dtype)
current_input_ids = decode_tokens if initial_input_ids is not None else None
position_ids = torch.tensor([[next_pos]], device=device) if next_pos is not None else None
# DeepStack visual features are injected on the prefill only; gemma4's forward lacks these kwargs.
extra = {}
if step == 0 and deepstack_embeds is not None:
@@ -1041,13 +1049,16 @@ class BaseGenerate:
x, _, past_key_values = self.model.forward(None, embeds=embeds, attention_mask=None, past_key_values=past_key_values, input_ids=current_input_ids, position_ids=position_ids, **extra, embeds_info=(embeds_info if step == 0 else None))
logits = self.logits(x)[:, -1]
next_token = self.sample_token(logits, temperature, top_k, top_p, min_p, repetition_penalty, initial_tokens + generated_token_ids, generator, do_sample=do_sample, presence_penalty=presence_penalty)
token_id = next_token[0].item()
decode_tokens.copy_(next_token)
del next_token, logits, x, embeds, position_ids
if step > 0 and compile_allocations:
comfy.model_prefetch.malloc_graph_end()
token_id = decode_tokens[0].item()
generated_token_ids.append(token_id)
embeds = self.model.embed_tokens(next_token).to(execution_dtype)
current_input_ids = next_token if initial_input_ids is not None else None
if next_pos is not None: # advance MRoPE position for the next (decode) step
position_ids = torch.tensor([[next_pos]], device=device)
if step > 0 and next_pos is not None:
next_pos += 1
pbar.update(1)
+22 -6
View File
@@ -28,6 +28,10 @@ from .qwen3vl import Qwen3VL, Qwen3VLSDTokenizer
VISION_START = 151652
VISION_END = 151653
# FL2VA/Ref2VA tokenizer_config extends Qwen with these, ids fixed by the released tokenizer
MINIMAX_EXTRA_TOKENS = {"<d>": 151669, "</d>": 151670, "<|cutoff|>": 151671,
"<|lyrics_start|>": 151672, "<|lyrics_end|>": 151673,
"<|caption_start|>": 151674, "<|caption_end|>": 151675}
QWEN_IMAGE_MEAN = [0.5, 0.5, 0.5]
QWEN_IMAGE_STD = [0.5, 0.5, 0.5]
@@ -122,15 +126,18 @@ class MiniMaxH3TEModel(comfy.sd1_clip.SD1ClipModel):
clip_model=MiniMaxH3ClipModel, model_options=model_options)
class MiniMaxQwenSDTokenizer(Qwen3VLSDTokenizer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tokenizer.add_special_tokens({"additional_special_tokens": list(MINIMAX_EXTRA_TOKENS)})
self.inv_vocab = {v: k for k, v in self.tokenizer.get_vocab().items()}
class MiniMaxH3Tokenizer(comfy.sd1_clip.SD1Tokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
tokenizer = lambda *a, **kw: Qwen3VLSDTokenizer(*a, **kw, embedding_size=5120, embedding_key="qwen3vl_32b")
tokenizer = lambda *a, **kw: MiniMaxQwenSDTokenizer(*a, **kw, embedding_size=5120, embedding_key="qwen3vl_32b")
super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data, name="qwen3vl_32b", tokenizer=tokenizer)
def _text_ids(self, text):
tok = self.qwen3vl_32b.tokenizer
return tok(text, add_special_tokens=False)["input_ids"]
@staticmethod
def _vision_entry(data, video_block=False):
emb = {"type": "image", "data": data, "original_type": "image"}
@@ -143,7 +150,16 @@ class MiniMaxH3Tokenizer(comfy.sd1_clip.SD1Tokenizer):
entries = []
def add_text(s):
entries.extend((tid, 1.0) for tid in self._text_ids(s))
if not s:
return
token_batches = self.qwen3vl_32b.tokenize_with_weights(
s,
return_word_ids=False,
disable_weights=True,
)
if len(token_batches) != 1:
raise ValueError("MiniMax H3 text segment exceeds the supported prompt length.")
entries.extend(token_batches[0])
def add_vision(data, video_block=False):
entries.append((VISION_START, 1.0))
+149
View File
@@ -0,0 +1,149 @@
"""Tokenizer-only conditioning for SenseNova U1.5.
The language model is part of the diffusion checkpoint, so CLIP only needs to
produce token ids. SenseNova extends the Qwen vocabulary with image-control
tokens; their order is significant because the checkpoint embeds them by id.
"""
import os
import torch
from transformers import Qwen2Tokenizer
from comfy import sd1_clip
SYSTEM_MESSAGE = (
"You are an image generation and editing assistant that accurately understands and executes "
"user intent.\n\nYou support two modes:\n\n1. Think Mode:\nIf the task requires reasoning, you "
"MUST start with a <think></think> block. Put all reasoning inside the block using plain text. "
"DO NOT include any image tags. Keep it reasonable and directly useful for producing the final "
"image.\n\n2. Non-Think Mode:\nIf no reasoning is needed, directly produce the final image.\n\n"
"Task Types:\n\nA. Text-to-Image Generation:\n"
"- Generate a high-quality image based on the user's description.\n"
"- Ensure visual clarity, semantic consistency, and completeness.\n"
"- DO NOT introduce elements that contradict or override the user's intent.\n\n"
"B. Image Editing:\n"
"- Use the provided image(s) as input or reference for modification or transformation.\n"
"- The result can be an edited image or a new image based on the reference(s).\n"
"- Preserve all unspecified attributes unless explicitly changed.\n\n"
"General Rules:\n"
"- For any visible text in the image, follow the language specified for the rendered text in "
"the user's description, not the language of the prompt. If no language is specified, use the "
"user's input language."
)
def build_generation_prompt(text):
return (
f"<|im_start|>system\n{SYSTEM_MESSAGE}<|im_end|>\n"
f"<|im_start|>user\n{text}<|im_end|>\n"
"<|im_start|>assistant\n<think>\n\n</think>\n\n<img>"
)
def build_unconditional_prompt():
return "<|im_start|>user\n<|im_end|>\n<|im_start|>assistant\n<img>"
class SenseNovaQwen2Tokenizer:
@classmethod
def from_pretrained(cls, *args, **kwargs):
tokenizer = Qwen2Tokenizer.from_pretrained(*args, **kwargs)
existing_special_tokens = [
token
for _, token in sorted(tokenizer.added_tokens_decoder.items())
if token.special
]
extra_tokens = [
"<IMG_CONTEXT>",
"<img>",
"</img>",
"<quad>",
"</quad>",
"<ref>",
"</ref>",
"<box>",
"</box>",
"<|action_start|>",
"<|action_end|>",
"<|plugin|>",
"<|interpreter|>",
]
extra_tokens.extend(f"<FAKE_PAD_{index}>" for index in range(254))
tokenizer.add_special_tokens(
{"additional_special_tokens": existing_special_tokens + extra_tokens}
)
return tokenizer
class SenseNovaQwenTokenizer(sd1_clip.SDTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
tokenizer_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "qwen25_tokenizer"
)
super().__init__(
tokenizer_path,
pad_with_end=False,
embedding_size=4096,
embedding_key="sensenova_u15",
tokenizer_class=SenseNovaQwen2Tokenizer,
has_start_token=False,
has_end_token=False,
pad_to_max_length=False,
max_length=99999999,
min_length=1,
pad_token=151643,
tokenizer_data=tokenizer_data,
)
class SenseNovaTokenizer(sd1_clip.SD1Tokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
name="sensenova_u15",
tokenizer=SenseNovaQwenTokenizer,
)
def tokenize_with_weights(self, text, return_word_ids=False, **kwargs):
prompt = build_generation_prompt(text) if text else build_unconditional_prompt()
tokens = super().tokenize_with_weights(
prompt,
return_word_ids=return_word_ids,
disable_weights=True,
**kwargs,
)
values = tokens["sensenova_u15"][0]
values = [value for value in values if int(value[0]) != 151643]
return {"sensenova_u15": [values]}
class SenseNovaTextEncoder(torch.nn.Module):
def __init__(self, device="cpu", dtype=None, model_options={}):
super().__init__()
self.dtypes = {torch.float32}
self.disable_offload = True
self.device = torch.device("cpu") if device is None else torch.device(device)
def encode_token_weights(self, token_weight_pairs):
pairs = token_weight_pairs["sensenova_u15"][0]
input_ids = torch.tensor([[int(value[0]) for value in pairs]], dtype=torch.long)
return (
input_ids.unsqueeze(-1).to(torch.float32),
None,
{"text_input_ids": input_ids},
)
def load_sd(self, sd):
return []
def get_sd(self):
return {}
def reset_clip_options(self):
pass
def set_clip_options(self, options):
pass
+43 -7
View File
@@ -82,19 +82,47 @@ _TYPES = {
"U16": torch.uint16,
}
_SAFETENSORS_MAX_HEADER_SIZE = 100_000_000
def _invalid_safetensors_error(message, ckpt):
return ValueError("{}\n\nFile path: {}\n\nThe safetensors file is corrupt or invalid. Make sure this is actually a safetensors file and not a ckpt or pt or other filetype.".format(message, ckpt))
def _incomplete_safetensors_error(message, ckpt):
return ValueError("{}\n\nFile path: {}\n\nThe safetensors file is corrupt/incomplete. Check the file size and make sure you have copied/downloaded it correctly.".format(message, ckpt))
def load_safetensors(ckpt):
import comfy_aimdo.model_mmap
file_size = os.path.getsize(ckpt)
if file_size < 8:
raise _incomplete_safetensors_error("The safetensors header is incomplete.", ckpt)
file_lock = threading.Lock()
model_mmap = comfy_aimdo.model_mmap.ModelMMAP(ckpt)
f = model_mmap.get_file_handle()
file_size = os.path.getsize(ckpt)
mv = memoryview((ctypes.c_uint8 * file_size).from_address(model_mmap.get()))
header_size = struct.unpack("<Q", mv[:8])[0]
header = json.loads(mv[8:8 + header_size].tobytes().decode("utf-8"))
if header_size > _SAFETENSORS_MAX_HEADER_SIZE:
raise _invalid_safetensors_error("The safetensors header is too large.", ckpt)
mv = mv[(data_base_offset := 8 + header_size):]
data_base_offset = 8 + header_size
if data_base_offset > file_size:
raise _incomplete_safetensors_error("The safetensors header is incomplete.", ckpt)
try:
header = json.loads(mv[8:data_base_offset].tobytes().decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raise _invalid_safetensors_error(str(e), ckpt) from e
if not isinstance(header, dict):
raise _invalid_safetensors_error("The safetensors header is invalid.", ckpt)
mv = mv[data_base_offset:]
data_size = len(mv)
sd = {}
for name, info in header.items():
@@ -102,13 +130,21 @@ def load_safetensors(ckpt):
continue
start, end = info["data_offsets"]
dtype = _TYPES[info["dtype"]]
if start < 0 or end < start:
raise _invalid_safetensors_error("Tensor '{}' has invalid data offsets.".format(name), ckpt)
if end > data_size:
raise _incomplete_safetensors_error("Tensor '{}' extends past the end of the file.".format(name), ckpt)
if math.prod(info["shape"]) * dtype.itemsize != end - start:
raise _invalid_safetensors_error("Tensor '{}' does not match its declared shape and dtype.".format(name), ckpt)
if start == end:
sd[name] = torch.empty(info["shape"], dtype =_TYPES[info["dtype"]])
sd[name] = torch.empty(info["shape"], dtype=dtype)
else:
with warnings.catch_warnings():
#We are working with read-only RAM by design
warnings.filterwarnings("ignore", message="The given buffer is not writable")
tensor = torch.frombuffer(mv[start:end], dtype=_TYPES[info["dtype"]]).view(info["shape"])
tensor = torch.frombuffer(mv[start:end], dtype=dtype).view(info["shape"])
storage = tensor.untyped_storage()
setattr(storage,
"_comfy_tensor_file_slice",
@@ -143,9 +179,9 @@ def load_torch_file(ckpt, safe_load=False, device=None, return_metadata=False):
if len(e.args) > 0:
message = e.args[0]
if "HeaderTooLarge" in message:
raise ValueError("{}\n\nFile path: {}\n\nThe safetensors file is corrupt or invalid. Make sure this is actually a safetensors file and not a ckpt or pt or other filetype.".format(message, ckpt))
raise _invalid_safetensors_error(message, ckpt)
if "MetadataIncompleteBuffer" in message:
raise ValueError("{}\n\nFile path: {}\n\nThe safetensors file is corrupt/incomplete. Check the file size and make sure you have copied/downloaded it correctly.".format(message, ckpt))
raise _incomplete_safetensors_error(message, ckpt)
raise e
else:
torch_args = {}
+5
View File
@@ -30,6 +30,11 @@ CLI_FEATURE_FLAG_REGISTRY: dict[str, FeatureFlagInfo] = {
"default": False,
"description": "Signal the frontend that telemetry collection is enabled",
},
"partner_run_gate_enabled": {
"type": "bool",
"default": True,
"description": "Gate the local Run button behind sign-in when the graph contains partner nodes",
},
}
+53 -2
View File
@@ -4,7 +4,7 @@ from fractions import Fraction
from typing import Optional, Union, IO
import io
import av
from .._util import VideoContainer, VideoCodec, VideoComponents
from .._util import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
class VideoInput(ABC):
"""
@@ -30,15 +30,27 @@ class VideoInput(ABC):
metadata: Optional[dict] = None,
bit_depth: int | None = None,
crf: float | None = None,
color_space: str | None = None,
preset: str | None = None,
):
"""
Abstract method to save the video input to a file.
bit_depth selects the encoded bit depth; None keeps the video's native depth.
crf selects the H.264 constant rate factor; None uses the encoder default.
crf selects the H.264 or AV1 constant rate factor; None uses the encoder default.
preset selects the H.264 encoder speed/compression trade-off (e.g. "ultrafast");
None uses the encoder default. Ignored for other codecs.
color_space="sRGB" selects SDR BT.709/sRGB, "HDR" selects BT.2020/HLG, and "HDR PQ"
selects BT.2020/PQ. Bit depth is selected independently.
Tensor-created videos default to sRGB when color_space is None. Loaded videos keep matching recognized native color
properties; other input pixels must already use the selected color space.
"""
pass
def get_color_space(self) -> str:
"""Return the video's color space as sRGB, HDR, HDR PQ, or auto when unspecified."""
return "auto"
@abstractmethod
def as_trimmed(
self,
@@ -54,6 +66,45 @@ class VideoInput(ABC):
"""
pass
def as_cropped(
self,
x: int = 0,
y: int = 0,
width: int = 0,
height: int = 0,
) -> VideoInput:
"""
Create a new VideoInput spatially cropped to the given pixel rectangle.
The rectangle is clamped to the frame and even-aligned for encoder
compatibility. An empty or full-frame rectangle returns the input
unchanged.
Default implementation materializes the video via get_components();
subclasses should override with lazier strategies when possible.
"""
components = self.get_components()
rect = normalize_crop_rect(
x, y, width, height, components.images.shape[2], components.images.shape[1]
)
if rect is None:
return self
from .._input_impl.video_types import VideoFromComponents
cx, cy, cw, ch = rect
return VideoFromComponents(
VideoComponents(
images=components.images[:, cy:cy + ch, cx:cx + cw, :].clone(),
audio=components.audio,
frame_rate=components.frame_rate,
metadata=components.metadata,
alpha=components.alpha[:, cy:cy + ch, cx:cx + cw].clone()
if components.alpha is not None
else None,
),
bit_depth=self.get_bit_depth(),
)
def get_stream_source(self) -> Union[str, io.BytesIO]:
"""
Get a streamable source for the video. This allows processing without
+355 -52
View File
@@ -1,6 +1,7 @@
from av.bitstream import BitStreamFilterContext
from av.container import InputContainer
from av.subtitles.stream import SubtitleStream
from av.video.reformatter import ColorRange
from av.video.reformatter import ColorPrimaries, ColorRange, ColorTrc
from fractions import Fraction
from typing import Optional
from .._input import AudioInput, VideoInput
@@ -12,10 +13,43 @@ import numpy as np
import math
import os
import torch
from .._util import VideoContainer, VideoCodec, VideoComponents
from .._util import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
import comfy.utils
import logging
VIDEO_ENCODERS = {
VideoCodec.H264: "h264",
VideoCodec.AV1: "libsvtav1",
}
VIDEO_CONTAINER_FORMATS = {
VideoContainer.MP4: "mp4",
VideoContainer.MKV: "matroska",
VideoContainer.WEBM: "webm",
}
WEBM_STREAM_CODECS = {
"video": {"av1", "vp8", "vp9"},
"audio": {"opus", "vorbis"},
"subtitle": {"webvtt"},
}
BT2020_NCL = 9
BT709_NCL = 1
HDR_COLOR_TRANSFERS = {
"HDR": ColorTrc.ARIB_STD_B67,
"HDR PQ": ColorTrc.SMPTE2084,
}
VIDEO_COLOR_TRANSFERS = {
"sRGB": ColorTrc.IEC61966_2_1,
**HDR_COLOR_TRANSFERS,
}
VIDEO_TRANSFER_COLOR_SPACES = {
ColorTrc.BT709: "sRGB",
ColorTrc.IEC61966_2_1: "sRGB",
ColorTrc.ARIB_STD_B67: "HDR",
ColorTrc.SMPTE2084: "HDR PQ",
}
def container_to_output_format(container_format: str | None) -> str | None:
"""
A container's `format` may be a comma-separated list of formats.
@@ -37,22 +71,24 @@ def get_open_write_kwargs(
) -> dict:
"""Get kwargs for writing a `VideoFromFile` to a file/stream with `av.open`"""
is_write_to_buffer = isinstance(dest, io.BytesIO)
is_mp4_file = not is_write_to_buffer and os.path.splitext(dest)[1].lower() == ".mp4"
movflags = "use_metadata_tags+faststart" if is_mp4_file else "use_metadata_tags"
open_kwargs = {
"mode": "w",
# If isobmff, preserve custom metadata tags (workflow, prompt, extra_pnginfo)
"options": {"movflags": movflags},
}
open_kwargs = {"mode": "w"}
if is_write_to_buffer:
# Set output format explicitly, since it cannot be inferred from file extension
if to_format == VideoContainer.AUTO:
to_format = container_format.lower()
elif isinstance(to_format, VideoContainer):
to_format = VIDEO_CONTAINER_FORMATS[to_format]
elif isinstance(to_format, str):
to_format = to_format.lower()
open_kwargs["format"] = container_to_output_format(to_format)
output_format = open_kwargs["format"] if is_write_to_buffer else os.path.splitext(dest)[1].lower().lstrip(".")
if output_format in ("mov", "mp4"):
# Preserve custom metadata tags (workflow, prompt, extra_pnginfo) in isobmff.
movflags = "use_metadata_tags" if is_write_to_buffer else "use_metadata_tags+faststart"
open_kwargs["options"] = {"movflags": movflags}
return open_kwargs
@@ -62,6 +98,31 @@ def video_stream_bit_depth(stream) -> int:
return max(component.bits for component in stream.format.components)
def isobmff_hevc_filter(output_container, stream, out_stream):
"""Apple players need the 'hvc1' sample entry, not FFmpeg's default 'hev1'. Annex B input without
extradata makes the muxer build hvcC from the first packet and strip in-band parameter sets;
'hvc1' sources already have a complete hvcC and only need the tag PyAV reset."""
if output_container.format.name not in ("mp4", "mov") or stream.codec.canonical_name != "hevc":
return None
try:
codec_tag = stream.codec_context.codec_tag
except UnicodeDecodeError:
codec_tag = ""
if codec_tag == "hvc1":
out_stream.codec_context.codec_tag = "hvc1"
return None
hevc_filter = BitStreamFilterContext("hevc_mp4toannexb", stream, out_stream)
out_stream.codec_context.codec_tag = "hvc1"
out_stream.codec_context.extradata = None
return hevc_filter
def filter_hevc_packet(hevc_filter, packet):
if packet.has_sidedata("new_extradata"):
raise ValueError("HEVC with multiple sample descriptions cannot be remuxed as hvc1; re-encode it instead")
return hevc_filter.filter(packet)
def last_decodable_audio_stream(container: InputContainer):
"""Streams FFmpeg has no decoder for have no codec context, and decoding their
packets crashes the process (e.g. APAC spatial-audio track in iPhone)."""
@@ -100,19 +161,76 @@ def write_output_metadata(container: InputContainer, output, metadata: dict | No
output.metadata[key] = value if isinstance(value, str) else json.dumps(value)
def mp4_output_open_kwargs(path: str | io.BytesIO, format: VideoContainer, codec: VideoCodec) -> dict:
if format != VideoContainer.AUTO and format != VideoContainer.MP4:
raise ValueError("Only MP4 format is supported for now")
if codec != VideoCodec.AUTO and codec != VideoCodec.H264:
raise ValueError("Only H264 codec is supported for now")
def video_output_config(path: str | io.BytesIO, format: VideoContainer, codec: VideoCodec) -> tuple[dict, VideoContainer, VideoCodec]:
if isinstance(format, str):
format = VideoContainer(format)
if isinstance(codec, str):
codec = VideoCodec(codec)
if format == VideoContainer.AUTO:
extension = os.path.splitext(os.fspath(path))[1].lower() if isinstance(path, (str, os.PathLike)) else ""
format = {
".mkv": VideoContainer.MKV,
".webm": VideoContainer.WEBM,
}.get(extension, VideoContainer.MP4)
if codec == VideoCodec.AUTO:
codec = VideoCodec.AV1 if format == VideoContainer.WEBM else VideoCodec.H264
if format == VideoContainer.WEBM and codec != VideoCodec.AV1:
raise ValueError("WebM output requires the AV1 codec")
# FFmpeg's faststart pass reopens the output by filename, so it cannot be used with file-like objects.
movflags = "use_metadata_tags+faststart" if isinstance(path, (str, os.PathLike)) else "use_metadata_tags"
open_kwargs = {"mode": "w", "options": {"movflags": movflags}}
if isinstance(format, VideoContainer) and format != VideoContainer.AUTO:
open_kwargs["format"] = format.value
elif isinstance(path, io.BytesIO):
open_kwargs["format"] = "mp4" # no file extension to infer the format from
return open_kwargs
open_kwargs = {"mode": "w", "format": VIDEO_CONTAINER_FORMATS[format]}
if format == VideoContainer.MP4:
movflags = "use_metadata_tags+faststart" if isinstance(path, (str, os.PathLike)) else "use_metadata_tags"
open_kwargs["options"] = {"movflags": movflags}
return open_kwargs, format, codec
def set_video_color_properties(target, color_space):
is_hdr = color_space in HDR_COLOR_TRANSFERS
target.color_primaries = ColorPrimaries.BT2020 if is_hdr else ColorPrimaries.BT709
target.color_trc = VIDEO_COLOR_TRANSFERS[color_space]
target.colorspace = BT2020_NCL if is_hdr else BT709_NCL
target.color_range = ColorRange.MPEG
def copy_color_properties(source, target):
target.color_primaries = source.color_primaries
target.color_trc = source.color_trc
target.colorspace = source.colorspace
target.color_range = source.color_range
def video_stream_color_space(stream) -> str | None:
if stream is None:
return None
return VIDEO_TRANSFER_COLOR_SPACES.get(stream.color_trc)
def video_encoder_options(
codec: VideoCodec, crf: float | None, preset: str | None = None
) -> dict[str, str]:
options = {}
if preset is not None and codec == VideoCodec.H264:
options["preset"] = preset
if crf is not None:
if codec == VideoCodec.AV1 and crf == 0:
options["svtav1-params"] = "lossless=1"
else:
options["crf"] = str(crf)
return options
def webm_streams_compatible(streams) -> bool:
for stream in streams:
allowed_codecs = WEBM_STREAM_CODECS.get(stream.type)
if allowed_codecs is not None and stream.codec_context is not None and stream.codec.canonical_name not in allowed_codecs:
return False
return True
def _rotation_quadrant(frame: av.VideoFrame) -> int:
return int(round(frame.rotation // 90)) % 4 if frame.rotation else 0
class VideoFromFile(VideoInput):
@@ -120,7 +238,8 @@ class VideoFromFile(VideoInput):
Class representing video input from a file.
"""
def __init__(self, file: str | io.BytesIO, *, start_time: float=0, duration: float=0):
def __init__(self, file: str | io.BytesIO, *, start_time: float=0, duration: float=0,
crop: tuple[int, int, int, int] | None = None):
"""
Initialize the VideoFromFile object based off of either a path on disk or a BytesIO object
containing the file contents.
@@ -128,6 +247,7 @@ class VideoFromFile(VideoInput):
self.__file = file
self.__start_time = start_time
self.__duration = duration
self.__crop = crop
def get_stream_source(self) -> str | io.BytesIO:
"""
@@ -157,7 +277,31 @@ class VideoFromFile(VideoInput):
for stream in container.streams:
if stream.type == 'video':
assert isinstance(stream, av.VideoStream)
return stream.width, stream.height
if self.__crop is None:
return stream.width, stream.height
display_width, display_height = self._get_display_dimensions()
rect = normalize_crop_rect(*self.__crop, display_width, display_height)
if rect is not None:
return rect[2], rect[3]
return display_width, display_height
raise ValueError(f"No video stream found in file '{self.__file}'")
def _get_display_dimensions(self) -> tuple[int, int]:
if isinstance(self.__file, io.BytesIO):
self.__file.seek(0)
with av.open(self.__file, mode='r') as container:
for stream in container.streams:
if stream.type == 'video':
assert isinstance(stream, av.VideoStream)
width, height = stream.width, stream.height
try:
frame = next(container.decode(stream), None)
except av.error.FFmpegError:
frame = None
if frame is not None and _rotation_quadrant(frame) % 2:
width, height = height, width
return width, height
raise ValueError(f"No video stream found in file '{self.__file}'")
def get_bit_depth(self) -> int:
@@ -167,6 +311,13 @@ class VideoFromFile(VideoInput):
video_stream = container.streams.video[0] if len(container.streams.video) > 0 else None
return video_stream_bit_depth(video_stream)
def get_color_space(self) -> str:
if isinstance(self.__file, io.BytesIO):
self.__file.seek(0)
with av.open(self.__file, mode="r") as container:
video_stream = container.streams.video[0] if len(container.streams.video) > 0 else None
return video_stream_color_space(video_stream) or "sRGB"
def get_duration(self) -> float:
"""
Returns the duration of the video in seconds.
@@ -307,6 +458,7 @@ class VideoFromFile(VideoInput):
def get_components_internal(self, container: InputContainer) -> VideoComponents:
video_stream = self._get_first_video_stream(container)
video_stream.thread_type = "AUTO"
start_time, duration = self.get_active_trim_window()
# Get video frames
@@ -327,6 +479,8 @@ class VideoFromFile(VideoInput):
streams = [video_stream]
has_first_audio_frame = False
checked_alpha = False
crop_rect = None
crop_resolved = False
# Default to False so we decode until EOF if duration is 0
video_done = False
@@ -397,9 +551,16 @@ class VideoFromFile(VideoInput):
img = np.ascontiguousarray(align_graph[2].pull().to_ndarray(format=image_format)[:frame.height, :frame.width])
else:
img = frame.to_ndarray(format=image_format)
if frame.rotation != 0:
k = int(round(frame.rotation // 90))
img = np.rot90(img, k=k, axes=(0, 1)).copy()
rotation_quadrant = _rotation_quadrant(frame)
if rotation_quadrant:
img = np.rot90(img, k=rotation_quadrant, axes=(0, 1)).copy()
if self.__crop is not None:
if not crop_resolved:
crop_rect = normalize_crop_rect(*self.__crop, img.shape[1], img.shape[0])
crop_resolved = True
if crop_rect is not None:
cx, cy, cw, ch = crop_rect
img = np.ascontiguousarray(img[cy:cy + ch, cx:cx + cw])
if alphas is None:
frames.append(torch.from_numpy(img))
else:
@@ -465,16 +626,29 @@ class VideoFromFile(VideoInput):
metadata: Optional[dict] = None,
bit_depth: int | None = None,
crf: float | None = None,
color_space: str | None = None,
preset: str | None = None,
):
if color_space is not None and color_space not in VIDEO_COLOR_TRANSFERS:
raise ValueError(f"Unsupported video color space: {color_space}")
_, output_format, _ = video_output_config(path, format, codec)
if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) # Reset the BytesIO object to the beginning
with av.open(self.__file, mode='r') as container:
container_format = container.format.name
video_stream = container.streams.video[0] if len(container.streams.video) > 0 else None
video_encoding = video_stream.codec.name if video_stream is not None else None
video_encoding = video_stream.codec.canonical_name if video_stream is not None else None
source_bit_depth = video_stream_bit_depth(video_stream)
source_color_space = video_stream_color_space(video_stream)
if source_color_space is not None and color_space is not None and source_color_space != color_space:
raise ValueError(
f"Cannot save {source_color_space} video as {color_space} without color conversion; "
f"use auto or {source_color_space}"
)
reuse_streams = True
if format != VideoContainer.AUTO and format not in container_format.split(","):
if format != VideoContainer.AUTO and VIDEO_CONTAINER_FORMATS[VideoContainer(format)] not in container_format.split(","):
reuse_streams = False
if output_format == VideoContainer.WEBM and not webm_streams_compatible(container.streams):
reuse_streams = False
if codec != VideoCodec.AUTO and codec != video_encoding and video_encoding is not None:
reuse_streams = False
@@ -482,13 +656,17 @@ class VideoFromFile(VideoInput):
reuse_streams = False
if crf is not None:
reuse_streams = False
if color_space is not None:
reuse_streams = False
if self.__start_time or self.__duration:
reuse_streams = False
if self.__crop is not None:
reuse_streams = False
if not reuse_streams:
if bit_depth is None:
bit_depth = source_bit_depth
return self._save_transcoded(container, path, format=format, codec=codec, metadata=metadata, bit_depth=bit_depth, crf=crf)
return self._save_transcoded(container, path, format=format, codec=codec, metadata=metadata, bit_depth=bit_depth, crf=crf, color_space=color_space, preset=preset)
streams = container.streams
@@ -499,19 +677,26 @@ class VideoFromFile(VideoInput):
# Add streams to the new container. Streams with no codec context cannot be used as an output template.
stream_map = {}
hevc_filters = {}
for stream in streams:
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)):
if stream.codec_context is None:
logging.warning("Skipping %s stream %d with unsupported codec", stream.type, stream.index)
continue
out_stream = output_container.add_stream_from_template(template=stream, opaque=True)
hevc_filter = isobmff_hevc_filter(output_container, stream, out_stream)
if hevc_filter is not None:
hevc_filters[stream] = hevc_filter
stream_map[stream] = out_stream
# Write packets to the new container
for packet in container.demux():
if packet.stream in stream_map and packet.dts is not None:
packet.stream = stream_map[packet.stream]
output_container.mux(packet)
out_stream = stream_map[packet.stream]
hevc_filter = hevc_filters.get(packet.stream)
for out_packet in filter_hevc_packet(hevc_filter, packet) if hevc_filter else (packet,):
out_packet.stream = out_stream
output_container.mux(out_packet)
def _save_transcoded(
self,
@@ -522,10 +707,13 @@ class VideoFromFile(VideoInput):
metadata: dict | None,
bit_depth: int,
crf: float | None = None,
color_space: str | None = None,
preset: str | None = None,
):
"""Re-encode to H.264/AAC one frame at a time; peak memory does not scale with video length."""
open_kwargs = mp4_output_open_kwargs(path, format, codec)
"""Re-encode one frame at a time; peak memory does not scale with video length."""
open_kwargs, output_format, output_codec = video_output_config(path, format, codec)
video_stream = self._get_first_video_stream(container)
video_stream.thread_type = "AUTO"
start_time, duration = self.get_active_trim_window()
start_pts = int(start_time / video_stream.time_base)
end_pts = int((start_time + duration) / video_stream.time_base) if duration else None
@@ -539,6 +727,8 @@ class VideoFromFile(VideoInput):
container.seek(start_pts, stream=video_stream)
audio_stream = last_decodable_audio_stream(container)
source_color_space = video_stream_color_space(video_stream)
preserve_source_color = source_color_space is not None
pix_fmt = "yuv420p10le" if bit_depth >= 10 else "yuv420p"
rate = Fraction(video_stream.average_rate) if video_stream.average_rate else Fraction(1)
@@ -558,12 +748,24 @@ class VideoFromFile(VideoInput):
logging.warning("Audio stream parameters could not be determined; ignoring audio.")
audio_stream = None
if audio_stream is not None:
if output_format == VideoContainer.WEBM:
sample_rate = 48000
audio_time_base = Fraction(1, sample_rate)
layout = {1: "mono", 2: "stereo", 6: "5.1"}.get(channels, "stereo")
resampler = av.audio.resampler.AudioResampler(format="fltp", layout=layout, rate=sample_rate)
if duration:
duration_cap = math.ceil(duration * sample_rate)
if duration:
window_seconds = duration
else:
try:
window_seconds = max(self._get_raw_duration() - start_time, 0.0)
except ValueError:
window_seconds = 0.0
progress_total = max(1, int(round(window_seconds * float(rate))))
pbar = comfy.utils.ProgressBar(progress_total)
streams = [video_stream] if audio_stream is None else [video_stream, audio_stream]
pts_step = max(1, int(round((1 / rate) / video_stream.time_base)))
video_done = False
@@ -576,6 +778,8 @@ class VideoFromFile(VideoInput):
source_size = None
rotation_k = 0
rotation_filter = None
crop_rect = None
crop_filter = None
audio_started = False
samples_written = 0
pending_audio = []
@@ -649,30 +853,48 @@ class VideoFromFile(VideoInput):
if end_pts is not None and frame.pts is not None:
frame_duration = min(frame_duration, end_pts - frame.pts)
if output is None:
rotation_k = int(round(frame.rotation // 90)) % 4 if frame.rotation else 0
rotation_k = _rotation_quadrant(frame)
if rotation_k % 2:
out_width, out_height = frame.height, frame.width
else:
out_width, out_height = frame.width, frame.height
if self.__crop is not None:
crop_rect = normalize_crop_rect(*self.__crop, out_width, out_height)
if crop_rect is not None:
out_width, out_height = crop_rect[2], crop_rect[3]
if (out_width % 2 or out_height % 2) and crop_rect is None:
even_width = out_width - out_width % 2
even_height = out_height - out_height % 2
if even_width > 0 and even_height > 0:
crop_rect = (0, 0, even_width, even_height)
out_width, out_height = even_width, even_height
if out_width % 2 or out_height % 2:
raise ValueError(f"H.264 output requires even dimensions, got {out_width}x{out_height}")
raise ValueError(f"{output_codec.value.upper()} output requires even dimensions, got {out_width}x{out_height}")
if any(component.is_alpha for component in frame.format.components):
logging.warning(
"Transcoded video output does not support alpha; the alpha channel will be discarded."
)
source_size = (frame.width, frame.height)
output = av.open(path, **open_kwargs)
# Add metadata before writing any streams
write_output_metadata(container, output, metadata)
out_video = output.add_stream("h264", rate=rate)
out_video = output.add_stream(VIDEO_ENCODERS[output_codec], rate=rate)
# no B-frames: reordering makes mp4 sample durations follow decode order,
# so irregular-VFR spans and trim windows land wrong
out_video.codec_context.max_b_frames = 0
out_video.width = out_width
out_video.height = out_height
out_video.pix_fmt = pix_fmt
if crf is not None:
out_video.options = {"crf": str(crf)}
out_video.options = video_encoder_options(output_codec, crf, preset)
if preserve_source_color:
copy_color_properties(video_stream, out_video.codec_context)
elif color_space is not None:
set_video_color_properties(out_video.codec_context, color_space)
# source pts pass through (rebased to 0), so variable frame rate survives
out_video.codec_context.time_base = video_stream.time_base
if audio_stream is not None:
out_audio = output.add_stream("aac", rate=sample_rate, layout=layout)
audio_codec = "libopus" if output_format == VideoContainer.WEBM else "aac"
out_audio = output.add_stream(audio_codec, rate=sample_rate, layout=layout)
if (frame.width, frame.height) != source_size:
# encoding would silently rescale the new geometry into the old one
raise ValueError(
@@ -697,11 +919,28 @@ class VideoFromFile(VideoInput):
rotation_filter = (g_src, g_sink)
rotation_filter[0].push(frame)
frame = rotation_filter[1].pull()
if frame.color_range == ColorRange.JPEG:
if crop_rect is not None:
if crop_filter is None:
g = av.filter.Graph()
g_src = g.add_buffer(width=frame.width, height=frame.height,
format=frame.format.name, time_base=video_stream.time_base)
g_crop = g.add("crop", f"{crop_rect[2]}:{crop_rect[3]}:{crop_rect[0]}:{crop_rect[1]}")
g_sink = g.add("buffersink")
g_src.link_to(g_crop)
g_crop.link_to(g_sink)
g.configure()
crop_filter = (g_src, g_sink)
crop_filter[0].push(frame)
frame = crop_filter[1].pull()
if frame.color_range == ColorRange.JPEG and not preserve_source_color:
# compress full-range sources (yuvj/MJPEG) to limited range
frame = frame.reformat(format=pix_fmt, src_color_range="JPEG", dst_color_range="MPEG")
else:
frame = frame.reformat(format=pix_fmt)
if preserve_source_color:
copy_color_properties(video_stream, frame)
elif color_space is not None:
set_video_color_properties(frame, color_space)
frame_output_end = None
if frame.pts is not None:
if video_pts_offset is None:
@@ -735,6 +974,7 @@ class VideoFromFile(VideoInput):
out_packet.duration = video_frame_durations.pop(out_packet.pts, 0)
output.mux(out_packet)
drain_audio()
pbar.update(1)
elif packet.stream == audio_stream and not audio_done:
for resampled in itertools.chain.from_iterable(map(resampler.resample, packet.decode())):
@@ -804,32 +1044,71 @@ class VideoFromFile(VideoInput):
self.get_stream_source(),
start_time=start_time + self.__start_time,
duration=duration,
crop=self.__crop,
)
if trimmed.get_duration() < duration and strict_duration:
if strict_duration and duration and trimmed.get_duration() < duration:
return None
return trimmed
def as_cropped(
self, x: int = 0, y: int = 0, width: int = 0, height: int = 0
) -> VideoInput:
if int(width) <= 0 or int(height) <= 0:
return self
display_width, display_height = self._get_display_dimensions()
outer = (
normalize_crop_rect(*self.__crop, display_width, display_height)
if self.__crop is not None
else None
)
if outer is None:
rect = normalize_crop_rect(x, y, width, height, display_width, display_height)
else:
inner = normalize_crop_rect(x, y, width, height, outer[2], outer[3])
rect = (
(outer[0] + inner[0], outer[1] + inner[1], inner[2], inner[3])
if inner is not None
else None
)
if rect is None:
return self
return VideoFromFile(
self.get_stream_source(),
start_time=self.__start_time,
duration=self.__duration,
crop=rect,
)
class VideoFromComponents(VideoInput):
"""
Class representing video input from tensors.
"""
def __init__(self, components: VideoComponents, bit_depth: int = 8):
def __init__(self, components: VideoComponents, bit_depth: int = 8, color_space: str = "sRGB"):
if color_space not in VIDEO_COLOR_TRANSFERS:
raise ValueError(f"Unsupported video color space: {color_space}")
self.__components = components
# Tensor components have no inherent bit depth; this is the depth used when encoding.
self.__bit_depth = bit_depth
self.__color_space = color_space
def get_components(self) -> VideoComponents:
return VideoComponents(
images=self.__components.images,
audio=self.__components.audio,
frame_rate=self.__components.frame_rate,
metadata=self.__components.metadata,
alpha=self.__components.alpha,
)
def get_bit_depth(self) -> int:
return self.__bit_depth
def get_color_space(self) -> str:
return self.__color_space
def save_to(
self,
path: str,
@@ -838,9 +1117,15 @@ class VideoFromComponents(VideoInput):
metadata: Optional[dict] = None,
bit_depth: int | None = None,
crf: float | None = None,
color_space: str | None = None,
preset: str | None = None,
):
"""Save the video to a file path or BytesIO buffer."""
open_kwargs = mp4_output_open_kwargs(path, format, codec)
if color_space is None:
color_space = self.__color_space
if color_space is not None and color_space not in VIDEO_COLOR_TRANSFERS:
raise ValueError(f"Unsupported video color space: {color_space}")
open_kwargs, output_format, output_codec = video_output_config(path, format, codec)
# None means "use the depth this video was created with" (CreateVideo's choice).
if bit_depth is None:
bit_depth = self.__bit_depth
@@ -854,22 +1139,28 @@ class VideoFromComponents(VideoInput):
frame_rate = Fraction(round(self.__components.frame_rate * 1000), 1000)
# Create a video stream
pix_fmt = "yuv420p10le" if is_10bit else "yuv420p"
video_stream = output.add_stream('h264', rate=frame_rate)
video_stream = output.add_stream(VIDEO_ENCODERS[output_codec], rate=frame_rate)
video_stream.width = self.__components.images.shape[2]
video_stream.height = self.__components.images.shape[1]
video_stream.pix_fmt = pix_fmt
if crf is not None:
video_stream.options = {"crf": str(crf)}
video_stream.options = video_encoder_options(output_codec, crf, preset)
if color_space is not None:
set_video_color_properties(video_stream.codec_context, color_space)
# Create an audio stream
audio_sample_rate = 1
audio_resampler = None
audio_stream: Optional[av.AudioStream] = None
if self.__components.audio:
audio_sample_rate = int(self.__components.audio['sample_rate'])
source_audio_sample_rate = int(self.__components.audio['sample_rate'])
audio_sample_rate = 48000 if output_format == VideoContainer.WEBM else source_audio_sample_rate
waveform = self.__components.audio['waveform']
waveform = waveform[0, :, :math.ceil((audio_sample_rate / frame_rate) * self.__components.images.shape[0])]
waveform = waveform[0, :, :math.ceil((source_audio_sample_rate / frame_rate) * self.__components.images.shape[0])]
layout = {1: 'mono', 2: 'stereo', 6: '5.1'}.get(waveform.shape[0], 'stereo')
audio_stream = output.add_stream('aac', rate=audio_sample_rate, layout=layout)
audio_codec = "libopus" if output_format == VideoContainer.WEBM else "aac"
audio_stream = output.add_stream(audio_codec, rate=audio_sample_rate, layout=layout)
if audio_sample_rate != source_audio_sample_rate:
audio_resampler = av.audio.resampler.AudioResampler(format="fltp", layout=layout, rate=audio_sample_rate)
# Encode video
for i, frame in enumerate(self.__components.images):
@@ -880,7 +1171,14 @@ class VideoFromComponents(VideoInput):
else:
img = (frame * 255).clamp(0, 255).byte().cpu().numpy() # shape: (H, W, 3)
frame = av.VideoFrame.from_ndarray(img, format='rgb24')
frame = frame.reformat(format=pix_fmt)
dst_colorspace = None
if color_space == "sRGB":
dst_colorspace = BT709_NCL
elif color_space in HDR_COLOR_TRANSFERS:
dst_colorspace = BT2020_NCL
frame = frame.reformat(format=pix_fmt, dst_colorspace=dst_colorspace)
if color_space is not None:
set_video_color_properties(frame, color_space)
packet = video_stream.encode(frame)
output.mux(packet)
@@ -890,9 +1188,14 @@ class VideoFromComponents(VideoInput):
if audio_stream and self.__components.audio:
frame = av.AudioFrame.from_ndarray(waveform.float().cpu().contiguous().numpy(), format='fltp', layout=layout)
frame.sample_rate = audio_sample_rate
frame.sample_rate = source_audio_sample_rate
frame.pts = 0
output.mux(audio_stream.encode(frame))
frames = [frame] if audio_resampler is None else audio_resampler.resample(frame)
for frame in frames:
output.mux(audio_stream.encode(frame))
if audio_resampler is not None:
for frame in audio_resampler.resample(None):
output.mux(audio_stream.encode(frame))
# Flush encoder
output.mux(audio_stream.encode(None))
+58
View File
@@ -1416,6 +1416,62 @@ class BoundingBoxes(ComfyTypeIO):
self.default = []
@comfytype(io_type="VIDEO_EDIT")
class VideoEdit(ComfyTypeIO):
class VideoTrimSection(TypedDict):
start_time: float
duration: float
class VideoCropSection(TypedDict):
x: int
y: int
width: int
height: int
class VideoEditDict(TypedDict, total=False):
trim: 'VideoEdit.VideoTrimSection'
crop: 'VideoEdit.VideoCropSection'
Type = VideoEditDict
class Input(WidgetInput):
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
socketless: bool=True, default: dict=None, features: list[str]=None, advanced: bool=None):
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
self.features = features if features is not None else ["trim", "crop"]
if default is None:
self.default = {}
if "trim" in self.features:
self.default["trim"] = {"start_time": 0.0, "duration": 0.0}
if "crop" in self.features:
self.default["crop"] = {"x": 0, "y": 0, "width": 0, "height": 0}
def as_dict(self):
return super().as_dict() | prune_dict({
"features": self.features,
})
@comfytype(io_type="RESOLUTION_PREVIEW")
class ResolutionPreview(ComfyTypeIO):
Type = dict
class Input(WidgetInput):
def __init__(self, id: str, display_name: str=None, optional=True, tooltip: str=None,
socketless: bool=True, advanced: bool=None,
ratio_widget: str="aspect_ratio", megapixels_widget: str="megapixels", multiple_widget: str="multiple"):
super().__init__(id, display_name, optional, tooltip, None, None, socketless, None, None, None, None, advanced)
self.ratio_widget = ratio_widget
self.megapixels_widget = megapixels_widget
self.multiple_widget = multiple_widget
def as_dict(self):
return super().as_dict() | prune_dict({
"ratio_widget": self.ratio_widget,
"megapixels_widget": self.megapixels_widget,
"multiple_widget": self.multiple_widget,
})
@comfytype(io_type="HISTOGRAM")
class Histogram(ComfyTypeIO):
"""A histogram represented as a list of bin counts."""
@@ -2493,5 +2549,7 @@ __all__ = [
"Curve",
"Histogram",
"Range",
"VideoEdit",
"ResolutionPreview",
"NodeReplace",
]
+6 -2
View File
@@ -457,13 +457,17 @@ class PreviewUI3D(_UIOutput):
class PreviewUI3DAdvanced(_UIOutput):
def __init__(self, model_file, camera_info, model_3d_info):
def __init__(self, model_file, camera_info, model_3d_info, folder_type: FolderType | None = None):
self.model_file = model_file
self.camera_info = camera_info
self.model_3d_info = model_3d_info
self.folder_type = folder_type
def as_dict(self):
return {"result": [self.model_file, self.camera_info, self.model_3d_info]}
model_file = self.model_file
if self.folder_type is not None:
model_file = f"{model_file} [{FolderType(self.folder_type).value}]"
return {"result": [model_file, self.camera_info, self.model_3d_info]}
class PreviewText(_UIOutput):
+2 -1
View File
@@ -1,4 +1,4 @@
from .video_types import VideoContainer, VideoCodec, VideoComponents
from .video_types import VideoContainer, VideoCodec, VideoComponents, normalize_crop_rect
from .geometry_types import VOXEL, MESH, SPLAT, File3D
from .image_types import SVG
@@ -7,6 +7,7 @@ __all__ = [
"VideoContainer",
"VideoCodec",
"VideoComponents",
"normalize_crop_rect",
"VOXEL",
"MESH",
"SPLAT",
+46 -3
View File
@@ -7,9 +7,13 @@ import torch
class VOXEL:
def __init__(self, data: torch.Tensor):
def __init__(self, data: torch.Tensor, voxel_colors=None, resolution=None):
self.data = data
self.voxel_colors = voxel_colors
self.resolution = resolution # each 3d model has its own resolution
def _comfy_cache_tensors(self):
return self.data, self.voxel_colors
class SPLAT:
"""A batch of 3D Gaussian splats in render-ready (activated, world-space) form.
@@ -28,15 +32,25 @@ class SPLAT:
self.sh = sh # (B, N, K, 3) spherical-harmonic color coefficients
self.counts = counts # (B,) real lengths, or None
def _comfy_cache_tensors(self):
return self.positions, self.scales, self.rotations, self.opacities, self.sh, self.counts
class MESH:
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor,
uvs: torch.Tensor | None = None,
vertex_colors: torch.Tensor | None = None,
texture: torch.Tensor | None = None,
metallic_roughness: torch.Tensor | None = None,
vertex_counts: torch.Tensor | None = None,
face_counts: torch.Tensor | None = None,
unlit: bool = False):
unlit: bool = False,
normals: torch.Tensor | None = None,
tangents: torch.Tensor | None = None,
normal_map: torch.Tensor | None = None,
occlusion_in_mr: bool = False,
material: dict | None = None,
emissive: torch.Tensor | None = None):
assert (vertex_counts is None) == (face_counts is None), \
"vertex_counts and face_counts must be provided together (both or neither)"
@@ -44,13 +58,42 @@ class MESH:
self.faces = faces # faces: (B, M, 3)
self.uvs = uvs # uvs: (B, N, 2)
self.vertex_colors = vertex_colors # vertex_colors: (B, N, 3 or 4)
self.texture = texture # texture: (B, H, W, 3)
# Optional per-vertex normals: (B, N, 3). When None, SaveGLB computes smooth
# area-weighted normals so viewers don't fall back to flat (per-face) shading.
self.normals = normals
self.texture = texture # texture (baseColor): (B, H, W, 3)
# glTF metallicRoughness texture: (B, H, W, 3), R unused, G=roughness, B=metallic
self.metallic_roughness = metallic_roughness
# When vertices/faces are zero-padded to a common N/M across the batch (variable-size mesh batch),
# these hold the real per-item lengths (B,). None means rows are uniform and no slicing is needed.
self.vertex_counts = vertex_counts
self.face_counts = face_counts
# Render flat / emissive (no scene lighting) when saved, e.g. for gaussian-splat-derived meshes.
self.unlit = unlit
# Extra maps / material overrides attached by bake, normal/AO, and SetMeshMaterial nodes;
# consumed by SaveGLB. Declared here (with defaults) so consumers read them directly.
self.tangents = tangents # (B, N, 4) per-vertex tangents for normal mapping
self.normal_map = normal_map # tangent-space normal map: (B, H, W, 3)
self.occlusion_in_mr = occlusion_in_mr # True = R channel of metallic_roughness holds AO (ORM)
self.material = material # SetMeshMaterial scalar/factor overrides
self.emissive = emissive # emissive map: (B, H, W, 3)
def _comfy_cache_tensors(self):
return (
self.vertices,
self.faces,
self.uvs,
self.vertex_colors,
self.texture,
self.metallic_roughness,
self.vertex_counts,
self.face_counts,
self.normals,
self.tangents,
self.normal_map,
self.material,
self.emissive,
)
class File3D:
+29
View File
@@ -7,6 +7,7 @@ from .._input import ImageInput, AudioInput, MaskInput
class VideoCodec(str, Enum):
AUTO = "auto"
H264 = "h264"
AV1 = "av1"
@classmethod
def as_input(cls) -> list[str]:
@@ -18,6 +19,8 @@ class VideoCodec(str, Enum):
class VideoContainer(str, Enum):
AUTO = "auto"
MP4 = "mp4"
MKV = "mkv"
WEBM = "webm"
@classmethod
def as_input(cls) -> list[str]:
@@ -35,6 +38,10 @@ class VideoContainer(str, Enum):
value = cls(value)
if value == VideoContainer.MP4 or value == VideoContainer.AUTO:
return "mp4"
if value == VideoContainer.MKV:
return "mkv"
if value == VideoContainer.WEBM:
return "webm"
return ""
@dataclass
@@ -48,3 +55,25 @@ class VideoComponents:
audio: Optional[AudioInput] = None
metadata: Optional[dict] = None
alpha: Optional[MaskInput] = None
def normalize_crop_rect(
x: int, y: int, width: int, height: int, source_width: int, source_height: int
) -> Optional[tuple[int, int, int, int]]:
width = int(width)
height = int(height)
if width <= 0 or height <= 0:
return None
x = max(0, min(int(x), source_width - 1))
y = max(0, min(int(y), source_height - 1))
x -= x % 2
y -= y % 2
width = min(width, source_width - x)
height = min(height, source_height - y)
if x == 0 and y == 0 and width == source_width and height == source_height:
return None
width -= width % 2
height -= height % 2
if width <= 0 or height <= 0:
return None
return x, y, width, height
+1 -1
View File
@@ -45,7 +45,7 @@ class AnthropicThinkingConfig(BaseModel):
class AnthropicOutputConfig(BaseModel):
"""Used with `thinking.type='adaptive'` on models like Opus 4.7."""
effort: Literal["low", "medium", "high"] | None = Field(None)
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = Field(None)
class AnthropicMessagesRequest(BaseModel):
+10
View File
@@ -166,3 +166,13 @@ class Flux3VideoContinuationRequest(Flux3VideoRequest):
start_video: str = Field(
..., description="MP4 (URL or base64); the new clip carries on from its final frames."
)
class BFLFluxVideoUpscaleRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
input_video: str = Field(..., description="MP4 (URL or base64), 1 to 20 seconds.")
upscale_factor: float = Field(2.0, ge=1.5, le=3.0)
creativity: int = Field(1, description="0 preserves the source precisely, 1 enhances detail.")
prompt: str | None = Field(None)
safety_tolerance: int = Field(2, ge=0, le=4)
+45 -34
View File
@@ -3,22 +3,13 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class Text2ImageTaskCreationRequest(BaseModel):
model: str = Field(...)
prompt: str = Field(...)
response_format: str | None = Field("url")
size: str | None = Field(None)
seed: int | None = Field(0, ge=0, le=2147483647)
guidance_scale: float | None = Field(..., ge=1.0, le=10.0)
watermark: bool | None = Field(False)
class Seedream4Options(BaseModel):
max_images: int = Field(15)
class Seedream5OptimizePromptOptions(BaseModel):
thinking: Literal["auto", "enabled", "disabled"] = Field(...)
thinking: Literal["auto", "enabled", "disabled"] | None = Field(None)
mode: Literal["standard", "fast"] | None = Field(None)
class Seedream4TaskCreationRequest(BaseModel):
@@ -187,19 +178,6 @@ class SeedanceVirtualLibraryCreateAssetRequest(BaseModel):
asset_type: str | None = Field(None, description="BytePlus asset type. Defaults to Image server-side when omitted.")
RECOMMENDED_PRESETS = [
("1024x1024 (1:1)", 1024, 1024),
("864x1152 (3:4)", 864, 1152),
("1152x864 (4:3)", 1152, 864),
("1280x720 (16:9)", 1280, 720),
("720x1280 (9:16)", 720, 1280),
("832x1248 (2:3)", 832, 1248),
("1248x832 (3:2)", 1248, 832),
("1512x648 (21:9)", 1512, 648),
("2048x2048 (1:1)", 2048, 2048),
("Custom", None, None),
]
RECOMMENDED_PRESETS_SEEDREAM_4 = [
("2048x2048 (1:1)", 2048, 2048),
("2304x1728 (4:3)", 2304, 1728),
@@ -301,6 +279,7 @@ SEEDANCE2_REF_VIDEO_PIXEL_LIMITS = {
"dreamina-seedance-2-5-260628": {
"480p": {"min": 409_600, "max": 8_295_044},
"720p": {"min": 409_600, "max": 8_295_044},
"1080p": {"min": 409_600, "max": 8_295_044},
},
}
@@ -325,16 +304,6 @@ def seedance2_reference_limits(model_id: str) -> dict:
# The time in this dictionary are given for 10 seconds duration.
VIDEO_TASKS_EXECUTION_TIME = {
"seedance-1-0-lite-t2v-250428": {
"480p": 40,
"720p": 60,
"1080p": 90,
},
"seedance-1-0-lite-i2v-250428": {
"480p": 40,
"720p": 60,
"1080p": 90,
},
"seedance-1-0-pro-250528": {
"480p": 70,
"720p": 85,
@@ -384,3 +353,45 @@ class SeedAudioResponse(BaseModel):
original_duration: float | None = Field(default=None)
code: int | None = Field(default=None)
message: str | None = Field(default=None)
class MediaKitVideoEnhanceRequest(BaseModel):
video_url: str = Field(...)
tool_version: str = Field(...)
scene: str | None = Field(None)
enhance_style: str | None = Field(None)
resolution: str | None = Field(None)
resolution_limit: int | None = Field(None)
fps: float | None = Field(None)
bitrate_level: str = Field(...)
class MediaKitError(BaseModel):
code: str | None = Field(None)
type: str | None = Field(None)
message: str | None = Field(None)
param: str | None = Field(None)
class MediaKitTaskCreateResponse(BaseModel):
success: bool = Field(...)
task_id: str | None = Field(None)
request_id: str | None = Field(None)
error: MediaKitError | None = Field(None)
class MediaKitTaskResult(BaseModel):
video_url: str = Field(...)
duration: float | None = Field(None)
fps: float | None = Field(None)
resolution: str | None = Field(None)
tool_version: str | None = Field(None)
class MediaKitTaskResponse(BaseModel):
success: bool = Field(...)
task_id: str | None = Field(None)
task_type: str | None = Field(None)
status: str | None = Field(None)
result: MediaKitTaskResult | None = Field(None)
error: MediaKitError | None = Field(None)
+107
View File
@@ -0,0 +1,107 @@
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# Only the workflows this build ships. The backend serves more.
ComfyCloudWorkflow = Literal[
"z-image-turbo/text-to-image",
"flux-2/text-to-image",
"minimax-h3/text-to-video",
"minimax-h3/first-last-frame-to-video",
"minimax-h3/reference-to-video",
"minimax-h3/image-to-video",
"mage-flow/text-to-image",
"mage-flow-turbo/text-to-image",
"minimax-music-3/text-to-audio",
]
# Only the inputs the shipped nodes send. The manifests accept more.
class ComfyCloudWorkflowInputs(BaseModel):
prompt: str | None = Field(None)
image_url: str | None = Field(None)
assets: dict[str, "ComfyCloudAssetInput"] | None = Field(None)
audio_url: str | None = Field(None)
first_frame_url: str | None = Field(None)
last_frame_url: str | None = Field(None)
instruction: str | None = Field(None)
prompt_enhance: bool | None = Field(None)
enhance_prompt: bool | None = Field(None)
negative_prompt: str | None = Field(None)
aspect_ratio: str | None = Field(None)
duration_seconds: float | None = Field(None)
quality_mode: str | None = Field(None)
seed: int | None = Field(None, ge=0, le=0xFFFFFFFFFFFFFFFF)
scale: str | None = Field(None)
width: int | None = Field(None, ge=256, le=2048)
height: int | None = Field(None, ge=256, le=2048)
resolution: str | None = Field(None)
rendering_speed: str | None = Field(None)
color_correction: str | None = Field(None)
turbo: bool | None = Field(None)
style_lora: bool | None = Field(None)
model: str | None = Field(None)
lora: str | None = Field(None)
steps: int | None = Field(None, ge=1)
turbo_steps: int | None = Field(None, ge=1)
fast_steps: int | None = Field(None, ge=1)
cfg: float | None = Field(None, ge=0)
guidance: float | None = Field(None, ge=0)
shift: float | None = Field(None, ge=0)
turbo_strength: float | None = Field(None, ge=0)
style_strength: float | None = Field(None, ge=0)
megapixels: float | None = Field(None, gt=0)
size_multiple: int | None = Field(None, ge=1)
sampler: str | None = Field(None)
scheduler: str | None = Field(None)
denoise: float | None = Field(None, ge=0, le=1)
text_encoder: str | None = Field(None)
ref_image_size: str | None = Field(None)
lyrics: str | None = Field(None)
max_duration: float | None = Field(None, gt=0)
caption_cfg: float | None = Field(None, ge=0)
top_k: int | None = Field(None, ge=1)
tiled_decode: bool | None = Field(None)
tile_size: int | None = Field(None, ge=1)
tile_overlap: int | None = Field(None, ge=0)
audio_quality: str | None = Field(None)
class ComfyCloudAssetInput(BaseModel):
type: Literal["IMAGE", "VIDEO", "AUDIO"] = Field(...)
url: str = Field(...)
class ComfyCloudGenerateRequest(BaseModel):
workflow: ComfyCloudWorkflow = Field(...)
inputs: ComfyCloudWorkflowInputs = Field(...)
class ComfyCloudGenerateResponse(BaseModel):
task_id: str = Field(..., min_length=1)
status: str = Field(...)
polling_url: str | None = Field(None)
cancel_url: str | None = Field(None)
@field_validator("task_id")
@classmethod
def task_id_must_not_be_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("task_id must not be blank")
return value
class ComfyCloudStatusResponse(BaseModel):
task_id: str = Field(..., min_length=1)
status: str = Field(...)
progress: float | None = Field(None)
output_url: str | None = Field(None)
error: str | None = Field(None)
@field_validator("task_id")
@classmethod
def task_id_must_not_be_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("task_id must not be blank")
return value
+49
View File
@@ -0,0 +1,49 @@
from pydantic import BaseModel, Field
class FishAudioProsody(BaseModel):
speed: float = Field(1.0, description="Speaking rate multiplier, 0.5-2.0")
volume: float = Field(0.0, description="Volume adjustment in decibels")
class FishAudioTTSRequest(BaseModel):
text: str = Field(..., description="Text to synthesize")
reference_id: str | list[str] | None = Field(None, description="Voice model ID or list of IDs")
temperature: float = Field(0.7, description="Expressiveness, 0-1")
top_p: float = Field(0.7, description="Nucleus sampling diversity, (0, 1]")
prosody: FishAudioProsody = Field(..., description="Speed and volume adjustments")
normalize: bool = Field(True, description="Normalize numbers and text for English and Chinese")
format: str = Field("wav", description="Output audio format")
class FishAudioASRRequest(BaseModel):
language: str | None = Field(None, description="Optional ISO 639-1 language hint")
ignore_timestamps: bool = Field(True, description="Skip precise timestamp computation")
class FishAudioASRSegment(BaseModel):
text: str | None = Field(None, description="Segment text")
start: float | None = Field(None, description="Segment start time in seconds")
end: float | None = Field(None, description="Segment end time in seconds")
class FishAudioASRResponse(BaseModel):
text: str | None = Field(None, description="Transcribed text")
duration: float | None = Field(None, description="Audio duration in seconds")
segments: list[FishAudioASRSegment] | None = Field(None, description="Timestamped transcript segments")
language_code: str | None = Field(None, description="Detected language as ISO 639-1 code")
language: str | None = Field(None, description="Detected language display name")
class FishAudioCreateModelRequest(BaseModel):
type: str = Field("tts", description="Model type")
title: str = Field(..., description="Voice model name")
train_mode: str = Field("fast", description="Training mode; fast is instantly available")
visibility: str = Field("private", description="Model visibility")
enhance_audio_quality: bool = Field(..., description="Enhance reference audio quality")
class FishAudioCreateModelResponse(BaseModel):
id: str = Field(..., alias="_id", description="Voice model ID for use as reference_id")
state: str | None = Field(None, description="Training state")
visibility: str | None = Field(None, description="Model visibility")
+23
View File
@@ -256,15 +256,32 @@ class GeminiInteractionMediaPart(BaseModel):
mime_type: str | None = Field(None)
class GeminiInteractionVideoConfig(BaseModel):
task: str | None = Field(
None, description="One of: text_to_video, image_to_video, reference_to_video, edit, extend."
)
class GeminiInteractionGenerationConfig(BaseModel):
temperature: float | None = Field(None, ge=0.0, le=2.0)
top_p: float | None = Field(None, ge=0.0, le=1.0)
video_config: GeminiInteractionVideoConfig | None = Field(None)
class GeminiInteractionResponseFormat(BaseModel):
type: Literal["video"] = "video"
resolution: str | None = Field(None, description="One of: 360p, 720p, 1080p, 4k.")
aspect_ratio: str | None = Field(None, description="One of: 16:9, 9:16.")
delivery: str | None = Field(
None, description="Set to 'uri' to receive a Files API URI instead of inline base64 data."
)
class GeminiInteractionRequest(BaseModel):
model: str = Field(...)
input: list[GeminiInteractionTextPart | GeminiInteractionMediaPart] = Field(...)
generation_config: GeminiInteractionGenerationConfig | None = Field(None)
response_format: GeminiInteractionResponseFormat | None = Field(None)
class GeminiInteractionModalityTokens(BaseModel):
@@ -299,3 +316,9 @@ class GeminiInteraction(BaseModel):
)
steps: list[GeminiInteractionStep] | None = Field(None)
usage: GeminiInteractionUsage | None = Field(None)
class GeminiFile(BaseModel):
name: str | None = Field(None, description="Resource name of the file, in the form 'files/<id>'.")
uri: str | None = Field(None)
state: str | None = Field(None, description="One of: PROCESSING, ACTIVE, FAILED.")
+11 -3
View File
@@ -14,6 +14,7 @@ class InputShouldRemesh(TypedDict):
class InputShouldTexture(TypedDict):
should_texture: str
enable_pbr: bool
texture_resolution: str
texture_prompt: str
texture_image: Input.Image | None
@@ -25,7 +26,7 @@ class MeshyTaskResponse(BaseModel):
class MeshyTextToModelRequest(BaseModel):
mode: str = Field("preview")
prompt: str = Field(..., max_length=600)
art_style: str = Field(..., description="'realistic' or 'sculpture'")
art_style: str = Field(...)
ai_model: str = Field(...)
topology: str | None = Field(..., description="'quad' or 'triangle'")
target_polycount: int | None = Field(..., ge=100, le=300000)
@@ -35,6 +36,7 @@ class MeshyTextToModelRequest(BaseModel):
)
symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'")
pose_mode: str = Field(...)
ultra_mode: bool = Field(False)
seed: int = Field(...)
moderation: bool = Field(False)
@@ -43,6 +45,7 @@ class MeshyRefineTask(BaseModel):
mode: str = Field("refine")
preview_task_id: str = Field(...)
enable_pbr: bool | None = Field(...)
texture_resolution: str = Field(...)
texture_prompt: str | None = Field(...)
texture_image_url: str | None = Field(...)
ai_model: str = Field(...)
@@ -61,7 +64,9 @@ class MeshyImageToModelRequest(BaseModel):
)
should_texture: bool = Field(...)
enable_pbr: bool | None = Field(...)
texture_resolution: str | None = Field(None)
pose_mode: str = Field(...)
ultra_mode: bool = Field(False)
texture_prompt: str | None = Field(None, max_length=600)
texture_image_url: str | None = Field(None)
seed: int = Field(...)
@@ -80,6 +85,7 @@ class MeshyMultiImageToModelRequest(BaseModel):
)
should_texture: bool = Field(...)
enable_pbr: bool | None = Field(...)
texture_resolution: str | None = Field(None)
pose_mode: str = Field(...)
texture_prompt: str | None = Field(None, max_length=600)
texture_image_url: str | None = Field(None)
@@ -103,8 +109,10 @@ class MeshyTextureRequest(BaseModel):
ai_model: str = Field(...)
enable_original_uv: bool = Field(...)
enable_pbr: bool = Field(...)
text_style_prompt: str | None = Field(...)
image_style_url: str | None = Field(...)
texture_resolution: str = Field(...)
text_style_prompt: str | None = Field(None)
image_style_url: str | None = Field(None)
multiview_image_urls: list[str] | None = Field(None)
class MeshyModelsUrls(BaseModel):
+45
View File
@@ -0,0 +1,45 @@
from pydantic import BaseModel, Field
class MuseImageToolEnablement(BaseModel):
enable_image_search: bool = Field(...)
enable_web_search: bool = Field(...)
enable_shell: bool = Field(...)
class MuseImageRequest(BaseModel):
model: str = Field(...)
prompt: str = Field(...)
n: int = Field(1, ge=1, le=10)
size: str | None = Field(None)
reasoning_strength: str = Field("high")
output_format: str = Field("png")
response_format: str = Field("b64_json")
tool_enablement: MuseImageToolEnablement | None = Field(None)
class MuseImageInput(BaseModel):
image_url: str = Field(...)
class MuseImageEditRequest(MuseImageRequest):
images: list[MuseImageInput] = Field(...)
class MuseImageData(BaseModel):
b64_json: str | None = Field(None)
url: str | None = Field(None)
class MuseImageUsage(BaseModel):
input_tokens: int | None = Field(None)
output_tokens: int | None = Field(None)
total_tokens: int | None = Field(None)
class MuseImageResponse(BaseModel):
created: int | None = Field(None)
data: list[MuseImageData] = Field(default_factory=list)
output_format: str | None = Field(None)
background: str | None = Field(None)
usage: MuseImageUsage | None = Field(None)
+35
View File
@@ -215,3 +215,38 @@ class Hailuo03Task(BaseModel):
class Hailuo03TaskQueryResponse(BaseModel):
task: Hailuo03Task = Field(...)
class Hailuo03MaxTaskCreationResponse(BaseModel):
request_id: str = Field(...)
status: str | None = Field(None)
class Hailuo03MaxTaskStatusResponse(BaseModel):
status: str | None = Field(None)
class Hailuo03MaxVideoFile(BaseModel):
url: str = Field(...)
content_type: str | None = Field(None)
file_name: str | None = Field(None)
file_size: int | None = Field(None)
class Hailuo03MaxVideoRequest(BaseModel):
prompt: str = Field(...)
duration: int = Field(..., ge=5, le=15)
resolution: str = Field(...)
prompt_expansion_mode: str = Field(...)
seed: int = Field(...)
aspect_ratio: str | None = Field(None)
image_url: str | None = Field(None)
end_image_url: str | None = Field(None)
reference_image_urls: list[str] | None = Field(None)
reference_video_urls: list[str] | None = Field(None)
reference_audio_urls: list[str] | None = Field(None)
class Hailuo03MaxVideoResult(BaseModel):
video: Hailuo03MaxVideoFile = Field(...)
expanded_prompt: str | None = Field(None)
+5 -1
View File
@@ -49,13 +49,17 @@ class OpenAIImageGenerationRequest(BaseModel):
prompt: str = Field(...)
quality: str | None = Field(None, description="The quality of the generated image")
size: str | None = Field(None, description="Size of the image (e.g., 1024x1024, 1536x1024, auto)")
style: str | None = Field(None, description="Style of the image (only for dall-e-3)")
class Reasoning(BaseModel):
effort: str | None = Field(None)
class ModelResponseProperties(BaseModel):
instructions: str | None = Field(None)
max_output_tokens: int | None = Field(None)
model: str | None = Field(None)
reasoning: Reasoning | None = Field(None)
temperature: float | None = Field(None, description="Controls randomness in the response", ge=0.0, le=2.0)
top_p: float | None = Field(
None,
+138 -54
View File
@@ -1,7 +1,4 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
@@ -60,33 +57,32 @@ class PixverseStyle(str, Enum):
cyberpunk = "cyberpunk"
# NOTE: forgoing descriptions for now in return for dev speed
class PixverseTextVideoRequest(BaseModel):
aspect_ratio: PixverseAspectRatio = Field(...)
quality: PixverseQuality = Field(...)
duration: PixverseDuration = Field(...)
model: Optional[str] = Field("v3.5")
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
model: str | None = Field("v3.5")
motion_mode: PixverseMotionMode | None = Field(PixverseMotionMode.normal)
prompt: str = Field(...)
negative_prompt: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
style: Optional[str] = Field(None)
template_id: Optional[int] = Field(None)
water_mark: Optional[bool] = Field(None)
negative_prompt: str | None = Field(None)
seed: int | None = Field(None)
style: str | None = Field(None)
template_id: int | None = Field(None)
water_mark: bool | None = Field(None)
class PixverseImageVideoRequest(BaseModel):
quality: PixverseQuality = Field(...)
duration: PixverseDuration = Field(...)
img_id: int = Field(...)
model: Optional[str] = Field("v3.5")
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
model: str | None = Field("v3.5")
motion_mode: PixverseMotionMode | None = Field(PixverseMotionMode.normal)
prompt: str = Field(...)
negative_prompt: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
style: Optional[str] = Field(None)
template_id: Optional[int] = Field(None)
water_mark: Optional[bool] = Field(None)
negative_prompt: str | None = Field(None)
seed: int | None = Field(None)
style: str | None = Field(None)
template_id: int | None = Field(None)
water_mark: bool | None = Field(None)
class PixverseTransitionVideoRequest(BaseModel):
@@ -94,53 +90,141 @@ class PixverseTransitionVideoRequest(BaseModel):
duration: PixverseDuration = Field(...)
first_frame_img: int = Field(...)
last_frame_img: int = Field(...)
model: Optional[str] = Field("v3.5")
motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
model: str | None = Field("v3.5")
motion_mode: PixverseMotionMode | None = Field(PixverseMotionMode.normal)
prompt: str = Field(...)
# negative_prompt: Optional[str] = Field(None)
seed: Optional[int] = Field(None)
# style: Optional[str] = Field(None)
# template_id: Optional[int] = Field(None)
# water_mark: Optional[bool] = Field(None)
class PixverseImageUploadResponse(BaseModel):
ErrCode: Optional[int] = None
ErrMsg: Optional[str] = None
Resp: Optional[PixverseImgIdResponseObject] = Field(None, alias='Resp')
seed: int | None = Field(None)
class PixverseImgIdResponseObject(BaseModel):
img_id: Optional[int] = None
img_id: int | None = None
class PixverseVideoResponse(BaseModel):
ErrCode: Optional[int] = Field(None)
ErrMsg: Optional[str] = Field(None)
Resp: Optional[PixverseVideoIdResponseObject] = Field(None)
class PixverseImageUploadResponse(BaseModel):
ErrCode: int | None = None
ErrMsg: str | None = None
Resp: PixverseImgIdResponseObject | None = Field(None)
class PixverseVideoIdResponseObject(BaseModel):
video_id: int = Field(..., description='Video_id')
video_id: int = Field(...)
credits: int | None = Field(None)
class PixverseGenerationStatusResponse(BaseModel):
ErrCode: Optional[int] = Field(None)
ErrMsg: Optional[str] = Field(None)
Resp: Optional[PixverseGenerationStatusResponseObject] = Field(None)
class PixverseVideoResponse(BaseModel):
ErrCode: int | None = Field(None)
ErrMsg: str | None = Field(None)
Resp: PixverseVideoIdResponseObject | None = Field(None)
class PixverseGenerationStatusResponseObject(BaseModel):
create_time: Optional[str] = Field(None)
id: Optional[int] = Field(None)
modify_time: Optional[str] = Field(None)
negative_prompt: Optional[str] = Field(None)
outputHeight: Optional[int] = Field(None)
outputWidth: Optional[int] = Field(None)
prompt: Optional[str] = Field(None)
resolution_ratio: Optional[int] = Field(None)
seed: Optional[int] = Field(None)
size: Optional[int] = Field(None)
status: Optional[int] = Field(None)
style: Optional[str] = Field(None)
url: Optional[str] = Field(None)
create_time: str | None = Field(None)
id: int | None = Field(None)
modify_time: str | None = Field(None)
negative_prompt: str | None = Field(None)
outputHeight: int | None = Field(None)
outputWidth: int | None = Field(None)
prompt: str | None = Field(None)
resolution_ratio: int | None = Field(None)
seed: int | None = Field(None)
size: int | None = Field(None)
status: int | None = Field(None)
style: str | None = Field(None)
has_audio: bool | None = Field(None)
credits: int | None = Field(None)
url: str | None = Field(None)
class PixverseGenerationStatusResponse(BaseModel):
ErrCode: int | None = Field(None)
ErrMsg: str | None = Field(None)
Resp: PixverseGenerationStatusResponseObject | None = Field(None)
class PixverseV6AspectRatio(str, Enum):
ratio_16_9 = "16:9"
ratio_4_3 = "4:3"
ratio_1_1 = "1:1"
ratio_3_4 = "3:4"
ratio_9_16 = "9:16"
ratio_2_3 = "2:3"
ratio_3_2 = "3:2"
ratio_21_9 = "21:9"
class PixverseV6Style(str, Enum):
none = "none"
anime = "anime"
animation_3d = "3d_animation"
clay = "clay"
comic = "comic"
cyberpunk = "cyberpunk"
realistic = "realistic"
class PixverseReferenceType(str, Enum):
subject = "subject"
background = "background"
class PixverseImageReference(BaseModel):
img_id: int = Field(...)
ref_name: str = Field(...)
type: PixverseReferenceType = Field(...)
class PixverseVideoReference(BaseModel):
ref_name: str = Field(...)
video_media_id: int | None = Field(None)
source_video_id: int | None = Field(None)
class PixverseV6BaseRequest(BaseModel):
model: str = Field("v6")
prompt: str = Field(...)
duration: int = Field(...)
quality: PixverseQuality = Field(...)
negative_prompt: str | None = Field(None)
seed: int | None = Field(None)
style: str | None = Field(None)
generate_audio_switch: bool | None = Field(None)
class PixverseV6TextVideoRequest(PixverseV6BaseRequest):
aspect_ratio: PixverseV6AspectRatio = Field(...)
generate_multi_clip_switch: bool | None = Field(None)
class PixverseV6ImageVideoRequest(PixverseV6BaseRequest):
img_id: int = Field(...)
generate_multi_clip_switch: bool | None = Field(None)
class PixverseV6TransitionVideoRequest(PixverseV6BaseRequest):
first_frame_img: int = Field(...)
last_frame_img: int = Field(...)
class PixverseV6ExtendVideoRequest(PixverseV6BaseRequest):
video_media_id: int = Field(...)
class PixverseV6FusionVideoRequest(PixverseV6BaseRequest):
aspect_ratio: str = Field(...)
image_references: list[PixverseImageReference] | None = Field(None)
video_references: list[PixverseVideoReference] | None = Field(None)
reference_mode: str | None = Field(None)
class PixverseMediaIdResponseObject(BaseModel):
media_id: int | None = Field(None)
media_type: str | None = Field(None)
url: str | None = Field(None)
width: int | None = Field(None)
height: int | None = Field(None)
class PixverseMediaUploadResponse(BaseModel):
ErrCode: int | None = Field(None)
ErrMsg: str | None = Field(None)
Resp: PixverseMediaIdResponseObject | None = Field(None)
+25 -1
View File
@@ -250,6 +250,26 @@ RECRAFT_V4_PRO_SIZES = [
"1536x2688",
]
RECRAFT_V4_STYLES_MODELS = frozenset(
{
"recraftv4_styles",
"recraftv4_styles_vector",
"recraftv4_styles_pro",
"recraftv4_styles_pro_vector",
}
)
RECRAFT_V4_VECTOR_MODEL_FOR_STYLE = {
"recraftv4": "recraftv4_vector",
"recraftv4_pro": "recraftv4_pro_vector",
}
RECRAFT_STYLE_MATCH_OPTIONS = ["precise", "flexible"]
RECRAFT_STYLE_REFERENCES_MAX = 10
RECRAFT_STYLE_REFERENCES_MAX_BYTES = 10 * 1024 * 1024
class RecraftColorObject(BaseModel):
rgb: list[int] = Field(..., description='An array of 3 integer values in range of 0...255 defining RGB Color Model')
@@ -272,6 +292,8 @@ class RecraftImageGenerationRequest(BaseModel):
substyle: str | None = Field(None, description='The substyle to apply to the generated image, depending on the style input')
controls: RecraftControlsObject | None = Field(None, description='A set of custom parameters to tweak generation process')
style_id: str | None = Field(None, description='Use a previously uploaded style as a reference; UUID')
style_match: str | None = Field(None, description='How closely to follow the referenced style: "precise" or "flexible" for V4 models')
style_reference_urls: list[str] | None = Field(None, description='URLs or data URLs of style reference images; a private style is created from them and returned as style_id')
strength: float | None = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
random_seed: int | None = Field(None, description="Seed for video generation")
@@ -286,10 +308,12 @@ class RecraftImageGenerationResponse(BaseModel):
credits: int = Field(..., description='Number of credits used for the generation')
data: list[RecraftReturnedObject] | None = Field(None, description='Array of generated image information')
image: RecraftReturnedObject | None = Field(None, description='Single generated image')
style_id: str | None = Field(None, description='The style applied to the generation, including one auto-created from style references')
class RecraftCreateStyleRequest(BaseModel):
style: str = Field(..., description="realistic_image, digital_illustration, vector_illustration, or icon")
style: str = Field(..., description="any, realistic_image, digital_illustration, vector_illustration, or icon")
model: str | None = Field(None, description="The model family the style is created for, e.g. recraftv4_styles")
class RecraftCreateStyleResponse(BaseModel):
+196 -11
View File
@@ -8,8 +8,11 @@ class TripoModelVersion(str, Enum):
v3_1_20260211 = "v3.1-20260211"
v3_0_20250812 = "v3.0-20250812"
v2_5_20250123 = "v2.5-20250123"
v2_0_20240919 = "v2.0-20240919"
v1_4_20240625 = "v1.4-20240625"
class TripoTextureModelVersion(str, Enum):
v3_0_20250812 = "v3.0-20250812"
v2_5_20250123 = "v2.5-20250123"
class TripoGeometryQuality(str, Enum):
@@ -20,6 +23,7 @@ class TripoGeometryQuality(str, Enum):
class TripoTextureQuality(str, Enum):
standard = "standard"
detailed = "detailed"
extreme = "extreme"
class TripoStyle(str, Enum):
@@ -39,12 +43,16 @@ class TripoTaskType(str, Enum):
IMAGE_TO_MODEL = "image_to_model"
MULTIVIEW_TO_MODEL = "multiview_to_model"
TEXTURE_MODEL = "texture_model"
REFINE_MODEL = "refine_model"
ANIMATE_PRERIGCHECK = "animate_prerigcheck"
ANIMATE_RIG = "animate_rig"
ANIMATE_RETARGET = "animate_retarget"
STYLIZE_MODEL = "stylize_model"
CONVERT_MODEL = "convert_model"
MESH_SEGMENTATION = "mesh_segmentation"
MESH_COMPLETION = "mesh_completion"
HIGHPOLY_TO_LOWPOLY = "highpoly_to_lowpoly"
GENERATE_MULTIVIEW_IMAGE = "generate_multiview_image"
EDIT_MULTIVIEW_IMAGE = "edit_multiview_image"
class TripoTextureAlignment(str, Enum):
@@ -67,6 +75,21 @@ class TripoSpec(str, Enum):
TRIPO = "tripo"
class TripoRigModelVersion(str, Enum):
v1_0_20240301 = "v1.0-20240301"
v2_5_20260210 = "v2.5-20260210"
class TripoRigType(str, Enum):
BIPED = "biped"
QUADRUPED = "quadruped"
HEXAPOD = "hexapod"
OCTOPOD = "octopod"
AVIAN = "avian"
SERPENTINE = "serpentine"
AQUATIC = "aquatic"
class TripoAnimation(str, Enum):
IDLE = "preset:idle"
WALK = "preset:walk"
@@ -86,6 +109,110 @@ class TripoAnimation(str, Enum):
AQUATIC_MARCH = "preset:aquatic:march"
TRIPO_BIPED_ANIMATIONS = (
"preset:biped:afraid",
"preset:biped:agree",
"preset:biped:angry_01",
"preset:biped:angry_02",
"preset:biped:angry_03",
"preset:biped:basketball_shot",
"preset:biped:bow",
"preset:biped:box_01",
"preset:biped:box_02",
"preset:biped:box_03",
"preset:biped:cast_a_spell",
"preset:biped:cheer",
"preset:biped:chop",
"preset:biped:clap",
"preset:biped:climb",
"preset:biped:complain_01",
"preset:biped:complain_02",
"preset:biped:cross_body_crunch",
"preset:biped:crossover_dribble",
"preset:biped:cry",
"preset:biped:dance_01",
"preset:biped:dance_02",
"preset:biped:dance_03",
"preset:biped:dance_04",
"preset:biped:dance_05",
"preset:biped:dance_06",
"preset:biped:defeat_02",
"preset:biped:defeat_03",
"preset:biped:depressed",
"preset:biped:dig",
"preset:biped:dive",
"preset:biped:dribble",
"preset:biped:fall",
"preset:biped:fire",
"preset:biped:flee_01",
"preset:biped:flee_02",
"preset:biped:flip",
"preset:biped:fold_arms",
"preset:biped:football_catch",
"preset:biped:football_save",
"preset:biped:football_pass",
"preset:biped:freaky",
"preset:biped:frightened",
"preset:biped:front_kick_01",
"preset:biped:front_kick_02",
"preset:biped:frustrated_01",
"preset:biped:frustrated_02",
"preset:biped:golf",
"preset:biped:greet_01",
"preset:biped:greet_02",
"preset:biped:greet_03",
"preset:biped:greet_04",
"preset:biped:heart_pose",
"preset:biped:hit_to_body_01",
"preset:biped:hit_to_body_02",
"preset:biped:hit_to_head",
"preset:biped:hit_to_side",
"preset:biped:hit_to_stomach",
"preset:biped:hug",
"preset:biped:hurt",
"preset:biped:idle",
"preset:biped:jump_down",
"preset:biped:jump",
"preset:biped:jump_rope_01",
"preset:biped:jump_rope_02",
"preset:biped:laugh_01",
"preset:biped:laugh_02",
"preset:biped:lift_heavy",
"preset:biped:look_around",
"preset:biped:make_a_call_01",
"preset:biped:make_a_call_02",
"preset:biped:pitch_baseball",
"preset:biped:play_mobile_game",
"preset:biped:play_video_game",
"preset:biped:run_upstairs",
"preset:biped:run",
"preset:biped:scared_01",
"preset:biped:scared_02",
"preset:biped:scratch",
"preset:biped:shoot",
"preset:biped:shovel",
"preset:biped:sing_01",
"preset:biped:sing_02",
"preset:biped:sing_03",
"preset:biped:sing_04",
"preset:biped:sit",
"preset:biped:slash",
"preset:biped:sob",
"preset:biped:standing_relax",
"preset:biped:surf",
"preset:biped:swagger",
"preset:biped:swim",
"preset:biped:turn",
"preset:biped:victory_celebration",
"preset:biped:volleyball",
"preset:biped:wait",
"preset:biped:walk",
"preset:biped:warm_up",
"preset:biped:wave_goodbye_01",
"preset:biped:wave_goodbye_02",
)
class TripoConvertFormat(str, Enum):
GLTF = "GLTF"
USDZ = "USDZ"
@@ -122,6 +249,14 @@ class TripoFbxPreset(str, Enum):
BLENDER = "blender"
MIXAMO = "mixamo"
_3DSMAX = "3dsmax"
BAKE_SCALE = "bake_scale"
class TripoExportOrientation(str, Enum):
PLUS_X = "+x"
MINUS_X = "-x"
PLUS_Y = "+y"
MINUS_Y = "-y"
class TripoFileTokenReference(BaseModel):
@@ -155,7 +290,7 @@ class TripoFileReference(RootModel):
class TripoTextToModelRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.TEXT_TO_MODEL, description="Type of task")
prompt: str = Field(..., description="The text prompt describing the model to generate", max_length=1024)
negative_prompt: str | None = Field(None, description="The negative text prompt", max_length=1024)
negative_prompt: str | None = Field(None, description="The negative text prompt", max_length=255)
model_version: TripoModelVersion | None = TripoModelVersion.v2_5_20250123
face_limit: int | None = Field(None, description="The number of faces to limit the generation to")
texture: bool | None = Field(True, description="Whether to apply texture to the generated model")
@@ -168,6 +303,7 @@ class TripoTextToModelRequest(BaseModel):
style: TripoStyle | None = None
auto_size: bool | None = Field(False, description="Whether to auto-size the model")
quad: bool | None = Field(False, description="Whether to apply quad to the generated model")
smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology")
class TripoImageToModelRequest(BaseModel):
@@ -188,6 +324,7 @@ class TripoImageToModelRequest(BaseModel):
auto_size: bool | None = Field(False, description="Whether to auto-size the model")
orientation: TripoOrientation | None = TripoOrientation.DEFAULT
quad: bool | None = Field(False, description="Whether to apply quad to the generated model")
smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology")
class TripoMultiviewToModelRequest(BaseModel):
@@ -206,15 +343,20 @@ class TripoMultiviewToModelRequest(BaseModel):
auto_size: bool | None = Field(False, description="Whether to auto-size the model")
orientation: TripoOrientation | None = Field(TripoOrientation.DEFAULT, description="The orientation for the model")
quad: bool | None = Field(False, description="Whether to apply quad to the generated model")
smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology")
class TripoTexturePrompt(BaseModel):
text: str | None = Field(None, description="Text guidance for texture generation")
style_image: TripoFileReference | None = Field(None, description="Style reference, only together with text")
image: TripoFileReference | None = Field(None, description="Single reference image")
images: list[TripoFileReference] | None = Field(None, description="Exactly 4 reference images: front, left, back, right")
class TripoTextureModelRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.TEXTURE_MODEL, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
model_version: TripoTextureModelVersion | None = Field(None, description="Texture model version")
texture: bool | None = Field(True, description="Whether to apply texture to the model")
pbr: bool | None = Field(True, description="Whether to apply PBR to the model")
model_seed: int | None = Field(None, description="The seed for the model")
@@ -228,16 +370,19 @@ class TripoTextureModelRequest(BaseModel):
description="Optional guidance for texturing. Required in practice for imported models, "
"which carry no source image to infer texture from.",
)
part_names: list[str] | None = Field(None, description="Parts of a segmented model to texture; all parts when omitted")
class TripoRefineModelRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.REFINE_MODEL, description="Type of task")
draft_model_task_id: str = Field(..., description="The task ID of the draft model")
class TripoAnimatePrerigcheckRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.ANIMATE_PRERIGCHECK, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
class TripoAnimateRigRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.ANIMATE_RIG, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
model_version: TripoRigModelVersion | None = Field(None, description="Rigging model version")
rig_type: TripoRigType | None = Field(None, description="Skeleton type")
out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format")
spec: TripoSpec | None = Field(TripoSpec.TRIPO, description="The specification for rigging")
@@ -245,9 +390,46 @@ class TripoAnimateRigRequest(BaseModel):
class TripoAnimateRetargetRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.ANIMATE_RETARGET, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
animation: TripoAnimation = Field(..., description="The animation to apply")
animation: str = Field(..., description="The animation preset to apply")
out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format")
bake_animation: bool | None = Field(True, description="Whether to bake the animation")
export_with_geometry: bool | None = Field(None, description="Whether to export geometry with the animation")
animate_in_place: bool | None = Field(None, description="Whether to play the animation in place")
class TripoMeshSegmentationRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.MESH_SEGMENTATION, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
class TripoMeshCompletionRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.MESH_COMPLETION, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of a mesh segmentation task")
part_names: list[str] | None = Field(None, description="Parts to complete; all parts when omitted")
class TripoHighpolyToLowpolyRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.HIGHPOLY_TO_LOWPOLY, description="Type of task")
original_model_task_id: str = Field(..., description="The task ID of the original model")
face_limit: int | None = Field(None, description="Target face count; adaptive when omitted")
quad: bool | None = Field(None, description="Whether to output a quad mesh")
bake: bool | None = Field(None, description="Whether to bake textures onto the low-poly mesh")
part_names: list[str] | None = Field(None, description="Parts to retopologize; whole model when omitted")
class TripoGenerateMultiviewImageRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.GENERATE_MULTIVIEW_IMAGE, description="Type of task")
file: TripoFileReference = Field(..., description="The source image")
class TripoMultiviewEditPrompt(BaseModel):
view: str = Field(..., description="front, left, back or right")
prompt: str = Field(..., description="Edit instruction for the view", max_length=1024)
class TripoEditMultiviewImageRequest(BaseModel):
type: TripoTaskType = Field(TripoTaskType.EDIT_MULTIVIEW_IMAGE, description="Type of task")
original_task_id: str = Field(..., description="The task ID of the multiview images to edit")
prompts: list[TripoMultiviewEditPrompt] = Field(..., description="Per-view edit instructions")
class TripoConvertModelRequest(BaseModel):
@@ -269,7 +451,7 @@ class TripoConvertModelRequest(BaseModel):
part_names: list[str] | None = Field(None, description="The names of the parts to include")
fbx_preset: TripoFbxPreset | None = Field(None, description="The preset for the FBX export")
export_vertex_colors: bool | None = Field(None, description="Whether to export the vertex colors")
export_orientation: TripoOrientation | None = Field(None, description="The orientation for the export")
export_orientation: TripoExportOrientation | None = Field(None, description="Forward axis of the exported model")
animate_in_place: bool | None = Field(None, description="Whether to animate in place")
@@ -333,6 +515,9 @@ class TripoTaskOutput(BaseModel):
pbr_model: str | None = Field(None, description="URL to the PBR model")
rendered_image: str | None = Field(None, description="URL to the rendered image")
riggable: bool | None = Field(None, description="Whether the model is riggable")
rig_type: str | None = Field(None, description="Recommended rig type")
topology: str | None = Field(None, description="Legacy name of rig_type")
generate_multiview_image: dict[str, str] | None = Field(None, description="View name to image URL")
class TripoTask(BaseModel):
@@ -345,7 +530,7 @@ class TripoTask(BaseModel):
create_time: int | None = Field(None, description="The creation time of the task")
running_left_time: int | None = Field(None, description="The estimated time left for the task")
queue_position: int | None = Field(None, description="The position in the queue")
consumed_credit: int | None = Field(None)
consumed_credit: float | None = Field(None)
class TripoTaskResponse(BaseModel):
+26
View File
@@ -184,6 +184,32 @@ class Wan27Text2VideoTaskCreationRequest(BaseModel):
parameters: Wan27Text2VideoParametersField = Field(...)
class Wan3MediaItem(BaseModel):
type: str = Field(...)
url: str = Field(...)
class Wan3InputField(BaseModel):
prompt: str | None = Field(None)
media: list[Wan3MediaItem] | None = Field(None)
class Wan3ParametersField(BaseModel):
resolution: str = Field(...)
ratio: str = Field(...)
duration: int = Field(..., ge=-1, le=30)
seed: int = Field(..., ge=0, le=2147483647)
audio: bool = Field(True)
prompt_extend: bool = Field(True)
watermark: bool = Field(False)
class Wan3TaskCreationRequest(BaseModel):
model: str = Field(...)
input: Wan3InputField = Field(...)
parameters: Wan3ParametersField = Field(...)
class TaskCreationOutputField(BaseModel):
task_id: str = Field(...)
task_status: str = Field(...)
+18 -4
View File
@@ -30,6 +30,7 @@ CLAUDE_MAX_IMAGES = 20
CLAUDE_MODELS: dict[str, str] = {
"Opus 5": "claude-opus-5",
"Opus 4.8": "claude-opus-4-8",
"Fable 5.1": "claude-fable-5-1",
"Fable 5": "claude-fable-5",
"Sonnet 5": "claude-sonnet-5",
"Opus 4.7": "claude-opus-4-7",
@@ -43,9 +44,11 @@ _THINKING_UNSUPPORTED = {"Haiku 4.5"}
# Models that use the newer "adaptive" thinking mode (Opus 4.7+ require it; older models keep the explicit budget API).
# Anthropic decides the actual budget when adaptive is used, based on the `output_config.effort` hint.
_ADAPTIVE_THINKING_MODELS = {"Opus 4.8", "Sonnet 5", "Opus 4.7", "Opus 4.6", "Sonnet 4.6"}
_ALWAYS_THINKING_MODELS = {"Opus 5", "Fable 5"}
_ALWAYS_THINKING_MODELS = {"Opus 5", "Fable 5.1", "Fable 5"}
_XHIGH_EFFORT_MODELS = {"Opus 5", "Opus 4.8", "Fable 5.1", "Fable 5", "Sonnet 5", "Opus 4.7"}
_MAX_EFFORT_MODELS = _XHIGH_EFFORT_MODELS | {"Opus 4.6", "Sonnet 4.6"}
_EXPLICIT_THINKING_OFF_MODELS = {"Sonnet 5"}
_NO_TEMPERATURE_MODELS = {"Opus 5", "Opus 4.8", "Fable 5", "Sonnet 5"}
_NO_TEMPERATURE_MODELS = {"Opus 5", "Opus 4.8", "Fable 5.1", "Fable 5", "Sonnet 5"}
# Budget mode (Sonnet 4.5): effort -> reasoning budget in tokens. Must be < max_tokens.
# Sized so even the "high" budget fits comfortably under the default max_tokens=32768.
@@ -57,6 +60,17 @@ _REASONING_BUDGET: dict[str, int] = {
_REASONING_EFFORTS = ["off", "low", "medium", "high"]
def _reasoning_effort_options(model_label: str) -> list[str]:
options = list(_REASONING_EFFORTS)
if model_label in _ALWAYS_THINKING_MODELS:
options.remove("off")
if model_label in _XHIGH_EFFORT_MODELS:
options.append("xhigh")
if model_label in _MAX_EFFORT_MODELS:
options.append("max")
return options
def _claude_model_inputs(model_label: str):
inputs: list = [
IO.Int.Input(
@@ -87,7 +101,7 @@ def _claude_model_inputs(model_label: str):
inputs.append(
IO.Combo.Input(
"reasoning_effort",
options=[e for e in _REASONING_EFFORTS if e != "off"],
options=_reasoning_effort_options(model_label),
default="high",
tooltip="Extended thinking effort. Reasoning is always enabled for this model.",
advanced=True,
@@ -97,7 +111,7 @@ def _claude_model_inputs(model_label: str):
inputs.append(
IO.Combo.Input(
"reasoning_effort",
options=_REASONING_EFFORTS,
options=_reasoning_effort_options(model_label),
default="off",
tooltip="Extended thinking effort. 'off' disables reasoning.",
advanced=True,
+147 -11
View File
@@ -1,6 +1,7 @@
import math
import torch
from pydantic import BaseModel
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
@@ -12,18 +13,19 @@ from comfy_api_nodes.apis.bfl import (
BFLFluxProGenerateResponse,
BFLFluxProUltraGenerateRequest,
BFLFluxStatusResponse,
BFLFluxVideoUpscaleRequest,
BFLFluxVTORequest,
BFLStatus,
Flux2ProGenerateRequest,
Flux3ImageToVideoRequest,
Flux3TextToVideoRequest,
Flux3VideoContinuationRequest,
Flux3VideoRequest,
)
from comfy_api_nodes.util import (
ApiEndpoint,
convert_mask_to_image,
download_url_to_image_tensor,
downscale_video_to_max_pixels,
download_url_to_video_output,
get_number_of_images,
poll_op,
@@ -35,6 +37,8 @@ from comfy_api_nodes.util import (
validate_aspect_ratio_string,
validate_image_dimensions,
validate_string,
validate_video_dimensions,
validate_video_duration,
)
@@ -1147,16 +1151,23 @@ class Flux3VideoNodeBase(IO.ComfyNode):
)
async def _flux3_execute(cls: type[IO.ComfyNode], request: Flux3VideoRequest) -> IO.NodeOutput:
initial_response = await sync_op(
cls,
ApiEndpoint(path="/proxy/bfl/v1/flux-3-video", method="POST"),
response_model=BFLFluxProGenerateResponse,
data=request,
_FLUX3_VIDEO_ENDPOINT = ApiEndpoint(path="/proxy/bfl/v1/flux-3-video", method="POST")
_FLUX_VIDEO_UPSCALE_ENDPOINT = ApiEndpoint(path="/proxy/bfl/v1/flux-tools/video-upscale-v1", method="POST")
_BFL_POLL_PROXY_PATH = "/proxy/bfl/v1/get_result"
async def _bfl_video_execute(
cls: type[IO.ComfyNode], endpoint: ApiEndpoint, request: BaseModel, poll_via_proxy: bool = False
) -> IO.NodeOutput:
initial_response = await sync_op(cls, endpoint, response_model=BFLFluxProGenerateResponse, data=request)
poll_endpoint = (
ApiEndpoint(path=_BFL_POLL_PROXY_PATH, query_params={"polling_url": initial_response.polling_url})
if poll_via_proxy
else ApiEndpoint(initial_response.polling_url)
)
response = await poll_op(
cls,
ApiEndpoint(initial_response.polling_url),
poll_endpoint,
response_model=BFLFluxStatusResponse,
status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress,
@@ -1221,7 +1232,7 @@ class Flux3TextToVideoNode(Flux3VideoNodeBase):
request = Flux3TextToVideoRequest(
**cls.common_fields(prompt, aspect_ratio, duration, resolution, generate_audio, safety_tolerance)
)
return await _flux3_execute(cls, request)
return await _bfl_video_execute(cls, _FLUX3_VIDEO_ENDPOINT, request)
class Flux3ImageToVideoNode(Flux3VideoNodeBase):
@@ -1319,7 +1330,7 @@ class Flux3ImageToVideoNode(Flux3VideoNodeBase):
keyframes=list(zip(times, urls)) if times is not None else urls,
**fields,
)
return await _flux3_execute(cls, request)
return await _bfl_video_execute(cls, _FLUX3_VIDEO_ENDPOINT, request)
class Flux3VideoContinuationNode(Flux3VideoNodeBase):
@@ -1370,7 +1381,131 @@ class Flux3VideoContinuationNode(Flux3VideoNodeBase):
fields = cls.common_fields(prompt, aspect_ratio, duration, resolution, generate_audio, safety_tolerance)
url = await upload_video_to_comfyapi(cls, video, wait_label="Uploading source video")
request = Flux3VideoContinuationRequest(start_video=url, **fields)
return await _flux3_execute(cls, request)
return await _bfl_video_execute(cls, _FLUX3_VIDEO_ENDPOINT, request)
_FLUX_VIDEO_UPSCALE_MODES = {"creative": 1, "precise": 0}
_FLUX_VIDEO_UPSCALE_MAX_INPUT_PIXELS = 3840 * 2160
_FLUX_VIDEO_UPSCALE_MAX_ASPECT_RATIO = 4.0
class FluxVideoUpscaleNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="FluxVideoUpscaleNode",
display_name="Flux Video Upscale",
category="partner/video/BFL",
description="Upscales a video 1.5 to 3 times with FLUX super-resolution, either preserving "
"the source precisely or creatively enhancing its detail.",
inputs=[
IO.Video.Input(
"video",
tooltip="Source clip of 1 to 20 seconds with an aspect ratio between 1:4 and 4:1. "
"The output is rendered at 24 fps and capped at about 14.4 megapixels per frame.",
),
IO.Float.Input(
"upscale_factor",
default=2.0,
min=1.5,
max=3.0,
step=0.1,
tooltip="Output size relative to the source. Very large sources are upscaled by "
"less than the requested factor because of the per-frame cap.",
),
IO.Combo.Input(
"mode",
options=list(_FLUX_VIDEO_UPSCALE_MODES),
default="creative",
tooltip="'creative' restores and invents fine detail, best for generated footage, "
"textures and scenery. 'precise' sharpens the source without changing it, "
"for faces, products and real footage.",
),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Optional description of the clip that steers the enhanced detail. "
"Leave empty for a neutral upscale.",
),
IO.Boolean.Input(
"auto_downscale",
default=True,
tooltip="Automatically downscale sources larger than 3840x2160 pixels in area to fit "
"the input limit. Aspect ratio is preserved; smaller videos are untouched.",
),
IO.Int.Input(
"safety_tolerance",
default=2,
min=0,
max=4,
advanced=True,
tooltip="Moderation tolerance, 0 is the strictest.",
),
IO.Int.Input(
"seed",
default=42,
min=0,
max=0xFFFFFFFF,
control_after_generate=True,
tooltip="Seed to determine if node should re-run; FLUX picks its own seed, so "
"actual results are nondeterministic regardless of this value.",
),
],
outputs=[IO.Video.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=["mode"]),
expr="""
(
$precise := widgets.mode = "precise";
{"type":"range_usd",
"min_usd": $precise ? 0.212 : 0.297,
"max_usd": $precise ? 0.848 : 1.188,
"format": {"approximate": true, "suffix": "/s", "note": "(1080p-4K output)"}}
)
""",
),
)
@classmethod
async def execute(
cls,
video: Input.Video,
upscale_factor: float,
mode: str,
prompt: str,
auto_downscale: bool,
safety_tolerance: int,
seed: int,
) -> IO.NodeOutput:
validate_video_duration(video, min_duration=1.0, max_duration=20.0)
validate_video_dimensions(video, min_width=64, min_height=64)
width, height = video.get_dimensions()
if max(width, height) > _FLUX_VIDEO_UPSCALE_MAX_ASPECT_RATIO * min(width, height):
raise ValueError(f"Video aspect ratio must be between 1:4 and 4:1, got {width}x{height}.")
if auto_downscale:
video = downscale_video_to_max_pixels(video, _FLUX_VIDEO_UPSCALE_MAX_INPUT_PIXELS)
elif width * height > _FLUX_VIDEO_UPSCALE_MAX_INPUT_PIXELS:
raise ValueError(
f"Video must be at most 3840x2160 pixels in area, got {width}x{height}. "
"Enable auto_downscale or use a smaller video."
)
url = await upload_video_to_comfyapi(cls, video, wait_label="Uploading source video")
request = BFLFluxVideoUpscaleRequest(
input_video=url,
upscale_factor=round(upscale_factor, 1),
creativity=_FLUX_VIDEO_UPSCALE_MODES[mode],
prompt=prompt.strip() or None,
safety_tolerance=safety_tolerance,
)
return await _bfl_video_execute(cls, _FLUX_VIDEO_UPSCALE_ENDPOINT, request, poll_via_proxy=True)
class BFLExtension(ComfyExtension):
@@ -1390,6 +1525,7 @@ class BFLExtension(ComfyExtension):
Flux3TextToVideoNode,
Flux3ImageToVideoNode,
Flux3VideoContinuationNode,
FluxVideoUpscaleNode,
]
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
import contextlib
import math
import posixpath
from io import BytesIO
from typing import ClassVar
from urllib.parse import quote, unquote, urlsplit
import torch
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.apis.comfy_cloud import (
ComfyCloudAssetInput,
ComfyCloudGenerateRequest,
ComfyCloudGenerateResponse,
ComfyCloudStatusResponse,
ComfyCloudWorkflow,
ComfyCloudWorkflowInputs,
)
from comfy_api_nodes.util import (
ApiEndpoint,
audio_bytes_to_audio_input,
download_url_to_bytesio,
download_url_to_image_tensor,
download_url_to_video_output,
get_number_of_images,
poll_op,
sync_op,
sync_op_raw,
upload_audio_to_comfyapi,
upload_image_to_comfyapi,
upload_video_to_comfyapi,
validate_string,
)
# Must stay in step with comfyCloudOutputBuckets in cloud's services/comfy-api/config/config.go.
_OUTPUT_BUCKETS = frozenset(
{
"comfy-cloud-assets",
"comfy-cloud-assets-stg",
"comfy-cloud-assets-test",
"partner-nodes-assets",
"partner-nodes-assets-staging",
}
)
_GENERATE_ENDPOINT = ApiEndpoint(path="/proxy/comfy-cloud/workflow/generate", method="POST")
_RUN_TIMEOUT_SECONDS = 2100
_POLL_INTERVAL_SECONDS = 5.0
_POLL_MAX_ATTEMPTS = int(_RUN_TIMEOUT_SECONDS / _POLL_INTERVAL_SECONDS) + 24 # +2 min of slack
_OUTPUT_DOWNLOAD_TIMEOUT = 30 * 60
_MAX_UPLOAD_IMAGE_PIXELS = 32_000_000
_MAX_UPLOAD_IMAGE_DIMENSION = 8192
_MAX_DECODED_AUDIO_BYTES = 256 * 1024 * 1024
# Hardcoded mirror of Metronome's GPU rate. Nothing links the two, so it must be
# changed by hand when the rate card moves.
#
# VERIFY AGAINST A REAL CHARGE, not the rate card. This was briefly set to
# 0.00185, read off rtx_pro_6000's card entry, which over-quoted every run by
# ~43%. Dividing an actual billed event gives the truth:
# credits_used 1.98 / gpu_seconds 7.244173 / 211 credits-per-USD = 0.0012954
#
# THIS IS THE LIST PRICE. Never put a promotional or time-boxed rate here.
# ComfyUI is pulled, not pushed, so a user keeps whatever value they last pulled
# for as long as they like. A promo rate shipped here does not expire when the
# promotion does: the rate card returns to list, that user's node still quotes
# the discount, and they are charged MORE than they were shown, indefinitely and
# invisibly to us. A promo belongs in Metronome alone, where ending it actually
# ends it, and in the launch copy. Quoting above the charged rate is the safe
# direction; quoting below it is not.
#
# The same asymmetry applies to any list-price INCREASE, which is why a
# server-supplied per-run estimate (BE-9841) is the only way to change this
# number safely rather than a nicety.
COMFY_CLOUD_GPU_SECOND_USD = 0.001295
COMFY_CLOUD_CREDITS_PER_USD = 211
COMFY_CLOUD_GPU_SECOND_CREDITS = COMFY_CLOUD_GPU_SECOND_USD * COMFY_CLOUD_CREDITS_PER_USD
_COMFY_CLOUD_PRICE_BADGE = IO.PriceBadge(
expr=(
f'{{"type":"usd","usd":{COMFY_CLOUD_GPU_SECOND_USD:.6f},'
'"format":{"suffix":"/GPU-second","approximate":true}}'
)
)
_COMFY_CLOUD_RATE_DESCRIPTION = (
f" Runs on a Comfy Cloud GPU, billed by how long it runs at "
f"${COMFY_CLOUD_GPU_SECOND_USD:.6f}/GPU-second "
f"({COMFY_CLOUD_GPU_SECOND_CREDITS:.2f} credits). Paid in credits, no Cloud "
"subscription required."
)
# Marked beta on purpose: the workflow set is curated by hand and expected to
# change, so a node can gain or lose options, and a workflow can be retired.
# display_name is not stored in a saved graph, so this is free to remove later;
# node_id and the input names are the parts that are permanent.
_COMFY_CLOUD_BETA_SUFFIX = " [BETA]"
_COMFY_CLOUD_BETA_DESCRIPTION = (
"BETA. The Comfy Cloud node set is still changing: options may be added or "
"removed, and a workflow may be retired. "
)
def _comfy_cloud_display_name(display_name: str) -> str:
"""A SUFFIX, not a prefix: the menu and node search sort alphabetically, so a
leading marker would file all of these under "[" instead of their model."""
return display_name + _COMFY_CLOUD_BETA_SUFFIX
def _comfy_cloud_description(summary: str) -> str:
"""Node descriptions carry the beta notice and the rate. The beta notice leads
because it is the caveat to read first; the rate is here because the price
badge only renders on Nodes 2.0, and a plain local install still defaults to
the classic canvas."""
return _COMFY_CLOUD_BETA_DESCRIPTION + summary + _COMFY_CLOUD_RATE_DESCRIPTION
_TEXT_LIMITS = {
"prompt": (1, 4096),
"instruction": (1, 4096),
"negative_prompt": (0, 2048),
"lyrics": (0, 4096),
}
def _task_endpoints(task_id: str) -> tuple[ApiEndpoint, ApiEndpoint]:
if not task_id.strip():
raise ValueError("Comfy Cloud returned an empty task ID.")
task_path = f"/proxy/comfy-cloud/workflow/tasks/{quote(task_id, safe='')}"
return ApiEndpoint(path=task_path), ApiEndpoint(path=f"{task_path}/cancel", method="POST")
def _with_input_sockets(inputs: list[IO.Input]) -> list[IO.Input]:
for input_spec in inputs:
if isinstance(input_spec, IO.WidgetInput):
input_spec.socketless = False
return inputs
def _cloud_schema(
node_id: str,
display_name: str,
summary: str,
category: str,
inputs: list[IO.Input],
output: IO.Output,
) -> IO.Schema:
"""Every Comfy Cloud node is the same schema but for its id, name, blurb, category,
inputs and output type, so they are all built here."""
return IO.Schema(
node_id=node_id,
display_name=_comfy_cloud_display_name(display_name),
category=category,
description=_comfy_cloud_description(summary),
inputs=_with_input_sockets(inputs),
outputs=[output],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=_COMFY_CLOUD_PRICE_BADGE,
)
def _validated_output_url(url: str) -> str:
parsed = urlsplit(url)
decoded_path = unquote(parsed.path)
is_proxy_path = (
not parsed.scheme
and not parsed.netloc
and decoded_path.startswith("/proxy/comfy-cloud/")
and posixpath.normpath(decoded_path) == decoded_path
)
# normpath first: a client resolves dot segments before sending, so
# ".../comfy-cloud-assets/../other/x.png" would advertise an allowed bucket here
# and fetch from another one on the wire.
bucket = decoded_path.lstrip("/").split("/", 1)[0]
is_signed_https_url = (
parsed.scheme == "https"
and parsed.hostname == "storage.googleapis.com"
and parsed.port is None
and parsed.username is None
and parsed.password is None
and posixpath.normpath(decoded_path) == decoded_path
and bucket in _OUTPUT_BUCKETS
)
if not is_proxy_path and not is_signed_https_url:
raise RuntimeError("Comfy Cloud returned an invalid output URL.")
return url
def _validate_image_upload(image: Input.Image) -> None:
if not isinstance(image, torch.Tensor):
return
if image.ndim not in (3, 4):
raise ValueError("Invalid input image shape.")
height, width = image.shape[-3:-1]
if max(height, width) > _MAX_UPLOAD_IMAGE_DIMENSION or height * width > _MAX_UPLOAD_IMAGE_PIXELS:
raise ValueError("Input image exceeds the 8192px or 32-megapixel Comfy Cloud limit.")
def _progress(response: ComfyCloudStatusResponse) -> float | None:
if response.progress is None or not math.isfinite(response.progress):
return None
return min(100.0, max(0.0, response.progress))
async def _poll_task(cls: type[IO.ComfyNode], task_id: str) -> ComfyCloudStatusResponse:
polling_endpoint, cancel_endpoint = _task_endpoints(task_id)
try:
return await poll_op(
cls,
polling_endpoint,
response_model=ComfyCloudStatusResponse,
status_extractor=lambda response: response.status,
progress_extractor=_progress,
cancel_endpoint=cancel_endpoint,
poll_interval=_POLL_INTERVAL_SECONDS,
max_poll_attempts=_POLL_MAX_ATTEMPTS,
)
except Exception:
with contextlib.suppress(Exception):
await sync_op_raw(cls, cancel_endpoint, max_retries=0)
raise
def _validate_node_inputs(cls: type[IO.ComfyNode], values: dict) -> dict:
validated = dict(values)
for input_spec in cls.define_schema().inputs:
if not isinstance(input_spec, IO.WidgetInput) or input_spec.id not in values:
continue
value = values[input_spec.id]
io_type = input_spec.get_io_type()
if io_type == "STRING":
if not isinstance(value, str):
raise ValueError(f"{input_spec.id} must be a string.")
value = value.strip()
minimum, maximum = _TEXT_LIMITS.get(input_spec.id, (0, None))
validate_string(
value,
min_length=minimum,
max_length=maximum,
field_name=input_spec.id,
)
validated[input_spec.id] = value
elif io_type == "COMBO" and value not in input_spec.options:
raise ValueError(f"Invalid {input_spec.id}: {value!r}.")
elif io_type == "BOOLEAN" and not isinstance(value, bool):
raise ValueError(f"{input_spec.id} must be a boolean.")
elif io_type == "INT":
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{input_spec.id} must be an integer.")
if input_spec.min is not None and value < input_spec.min:
raise ValueError(f"{input_spec.id} must be at least {input_spec.min}.")
if input_spec.max is not None and value > input_spec.max:
raise ValueError(f"{input_spec.id} must be at most {input_spec.max}.")
elif io_type == "FLOAT":
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f"{input_spec.id} must be a finite number.")
if input_spec.min is not None and value < input_spec.min:
raise ValueError(f"{input_spec.id} must be at least {input_spec.min}.")
if input_spec.max is not None and value > input_spec.max:
raise ValueError(f"{input_spec.id} must be at most {input_spec.max}.")
if input_spec.step:
origin = input_spec.min or 0
steps = (value - origin) / input_spec.step
if not math.isclose(steps, round(steps), abs_tol=1e-7):
raise ValueError(f"{input_spec.id} must use increments of {input_spec.step}.")
return validated
_ASPECT_RATIOS = ["1:1", "3:4", "2:3", "3:2", "4:3", "16:9", "9:16", "21:9"]
_VIDEO_RESOLUTIONS = ["480p", "720p"]
_MINIMAX_MUSIC3_QUALITIES = ["V0", "128k", "320k"]
_UINT64_MAX = 0xFFFFFFFFFFFFFFFF
_NEGATIVE_PROMPT_TOOLTIP = "Leave empty to keep the negative prompt this pipeline was tuned with."
def _prompt_input(name: str = "prompt") -> IO.String.Input:
return IO.String.Input(name, multiline=True, default="")
def _negative_prompt_input() -> IO.String.Input:
return IO.String.Input(
"negative_prompt", multiline=True, default="", tooltip=_NEGATIVE_PROMPT_TOOLTIP
)
def _aspect_ratio_input(default: str = "1:1") -> IO.Combo.Input:
return IO.Combo.Input("aspect_ratio", options=_ASPECT_RATIOS, default=default)
def _megapixels_input() -> IO.Float.Input:
# Resolution Selector graphs take a ratio and pixel budget rather than raw dimensions.
return IO.Float.Input(
"megapixels", default=1.0, min=0.1, max=16.0, step=0.1,
tooltip="Total pixel budget. 1.0 is about 1024x1024 at a square ratio.",
)
def _seed_input(maximum: int = _UINT64_MAX) -> IO.Int.Input:
return IO.Int.Input("seed", default=42, min=0, max=maximum, control_after_generate=True)
def _video_resolution_input(advanced: bool = True) -> IO.Combo.Input:
return IO.Combo.Input(
"resolution",
options=_VIDEO_RESOLUTIONS,
default="480p",
advanced=advanced,
tooltip="Frame size budget. 720p costs roughly twice the GPU-seconds of 480p.",
)
async def _submit_workflow(
cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs
) -> str:
task = await sync_op(
cls,
_GENERATE_ENDPOINT,
response_model=ComfyCloudGenerateResponse,
data=ComfyCloudGenerateRequest(workflow=workflow, inputs=inputs),
)
result = await _poll_task(cls, task.task_id)
if not result.output_url:
raise RuntimeError("Comfy Cloud task completed without an output URL.")
return _validated_output_url(result.output_url)
async def _run_image_workflow(
cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs
) -> IO.NodeOutput:
url = await _submit_workflow(cls, workflow, inputs)
return IO.NodeOutput(
await download_url_to_image_tensor(
url, timeout=_OUTPUT_DOWNLOAD_TIMEOUT, cls=cls, allow_redirects=False
)
)
async def _run_video_workflow(
cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs
) -> IO.NodeOutput:
url = await _submit_workflow(cls, workflow, inputs)
return IO.NodeOutput(
await download_url_to_video_output(
url, timeout=_OUTPUT_DOWNLOAD_TIMEOUT, cls=cls, allow_redirects=False
)
)
async def _run_audio_workflow(
cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs
) -> IO.NodeOutput:
url = await _submit_workflow(cls, workflow, inputs)
buffer = BytesIO()
await download_url_to_bytesio(
url, buffer, timeout=_OUTPUT_DOWNLOAD_TIMEOUT, cls=cls, allow_redirects=False
)
return IO.NodeOutput(audio_bytes_to_audio_input(buffer.getvalue()))
async def _upload_workflow_image(cls: type[IO.ComfyNode], image: Input.Image, **options) -> str:
if get_number_of_images(image) != 1:
raise ValueError("Exactly one input image is required.")
_validate_image_upload(image)
return await upload_image_to_comfyapi(cls, image, **options)
def _image_schema(cls: type[IO.ComfyNode], inputs: list[IO.Input]) -> IO.Schema:
return _cloud_schema(
cls.node_id, cls.display_name, cls.summary, "comfy cloud/image", inputs, IO.Image.Output()
)
class ComfyCloudFlux2TextToImageNode(IO.ComfyNode):
node_id = "ComfyCloudFlux2TextToImageNode"
display_name = "Comfy Cloud Flux 2 Text to Image"
summary = (
"Generates an image from a text prompt with Flux 2 dev. Turbo swaps in the chosen LoRA "
"and a short schedule, trading a little fidelity for a much quicker run; switch it off "
"for the full-length dev pass with no LoRA."
)
@classmethod
def define_schema(cls) -> IO.Schema:
return _image_schema(
cls,
[
_prompt_input(),
_seed_input(),
_aspect_ratio_input(),
_megapixels_input(),
IO.Boolean.Input(
"turbo",
default=True,
tooltip="Run the Turbo LoRA on a short schedule, trading a little fidelity "
"for a much quicker run. Off runs the full dev pass with no LoRA.",
),
],
)
@classmethod
async def execute(
cls,
prompt: str,
seed: int = 42,
aspect_ratio: str = "1:1",
megapixels: float = 1.0,
turbo: bool = True,
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
return await _run_image_workflow(
cls,
"flux-2/text-to-image",
ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, megapixels=megapixels, turbo=turbo, seed=seed,
),
)
class ComfyCloudZImageTurboNode(IO.ComfyNode):
node_id = "ComfyCloudZImageTurboNode"
display_name = "Comfy Cloud Z-Image Turbo Text to Image"
summary = (
"Generates an image from a text prompt with Z-Image Turbo in 8 steps. One of the "
"quickest and cheapest nodes here, which makes it the one to iterate on."
)
@classmethod
def define_schema(cls) -> IO.Schema:
return _image_schema(
cls,
[
_prompt_input(),
_seed_input(),
_aspect_ratio_input(),
_megapixels_input(),
],
)
@classmethod
async def execute(
cls,
prompt: str,
seed: int = 42,
aspect_ratio: str = "1:1",
megapixels: float = 1.0,
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
return await _run_image_workflow(
cls,
"z-image-turbo/text-to-image",
ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, megapixels=megapixels, seed=seed,
),
)
class _ComfyCloudMageFlowNode(IO.ComfyNode):
"""Mage-Flow text to image. The base and turbo graphs are the same pipeline at
two schedule lengths, so they differ only in their step and cfg defaults and
which checkpoints load."""
workflow: ClassVar[ComfyCloudWorkflow]
node_id: ClassVar[str]
display_name: ClassVar[str]
summary: ClassVar[str]
@classmethod
def define_schema(cls) -> IO.Schema:
return _image_schema(
cls,
[
_prompt_input(),
_negative_prompt_input(),
_seed_input(),
_aspect_ratio_input(),
_megapixels_input(),
],
)
@classmethod
async def execute(
cls,
prompt: str,
negative_prompt: str = "",
seed: int = 42,
aspect_ratio: str = "1:1",
megapixels: float = 1.0,
) -> IO.NodeOutput:
validated = _validate_node_inputs(cls, locals())
return await _run_image_workflow(
cls,
cls.workflow,
ComfyCloudWorkflowInputs(
prompt=validated["prompt"], negative_prompt=validated["negative_prompt"],
aspect_ratio=aspect_ratio, megapixels=megapixels, seed=seed,
),
)
class ComfyCloudMageFlowTextToImageNode(_ComfyCloudMageFlowNode):
workflow = "mage-flow/text-to-image"
node_id = "ComfyCloudMageFlowTextToImageNode"
display_name = "Comfy Cloud Mage Flow Text to Image"
summary = (
"Generates an image from a text prompt with Mage-Flow over a full 30-step pass. "
"It takes a negative prompt, which the distilled turbo variant cannot use well."
)
class ComfyCloudMageFlowTurboTextToImageNode(_ComfyCloudMageFlowNode):
workflow = "mage-flow-turbo/text-to-image"
node_id = "ComfyCloudMageFlowTurboTextToImageNode"
display_name = "Comfy Cloud Mage Flow Turbo Text to Image"
summary = (
"Generates an image from a text prompt with distilled Mage-Flow in 4 steps at cfg 1. "
"Roughly a seventh of the GPU time of the full pass, which makes it the one to iterate on."
)
class ComfyCloudMiniMaxMusic3TextToAudioNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _cloud_schema(
"ComfyCloudMiniMaxMusic3TextToAudioNode",
"Comfy Cloud MiniMax Music 3 Text to Audio",
(
"Generates a full song from a description with MiniMax Music 3. The prompt "
"carries the style, instrumentation and mood; lyrics are sung rather than "
"described, and an empty lyric leaves the track instrumental."
),
"comfy cloud/audio",
[
_prompt_input(),
IO.String.Input(
"lyrics", multiline=True, default="",
tooltip="Words to sing. Leave empty for an instrumental.",
),
# SeedNode caps at int64, below the uint64 the other graphs take.
_seed_input(0x7FFFFFFFFFFFFFFF),
IO.Float.Input(
"max_duration", default=120.0, min=0.04, max=360.0, step=0.04,
tooltip="Longest the track may run. The model can end the song earlier.",
),
IO.Combo.Input(
"audio_quality",
options=_MINIMAX_MUSIC3_QUALITIES,
default=_MINIMAX_MUSIC3_QUALITIES[0],
tooltip="mp3 bitrate. V0 is variable and the highest quality of the three.",
),
],
IO.Audio.Output(),
)
@classmethod
async def execute(
cls,
prompt: str,
lyrics: str = "",
seed: int = 42,
max_duration: float = 120.0,
audio_quality: str = "V0",
) -> IO.NodeOutput:
validated = _validate_node_inputs(cls, locals())
return await _run_audio_workflow(
cls,
"minimax-music-3/text-to-audio",
ComfyCloudWorkflowInputs(
prompt=validated["prompt"], lyrics=validated["lyrics"], seed=seed,
max_duration=max_duration, caption_cfg=1.5,
audio_quality=audio_quality,
),
)
def _video_schema(node_id: str, display_name: str, summary: str, inputs: list[IO.Input]) -> IO.Schema:
return _cloud_schema(
node_id, display_name, summary, "comfy cloud/video", inputs, IO.Video.Output()
)
_MINIMAX_H3_MAX_REFERENCES = 4
_MINIMAX_H3_MAX_AUDIO_REFERENCES = 3
def _minimax_h3_inputs(default_ratio: str, plain: list[IO.Input] | None = None) -> list[IO.Input]:
"""Everything the three fl2va/ref2va graphs expose past their media inputs.
The turbo LoRA branch those templates carry is not here: its weights have
catalog entries but no bytes in the mirror, so cloud's frozen graphs leave
the branch out until they are uploaded.
"""
return [
_prompt_input(),
_seed_input(),
_aspect_ratio_input(default_ratio),
_video_resolution_input(advanced=False),
IO.Int.Input(
"duration_seconds",
default=5,
min=5,
max=15,
display_mode=IO.NumberDisplay.slider,
tooltip=(
"Length in seconds. The pipeline quantises to 17-frame steps at 24fps, "
"so the clip lands within about two thirds of a second of this."
),
),
*(plain or []),
]
async def _minimax_h3_asset(cls: type[IO.ComfyNode], image: Input.Image) -> ComfyCloudAssetInput:
return ComfyCloudAssetInput(
type="IMAGE", url=await _upload_workflow_image(cls, image, total_pixels=2048 * 2048)
)
async def _minimax_h3_video_asset(cls: type[IO.ComfyNode], video: Input.Video) -> ComfyCloudAssetInput:
return ComfyCloudAssetInput(type="VIDEO", url=await upload_video_to_comfyapi(cls, video))
async def _minimax_h3_audio_asset(cls: type[IO.ComfyNode], audio: Input.Audio) -> ComfyCloudAssetInput:
return ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, audio))
class ComfyCloudMiniMaxH3TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _video_schema(
"ComfyCloudMiniMaxH3TextToVideoNode",
"Comfy Cloud MiniMax H3 Text to Video",
(
"Generates a video with a matching soundtrack from a text prompt, using MiniMax "
"H3. Picture and audio come out of the same pass rather than being dubbed on "
"afterwards."
),
_minimax_h3_inputs("16:9"),
)
@classmethod
async def execute(
cls,
prompt: str,
seed: int = 42,
aspect_ratio: str = "16:9",
resolution: str = "480p",
duration_seconds: int = 5
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
inputs = ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, duration_seconds=duration_seconds,
seed=seed, resolution=resolution
)
return await _run_video_workflow(cls, "minimax-h3/text-to-video", inputs)
# MiniMax H3's two Qwen3-VL encoder precisions. Keys, not filenames: cloud holds
# the file each one maps to.
class ComfyCloudMiniMaxH3FirstLastFrameToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _video_schema(
"ComfyCloudMiniMaxH3FirstLastFrameToVideoNode",
"Comfy Cloud MiniMax H3 First-Last Frame to Video",
(
"Generates the motion between two keyframes, with a matching soundtrack, using "
"MiniMax H3. Give it the opening and closing frames and the model fills in the "
"shot between them. The last frame is optional: leave it out and the motion runs "
"away from the first frame instead."
),
[
IO.Image.Input("first_frame"),
IO.Image.Input("last_frame", optional=True),
*_minimax_h3_inputs("1:1"),
],
)
@classmethod
async def execute(
cls,
first_frame: Input.Image,
prompt: str,
seed: int = 42,
aspect_ratio: str = "1:1",
resolution: str = "480p",
duration_seconds: int = 5,
last_frame: Input.Image | None = None,
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
assets = {"first_frame": await _minimax_h3_asset(cls, first_frame)}
if last_frame is not None:
assets["last_frame"] = await _minimax_h3_asset(cls, last_frame)
return await _run_video_workflow(
cls,
"minimax-h3/first-last-frame-to-video",
ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, resolution=resolution,
duration_seconds=duration_seconds, seed=seed, assets=assets
),
)
class ComfyCloudMiniMaxH3ImageToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _video_schema(
"ComfyCloudMiniMaxH3ImageToVideoNode",
"Comfy Cloud MiniMax H3 Image to Video",
(
"Animates a still into a video with a matching soundtrack, using MiniMax H3. "
"Feed it a clip's closing frame and it continues the sequence, so chaining "
"several builds a longer shot."
),
[IO.Image.Input("first_frame"), *_minimax_h3_inputs("1:1")],
)
@classmethod
async def execute(
cls,
first_frame: Input.Image,
prompt: str,
seed: int = 42,
aspect_ratio: str = "1:1",
resolution: str = "480p",
duration_seconds: int = 5
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
return await _run_video_workflow(
cls,
"minimax-h3/image-to-video",
ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, resolution=resolution,
duration_seconds=duration_seconds, seed=seed,
assets={"first_frame": await _minimax_h3_asset(cls, first_frame)},
),
)
class ComfyCloudMiniMaxH3ReferenceToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _video_schema(
"ComfyCloudMiniMaxH3ReferenceToVideoNode",
"Comfy Cloud MiniMax H3 Reference to Video",
(
"Generates a video with a matching soundtrack from optional image, video, and "
"audio references, using MiniMax H3. The references carry subject and style "
"across the shot, and the prompt addresses images by connection order."
),
[
IO.Autogrow.Input(
"reference_images",
template=IO.Autogrow.TemplatePrefix(
input=IO.Image.Input("reference_image"),
prefix="reference_image_",
min=0,
max=_MINIMAX_H3_MAX_REFERENCES,
),
tooltip=(
"Up to four references, addressed in the prompt as <Picture 1> upwards "
"in connection order."
),
),
IO.Video.Input("ref_video", optional=True),
IO.Autogrow.Input(
"ref_audio",
template=IO.Autogrow.TemplatePrefix(
input=IO.Audio.Input("ref_audio"),
prefix="ref_audio_",
min=0,
max=_MINIMAX_H3_MAX_AUDIO_REFERENCES,
),
),
*_minimax_h3_inputs(
"16:9",
plain=[
IO.Combo.Input(
"ref_image_size",
options=["match", "max"],
default="match",
tooltip=(
"'match' scales each reference to the output's pixel area; 'max' "
"sends it at the 2048px short edge for the closest likeness. "
"Reference tokens ride through every sampling step, so 'max' "
"costs several times the GPU-seconds."
),
)
],
),
],
)
@classmethod
async def execute(
cls,
prompt: str,
reference_images: dict[str, Input.Image] | None = None,
ref_video: Input.Video | None = None,
ref_audio: dict[str, Input.Audio] | None = None,
seed: int = 42,
aspect_ratio: str = "16:9",
resolution: str = "480p",
duration_seconds: int = 5,
ref_image_size: str = "match"
) -> IO.NodeOutput:
prompt = _validate_node_inputs(cls, locals())["prompt"]
images = [image for image in (reference_images or {}).values() if image is not None]
if len(images) > _MINIMAX_H3_MAX_REFERENCES:
raise ValueError(f"At most {_MINIMAX_H3_MAX_REFERENCES} reference images are supported.")
audios = [audio for audio in (ref_audio or {}).values() if audio is not None]
if len(audios) > _MINIMAX_H3_MAX_AUDIO_REFERENCES:
raise ValueError(f"At most {_MINIMAX_H3_MAX_AUDIO_REFERENCES} reference audio inputs are supported.")
# Numbered by connection order, which is the order the prompt's
# <Picture i> tags refer to them in.
assets = {
f"reference_image_{index}": await _minimax_h3_asset(cls, image)
for index, image in enumerate(images, 1)
}
if ref_video is not None:
assets["ref_video"] = await _minimax_h3_video_asset(cls, ref_video)
assets.update({
f"ref_audio_{index}": await _minimax_h3_audio_asset(cls, audio)
for index, audio in enumerate(audios, 1)
})
return await _run_video_workflow(
cls,
"minimax-h3/reference-to-video",
ComfyCloudWorkflowInputs(
prompt=prompt, aspect_ratio=aspect_ratio, resolution=resolution,
duration_seconds=duration_seconds, seed=seed, ref_image_size=ref_image_size,
assets=assets,
),
)
class ComfyCloudExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
ComfyCloudMiniMaxH3TextToVideoNode,
ComfyCloudMiniMaxH3FirstLastFrameToVideoNode,
# ComfyCloudMiniMaxH3ReferenceToVideoNode, # Disabled until the server-side issue is fixed.
ComfyCloudMiniMaxH3ImageToVideoNode,
ComfyCloudMiniMaxMusic3TextToAudioNode,
ComfyCloudFlux2TextToImageNode,
ComfyCloudZImageTurboNode,
ComfyCloudMageFlowTextToImageNode,
ComfyCloudMageFlowTurboTextToImageNode,
]
async def comfy_entrypoint() -> ComfyCloudExtension:
return ComfyCloudExtension()
+454
View File
@@ -0,0 +1,454 @@
import json
import re
import uuid
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.apis.fishaudio import (
FishAudioASRRequest,
FishAudioASRResponse,
FishAudioCreateModelRequest,
FishAudioCreateModelResponse,
FishAudioProsody,
FishAudioTTSRequest,
)
from comfy_api_nodes.util import (
ApiEndpoint,
audio_bytes_to_audio_input,
audio_ndarray_to_bytesio,
audio_tensor_to_contiguous_ndarray,
sync_op,
sync_op_raw,
validate_string,
)
FISHAUDIO_VOICE = "FISHAUDIO_VOICE"
FISHAUDIO_VOICES = [
("802e3bc2b27e49c2995d23ef70e6ac89", "Energetic Male (en)"),
("b545c585f631496c914815291da4e893", "Friendly Women (en)"),
("933563129e564b19a115bedd57b7406a", "Sarah (en)"),
("8d21b053e2804e2a890e1cf62f267b6f", "Verity (en)"),
("f48d143a59a946ab87c0130fd081f349", "Polo (en)"),
("bf322df2096a46f18c579d0baa36f41d", "Adrian (en)"),
("98655a12fa944e26b274c535e5e03842", "E-girl (en)"),
("0327fdb5da9e4fd782899a8058c8ae2b", "Narrator (en)"),
("5212eb29e500460391d03af42af6552e", "Warm Conversational Voice (en)"),
("5c8dc6a69c0b4edfb32634db6384bf34", "Warm Storyteller (en)"),
("7a18a1851d2649108c48ec9f2c80eb2c", "Dramatic Character Male (en)"),
("59cb5986671546eaa6ca8ae6f29f6d22", "News Narrator (zh)"),
("bf6c479f5a384b8d857310030035824b", "Lively Female (zh)"),
("faccba1a8ac54016bcfc02761285e67f", "Gentle Female (zh)"),
("5161d41404314212af1254556477c17d", "Energetic Female (ja)"),
("0089dce5fefb4c6ba9b9f2f0debe1ddc", "Calm Female (ja)"),
("45c5d3723c9c42f598e4776dcfd5f02d", "Calm Male (ja)"),
]
FISHAUDIO_VOICE_MAP = {label: voice_id for voice_id, label in FISHAUDIO_VOICES}
MAX_REFERENCE_AUDIO_SECONDS = 270
def _rewrite_voice_tags(text: str, voice_count: int) -> tuple[str, set[int]]:
referenced: set[int] = set()
def repl(match: re.Match) -> str:
index = int(match.group(1))
if index < 1 or index > voice_count:
raise ValueError(
f"@Voice{index} does not match any connected voice ({voice_count} connected)."
)
referenced.add(index)
return f"<|speaker:{index - 1}|>"
rewritten = re.sub(r"(?<!\S)@voice([0-9]+)\b", repl, text, flags=re.IGNORECASE)
return rewritten, referenced
def _tts_option_inputs() -> list:
return [
IO.Float.Input(
"temperature",
default=0.7,
min=0.0,
max=1.0,
step=0.01,
display_mode=IO.NumberDisplay.slider,
tooltip="Expressiveness. Higher values are more varied, lower values are more consistent.",
),
IO.Float.Input(
"top_p",
default=0.7,
min=0.01,
max=1.0,
step=0.01,
display_mode=IO.NumberDisplay.slider,
tooltip="Diversity via nucleus sampling.",
),
IO.Float.Input(
"speed",
default=1.0,
min=0.5,
max=2.0,
step=0.01,
display_mode=IO.NumberDisplay.slider,
tooltip="Speaking rate. 1.0 is normal, <1.0 slower, >1.0 faster.",
),
IO.Float.Input(
"volume",
default=0.0,
min=-10.0,
max=10.0,
step=0.5,
display_mode=IO.NumberDisplay.slider,
tooltip="Volume adjustment in decibels. 0 is no change.",
),
IO.Boolean.Input(
"normalize",
default=True,
tooltip="Normalize numbers and text for English and Chinese, "
"improving stability for numbers and dates.",
),
]
def _multi_speaker_inputs() -> list:
return [
IO.Autogrow.Input(
"voices",
template=IO.Autogrow.TemplatePrefix(
IO.Custom(FISHAUDIO_VOICE).Input("voice"),
prefix="voice",
min=0,
max=5,
),
tooltip="Voices for synthesis. Leave empty for the default voice. "
"With two or more voices, mark speaker changes in the text with @Voice1, @Voice2, etc.",
),
*_tts_option_inputs(),
]
class FishAudioVoiceSelector(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="FishAudioVoiceSelector",
display_name="Fish Audio Voice Selector",
category="partner/audio/Fish Audio",
description="Select a voice from the Fish Audio library for text-to-speech generation.",
inputs=[
IO.DynamicCombo.Input(
"voice",
options=[
*(IO.DynamicCombo.Option(label, []) for _, label in FISHAUDIO_VOICES),
IO.DynamicCombo.Option(
"custom",
[
IO.String.Input(
"voice_id",
default="",
tooltip="Voice model ID from fish.audio, e.g. the ID in "
"https://fish.audio/m/<id>/.",
),
],
),
],
tooltip="Choose a voice, or 'custom' to enter any fish.audio voice model ID.",
),
],
outputs=[
IO.Custom(FISHAUDIO_VOICE).Output(display_name="voice"),
],
is_api_node=False,
)
@classmethod
def execute(cls, voice: dict) -> IO.NodeOutput:
selected = voice["voice"]
if selected == "custom":
voice_id = voice["voice_id"].strip()
if not voice_id:
raise ValueError("Custom voice ID is empty.")
return IO.NodeOutput(voice_id)
voice_id = FISHAUDIO_VOICE_MAP.get(selected)
if not voice_id:
raise ValueError(f"Unknown voice: {selected}")
return IO.NodeOutput(voice_id)
class FishAudioTextToSpeech(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="FishAudioTextToSpeech",
display_name="Fish Audio Text to Speech",
category="partner/audio/Fish Audio",
description="Convert text to speech. Supports emotion cues in the text "
"([happy], [whispering] on s2.1-pro; (happy) on s1) and multi-speaker dialogue "
"via @Voice1/@Voice2 tags with multiple connected voices.",
inputs=[
IO.String.Input(
"text",
multiline=True,
default="",
tooltip="The text to convert to speech. With two or more voices connected, "
"mark speaker changes with @Voice1, @Voice2, etc.",
),
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option("s2.1-pro", _multi_speaker_inputs()),
IO.DynamicCombo.Option(
"s1",
[
IO.Custom(FISHAUDIO_VOICE).Input(
"voice",
optional=True,
tooltip="Voice for synthesis. Leave unconnected for the default voice.",
),
*_tts_option_inputs(),
],
),
],
tooltip="Model to use for text-to-speech.",
),
IO.Int.Input(
"seed",
default=42,
min=0,
max=2147483647,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
tooltip="Seed controls whether the node should re-run; "
"results are non-deterministic regardless of seed.",
),
],
outputs=[
IO.Audio.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=["text"]),
expr="""
(
$t := widgets.text;
$type($t) = "string"
? (
$bytes := $length($t) + 2 * $count($match($t, /[^\\x00-\\x7F]/));
{"type":"usd","usd": $bytes * 21.45 / 1000000, "format":{"approximate":true}}
)
: {"type":"usd","usd": 0.02145, "format":{"approximate":true, "suffix":"/1K bytes"}}
)
""",
),
)
@classmethod
async def execute(
cls,
text: str,
model: dict,
seed: int,
) -> IO.NodeOutput:
validate_string(text, field_name="text", min_length=1)
model_name = model["model"]
if model_name == "s1":
voices = [model["voice"]] if model.get("voice") else []
else:
voices = [model["voices"][key] for key in model["voices"]]
rewritten, referenced = _rewrite_voice_tags(text, len(voices))
if len(voices) >= 2:
missing = [i for i in range(1, len(voices) + 1) if i not in referenced]
if missing:
raise ValueError(
"With multiple voices, the text must mark speaker changes with tags for "
"each connected voice; missing: " + ", ".join(f"@Voice{i}" for i in missing)
)
reference_id: str | list[str] | None = None
if len(voices) == 1:
reference_id = voices[0]
elif voices:
reference_id = voices
request = FishAudioTTSRequest(
text=rewritten,
reference_id=reference_id,
temperature=model["temperature"],
top_p=model["top_p"],
prosody=FishAudioProsody(speed=model["speed"], volume=model["volume"]),
normalize=model["normalize"],
)
response = await sync_op_raw(
cls,
ApiEndpoint(
path="/proxy/fishaudio/v1/tts",
method="POST",
headers={"model": model_name},
),
data=request,
as_binary=True,
)
return IO.NodeOutput(audio_bytes_to_audio_input(response))
class FishAudioSpeechToText(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="FishAudioSpeechToText",
display_name="Fish Audio Speech to Text",
category="partner/audio/Fish Audio",
description="Transcribe audio to text with automatic language detection.",
inputs=[
IO.Audio.Input(
"audio",
tooltip="Audio to transcribe.",
),
IO.String.Input(
"language",
default="",
tooltip="ISO 639-1 language hint (e.g. 'en', 'zh'). "
"The language is auto-detected regardless.",
),
IO.Boolean.Input(
"precise_timestamps",
default=False,
tooltip="Return word-level timestamped segments.",
),
],
outputs=[
IO.String.Output(id="text", display_name="text"),
IO.String.Output(id="language_code", display_name="language_code"),
IO.String.Output(id="segments_json", display_name="segments_json"),
],
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.00858,"format":{"approximate":true,"suffix":"/minute"}}""",
),
)
@classmethod
async def execute(
cls,
audio: Input.Audio,
language: str,
precise_timestamps: bool,
) -> IO.NodeOutput:
audio_data_np = audio_tensor_to_contiguous_ndarray(audio["waveform"])
audio_bytes_io = audio_ndarray_to_bytesio(audio_data_np, audio["sample_rate"], "mp4", "aac")
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/fishaudio/v1/asr", method="POST"),
response_model=FishAudioASRResponse,
data=FishAudioASRRequest(
language=language.strip() or None,
ignore_timestamps=not precise_timestamps,
),
files={"audio": ("audio.mp4", audio_bytes_io, "audio/mp4")},
content_type="multipart/form-data",
)
segments_json = json.dumps(
[s.model_dump(exclude_none=True) for s in (response.segments or [])],
indent=2,
)
return IO.NodeOutput(response.text or "", response.language_code or "", segments_json)
class FishAudioInstantVoiceClone(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="FishAudioInstantVoiceClone",
display_name="Fish Audio Instant Voice Clone",
category="partner/audio/Fish Audio",
description="Create a private cloned voice from audio samples, instantly usable "
"for text-to-speech. Provide 1-20 recordings, 10-30 seconds each recommended, "
"under 270 seconds in total.",
inputs=[
IO.Autogrow.Input(
"files",
template=IO.Autogrow.TemplatePrefix(
IO.Audio.Input("audio"),
prefix="audio",
min=1,
max=20,
),
tooltip="Audio recordings for voice cloning.",
),
IO.Boolean.Input(
"enhance_audio_quality",
default=True,
tooltip="Enhance reference audio quality before training.",
),
],
outputs=[
IO.Custom(FISHAUDIO_VOICE).Output(display_name="voice"),
],
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}"""),
)
@classmethod
async def execute(
cls,
files: IO.Autogrow.Type,
enhance_audio_quality: bool,
) -> IO.NodeOutput:
total_seconds = 0.0
for key in files:
audio = files[key]
total_seconds += audio["waveform"].shape[-1] / audio["sample_rate"]
if total_seconds >= MAX_REFERENCE_AUDIO_SECONDS:
raise ValueError(
f"Total reference audio is {total_seconds:.0f} seconds; "
f"it must be under {MAX_REFERENCE_AUDIO_SECONDS} seconds."
)
file_tuples: list[tuple[str, tuple[str, bytes, str]]] = []
for key in files:
audio = files[key]
audio_data_np = audio_tensor_to_contiguous_ndarray(audio["waveform"])
audio_bytes_io = audio_ndarray_to_bytesio(audio_data_np, audio["sample_rate"], "mp4", "aac")
file_tuples.append(("voices", (f"{key}.mp4", audio_bytes_io.getvalue(), "audio/mp4")))
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/fishaudio/model", method="POST"),
response_model=FishAudioCreateModelResponse,
data=FishAudioCreateModelRequest(
title=str(uuid.uuid4()),
enhance_audio_quality=enhance_audio_quality,
),
files=file_tuples,
content_type="multipart/form-data",
)
return IO.NodeOutput(response.id)
class FishAudioExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
FishAudioVoiceSelector,
FishAudioTextToSpeech,
FishAudioSpeechToText,
FishAudioInstantVoiceClone,
]
async def comfy_entrypoint() -> FishAudioExtension:
return FishAudioExtension()
+305 -2
View File
@@ -16,6 +16,7 @@ import folder_paths
from comfy_api.latest import IO, ComfyExtension, Input, InputImpl, Types
from comfy_api_nodes.apis.gemini import (
GeminiContent,
GeminiFile,
GeminiFileData,
GeminiGenerateContentRequest,
GeminiGenerationConfig,
@@ -28,7 +29,9 @@ from comfy_api_nodes.apis.gemini import (
GeminiInteractionGenerationConfig,
GeminiInteractionMediaPart,
GeminiInteractionRequest,
GeminiInteractionResponseFormat,
GeminiInteractionTextPart,
GeminiInteractionVideoConfig,
GeminiMimeType,
GeminiPart,
GeminiRole,
@@ -44,7 +47,9 @@ from comfy_api_nodes.util import (
download_url_to_video_output,
get_number_of_images,
pad_images_to_common_channels,
poll_op,
sync_op,
sync_op_raw,
tensor_to_base64_string,
upload_audio_to_comfyapi,
upload_image_to_comfyapi,
@@ -262,7 +267,7 @@ async def get_video_from_interaction(
if content.data:
return InputImpl.VideoFromFile(BytesIO(base64.b64decode(content.data)))
if content.uri:
return await download_url_to_video_output(content.uri, cls=cls)
return await download_interaction_video(content.uri, cls=cls)
model_message = get_text_from_interaction(interaction).strip()
if model_message:
raise ValueError(f"Gemini did not generate a video. Model response: {model_message}")
@@ -272,6 +277,30 @@ async def get_video_from_interaction(
)
async def download_interaction_video(uri: str, cls: type[IO.ComfyNode] | None = None) -> InputImpl.VideoFromFile:
if "/files/" not in uri:
return await download_url_to_video_output(uri, cls=cls)
name = uri.split("?", 1)[0].rsplit("/files/", 1)[-1].split(":", 1)[0]
await poll_op(
cls,
ApiEndpoint(path=f"{GEMINI_INTERACTIONS_ENDPOINT}/files/{name}"),
response_model=GeminiFile,
status_extractor=lambda file: file.state,
completed_statuses=["ACTIVE"],
failed_statuses=["FAILED"],
queued_statuses=["PROCESSING"],
poll_interval=3.0,
max_poll_attempts=200,
)
video_bytes = await sync_op_raw(
cls,
ApiEndpoint(path=f"{GEMINI_INTERACTIONS_ENDPOINT}/files/{name}:download", query_params={"alt": "media"}),
as_binary=True,
wait_label="Downloading video",
)
return InputImpl.VideoFromFile(BytesIO(video_bytes))
def create_video_parts(video_input: Input.Video) -> list[GeminiPart]:
"""Convert a single video input to Gemini API compatible parts (inline MP4/H.264)."""
base_64_string = video_to_base64_string(
@@ -592,6 +621,7 @@ class GeminiNode(IO.ComfyNode):
GEMINI_V2_MODELS: dict[str, str] = {
"Gemini 3.7 Flash": "gemini-3.7-flash",
"Gemini 3.1 Pro": "gemini-3.1-pro-preview",
"Gemini 3.5 Flash": "gemini-3.5-flash",
"Gemini 3.1 Flash-Lite": "gemini-3.1-flash-lite-preview",
@@ -694,6 +724,10 @@ class GeminiNodeV2(IO.ComfyNode):
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"Gemini 3.7 Flash",
_gemini_text_model_inputs("MEDIUM", ["LOW", "MEDIUM", "HIGH"]),
),
IO.DynamicCombo.Option(
"Gemini 3.5 Flash",
_gemini_text_model_inputs("MEDIUM", ["MINIMAL", "LOW", "MEDIUM", "HIGH"]),
@@ -739,6 +773,11 @@ class GeminiNodeV2(IO.ComfyNode):
"usd": [0.00025, 0.0015],
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
}
: $contains($m, "3.7 flash") ? {
"type": "list_usd",
"usd": [0.00215, 0.01073],
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
}
: $contains($m, "3.5 flash") ? {
"type": "list_usd",
"usd": [0.0015, 0.009],
@@ -1543,9 +1582,11 @@ class GeminiNanoBanana2V2(IO.ComfyNode):
OMNI_MAX_IMAGES = 14
OMNI_MAX_VIDEOS = 3
OMNI_URI_DELIVERY_RESOLUTIONS = ("1080p", "4k")
OMNI_MODELS: dict[str, str] = {
"Omni Flash": "gemini-omni-flash-preview",
"Omni Flash 1.1": "gemini-omni-1.1-flash",
}
@@ -1640,8 +1681,9 @@ class GeminiVideoOmni(IO.ComfyNode):
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=IO.PriceBadge(
expr='{"type":"usd","usd":0.101,"format":{"suffix":"/second","approximate":true}}'
expr='{"type":"usd","usd":0.1449,"format":{"suffix":"/second","approximate":true}}'
),
)
@@ -1697,6 +1739,266 @@ class GeminiVideoOmni(IO.ComfyNode):
)
def _omni_task_input(with_extend: bool) -> Input:
return IO.Combo.Input(
"task_type",
options=["auto", "text_to_video", "image_to_video", "reference_to_video", "edit"]
+ (["extend"] if with_extend else []),
default="auto",
tooltip="What to do with the prompt and the attached media. With 'auto' the model decides. "
"'text_to_video' generates from the prompt alone and rejects attached media. 'image_to_video' "
"animates one image, or interpolates from a starting frame to an ending frame when two are "
"attached. 'reference_to_video' treats the attached media as subject references. "
"'edit' rewrites exactly one attached video"
+ (", and 'extend' appends new footage to it, so the output starts with the input video." if with_extend else "."),
)
def _omni_seed_input() -> Input:
return 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.",
)
def _omni_v2_flash_inputs() -> list[Input]:
return [
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Describe the video to generate, or the edit to apply to an attached video. Specify the "
'length directly in the prompt, e.g. "a 6-second clip"; length may be 3-10 seconds. '
"The output is 720p, 24 FPS, with audio.",
),
IO.Combo.Input(
"aspect_ratio",
options=["16:9", "9:16"],
default="16:9",
tooltip="Output aspect ratio: 16:9 (landscape) or 9:16 (portrait). "
"The 'edit' task keeps the aspect ratio of the input video instead.",
),
_omni_task_input(with_extend=False),
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,
),
_omni_seed_input(),
]
def _omni_v2_flash_1_1_inputs() -> list[Input]:
return [
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Describe the video to generate, or the edit to apply to an attached video. Specify the "
'length directly in the prompt, e.g. "a 6-second clip" or, for the \'extend\' task, "extend by 5 seconds"; '
"the generated length may be 3-10 seconds and defaults to 10. The output has audio.",
),
IO.Combo.Input(
"resolution",
options=["360p", "720p", "1080p", "4k"],
default="720p",
tooltip="Output resolution.",
),
IO.Combo.Input(
"aspect_ratio",
options=["16:9", "9:16"],
default="16:9",
tooltip="Output aspect ratio: 16:9 (landscape) or 9:16 (portrait). "
"The 'edit' and 'extend' tasks keep the aspect ratio of the input video instead.",
),
_omni_task_input(with_extend=True),
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; "
"with the 'image_to_video' task the first one is the starting frame and an optional second one "
"is the ending frame.",
),
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.",
),
_omni_seed_input(),
]
class GeminiVideoOmniV2(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="GeminiVideoOmniV2",
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 models. "
"Optionally provide reference images and/or videos to guide or edit the result. Describe the desired "
"length (3-10s) directly in the prompt.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option("Omni Flash 1.1", _omni_v2_flash_1_1_inputs()),
IO.DynamicCombo.Option("Omni Flash", _omni_v2_flash_inputs()),
],
tooltip="The Gemini video model used to generate the video.",
),
],
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(
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution"]),
expr="""
(
$prices := {"360p": 0.0483, "720p": 0.1449, "1080p": 0.2174, "4k": 0.4349};
$r := $lookup(widgets, "model.resolution");
{"type":"usd","usd": $r ? $lookup($prices, $r) : 0.1449,
"format":{"suffix":"/second","approximate":true}}
)
""",
),
)
@classmethod
async def execute(cls, model: dict) -> IO.NodeOutput:
prompt = model.get("prompt") or ""
validate_string(prompt, strip_whitespace=True, min_length=1)
model_id = OMNI_MODELS[model["model"]]
task = model["task_type"]
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]
total_images = sum(get_number_of_images(t) for t in images)
if total_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.1)
if task == "text_to_video" and (images or videos):
raise ValueError("The 'text_to_video' task generates from the prompt alone; detach the reference media.")
if task == "image_to_video" and videos:
raise ValueError(
"The 'image_to_video' task takes images only; detach the video(s) or use 'reference_to_video'."
)
if task == "image_to_video" and not 1 <= total_images <= 2:
raise ValueError(
"The 'image_to_video' task takes one image as the starting frame, "
"and an optional second one as the ending frame."
)
if task in ("edit", "extend") and len(videos) != 1:
raise ValueError(f"The '{task}' task requires exactly one input video.")
parts: list[GeminiInteractionTextPart | GeminiInteractionMediaPart] = []
if images or videos:
# The Interactions API accepts video only inline or as a Files API URI, not as an HTTP URL.
media_parts = await build_gemini_media_parts(
cls, [], [], videos, url_budget=0, max_inline_bytes=GEMINI_INTERACTIONS_MAX_INLINE_BYTES
)
video_inline_bytes = sum(len(p.inlineData.data) for p in media_parts)
media_parts += await build_gemini_media_parts(
cls, images, [], [], max_inline_bytes=GEMINI_INTERACTIONS_MAX_INLINE_BYTES - video_inline_bytes
)
parts.extend(to_interaction_media_part(p) for p in media_parts)
parts.append(GeminiInteractionTextPart(text=prompt))
resolution = model.get("resolution")
response_format = GeminiInteractionResponseFormat(
resolution=resolution,
aspect_ratio=None if task in ("edit", "extend") else model["aspect_ratio"],
delivery="uri" if resolution in OMNI_URI_DELIVERY_RESOLUTIONS else None,
)
generation_config = None
if task != "auto" or "temperature" in model:
generation_config = GeminiInteractionGenerationConfig(
temperature=model.get("temperature"),
top_p=model.get("top_p"),
video_config=GeminiInteractionVideoConfig(task=task) if task != "auto" else None,
)
interaction = await sync_op(
cls,
ApiEndpoint(path=GEMINI_INTERACTIONS_ENDPOINT, method="POST"),
data=GeminiInteractionRequest(
model=model_id,
input=parts,
generation_config=generation_config,
response_format=response_format,
),
response_model=GeminiInteraction,
)
if interaction.status != "completed":
model_message = get_text_from_interaction(interaction).strip()
raise ValueError(
f"Gemini interaction did not complete (status: {interaction.status})."
+ (f" Model response: {model_message}" if model_message else "")
)
return IO.NodeOutput(
await get_video_from_interaction(interaction, cls=cls),
get_text_from_interaction(interaction),
)
class GeminiExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@@ -1708,6 +2010,7 @@ class GeminiExtension(ComfyExtension):
GeminiNanoBanana2,
GeminiNanoBanana2V2,
GeminiVideoOmni,
GeminiVideoOmniV2,
GeminiInputFiles,
]
+10 -8
View File
@@ -414,12 +414,12 @@ class HeyGenAvatarVideoNode(IO.ComfyNode):
depends_on=IO.PriceBadgeDepends(widgets=["engine"]),
expr="""
widgets.engine = "avatar_iii"
? {"type":"range_usd","min_usd":0.023881,"max_usd":0.061919,"format":{"suffix":"/second"}}
? {"type":"range_usd","min_usd":0.0143,"max_usd":0.023595,"format":{"suffix":"/second"}}
: widgets.engine = "avatar_v"
? {"type":"usd","usd":0.095381,"format":{"suffix":"/second"}}
? {"type":"usd","usd":0.1716,"format":{"suffix":"/second"}}
: widgets.engine = "avatar_iv"
? {"type":"range_usd","min_usd":0.0715,"max_usd":0.095381,"format":{"suffix":"/second"}}
: {"type":"range_usd","min_usd":0.023881,"max_usd":0.095381,"format":{"suffix":"/second"}}
? {"type":"range_usd","min_usd":0.055055,"max_usd":0.115115,"format":{"suffix":"/second"}}
: {"type":"range_usd","min_usd":0.0143,"max_usd":0.1716,"format":{"suffix":"/second"}}
""",
),
)
@@ -522,7 +522,8 @@ class HeyGenCreateAvatarNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":1.43}""",
depends_on=IO.PriceBadgeDepends(widgets=["source"]),
expr="""{"type":"usd","usd": widgets.source = "photo" ? 1.8876 : 1.43}""",
),
)
@@ -604,7 +605,7 @@ class HeyGenVideoTranslateNode(IO.ComfyNode):
"mode",
options=["speed", "precision"],
default="speed",
tooltip="'speed' is faster; 'precision' produces higher-quality lip sync at twice the price.",
tooltip="'speed' is faster; 'precision' produces higher-quality lip sync at a higher price.",
),
IO.Boolean.Input(
"translate_audio_only",
@@ -638,8 +639,9 @@ class HeyGenVideoTranslateNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["mode"]),
expr="""{"type":"usd","usd": widgets.mode = "precision" ? 0.095381 : 0.047619,"""
depends_on=IO.PriceBadgeDepends(widgets=["mode", "translate_audio_only"]),
expr="""{"type":"usd","usd": widgets.mode = "precision" ? 0.03575 """
""": widgets.translate_audio_only = true ? 0.013585 : 0.019305,"""
""""format":{"suffix":"/second"}}""",
),
)
+1 -1
View File
@@ -531,7 +531,7 @@ class IdeogramPImage(IO.ComfyNode):
def define_schema(cls):
return IO.Schema(
node_id="IdeogramPImage",
display_name="Ideogram P-Image",
display_name="Ideogram & Pruna P-Image",
category="partner/image/Ideogram",
description="Generates images using P-Image, Ideogram's fast text-to-image model. "
"Strong typography and photorealism; "
+3 -92
View File
@@ -20,8 +20,6 @@ from comfy_api_nodes.apis import (
KlingText2VideoResponse,
KlingImage2VideoRequest,
KlingImage2VideoResponse,
KlingVideoExtendRequest,
KlingVideoExtendResponse,
KlingLipSyncVoiceLanguage,
KlingLipSyncInputObject,
KlingLipSyncRequest,
@@ -102,7 +100,6 @@ def _generate_storyboard_inputs(count: int) -> list:
KLING_API_VERSION = "v1"
PATH_TEXT_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/text2video"
PATH_IMAGE_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/image2video"
PATH_VIDEO_EXTEND = f"/proxy/kling/{KLING_API_VERSION}/videos/video-extend"
PATH_LIP_SYNC = f"/proxy/kling/{KLING_API_VERSION}/videos/lip-sync"
PATH_IMAGE_GENERATIONS = f"/proxy/kling/{KLING_API_VERSION}/images/generations"
@@ -116,7 +113,6 @@ AVERAGE_DURATION_T2V = 319
AVERAGE_DURATION_I2V = 164
AVERAGE_DURATION_LIP_SYNC = 455
AVERAGE_DURATION_IMAGE_GEN = 32
AVERAGE_DURATION_VIDEO_EXTEND = 320
MODE_TEXT2VIDEO = {
@@ -1629,85 +1625,6 @@ class KlingStartEndFrameNode(IO.ComfyNode):
)
class KlingVideoExtendNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="KlingVideoExtendNode",
display_name="Kling Video Extend",
category="partner/video/Kling",
description="Kling Video Extend Node. Extend videos made by other Kling nodes. The video_id is created by using other Kling Nodes.",
inputs=[
IO.String.Input(
"prompt",
multiline=True,
tooltip="Positive text prompt for guiding the video extension",
),
IO.String.Input(
"negative_prompt",
multiline=True,
tooltip="Negative text prompt for elements to avoid in the extended video",
),
IO.Float.Input("cfg_scale", default=0.5, min=0.0, max=1.0),
IO.String.Input(
"video_id",
force_input=True,
tooltip="The ID of the video to be extended. Supports videos generated by text-to-video, image-to-video, and previous video extension operations. Cannot exceed 3 minutes total duration after extension.",
),
],
outputs=[
IO.Video.Output(),
IO.String.Output(display_name="video_id"),
IO.String.Output(display_name="duration"),
],
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.28}""",
),
)
@classmethod
async def execute(
cls,
prompt: str,
negative_prompt: str,
cfg_scale: float,
video_id: str,
) -> IO.NodeOutput:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
task_creation_response = await sync_op(
cls,
ApiEndpoint(path=PATH_VIDEO_EXTEND, method="POST"),
response_model=KlingVideoExtendResponse,
data=KlingVideoExtendRequest(
prompt=prompt if prompt else None,
negative_prompt=negative_prompt if negative_prompt else None,
cfg_scale=cfg_scale,
video_id=video_id,
),
)
validate_task_creation_response(task_creation_response)
task_id = task_creation_response.data.task_id
final_response = await poll_op(
cls,
ApiEndpoint(path=f"{PATH_VIDEO_EXTEND}/{task_id}"),
response_model=KlingVideoExtendResponse,
estimated_duration=AVERAGE_DURATION_VIDEO_EXTEND,
status_extractor=lambda r: (r.data.task_status.value if r.data and r.data.task_status else None),
)
validate_video_result_response(final_response)
video = get_video_from_response(final_response)
return IO.NodeOutput(await download_url_to_video_output(str(video.url)), str(video.id), str(video.duration))
class KlingLipSyncAudioToVideoNode(IO.ComfyNode):
"""Kling Lip Sync Audio to Video Node. Syncs mouth movements in a video file to the audio content of an audio file."""
@@ -1866,7 +1783,7 @@ class KlingImageGenerationNode(IO.ComfyNode):
tooltip="Subject reference similarity",
advanced=True,
),
IO.Combo.Input("model_name", options=["kling-v3", "kling-v2"]),
IO.Combo.Input("model_name", options=["kling-v3"]),
IO.Combo.Input(
"aspect_ratio",
options=[i.value for i in KlingImageGenAspectRatio],
@@ -1902,13 +1819,8 @@ class KlingImageGenerationNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model_name", "n"]),
expr="""
(
$base := $contains(widgets.model_name,"kling-v3") ? 0.028 : 0.014;
{"type":"usd","usd": $base * widgets.n}
)
""",
depends_on=IO.PriceBadgeDepends(widgets=["n"]),
expr="""{"type":"usd","usd": 0.028 * widgets.n}""",
),
)
@@ -2745,7 +2657,6 @@ class KlingExtension(ComfyExtension):
KlingTextToVideoNode,
KlingImage2VideoNode,
KlingStartEndFrameNode,
KlingVideoExtendNode,
KlingLipSyncAudioToVideoNode,
KlingLipSyncTextToVideoNode,
KlingImageGenerationNode,
+1 -186
View File
@@ -1,26 +1,19 @@
from io import BytesIO
from pydantic import BaseModel, Field
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input, InputImpl
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_video_output,
get_number_of_images,
poll_op,
sync_op,
sync_op_raw,
upload_audio_to_comfyapi,
upload_images_to_comfyapi,
validate_string,
)
MODELS_MAP = {
"LTX-2 (Pro)": "ltx-2-pro",
"LTX-2 (Fast)": "ltx-2-fast",
}
V25_MODELS_MAP = {
"LTX-2.5 (Fast)": "ltx-2-5-fast",
"LTX-2.5 (Pro)": "ltx-2-5-pro",
@@ -79,21 +72,6 @@ async def _v25_submit_and_poll(cls: type[IO.ComfyNode], route: str, data: BaseMo
return IO.NodeOutput(await download_url_to_video_output(job.result.video_url, cls=cls))
PRICE_BADGE = IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "duration", "resolution"]),
expr="""
(
$prices := {
"ltx-2 (pro)": {"1920x1080":0.06,"2560x1440":0.12,"3840x2160":0.24},
"ltx-2 (fast)": {"1920x1080":0.04,"2560x1440":0.08,"3840x2160":0.16}
};
$modelPrices := $lookup($prices, $lowercase(widgets.model));
$pps := $lookup($modelPrices, widgets.resolution);
{"type":"usd","usd": $pps * widgets.duration}
)
""",
)
V25_PRICE_BADGE = IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.duration", "model.resolution"]),
expr="""
@@ -217,167 +195,6 @@ def _v25_validate_settings(model: dict) -> None:
raise ValueError("Durations over 10s require a 720p or 1080p resolution and 24/25 FPS.")
class TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="LtxvApiTextToVideo",
display_name="LTXV Text To Video",
category="partner/video/LTXV",
description="Professional-quality videos with customizable duration and resolution.",
inputs=[
IO.Combo.Input("model", options=list(MODELS_MAP.keys())),
IO.String.Input(
"prompt",
multiline=True,
default="",
),
IO.Combo.Input("duration", options=[6, 8, 10, 12, 14, 16, 18, 20], default=8),
IO.Combo.Input(
"resolution",
options=[
"1920x1080",
"2560x1440",
"3840x2160",
],
),
IO.Combo.Input("fps", options=[25, 50], default=25),
IO.Boolean.Input(
"generate_audio",
default=False,
optional=True,
tooltip="When true, the generated video will include AI-generated audio matching the scene.",
advanced=True,
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=PRICE_BADGE,
)
@classmethod
async def execute(
cls,
model: str,
prompt: str,
duration: int,
resolution: str,
fps: int = 25,
generate_audio: bool = False,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=10000)
if duration > 10 and (model != "LTX-2 (Fast)" or resolution != "1920x1080" or fps != 25):
raise ValueError(
"Durations over 10s are only available for the Fast model at 1920x1080 resolution and 25 FPS."
)
response = await sync_op_raw(
cls,
ApiEndpoint("/proxy/ltx/v1/text-to-video", "POST"),
data=ExecuteTaskRequest(
prompt=prompt,
model=MODELS_MAP[model],
duration=duration,
resolution=resolution,
fps=fps,
generate_audio=generate_audio,
),
as_binary=True,
max_retries=1,
)
return IO.NodeOutput(InputImpl.VideoFromFile(BytesIO(response)))
class ImageToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="LtxvApiImageToVideo",
display_name="LTXV Image To Video",
category="partner/video/LTXV",
description="Professional-quality videos with customizable duration and resolution based on start image.",
inputs=[
IO.Image.Input("image", tooltip="First frame to be used for the video."),
IO.Combo.Input("model", options=list(MODELS_MAP.keys())),
IO.String.Input(
"prompt",
multiline=True,
default="",
),
IO.Combo.Input("duration", options=[6, 8, 10, 12, 14, 16, 18, 20], default=8),
IO.Combo.Input(
"resolution",
options=[
"1920x1080",
"2560x1440",
"3840x2160",
],
),
IO.Combo.Input("fps", options=[25, 50], default=25),
IO.Boolean.Input(
"generate_audio",
default=False,
optional=True,
tooltip="When true, the generated video will include AI-generated audio matching the scene.",
advanced=True,
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=PRICE_BADGE,
)
@classmethod
async def execute(
cls,
image: Input.Image,
model: str,
prompt: str,
duration: int,
resolution: str,
fps: int = 25,
generate_audio: bool = False,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=10000)
if duration > 10 and (model != "LTX-2 (Fast)" or resolution != "1920x1080" or fps != 25):
raise ValueError(
"Durations over 10s are only available for the Fast model at 1920x1080 resolution and 25 FPS."
)
if get_number_of_images(image) != 1:
raise ValueError("Currently only one input image is supported.")
response = await sync_op_raw(
cls,
ApiEndpoint("/proxy/ltx/v1/image-to-video", "POST"),
data=ExecuteTaskRequest(
image_uri=(await upload_images_to_comfyapi(cls, image, max_images=1, mime_type="image/png"))[0],
prompt=prompt,
model=MODELS_MAP[model],
duration=duration,
resolution=resolution,
fps=fps,
generate_audio=generate_audio,
),
as_binary=True,
max_retries=1,
)
return IO.NodeOutput(InputImpl.VideoFromFile(BytesIO(response)))
class Ltx25TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@@ -584,8 +401,6 @@ class LtxvApiExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
TextToVideoNode,
ImageToVideoNode,
Ltx25TextToVideoNode,
Ltx25ImageToVideoNode,
Ltx25AudioToVideoNode,
+196 -21
View File
@@ -35,9 +35,9 @@ class MeshyTextToModelNode(IO.ComfyNode):
display_name="Meshy: Text to Model",
category="partner/3d/Meshy",
inputs=[
IO.Combo.Input("model", options=["latest"]),
IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]),
IO.String.Input("prompt", multiline=True, default=""),
IO.Combo.Input("style", options=["realistic", "sculpture"]),
IO.Combo.Input("style", options=["realistic"]),
IO.DynamicCombo.Input(
"should_remesh",
options=[
@@ -75,6 +75,11 @@ class MeshyTextToModelNode(IO.ComfyNode):
tooltip="Seed controls whether the node should re-run; "
"results are non-deterministic regardless of seed.",
),
IO.Boolean.Input(
"ultra_mode",
default=False,
tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.",
),
],
outputs=[
IO.String.Output(display_name="model_file"), # for backward compatibility only
@@ -90,7 +95,13 @@ class MeshyTextToModelNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":0.8}""",
depends_on=IO.PriceBadgeDepends(widgets=["model", "ultra_mode"]),
expr="""
(
$credits := 20 + ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0);
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
)
@@ -104,8 +115,11 @@ class MeshyTextToModelNode(IO.ComfyNode):
symmetry_mode: str,
pose_mode: str,
seed: int,
ultra_mode: bool,
) -> IO.NodeOutput:
validate_string(prompt, field_name="prompt", min_length=1, max_length=600)
if ultra_mode and model not in ("meshy-7", "latest"):
raise ValueError("ultra_mode requires the meshy-7 or latest model")
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"),
@@ -119,6 +133,7 @@ class MeshyTextToModelNode(IO.ComfyNode):
should_remesh=should_remesh["should_remesh"] == "true",
symmetry_mode=symmetry_mode,
pose_mode=pose_mode.lower(),
ultra_mode=ultra_mode,
seed=seed,
),
)
@@ -148,14 +163,12 @@ class MeshyRefineNode(IO.ComfyNode):
category="partner/3d/Meshy",
description="Refine a previously created draft model.",
inputs=[
IO.Combo.Input("model", options=["latest"]),
IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]),
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
IO.Boolean.Input(
"enable_pbr",
default=False,
tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color. "
"Note: this should be set to false when using Sculpture style, "
"as Sculpture style generates its own set of PBR maps.",
tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color.",
advanced=True,
),
IO.String.Input(
@@ -170,6 +183,11 @@ class MeshyRefineNode(IO.ComfyNode):
tooltip="Only one of 'texture_image' or 'texture_prompt' may be used at the same time.",
optional=True,
),
IO.Combo.Input(
"texture_resolution",
options=["2k", "4k", "8k"],
tooltip="Base color texture resolution. Higher resolutions capture more surface detail.",
),
],
outputs=[
IO.String.Output(display_name="model_file"), # for backward compatibility only
@@ -185,7 +203,13 @@ class MeshyRefineNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":0.4}""",
depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]),
expr="""
(
$credits := widgets.texture_resolution = "8k" ? 15 : 10;
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
)
@@ -196,6 +220,7 @@ class MeshyRefineNode(IO.ComfyNode):
meshy_task_id: str,
enable_pbr: bool,
texture_prompt: str,
texture_resolution: str,
texture_image: Input.Image | None = None,
) -> IO.NodeOutput:
if texture_prompt and texture_image is not None:
@@ -212,6 +237,7 @@ class MeshyRefineNode(IO.ComfyNode):
data=MeshyRefineTask(
preview_task_id=meshy_task_id,
enable_pbr=enable_pbr,
texture_resolution=texture_resolution,
texture_prompt=texture_prompt if texture_prompt else None,
texture_image_url=texture_image_url,
ai_model=model,
@@ -242,7 +268,7 @@ class MeshyImageToModelNode(IO.ComfyNode):
display_name="Meshy: Image to Model",
category="partner/3d/Meshy",
inputs=[
IO.Combo.Input("model", options=["latest"]),
IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]),
IO.Image.Input("image"),
IO.DynamicCombo.Input(
"should_remesh",
@@ -290,6 +316,12 @@ class MeshyImageToModelNode(IO.ComfyNode):
"may be used at the same time.",
optional=True,
),
IO.Combo.Input(
"texture_resolution",
options=["2k", "4k", "8k"],
tooltip="Base color texture resolution. "
"Higher resolutions capture more surface detail.",
),
],
),
IO.DynamicCombo.Option("false", []),
@@ -313,6 +345,11 @@ class MeshyImageToModelNode(IO.ComfyNode):
tooltip="Seed controls whether the node should re-run; "
"results are non-deterministic regardless of seed.",
),
IO.Boolean.Input(
"ultra_mode",
default=False,
tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.",
),
],
outputs=[
IO.String.Output(display_name="model_file"), # for backward compatibility only
@@ -328,11 +365,17 @@ class MeshyImageToModelNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]),
depends_on=IO.PriceBadgeDepends(
widgets=["model", "should_texture", "should_texture.texture_resolution", "ultra_mode"],
),
expr="""
(
$prices := {"true": 1.2, "false": 0.8};
{"type":"usd","usd": $lookup($prices, widgets.should_texture)}
$textured := widgets.should_texture = "true";
$resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k";
$credits := ($textured ? 30 : 20)
+ ($resolution = "8k" ? 5 : 0)
+ ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0);
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
@@ -348,7 +391,10 @@ class MeshyImageToModelNode(IO.ComfyNode):
should_texture: InputShouldTexture,
pose_mode: str,
seed: int,
ultra_mode: bool,
) -> IO.NodeOutput:
if ultra_mode and model not in ("meshy-7", "latest"):
raise ValueError("ultra_mode requires the meshy-7 or latest model")
texture = should_texture["should_texture"] == "true"
texture_image_url = texture_prompt = None
if texture:
@@ -376,7 +422,9 @@ class MeshyImageToModelNode(IO.ComfyNode):
should_remesh=should_remesh["should_remesh"] == "true",
should_texture=texture,
enable_pbr=should_texture.get("enable_pbr", None),
texture_resolution=should_texture.get("texture_resolution", None),
pose_mode=pose_mode.lower(),
ultra_mode=ultra_mode,
texture_prompt=texture_prompt,
texture_image_url=texture_image_url,
seed=seed,
@@ -407,7 +455,7 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
display_name="Meshy: Multi-Image to Model",
category="partner/3d/Meshy",
inputs=[
IO.Combo.Input("model", options=["latest"]),
IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]),
IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=2, max=4),
@@ -458,6 +506,12 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
"may be used at the same time.",
optional=True,
),
IO.Combo.Input(
"texture_resolution",
options=["2k", "4k", "8k"],
tooltip="Base color texture resolution. "
"Higher resolutions capture more surface detail.",
),
],
),
IO.DynamicCombo.Option("false", []),
@@ -496,11 +550,15 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]),
depends_on=IO.PriceBadgeDepends(
widgets=["should_texture", "should_texture.texture_resolution"],
),
expr="""
(
$prices := {"true": 0.6, "false": 0.2};
{"type":"usd","usd": $lookup($prices, widgets.should_texture)}
$textured := widgets.should_texture = "true";
$resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k";
$credits := ($textured ? 30 : 20) + ($resolution = "8k" ? 5 : 0);
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
@@ -546,6 +604,7 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
should_remesh=should_remesh["should_remesh"] == "true",
should_texture=texture,
enable_pbr=should_texture.get("enable_pbr", None),
texture_resolution=should_texture.get("texture_resolution", None),
pose_mode=pose_mode.lower(),
texture_prompt=texture_prompt,
texture_image_url=texture_image_url,
@@ -609,7 +668,7 @@ class MeshyRigModelNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":0.2}""",
expr="""{"type":"usd","usd": 0.286}""",
),
)
@@ -681,7 +740,7 @@ class MeshyAnimateModelNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":0.12}""",
expr="""{"type":"usd","usd": 0.1716}""",
),
)
@@ -724,7 +783,7 @@ class MeshyTextureNode(IO.ComfyNode):
display_name="Meshy: Texture Model",
category="partner/3d/Meshy",
inputs=[
IO.Combo.Input("model", options=["latest"]),
IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]),
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
IO.Boolean.Input(
"enable_original_uv",
@@ -748,10 +807,15 @@ class MeshyTextureNode(IO.ComfyNode):
tooltip="A 2d image to guide the texturing process. "
"Can not be used at the same time with 'text_style_prompt'.",
),
IO.Combo.Input(
"texture_resolution",
options=["2k", "4k", "8k"],
tooltip="Base color texture resolution. Higher resolutions capture more surface detail.",
),
],
outputs=[
IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"),
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
],
@@ -763,7 +827,13 @@ class MeshyTextureNode(IO.ComfyNode):
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
expr="""{"type":"usd","usd":0.4}""",
depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]),
expr="""
(
$credits := widgets.texture_resolution = "8k" ? 15 : 10;
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
)
@@ -775,6 +845,7 @@ class MeshyTextureNode(IO.ComfyNode):
enable_original_uv: bool,
pbr: bool,
text_style_prompt: str,
texture_resolution: str,
image_style: Input.Image | None = None,
) -> IO.NodeOutput:
if text_style_prompt and image_style is not None:
@@ -793,6 +864,7 @@ class MeshyTextureNode(IO.ComfyNode):
ai_model=model,
enable_original_uv=enable_original_uv,
enable_pbr=pbr,
texture_resolution=texture_resolution,
text_style_prompt=text_style_prompt if text_style_prompt else None,
image_style_url=image_style_url,
),
@@ -813,6 +885,108 @@ class MeshyTextureNode(IO.ComfyNode):
)
class MeshyTextureMultiViewNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="MeshyTextureMultiViewNode",
display_name="Meshy: Texture Model (Multi-View)",
category="partner/3d/Meshy",
description="Texture a previously created model using 1 to 4 reference views of the same object.",
inputs=[
IO.Combo.Input("model", options=["meshy-7"]),
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
IO.Autogrow.Input(
"multiview_images",
template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=1, max=4),
tooltip="Reference views of the same object. The first image is the primary (front) view; "
"the order of the remaining views does not matter.",
),
IO.Boolean.Input(
"enable_original_uv",
default=True,
tooltip="Use the original UV of the model instead of generating new UVs. "
"When enabled, Meshy preserves existing textures from the uploaded model. "
"If the model has no original UV, the quality of the output might not be as good.",
advanced=True,
),
IO.Boolean.Input("pbr", default=False, advanced=True),
IO.Combo.Input(
"texture_resolution",
options=["2k", "4k", "8k"],
tooltip="Base color texture resolution. Higher resolutions capture more surface detail.",
),
],
outputs=[
IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
is_output_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]),
expr="""
(
$credits := widgets.texture_resolution = "8k" ? 15 : 10;
{"type":"usd","usd": $round($credits * 0.0572, 4)}
)
""",
),
)
@classmethod
async def execute(
cls,
model: str,
meshy_task_id: str,
multiview_images: IO.Autogrow.Type,
enable_original_uv: bool,
pbr: bool,
texture_resolution: str,
) -> IO.NodeOutput:
reference_views = list(multiview_images.values())
view_count = sum(v.shape[0] if len(v.shape) > 3 else 1 for v in reference_views)
if not 1 <= view_count <= 4:
raise ValueError("multiview_images must contain 1 to 4 images")
response = await sync_op(
cls,
endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/retexture", method="POST"),
response_model=MeshyTaskResponse,
data=MeshyTextureRequest(
input_task_id=meshy_task_id,
ai_model=model,
enable_original_uv=enable_original_uv,
enable_pbr=pbr,
texture_resolution=texture_resolution,
multiview_image_urls=await upload_images_to_comfyapi(
cls, reference_views, max_images=4, wait_label="Uploading reference views"
),
),
)
task_id = response.result
result = await poll_op(
cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{task_id}"),
response_model=MeshyModelResult,
status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress,
)
return IO.NodeOutput(
f"{task_id}.glb",
task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@@ -824,6 +998,7 @@ class MeshyExtension(ComfyExtension):
MeshyRigModelNode,
MeshyAnimateModelNode,
MeshyTextureNode,
MeshyTextureMultiViewNode,
]
+305
View File
@@ -0,0 +1,305 @@
import base64
import re
from io import BytesIO
import torch
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension
from comfy_api_nodes.apis.meta import (
MuseImageEditRequest,
MuseImageInput,
MuseImageRequest,
MuseImageResponse,
MuseImageToolEnablement,
)
from comfy_api_nodes.util import (
ApiEndpoint,
bytesio_to_image_tensor,
sync_op,
upload_images_to_comfyapi,
validate_string,
)
GENERATIONS_PATH = "/proxy/meta/v1/images/generations"
EDITS_PATH = "/proxy/meta/v1/images/edits"
MUSE_IMAGE_MODELS = ["muse-image-1.0"]
MAX_INPUT_IMAGES = 10
ASPECT_RATIOS = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "21:9", "9:21", "2:1", "1:2"]
REASONING_STRENGTHS = ["high", "low"]
_IMAGE_REF_RE = re.compile(r"@image(?P<idx>\d*)(?!\w)", re.IGNORECASE | re.ASCII)
def _resolve_image_refs(prompt: str, total_images: int) -> str:
parts = []
pos = 0
prev_end = -1
for match in _IMAGE_REF_RE.finditer(prompt):
start = match.start()
if start > 0 and start != prev_end and (prompt[start - 1].isalnum() or prompt[start - 1] == "_"):
continue
idx = int(match.group("idx") or 1)
if not 1 <= idx <= total_images:
raise ValueError(
f"The prompt references @Image{idx}, but only {total_images} reference images "
f"are connected (a batched input counts once per image)."
)
parts.append(prompt[pos:start])
parts.append(f"image {idx}")
pos = match.end()
prev_end = match.end()
parts.append(prompt[pos:])
return "".join(parts)
def _size(aspect_ratio: str) -> str | None:
return None if aspect_ratio == "auto" else aspect_ratio.replace(":", "x")
def _decode_images(response: MuseImageResponse) -> torch.Tensor:
images = [
bytesio_to_image_tensor(BytesIO(base64.b64decode(item.b64_json)))
for item in response.data
if item.b64_json
]
if not images:
raise Exception("The response contains no images.")
return torch.cat(images)
def _reasoning_strength_input() -> IO.Combo.Input:
return IO.Combo.Input(
"reasoning_strength",
options=REASONING_STRENGTHS,
tooltip="How much the model thinks, plans and self-refines before rendering.",
)
def _t2i_model_option(model_id: str) -> IO.DynamicCombo.Option:
return IO.DynamicCombo.Option(
model_id,
[
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Prompt describing the image. The model reasons about the prompt, and may use "
"its built-in web and image search, before rendering.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
tooltip="Aspect ratio of the output. Images are rendered at about 2.5 megapixels "
"(1:1 is 1600x1600, 16:9 is 2048x1152); 'auto' lets the model choose from the prompt.",
),
_reasoning_strength_input(),
*_tool_toggle_inputs(),
_seed_input(),
],
)
def _edit_model_option(model_id: str) -> IO.DynamicCombo.Option:
return IO.DynamicCombo.Option(
model_id,
[
IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("image"),
names=[f"image_{i}" for i in range(1, MAX_INPUT_IMAGES + 1)],
min=1,
),
tooltip=f"1-{MAX_INPUT_IMAGES} reference images to edit or combine. Refer to them in the prompt "
"as @Image1, @Image2, ..., numbered in input order; a batched input counts once per image.",
),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Editing instructions. Supports @Image1-style references to the input images.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
tooltip="Aspect ratio of the output. Images are rendered at about 2.5 megapixels "
"(1:1 is 1600x1600, 16:9 is 2048x1152); 'auto' keeps the aspect ratio of the input.",
),
_reasoning_strength_input(),
*_tool_toggle_inputs(),
_seed_input(),
],
)
def _tool_toggle_inputs() -> list[IO.Boolean.Input]:
return [
IO.Boolean.Input(
"enable_web_search",
default=True,
advanced=True,
tooltip="Lets the model search the web for facts and live information while planning the image.",
),
IO.Boolean.Input(
"enable_image_search",
default=True,
advanced=True,
tooltip="Lets the model search for reference images while planning the image.",
),
IO.Boolean.Input(
"enable_shell",
default=True,
advanced=True,
tooltip="Lets the model run code while planning, for precise layouts, charts and diagrams; "
"when off, quantities and alignment are approximated.",
),
]
def _tool_enablement(model: dict) -> MuseImageToolEnablement | None:
if model["enable_web_search"] and model["enable_image_search"] and model["enable_shell"]:
return None
return MuseImageToolEnablement(
enable_image_search=model["enable_image_search"],
enable_web_search=model["enable_web_search"],
enable_shell=model["enable_shell"],
)
def _seed_input() -> IO.Int.Input:
return IO.Int.Input(
"seed",
default=42,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
tooltip="Seed to determine if node should re-run; the API has no seed, "
"so actual results are nondeterministic regardless of this value.",
)
def _price_badge() -> IO.PriceBadge:
return IO.PriceBadge(expr="""{"type":"usd","usd":0.0143}""")
class MetaMuseImageTextToImageApi(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="MetaMuseImageTextToImageApi",
display_name="Meta Muse Image Text to Image",
category="partner/image/Meta",
description="Generates images from a text prompt using Meta's Muse Image model, "
"which reasons about the prompt before rendering.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_t2i_model_option(model_id) for model_id in MUSE_IMAGE_MODELS],
tooltip="Model to use.",
),
],
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=_price_badge(),
)
@classmethod
async def execute(cls, model: dict):
validate_string(model["prompt"], min_length=1)
response = await sync_op(
cls,
ApiEndpoint(path=GENERATIONS_PATH, method="POST"),
response_model=MuseImageResponse,
data=MuseImageRequest(
model=model["model"],
prompt=model["prompt"],
size=_size(model["aspect_ratio"]),
reasoning_strength=model["reasoning_strength"],
tool_enablement=_tool_enablement(model),
),
)
return IO.NodeOutput(_decode_images(response))
class MetaMuseImageEditApi(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="MetaMuseImageEditApi",
display_name="Meta Muse Image Edit",
category="partner/image/Meta",
description=f"Edits or combines up to {MAX_INPUT_IMAGES} reference images guided by a text prompt "
"using Meta's Muse Image model.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_edit_model_option(model_id) for model_id in MUSE_IMAGE_MODELS],
tooltip="Model to use.",
),
],
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=_price_badge(),
)
@classmethod
async def execute(cls, model: dict):
validate_string(model["prompt"], min_length=1)
reference_images = [image for key in model["images"] for image in model["images"][key]]
if len(reference_images) > MAX_INPUT_IMAGES:
raise ValueError(
f"A maximum of {MAX_INPUT_IMAGES} reference images is supported; got {len(reference_images)} "
f"(a batched input counts once per image)."
)
prompt = _resolve_image_refs(model["prompt"], len(reference_images))
urls = await upload_images_to_comfyapi(
cls,
[image[..., :3] for image in reference_images],
max_images=MAX_INPUT_IMAGES,
mime_type="image/png",
wait_label="Uploading reference images",
)
response = await sync_op(
cls,
ApiEndpoint(path=EDITS_PATH, method="POST"),
response_model=MuseImageResponse,
data=MuseImageEditRequest(
model=model["model"],
prompt=prompt,
size=_size(model["aspect_ratio"]),
reasoning_strength=model["reasoning_strength"],
tool_enablement=_tool_enablement(model),
images=[MuseImageInput(image_url=url) for url in urls],
),
)
return IO.NodeOutput(_decode_images(response))
class MetaApiExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
MetaMuseImageTextToImageApi,
MetaMuseImageEditApi,
]
async def comfy_entrypoint() -> MetaApiExtension:
return MetaApiExtension()
+324 -68
View File
@@ -10,6 +10,10 @@ from comfy_api_nodes.apis.minimax import (
Hailuo03ContextIRRequest,
Hailuo03ImageContent,
Hailuo03ImageContentUrl,
Hailuo03MaxTaskCreationResponse,
Hailuo03MaxTaskStatusResponse,
Hailuo03MaxVideoRequest,
Hailuo03MaxVideoResult,
Hailuo03RegenerationRequest,
Hailuo03TaskCreationRequest,
Hailuo03TaskCreationResponse,
@@ -27,6 +31,7 @@ from comfy_api_nodes.apis.minimax import (
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_video_output,
downscale_image_tensor_by_max_sides,
poll_op,
sync_op,
upload_audio_to_comfyapi,
@@ -461,6 +466,15 @@ HAILUO_03_FAILED_STATUSES = ["failed", "cancelled", "expired"]
HAILUO_03_CONTEXT_IR_ENDPOINT = "/proxy/minimax/v2/h3_context_ir"
HAILUO_03_REGENERATION_ENDPOINT = "/proxy/minimax/v2/video_regeneration"
HAILUO_03_MAX_MODEL = "MiniMax H3 Max"
HAILUO_03_MAX_TURBO_MODEL = "MiniMax H3 Max Turbo"
HAILUO_03_MAX_ENDPOINTS = {
HAILUO_03_MAX_MODEL: "/proxy/fal/minimax/h3-max",
HAILUO_03_MAX_TURBO_MODEL: "/proxy/fal/minimax/h3-max-turbo",
}
HAILUO_03_MAX_PROMPT_MAX_LENGTH = 50000
HAILUO_03_MAX_REFERENCE_IMAGE_MAX_SIDES = {"high": (5120, 2048), "standard": (2048, 1024)}
def _hailuo03_model_inputs(include_ratio: bool = True, allow_adaptive: bool = True):
inputs = [
@@ -541,6 +555,86 @@ async def _hailuo03_run_task(
return IO.NodeOutput(await download_url_to_video_output(video_url))
def _hailuo03_max_model_inputs(include_ratio: bool = True, allow_adaptive: bool = False):
inputs = [
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Text prompt for video generation.",
),
IO.Combo.Input(
"resolution",
options=["480P", "768P"],
default="768P",
tooltip="Resolution of the output video.",
),
]
if include_ratio:
ratio_options = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]
if allow_adaptive:
ratio_options.insert(0, "adaptive")
inputs.append(
IO.Combo.Input(
"ratio",
options=ratio_options,
default=ratio_options[0],
tooltip="Aspect ratio of the output video.",
)
)
inputs.extend(
[
IO.Int.Input(
"duration",
default=5,
min=5,
max=15,
step=1,
tooltip="Duration of the output video in seconds (5-15).",
display_mode=IO.NumberDisplay.slider,
),
IO.Combo.Input(
"prompt_expansion_mode",
options=["balanced", "quality"],
default="balanced",
tooltip="How much effort is spent rewriting the prompt before generation.",
),
]
)
return inputs
async def _hailuo03_max_run_task(
cls: type[IO.ComfyNode],
*,
model: str,
endpoint: str,
request: Hailuo03MaxVideoRequest,
) -> IO.NodeOutput:
base_endpoint = HAILUO_03_MAX_ENDPOINTS[model]
submit = await sync_op(
cls,
ApiEndpoint(path=f"{base_endpoint}/{endpoint}", method="POST"),
response_model=Hailuo03MaxTaskCreationResponse,
data=request,
)
await poll_op(
cls,
ApiEndpoint(path=f"{base_endpoint}/requests/{submit.request_id}/status"),
response_model=Hailuo03MaxTaskStatusResponse,
status_extractor=lambda r: r.status,
completed_statuses=["COMPLETED"],
queued_statuses=["IN_QUEUE"],
poll_interval=5,
)
result = await sync_op(
cls,
ApiEndpoint(path=f"{base_endpoint}/requests/{submit.request_id}"),
response_model=Hailuo03MaxVideoResult,
)
return IO.NodeOutput(await download_url_to_video_output(result.video.url))
class MinimaxHailuo03TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@@ -548,11 +642,15 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode):
node_id="MinimaxHailuo03TextToVideoNode",
display_name="MiniMax H3 Text to Video",
category="partner/video/MiniMax",
description="Generate video from a text prompt using the MiniMax H3 model.",
description="Generate video from a text prompt using the MiniMax H3 models.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(allow_adaptive=False))],
options=[
IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(allow_adaptive=False)),
IO.DynamicCombo.Option(HAILUO_03_MAX_MODEL, _hailuo03_max_model_inputs()),
IO.DynamicCombo.Option(HAILUO_03_MAX_TURBO_MODEL, _hailuo03_max_model_inputs()),
],
tooltip="Model to use for video generation.",
),
IO.Int.Input(
@@ -583,11 +681,17 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]),
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]),
expr="""
(
$dur := $lookup(widgets, "model.duration");
$rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859;
$res := $lookup(widgets, "model.resolution");
$model := $lookup(widgets, "model");
$rate := $model = "minimax h3 max turbo"
? ($res = "480p" ? 0.03575 : 0.0572)
: $model = "minimax h3 max"
? ($res = "480p" ? 0.0715 : 0.1144)
: ($res = "768p" ? 0.1287 : 0.1859);
{"type": "usd", "usd": $dur * $rate}
)
""",
@@ -602,6 +706,23 @@ class MinimaxHailuo03TextToVideoNode(IO.ComfyNode):
watermark: bool,
) -> IO.NodeOutput:
validate_string(model["prompt"], strip_whitespace=True, min_length=1)
if model["model"] in HAILUO_03_MAX_ENDPOINTS:
if watermark:
raise ValueError("Watermark is only supported by MiniMax H3.")
validate_string(model["prompt"], strip_whitespace=False, max_length=HAILUO_03_MAX_PROMPT_MAX_LENGTH)
return await _hailuo03_max_run_task(
cls,
model=model["model"],
endpoint="text-to-video",
request=Hailuo03MaxVideoRequest(
prompt=model["prompt"],
duration=model["duration"],
resolution=model["resolution"],
prompt_expansion_mode=model["prompt_expansion_mode"],
seed=seed,
aspect_ratio=model["ratio"],
),
)
return await _hailuo03_run_task(
cls,
model_id=HAILUO_03_MODELS[model["model"]],
@@ -622,11 +743,17 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode):
display_name="MiniMax H3 First-Last-Frame to Video",
category="partner/video/MiniMax",
description="Generate video from a first frame image and an optional last frame image "
"using the MiniMax H3 model. The aspect ratio of the video follows the supplied images.",
"using the MiniMax H3 models. The aspect ratio of the video follows the supplied images.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(include_ratio=False))],
options=[
IO.DynamicCombo.Option("MiniMax H3", _hailuo03_model_inputs(include_ratio=False)),
IO.DynamicCombo.Option(HAILUO_03_MAX_MODEL, _hailuo03_max_model_inputs(include_ratio=False)),
IO.DynamicCombo.Option(
HAILUO_03_MAX_TURBO_MODEL, _hailuo03_max_model_inputs(include_ratio=False)
),
],
tooltip="Model to use for video generation.",
),
IO.Image.Input(
@@ -666,11 +793,17 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model.resolution", "model.duration"]),
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution", "model.duration"]),
expr="""
(
$dur := $lookup(widgets, "model.duration");
$rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859;
$res := $lookup(widgets, "model.resolution");
$model := $lookup(widgets, "model");
$rate := $model = "minimax h3 max turbo"
? ($res = "480p" ? 0.03575 : 0.0572)
: $model = "minimax h3 max"
? ($res = "480p" ? 0.0715 : 0.1144)
: ($res = "768p" ? 0.1287 : 0.1859);
{"type": "usd", "usd": $dur * $rate}
)
""",
@@ -691,6 +824,32 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode):
if frame is not None:
validate_image_aspect_ratio(frame, (2, 5), (5, 2), strict=False) # 0.4 to 2.5
validate_image_dimensions(frame, min_width=256, min_height=256)
if model["model"] in HAILUO_03_MAX_ENDPOINTS:
if watermark:
raise ValueError("Watermark is only supported by MiniMax H3.")
validate_string(model["prompt"], strip_whitespace=False, max_length=HAILUO_03_MAX_PROMPT_MAX_LENGTH)
image_url = (
await upload_images_to_comfyapi(cls, first_frame, max_images=1, wait_label="Uploading first frame")
)[0]
end_image_url = None
if last_frame is not None:
end_image_url = (
await upload_images_to_comfyapi(cls, last_frame, max_images=1, wait_label="Uploading last frame")
)[0]
return await _hailuo03_max_run_task(
cls,
model=model["model"],
endpoint="image-to-video",
request=Hailuo03MaxVideoRequest(
prompt=model["prompt"],
duration=model["duration"],
resolution=model["resolution"],
prompt_expansion_mode=model["prompt_expansion_mode"],
seed=seed,
image_url=image_url,
end_image_url=end_image_url,
),
)
content: list = [
Hailuo03TextContent(text=model["prompt"]),
@@ -730,6 +889,54 @@ class MinimaxHailuo03FirstLastFrameNode(IO.ComfyNode):
)
def _hailuo03_reference_inputs():
return [
IO.Autogrow.Input(
"reference_images",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("reference_image"),
names=[
"image_1",
"image_2",
"image_3",
"image_4",
"image_5",
"image_6",
"image_7",
"image_8",
"image_9",
],
min=0,
),
tooltip="Subject or style reference images, referred to in the prompt "
"as 'Image 1'..'Image 9' in connection order. Up to 9 images.",
),
IO.Autogrow.Input(
"reference_videos",
template=IO.Autogrow.TemplateNames(
IO.Video.Input("reference_video"),
names=["video_1", "video_2", "video_3"],
min=0,
),
tooltip="Motion or scene reference videos, referred to in the prompt "
"as 'Video 1'..'Video 3' in connection order. Up to 3 videos, "
"2-15 seconds each, 15 seconds in total.",
),
IO.Autogrow.Input(
"reference_audios",
template=IO.Autogrow.TemplateNames(
IO.Audio.Input("reference_audio"),
names=["audio_1", "audio_2", "audio_3"],
min=0,
),
tooltip="Audio references, referred to in the prompt as "
"'Audio 1'..'Audio 3' in connection order. Up to 3 clips, "
"2-15 seconds each, 15 seconds in total. Cannot be used without "
"a reference image or video.",
),
]
class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@@ -738,7 +945,7 @@ class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
display_name="MiniMax H3 Reference to Video",
category="partner/video/MiniMax",
description="Generate video conditioned on reference images, videos, and audio using the "
"MiniMax H3 model. Refer to the references in the prompt by their order: "
"MiniMax H3 models. Refer to the references in the prompt by their order: "
"'Image 1', 'Image 2', 'Video 1', 'Audio 1', and so on.",
inputs=[
IO.DynamicCombo.Input(
@@ -746,53 +953,23 @@ class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
options=[
IO.DynamicCombo.Option(
"MiniMax H3",
[*_hailuo03_model_inputs(), *_hailuo03_reference_inputs()],
),
IO.DynamicCombo.Option(
HAILUO_03_MAX_MODEL,
[
*_hailuo03_model_inputs(),
IO.Autogrow.Input(
"reference_images",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("reference_image"),
names=[
"image_1",
"image_2",
"image_3",
"image_4",
"image_5",
"image_6",
"image_7",
"image_8",
"image_9",
],
min=0,
),
tooltip="Subject or style reference images, referred to in the prompt "
"as 'Image 1'..'Image 9' in connection order. Up to 9 images.",
),
IO.Autogrow.Input(
"reference_videos",
template=IO.Autogrow.TemplateNames(
IO.Video.Input("reference_video"),
names=["video_1", "video_2", "video_3"],
min=0,
),
tooltip="Motion or scene reference videos, referred to in the prompt "
"as 'Video 1'..'Video 3' in connection order. Up to 3 videos, "
"2-15 seconds each, 15 seconds in total.",
),
IO.Autogrow.Input(
"reference_audios",
template=IO.Autogrow.TemplateNames(
IO.Audio.Input("reference_audio"),
names=["audio_1", "audio_2", "audio_3"],
min=0,
),
tooltip="Audio references, referred to in the prompt as "
"'Audio 1'..'Audio 3' in connection order. Up to 3 clips, "
"2-15 seconds each, 15 seconds in total. Cannot be used without "
"a reference image or video.",
*_hailuo03_max_model_inputs(allow_adaptive=True),
IO.Combo.Input(
"reference_detail",
options=["high", "standard"],
default="standard",
tooltip="Detail level at which reference images are sent. 'high' sends them at "
"the largest size the model uses (up to a 2048 pixel short side); "
"'standard' downsizes them to at most 2048x1024 to reduce the reference cost.",
),
*_hailuo03_reference_inputs(),
],
)
),
],
tooltip="Model to use for video generation.",
),
@@ -825,22 +1002,44 @@ class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(
widgets=["model.resolution", "model.duration"],
input_groups=["model.reference_images", "model.reference_videos"],
widgets=["model", "model.resolution", "model.duration", "model.reference_detail"],
input_groups=["model.reference_images", "model.reference_videos", "model.reference_audios"],
),
expr="""
(
$dur := $lookup(widgets, "model.duration");
$rate := $lookup(widgets, "model.resolution") = "768p" ? 0.1287 : 0.1859;
$res := $lookup(widgets, "model.resolution");
$imgsRaw := $lookup(inputGroups, "model.reference_images");
$imgs := $imgsRaw ? $imgsRaw : 0;
$vidsRaw := $lookup(inputGroups, "model.reference_videos");
$vids := $vidsRaw ? $vidsRaw : 0;
$base := $dur * $rate + ($imgs > 5 ? ($imgs - 5) * 0.0572 : 0);
$vids > 0
? {"type": "range_usd", "min_usd": $base + $vids * 2 * $rate,
"max_usd": $base + 15 * $rate, "format": {"approximate": true}}
: {"type": "usd", "usd": $base}
$audsRaw := $lookup(inputGroups, "model.reference_audios");
$auds := $audsRaw ? $audsRaw : 0;
$lookup(widgets, "model") = "minimax h3 max"
? (
$unitRate := $res = "480p" ? 1 : 1.6;
$frameTokens := $res = "480p" ? 390 : 1008;
$base := $dur * $unitRate;
$minTokens := $imgs * 576 + $vids * 12 * $frameTokens + $auds * 160;
$capFrames := $vids * (7 * $dur + 2);
$maxFrames := $capFrames > 265 ? 265 : $capFrames;
$imgMax := $lookup(widgets, "model.reference_detail") = "standard" ? 2560 : 10240;
$maxTokens := $imgs * $imgMax + $maxFrames * $frameTokens + ($auds > 0 ? 1200 : 0);
$minUnits := $base + ($minTokens > 4096 ? ($minTokens - 4096) * 0.0004 : 0);
$maxUnits := $base + ($maxTokens > 4096 ? ($maxTokens - 4096) * 0.0004 : 0);
$minUnits = $maxUnits
? {"type": "usd", "usd": $base * 0.0715}
: {"type": "range_usd", "min_usd": $minUnits * 0.0715, "max_usd": $maxUnits * 0.0715,
"format": {"approximate": true}}
)
: (
$rate := $res = "768p" ? 0.1287 : 0.1859;
$base := $dur * $rate + ($imgs > 5 ? ($imgs - 5) * 0.0572 : 0);
$vids > 0
? {"type": "range_usd", "min_usd": $base + $vids * 2 * $rate,
"max_usd": $base + 15 * $rate, "format": {"approximate": true}}
: {"type": "usd", "usd": $base}
)
)
""",
),
@@ -861,6 +1060,11 @@ class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
if not reference_images and not reference_videos:
raise ValueError("At least one reference image or video is required.")
is_max = model["model"] == HAILUO_03_MAX_MODEL
min_clip_duration = 2.0 if is_max else 1.8
max_total_duration = 15.0 if is_max else 15.1
max_fps = 60.19 if is_max else 60.5
for image in reference_images.values():
validate_image_aspect_ratio(image, (2, 5), (5, 2), strict=False) # 0.4 to 2.5
validate_image_dimensions(image, min_width=256, min_height=256)
@@ -871,29 +1075,81 @@ class MinimaxHailuo03ReferenceNode(IO.ComfyNode):
fps = float(video.get_frame_rate())
except Exception:
fps = 0.0
if fps and not (23.9 <= fps <= 60.5):
if fps and not (23.9 <= fps <= max_fps):
raise ValueError(f"Reference video {i} is {fps:.2f} FPS. Supported range is 23.976-60 FPS.")
try:
dur = video.get_duration()
except Exception:
continue
if dur < 1.8:
if dur < min_clip_duration:
raise ValueError(f"Reference video {i} is too short: {dur:.1f}s. Minimum duration is 2 seconds.")
total_video_duration += dur
if total_video_duration > 15.1:
if total_video_duration > max_total_duration:
raise ValueError(
f"Total reference video duration is {total_video_duration:.1f}s. Maximum is 15 seconds."
f"Total reference video duration is {total_video_duration:.2f}s. Maximum is 15 seconds."
)
total_audio_duration = 0.0
for i, audio in enumerate(reference_audios.values(), 1):
dur = int(audio["waveform"].shape[-1]) / int(audio["sample_rate"])
if dur < 1.8:
if dur < min_clip_duration:
raise ValueError(f"Reference audio {i} is too short: {dur:.1f}s. Minimum duration is 2 seconds.")
total_audio_duration += dur
if total_audio_duration > 15.1:
if total_audio_duration > max_total_duration:
raise ValueError(
f"Total reference audio duration is {total_audio_duration:.1f}s. Maximum is 15 seconds."
f"Total reference audio duration is {total_audio_duration:.2f}s. Maximum is 15 seconds."
)
if is_max:
if watermark:
raise ValueError("Watermark is only supported by MiniMax H3.")
validate_string(model["prompt"], strip_whitespace=False, max_length=HAILUO_03_MAX_PROMPT_MAX_LENGTH)
if len(reference_images) + len(reference_videos) + len(reference_audios) > 12:
raise ValueError("MiniMax H3 Max accepts at most 12 reference files in total.")
max_long_side, max_short_side = HAILUO_03_MAX_REFERENCE_IMAGE_MAX_SIDES[model["reference_detail"]]
reference_image_urls = [
(
await upload_images_to_comfyapi(
cls,
downscale_image_tensor_by_max_sides(
image, max_long_side=max_long_side, max_short_side=max_short_side
),
max_images=1,
total_pixels=None,
wait_label=f"Uploading image {i}",
)
)[0]
for i, image in enumerate(reference_images.values(), 1)
]
reference_video_urls = [
await upload_video_to_comfyapi(cls, video, wait_label=f"Uploading video {i}")
for i, video in enumerate(reference_videos.values(), 1)
]
reference_audio_urls = [
await upload_audio_to_comfyapi(
cls,
audio,
container_format="mp3",
codec_name="libmp3lame",
mime_type="audio/mpeg",
)
for audio in reference_audios.values()
]
return await _hailuo03_max_run_task(
cls,
model=HAILUO_03_MAX_MODEL,
endpoint="reference-to-video",
request=Hailuo03MaxVideoRequest(
prompt=model["prompt"],
duration=model["duration"],
resolution=model["resolution"],
prompt_expansion_mode=model["prompt_expansion_mode"],
seed=seed,
aspect_ratio=model["ratio"],
reference_image_urls=reference_image_urls or None,
reference_video_urls=reference_video_urls or None,
reference_audio_urls=reference_audio_urls or None,
),
)
content: list = [Hailuo03TextContent(text=model["prompt"])]

Some files were not shown because too many files have changed in this diff Show More