mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-09 02:26:31 +08:00
Merge branch 'master' into glary/fix-image-invert-alpha
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
"""SenseNova U1.5 model implementation."""
|
||||
@@ -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"))
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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.")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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(...)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+500
-415
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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"}}""",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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; "
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user