Compare commits

...
101 Commits
Author SHA1 Message Date
Alex Artyomov 44c8a36b7c Merge pull request #578 from Lightricks/publish-comfy-registry
Add the Comfy Registry publish workflow
2026-10-14 13:41:14 +03:00
Alex af31f726c0 Add the workflow that publishes a merged pack version to the Comfy Registry.
Merging this file does not publish. The job runs when pyproject.toml changes on master.
2026-10-13 16:06:16 +03:00
DoronGenzelHass 7f22eb16eb Merge pull request #577 from Lightricks/pr-2026-10-13
Update ComfyUI-LTXVideo - 2026-10-13
2026-10-13 15:55:36 +03:00
github-actions[bot] db476dacc1 Automated PR - 2026-10-13 2026-10-13 12:50:13 +00:00
DoronGenzelHass 3bf3ca6259 Merge pull request #574 from Lightricks/pr-2026-10-01
Update ComfyUI-LTXVideo - 2026-10-01
2026-10-01 11:18:00 +03:00
github-actions[bot] 5eb48fdcae Automated PR - 2026-10-01 2026-10-01 08:05:08 +00:00
Alex Artyomov bf2ca0264f Merge pull request #573 from Lightricks/pr-2026-09-30
Update ComfyUI-LTXVideo - 2026-09-30
2026-09-30 19:13:53 +03:00
github-actions[bot] e6fdfe6686 Automated PR - 2026-09-30 2026-09-30 16:13:15 +00:00
Adam Oster 5722b53a08 Merge pull request #570 from Lightricks/pr-2026-09-29
Update ComfyUI-LTXVideo - 2026-09-29
2026-09-29 15:38:33 +03:00
github-actions[bot] 71d8972bd2 Automated PR - 2026-09-29 2026-09-29 10:56:37 +00:00
Adam Oster 61ee82b23b Merge pull request #567 from Lightricks/pr-2026-09-24
Update ComfyUI-LTXVideo - 2026-09-24
2026-09-24 21:11:45 +03:00
github-actions[bot] c75eacae23 Automated PR - 2026-09-24 2026-09-24 18:08:02 +00:00
DoronGenzelHass f8387c893d Merge pull request #565 from Lightricks/pr-2026-09-22
Update ComfyUI-LTXVideo - 2026-09-22
2026-09-22 20:41:05 +03:00
github-actions[bot] d164816f8a Automated PR - 2026-09-22 2026-09-22 17:39:48 +00:00
DoronGenzelHass cb1f8204dc Merge pull request #564 from Lightricks/pr-2026-09-22
Update ComfyUI-LTXVideo - 2026-09-22
2026-09-22 11:27:55 +03:00
github-actions[bot] 35364168d9 Automated PR - 2026-09-22 2026-09-22 08:20:29 +00:00
Noa Lichtenstein dfb2786749 Merge pull request #560 from Lightricks/pr-2026-09-17
Update ComfyUI-LTXVideo - 2026-09-17
2026-09-17 18:25:06 -04:00
github-actions[bot] 63a7f34e64 Automated PR - 2026-09-17 2026-09-17 22:05:55 +00:00
Adam Oster 15d09abb5a Merge pull request #550 from Lightricks/pr-2026-08-20
Update ComfyUI-LTXVideo - 2026-08-20
2026-08-20 16:58:43 +03:00
github-actions[bot] b9446f5972 Automated PR - 2026-08-20 2026-08-20 13:55:32 +00:00
Michael Kupchick ac4d998390 Merge pull request #547 from Lightricks/pr-2026-08-11
Update ComfyUI-LTXVideo - 2026-08-11
2026-08-11 21:21:33 +03:00
github-actions[bot] 9d1672beb6 Automated PR - 2026-08-11 2026-08-11 18:19:31 +00:00
Michael Kupchick 3b9c5cde47 Merge pull request #532 from kijai/master
Support ComfyUI core rope change
2026-07-27 16:32:24 +03:00
kijai 548a39373b Support core rope change 2026-07-24 23:49:26 +03:00
Michael Kupchick aceeae9635 Merge pull request #521 from Lightricks/pr-2026-06-29 2026-06-30 09:12:53 +03:00
github-actions[bot] ec7fe89955 Automated PR - 2026-06-29 2026-06-29 15:38:22 +00:00
Amit Pintz 4f45fd6c22 Merge pull request #513 from Lightricks/pr-2026-06-17
Update ComfyUI-LTXVideo - 2026-06-17
2026-06-17 17:28:40 +03:00
github-actions[bot] 2e2ac81759 Automated PR - 2026-06-17 2026-06-17 14:04:55 +00:00
Barak Yoresh 229437c6b6 Merge pull request #487 from Lightricks/pr-2026-05-11
Update ComfyUI-LTXVideo - 2026-05-11
2026-05-11 17:40:39 +03:00
github-actions[bot] 75e1ed26ab Automated PR - 2026-05-11 2026-05-11 14:34:55 +00:00
Michael Kupchick 964be9688f Merge pull request #484 from Lightricks/pr-2026-05-11
Update ComfyUI-LTXVideo - 2026-05-11
2026-05-11 16:16:16 +03:00
github-actions[bot] d50612a64a Automated PR - 2026-05-11 2026-05-11 13:14:12 +00:00
Michael Kupchick 2acf7af899 Merge pull request #471 from nathanshipley/fix/hdr-workflow-tiled-vae-flicker
fix(workflow): swap VAEDecodeTiled for VAEDecode in LTX-2.3 HDR ICLoRA example workflow
2026-04-26 11:38:17 +03:00
testandClaude Opus 4.7 09b90d0f33 fix(workflow): swap VAEDecodeTiled for VAEDecode in LTX-2.3 HDR ICLoRA example workflow
The LTX-2.3 ICLoRA HDR Distilled example workflow used VAEDecodeTiled,
which produces flickering / ghosting / strobing artifacts in the output
video (reported in #470). Switching to a plain VAEDecode node resolves
the issue.

- Replace VAEDecodeTiled (node 4851) with VAEDecode; preserve existing
  samples / vae / IMAGE connections.
- Remove obsolete "in case of OOM use tile_size of 768 / temporal size
  16" Note node (5121), which only applied to the tiled decode.

Fixes #470.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:52:55 -04:00
Michael Kupchick 937831df0e Merge pull request #469 from Lightricks/pr-2026-04-23 2026-04-23 16:04:17 +03:00
github-actions[bot] b82708a54f Automated PR - 2026-04-23 2026-04-23 12:43:40 +00:00
Michael Kupchick d43181d572 Merge pull request #463 from Lightricks/pr-2026-04-13
Update ComfyUI-LTXVideo - 2026-04-13
2026-04-13 18:50:39 +03:00
github-actions[bot] 5984491048 Automated PR - 2026-04-13 2026-04-13 15:47:44 +00:00
Michael Kupchick 3686d832c6 Merge pull request #462 from Lightricks/pr-2026-04-13
Update ComfyUI-LTXVideo - 2026-04-13
2026-04-13 18:32:38 +03:00
github-actions[bot] 6df1f3ff90 Automated PR - 2026-04-13 2026-04-13 15:31:06 +00:00
Yaron Inger 531512f728 Merge pull request #426 from Lightricks/pr-2026-03-06
Update ComfyUI-LTXVideo - 2026-03-06
2026-03-06 08:27:12 -08:00
sync-bot c5f4724bc2 Automated PR - 2026-03-06 2026-03-06 16:26:29 +00:00
Michael Kupchick 58a5c12c2d Merge pull request #423 from Lightricks/pr-2026-03-06
Update ComfyUI-LTXVideo - 2026-03-06
2026-03-06 09:03:09 +02:00
sync-bot 5c3d20803d Automated PR - 2026-03-06 2026-03-06 06:58:00 +00:00
Michael Kupchick 82bd963cde Merge pull request #411 from Lightricks/pr-2026-02-11
Update ComfyUI-LTXVideo - 2026-02-11
2026-02-11 11:55:23 +02:00
sync-bot bb70ecba66 Automated PR - 2026-02-11 2026-02-11 09:54:06 +00:00
Michael Kupchick 49add6dddb Merge pull request #398 from Lightricks/pr-2026-01-29
Update ComfyUI-LTXVideo - 2026-01-29
2026-01-29 18:46:54 +02:00
sync-bot f82614dc6d Automated PR - 2026-01-29 2026-01-29 16:45:24 +00:00
Michael Kupchick d153ca3f78 Merge pull request #391 from Lightricks/pr-2026-01-27
Update ComfyUI-LTXVideo - 2026-01-27
2026-01-27 15:39:40 +02:00
sync-bot d008795913 Automated PR - 2026-01-27 2026-01-27 13:10:16 +00:00
Michael Kupchick cd5d371518 Merge pull request #374 from Lightricks/pr-2026-01-15 2026-01-15 21:28:35 +02:00
sync-bot 683d46186a Automated PR - 2026-01-15 2026-01-15 19:20:05 +00:00
Michael Kupchick 6790484426 Merge pull request #319 from Lightricks/pr-2026-01-07
Update ComfyUI-LTXVideo - 2026-01-07
2026-01-07 14:27:05 +02:00
sync-bot adb1f9c95c Automated PR - 2026-01-07 2026-01-07 12:26:23 +00:00
Michael Kupchick 63c8a9285c Merge pull request #313 from Lightricks/pr-2026-01-07
Update ComfyUI-LTXVideo - 2026-01-07
2026-01-07 08:06:33 +02:00
sync-bot b7a8a2cfc2 Automated PR - 2026-01-07 2026-01-07 06:04:51 +00:00
michael e56af6973c pyproject.toml: delete. 2026-01-06 21:12:50 +00:00
Michael Kupchick aa0e2f1709 README: Fix Gemma 3 link. 2026-01-06 08:53:34 +02:00
Michael Kupchick 2c0bb1b9b8 Add 'accelerate' to requirements. 2026-01-06 08:14:04 +02:00
Michael Kupchick aebe760c30 Merge pull request #290 from Lightricks/pr-2026-01-06
Update ComfyUI-LTXVideo - 2026-01-06
2026-01-06 08:09:33 +02:00
sync-bot e1d2cff331 Automated PR - 2026-01-06 2026-01-06 06:06:43 +00:00
Michael Kupchick 08f892eace Merge pull request #287 from Lightricks/michaellightricks-patch-1
README.md: fix getting started link.
2025-12-02 16:57:40 +02:00
Michael Kupchick 42f509b3e8 README.md: fix getting started link. 2025-12-02 15:04:30 +02:00
Michael Kupchick 445e087a43 Merge pull request #278 from rluxemburg/master
Add Getting Started standalone page
2025-10-20 21:05:22 +03:00
Rachel Luxemburg ce66eea33e Update README for new Getting Started
Added a link to the new "Getting Started" page and removed the installation section.
2025-10-20 13:43:07 -04:00
Rachel Luxemburg 8b2d38c2ec Create getting started guide for ComfyUI
Moving the installation instructions from the readme file to a separate file for better discoverability.
2025-10-20 13:32:58 -04:00
Michael Kupchick 36fdaf500b Merge pull request #258 from Lightricks/pr-2025-07-16
Update ComfyUI-LTXVideo - 2025-07-16
2025-07-16 18:41:38 +03:00
sync-bot 35ec43af1f Automated PR - 2025-07-16 2025-07-16 15:34:42 +00:00
Michael Kupchick 8761bf3c00 Merge pull request #257 from Lightricks/pr-2025-07-16
Update ComfyUI-LTXVideo - 2025-07-16
2025-07-16 17:28:33 +03:00
sync-bot ee11be3ce2 Automated PR - 2025-07-16 2025-07-16 14:19:39 +00:00
Michael Kupchick ba2626426f Merge pull request #249 from Lightricks/pr-2025-07-08
Update ComfyUI-LTXVideo - 2025-07-08
2025-07-08 17:23:07 +03:00
sync-bot 688efe72d2 Automated PR - 2025-07-08 2025-07-08 14:12:50 +00:00
Michael Kupchick e87087e95a Merge pull request #243 from Lightricks/pr-2025-06-20
Update ComfyUI-LTXVideo - 2025-06-20
2025-06-20 17:01:32 +03:00
sync-bot 653a95bdd1 Automated PR - 2025-06-20 2025-06-20 08:32:45 +00:00
Michael Kupchick 5862cc77e6 Merge pull request #242 from Lightricks/pr-2025-06-19
Update ComfyUI-LTXVideo - 2025-06-19
2025-06-19 20:55:11 +03:00
sync-bot ca193cdfe8 Automated PR - 2025-06-19 2025-06-19 16:48:19 +00:00
Michael Kupchick 6e9e6de056 Merge pull request #221 from Lightricks/pr-2025-05-14
Update ComfyUI-LTXVideo - 2025-05-14
2025-05-14 20:11:59 +03:00
sync-bot b19dc8618f Automated PR - 2025-05-14 2025-05-14 17:06:45 +00:00
Michael Kupchick 7875c1cf64 Merge pull request #220 from Lightricks/pr-2025-05-14
Update ComfyUI-LTXVideo - 2025-05-14
2025-05-14 19:29:47 +03:00
sync-bot a877517b63 Automated PR - 2025-05-14 2025-05-14 16:26:25 +00:00
Andrew Kvochko 1c8909531c Merge pull request #218 from Lightricks/pr-2025-05-14
Update ComfyUI-LTXVideo - 2025-05-14
2025-05-14 18:08:19 +03:00
sync-bot 36516b4e71 Automated PR - 2025-05-14 2025-05-14 15:05:28 +00:00
Gilad Schreiber e63938b095 Merge pull request #191 from Lightricks/pr-2025-05-08
Update ComfyUI-LTXVideo - 2025-05-08
2025-05-08 16:58:11 +03:00
sync-bot d617d4bd31 Automated PR - 2025-05-08 2025-05-08 13:48:06 +00:00
Gilad Schreiber fa234963e5 Merge pull request #173 from yuvraj108c/master
Fix typo in readme workflow urls
2025-05-07 14:36:57 +03:00
Andrew Kvochko 5f6848be70 Merge pull request #177 from Lightricks/pr-2025-05-06
Update ComfyUI-LTXVideo - 2025-05-06
2025-05-06 22:20:48 +03:00
sync-bot bcfd123014 Automated PR - 2025-05-06 2025-05-06 19:19:18 +00:00
Andrew Kvochko 972af4120d Merge pull request #175 from Lightricks/pr-2025-05-06
Update ComfyUI-LTXVideo - 2025-05-06
2025-05-06 18:23:16 +03:00
sync-bot 466330e953 Automated PR - 2025-05-06 2025-05-06 15:22:55 +00:00
Yuvraj Seegolam 8ef49793d7 Fix typo in readme workflow urls 2025-05-06 18:23:50 +04:00
Michael Kupchick 0992f16e0c Merge pull request #172 from Lightricks/pr-2025-05-06
Update ComfyUI-LTXVideo - 2025-05-06
2025-05-06 16:09:24 +03:00
sync-bot 864ffa6183 Automated PR - 2025-05-06 2025-05-06 13:07:33 +00:00
Michael Kupchick ddb96675f0 Merge pull request #153 from Lightricks/pr-2025-04-17
Update ComfyUI-LTXVideo - 2025-04-17
2025-04-17 18:20:56 +03:00
sync-bot bdde649e25 Automated PR - 2025-04-17 2025-04-17 15:00:46 +00:00
Michael Kupchick e2a73515dd Merge pull request #132 from Lightricks/pr-2025-03-10
Update ComfyUI-LTXVideo - 2025-03-10
2025-03-10 15:26:10 +02:00
sync-bot 228f8cbcba Automated PR - 2025-03-10 2025-03-10 10:02:44 +00:00
Michael Kupchick eabd4b6416 Merge pull request #122 from Lightricks/pr-2025-03-06
Update ComfyUI-LTXVideo - 2025-03-06
2025-03-06 15:22:43 +02:00
sync-bot 53a474cbe4 Automated PR - 2025-03-06 2025-03-06 08:50:53 +00:00
Michael Kupchick 55f2d9ebb8 Merge pull request #116 from Lightricks/pr-2025-03-05
Update ComfyUI-LTXVideo - 2025-03-05
2025-03-05 17:56:56 +02:00
sync-bot 95a63bbb6d Automated PR - 2025-03-05 2025-03-05 15:54:13 +00:00
Michael Kupchick 4c5add5f46 Merge pull request #87 from Lightricks/pr-2024-12-22
Update ComfyUI-LTXVideo - 2024-12-22
2024-12-22 16:57:35 +02:00
118 changed files with 2671855 additions and 4285 deletions
+8
View File
@@ -1,2 +1,10 @@
**/*.png filter=lfs diff=lfs merge=lfs -text
**/*.mp4 filter=lfs diff=lfs merge=lfs -text
**/*.jpg filter=lfs diff=lfs merge=lfs -text
example_workflows/low_level/end.jpg -filter -diff -merge -text
example_workflows/low_level/fox.jpg -filter -diff -merge -text
example_workflows/low_level/shrek2.jpg -filter -diff -merge -text
example_workflows/low_level/start.jpg -filter -diff -merge -text
example_workflows/tricks/shrek2.jpg -filter -diff -merge -text
example_workflows/tricks/shrek3.jpg -filter -diff -merge -text
@@ -0,0 +1,141 @@
name: Publish to Comfy Registry
on:
workflow_dispatch:
push:
branches:
- master
paths:
- pyproject.toml
concurrency:
group: publish-comfy-registry
cancel-in-progress: false
jobs:
publish:
if: github.repository_owner == 'Lightricks'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Check out the release commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event_name == 'push' && github.sha || 'master' }}
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Read the version
id: version
run: |
set -euo pipefail
version=$(python3 -c '
import sys
import tomllib
with open("pyproject.toml", "rb") as handle:
version = tomllib.load(handle).get("project", {}).get("version")
parts = str(version).split(".")
if len(parts) != 3 or any(not part.isdigit() for part in parts):
print(f"version is not X.Y.Z: {version!r}", file=sys.stderr)
raise SystemExit(1)
print(version)
')
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Check the published version
id: registry
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
node=$(curl -fsS https://api.comfy.org/nodes/ComfyUI-LTXVideo)
published=$(printf '%s\n' "$node" | python3 -c '
import json
import sys
node = json.load(sys.stdin)
latest = node.get("latest_version")
if latest is None:
raise SystemExit(0)
if not isinstance(latest, dict):
print("latest_version is not a JSON object", file=sys.stderr)
raise SystemExit(1)
version = latest.get("version")
if version is None:
raise SystemExit(0)
parts = str(version).split(".")
if len(parts) != 3 or any(not part.isdigit() for part in parts):
print(f"latest_version.version is not X.Y.Z: {version!r}", file=sys.stderr)
raise SystemExit(1)
print(version)
')
if [ "$published" = "$VERSION" ]; then
echo "Registry already has ${VERSION}."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to the registry
if: steps.registry.outputs.skip != 'true'
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
run: |
set -euo pipefail
python3 -m pip install "comfy-cli==1.22.0"
comfy --skip-prompt --no-enable-telemetry env
comfy node publish --token "$REGISTRY_ACCESS_TOKEN"
- name: Tag and release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
version="$VERSION"
tag="v${version}"
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "Tag ${tag} already exists."
else
git tag "${tag}"
git push origin "refs/tags/${tag}"
fi
if gh release view "${tag}" >/dev/null 2>&1; then
echo "Release ${tag} already exists."
exit 0
fi
previous=$(
git tag --list "v*" | python3 -c '
import sys
current = tuple(int(part) for part in sys.argv[1].split("."))
lower = []
for tag in sys.stdin:
tag = tag.strip()
if not tag.startswith("v"):
continue
parts = tag[1:].split(".")
if len(parts) != 3 or any(not part.isdigit() for part in parts):
continue
parsed = tuple(int(part) for part in parts)
if parsed < current:
lower.append((parsed, tag))
if lower:
print(max(lower)[1])
' "$version"
)
if [ -z "$previous" ]; then
notes="Initial release."
else
notes=$(git log --pretty=format:"%h %s" "${previous}..HEAD")
if [ -z "$notes" ]; then
notes="No commits since ${previous}."
fi
fi
gh release create "${tag}" --title "${tag}" --notes "${notes}"
+346 -166
View File
@@ -1,201 +1,381 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
LTX-2 Community License Agreement
License date: January 5, 2026
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
By using or distributing any portion or element of LTX-2, you agree
to be bound by this Agreement.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Agreement" means the terms and conditions for the license, use,
reproduction, and distribution of LTX-2 and the Complementary
Materials, as specified in this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Control" means the direct or indirect ownership of more than
fifty percent (50%) of the voting securities or other ownership
interests, or the power to direct the management and policies of
such Entity through voting rights, contract, or otherwise.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"Data" means a collection of information and/or content extracted
from the dataset used with LTX-2, including to train, pretrain,
or otherwise evaluate LTX-2. The Data is not licensed under this
Agreement.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Derivatives of LTX-2" means all modifications to LTX-2, works
based on LTX-2, or any other model which is created or initialized
by transfer of patterns of the weights, parameters, activations or
output of LTX-2, to the other model, in order to cause the other
model to perform similarly to LTX-2, including – but not limited
to - distillation methods entailing the use of intermediate data
representations or methods based on the generation of synthetic
data by LTX-2 for training the other model. For clarity, Derivatives
of LTX-2 include: (i) any fine-tuned or adapted weights, parameters,
or checkpoints derived from LTX-2; (ii) derivative model architectures
that incorporate or are based upon LTX-2's architecture; and
(iii) any modified or extended versions of the Complementary
Materials. All intellectual property rights in Derivatives of LTX-2
shall be subject to the terms of this Agreement, and you may not
claim exclusive ownership rights in any Derivatives of LTX-2 that
would restrict the rights granted herein.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Entity" means any individual, corporation, partnership, limited
liability company, or other legal entity. For purposes of this
Agreement, an Entity shall be deemed to include, on an aggregative
basis, all subsidiaries, affiliates, and other companies under
common Control with such Entity. When determining whether an Entity
meets any threshold under this Agreement (including revenue
thresholds), all subsidiaries, affiliates, and companies under
common Control shall be considered collectively.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Harm" includes but is not limited to physical, mental,
psychological, financial and reputational damage, pain, or loss.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Licensor" or "Lightricks" means the owner that is granting the
license under this Agreement. For the purposes of this Agreement,
the Licensor is Lightricks Ltd.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"LTX-2" means the large language models, text/image/video/audio/3D
generation models, and multimodal large language models and their
software and algorithms, including trained model weights, parameters
(including optimizer states), machine-learning model code,
inference-enabling code, training-enabling code, fine-tuning
enabling code, accompanying source code, scripts, documentation,
tutorials, examples, and all other elements of the foregoing
distributed and made publicly available by Lightricks (including,
for example, at https://github.com/Lightricks/LTX-2) for the LTX-2
model released on January 5, 2026. This license is applicable to
all LTX-2 versions released since January 5, 2026, and all future
releases of LTX-2 under this license.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Output" means the results of operating LTX-2 as embodied in
informational content resulting therefrom.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
"you" (or "your") means an individual or legal Entity licensing
LTX-2 in accordance with this Agreement and/or making use of LTX-2
for whichever purpose and in any field of use, including usage of
LTX-2 in an end-use application - e.g. chatbot, translator, image
generator.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
2. Grant of License. Subject to the terms and conditions of this
Agreement, you are granted a non-exclusive, worldwide,
non-transferable and royalty-free limited license under Licensor's
intellectual property or other rights owned by Licensor embodied
in LTX-2 to use, reproduce, prepare, distribute, publicly display,
publicly perform, sublicense, copy, create derivative works of,
and make modifications to LTX-2, for any purpose, subject to the
restrictions set forth in Attachment A; provided however, that
Entities with annual revenues of at least $10,000,000 (the
"Commercial Entities") are required to obtain a paid commercial
use license in order to use LTX-2 and Derivatives of LTX-2,
subject to the terms and provisions of a different license (the
"Commercial Use Agreement"), as will be provided by the Licensor.
Commercial Entities interested in such a commercial license are
required to [contact Licensor](https://ltx.io/model/licensing).
Any commercial use of LTX-2 or Derivatives of LTX-2 by the
Commercial Entities not in accordance with this Agreement and/or
the Commercial Use Agreement is strictly prohibited and shall be
deemed a material breach of this Agreement. Such material breach
will be subject, in addition to any license fees owed to Licensor
for the period such Commercial Entity used LTX-2 (as will be
determined by Licensor), to liquidated damages, which will be paid
to Licensor immediately upon demand, in an amount equal to double
the amount that would otherwise have been paid by you for the
relevant period of time. Such amount reflects a reasonable estimation
of the losses and administrative costs incurred due to such breach.
You agree and understand that this remedy does not limit the Licensor's
right to pursue other remedies available at law or equity.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
3. Distribution and Redistribution. You may host for third parties
remote access purposes (e.g. software-as-a-service), reproduce
and distribute copies of LTX-2 or Derivatives of LTX-2 thereof in
any medium, with or without modifications, provided that you meet
the following conditions:
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) Use-based restrictions as referenced in paragraph 4 and all
provisions of Attachment A MUST be included as an enforceable
provision by you in any type of legal agreement (e.g. a
license) governing the use and/or distribution of LTX-2 or
Derivatives of LTX-2, and you shall give notice to subsequent
users you distribute to, that LTX-2 or Derivatives of LTX-2
are subject to paragraph 4 and Attachment A in their entirety,
including all use restrictions and acceptable use policies;
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must provide any third party recipients of LTX-2 or
Derivatives of LTX-2 a copy of this Agreement, including all
attachments and use policies. Any Derivative of LTX-2 (as
defined in Section 1, including but not limited to fine-tuned
weights, modified training code, models trained on Outputs, or
any other derivative) must be distributed exclusively under
the terms of this Agreement with a complete copy of this
license included;
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must cause any modified files to carry prominent notices
stating that you changed the files;
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) You must retain all copyright, patent, trademark, and
attribution notices excluding those notices that do not
pertain to any part of LTX-2, Derivatives of LTX-2.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add your own copyright statement to your modifications and
may provide additional or different license terms and conditions -
respecting paragraph 3(a) - for use, reproduction, or distribution
of your modifications, or for any such Derivatives of LTX-2 as a
whole, provided your use, reproduction, and distribution of LTX-2
otherwise complies with the conditions stated in this Agreement,
and you provide a complete copy of this Agreement with any such
use, reproduction and distribution of LTX-2 and any Derivatives
thereof.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
4. Use-based restrictions. The restrictions set forth in Attachment A
are considered Use-based restrictions. Therefore, you cannot use
LTX-2 and the Derivatives of LTX-2 in violation of the specified
restricted uses. You may use LTX-2 subject to this Agreement,
including only for lawful purposes and in accordance with the
Agreement. "Use" may include creating any content with, fine-tuning,
updating, running, training, evaluating and/or re-parametrizing
LTX-2. You shall require all of your users who use LTX-2 or a
Derivative of LTX-2 to comply with the terms of this paragraph 4.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
5. The Output You Generate. Except as set forth herein, Licensor
claims no rights in the Output you generate using LTX-2. You are
accountable for input you insert into LTX-2, the Output you
generate and its subsequent uses. No use of the Output can
contravene any provision as stated in the Agreement.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
6. Updates and Runtime Restrictions. To the maximum extent permitted
by law, Licensor reserves the right to restrict (remotely or
otherwise) usage of LTX-2 in violation of this Agreement, update
LTX-2 through electronic means, or modify the Output of LTX-2
based on updates. You shall undertake reasonable efforts to use
the latest version of LTX-2. Any use of the non-current version
of LTX-2 is done solely at your risk.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
7. Export Controls and Sanctions Compliance. You acknowledge that
LTX-2, Derivatives of LTX-2 may be subject to export control laws
and regulations, including but not limited to the U.S. Export
Administration Regulations and sanctions programs administered by
the Office of Foreign Assets Control (OFAC). You represent and
warrant that you and any users of LTX-2 are not (i) located in,
organized under the laws of, or ordinarily resident in any country
or territory subject to comprehensive sanctions; (ii) identified
on any U.S. government restricted party list, including the
Specially Designated Nationals and Blocked Persons List; or
(iii) otherwise prohibited from receiving LTX-2 under applicable
law. You shall not export, re-export, or transfer LTX-2, directly
or indirectly, in violation of any applicable export control or
sanctions laws or regulations. You agree to comply with all
applicable trade control laws and shall indemnify and hold
Licensor harmless from any claims arising from your failure to
comply with such laws.
8. Trademarks and related. Nothing in this Agreement permits you to
make use of Licensor's trademarks, trade names, logos or to
otherwise suggest endorsement or misrepresent the relationship
between the parties; and any rights not expressly granted herein
are reserved by the Licensor.
9. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides LTX-2 on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
implied, including, without limitation, any warranties or
conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS
FOR A PARTICULAR PURPOSE. You are solely responsible for
determining the appropriateness of using or redistributing LTX-2
and Derivatives of LTX-2 and assume any risks associated with
your exercise of permissions under this Agreement.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
10. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall Licensor be liable
to you for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as
a result of this Agreement or out of the use or inability to use
LTX-2 (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if Licensor has been
advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
11. Accepting Warranty or Additional Liability. While redistributing
LTX-2 and Derivatives of LTX-2, you may, provided you do not
violate the terms of this Agreement, choose to offer and charge
a fee for, acceptance of support, warranty, indemnity, or other
liability obligations. However, in accepting such obligations,
you may act only on your own behalf and on your sole
responsibility, not on behalf of Licensor, and only if you agree
to indemnify, defend, and hold Licensor harmless for any liability
incurred by, or claims asserted against Licensor, by reason of
your accepting any such warranty or additional liability.
12. Governing Law. This Agreement and all relations, disputes, claims
and other matters arising hereunder (including non-contractual
disputes or claims) will be governed exclusively by, and construed
exclusively in accordance with, the laws of the State of New York.
To the extent permitted by law, choice of laws rules and the
United Nations Convention on Contracts for the International Sale
of Goods will not apply. For the purposes of adjudicating any
action or proceeding to enforce the terms of this Agreement, you
hereby irrevocably consent to the exclusive jurisdiction of, and
venue in, the federal and state courts located in the County of
New York within the State of New York. The prevailing party in
any claim or dispute between the parties under this Agreement
will be entitled to reimbursement of its reasonable attorneys'
fees and costs. You hereby waive the right to a trial by jury,
to participate in a class or representative action (including in
arbitration), or to combine individual proceedings in court or
in arbitration without the consent of all parties.
13. Term and Termination. This Agreement is effective upon your
acceptance and continues until terminated. Licensor may terminate
this Agreement immediately upon written notice to you if you
breach any provision of this Agreement, including but not limited
to violations of the use restrictions in Attachment A or
unauthorized commercial use. Upon termination: (a) all rights
granted to you under this Agreement will immediately cease;
(b) you must immediately cease all use of LTX-2 and Derivatives
of LTX-2; (c) you must delete or destroy all copies of LTX-2
and Derivatives of LTX-2 in your possession or control; and
(d) you must notify any third parties to whom you distributed
LTX-2 or Derivatives of LTX-2 of the termination. Sections 8-13,
and Section 15 shall survive termination of this Agreement.
Termination does not relieve you of any obligations incurred
prior to termination, including payment obligations under
Section 2. In addition, if You commence a lawsuit or other
proceedings (including a cross-claim or counterclaim in a lawsuit)
against Licensor or any person or entity alleging that LTX-2 or
any Output, or any portion of any of the foregoing, infringe any
intellectual property or other right owned or licensable by you,
then all licenses granted to you under this Agreement shall
terminate as of the date such lawsuit or other proceeding is filed.
14. Disputes and Arbitration. All disputes arising in connection with
this Agreement shall be finally settled by arbitration under the
Rules of Arbitration of the International Chamber of Commerce
("ICC Rules"), by one (1) arbitrator appointed in accordance with
the ICC Rules. The seat of arbitration shall be New York, NY, USA,
and the proceedings shall be conducted in English. The arbitrator
shall be empowered to grant any relief that a court could grant.
Judgment on the arbitration award may be entered by any court
having jurisdiction thereof. Each party waives its right to a
trial by jury and to participate in any class or representative
action.
15. If any provision of this Agreement is held to be
invalid, illegal
or unenforceable, the remaining provisions shall be unaffected
thereby and remain valid as if such provision had not been set
forth herein.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
ATTACHMENT A: Use Restrictions
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
When using the Outputs, LTX-2 and any Derivatives thereof, you
will comply with the Acceptable Use Policy. In addition, you
agree not to use the Outputs, LTX-2 or its Derivatives in any
of the following ways:
Copyright [yyyy] [name of copyright owner]
1. In any way that violates any applicable national, federal,
state, local or international law or regulation;
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
2. For the purpose of exploiting, Harming or attempting to
exploit or Harm minors in any way;
http://www.apache.org/licenses/LICENSE-2.0
3. To generate or disseminate false information and/or content
with the purpose of Harming others;
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
4. To generate or disseminate personal identifiable information
that can be used to Harm an individual;
5. To generate or disseminate information and/or content (e.g.
images, code, posts, articles), and place the information
and/or content in any context (e.g. bot generating tweets)
without expressly and intelligibly disclaiming that the
information and/or content is machine generated;
6. To defame, disparage or otherwise harass others;
7. To impersonate or attempt to impersonate (e.g. deepfakes)
others without their consent;
8. For fully automated decision making that adversely impacts an
individual's legal rights or otherwise creates or modifies a
binding, enforceable obligation;
9. For any use intended to or which has the effect of
discriminating against or Harming individuals or groups based
on online or offline social behavior or known or predicted
personal or personality characteristics;
10. To exploit any of the vulnerabilities of a specific group of
persons based on their age, social, physical or mental
characteristics, in order to materially distort the behavior
of a person pertaining to that group in a manner that causes
or is likely to cause that person or another person physical
or psychological Harm;
11. For any use intended to or which has the effect of
discriminating against individuals or groups based on legally
protected characteristics or categories;
12. To provide medical advice and medical results interpretation;
13. To generate or disseminate information for the purpose to be
used for administration of justice, law enforcement,
immigration or asylum processes, such as predicting an
individual will commit fraud/crime commitment (e.g. by text
profiling, drawing causal relationships between assertions
made in documents, indiscriminate and arbitrarily-targeted use);
14. To generate and/or disseminate malware (including – but not
limited to – ransomware) or any other content to be used for
the purpose of harming electronic systems;
15. To engage in, promote, incite, or facilitate discrimination
or other unlawful or harmful conduct in the provision of
employment, employment benefits, credit, housing, or other
essential goods and services;
16. To engage in, promote, incite, or facilitate the harassment,
abuse, threatening, or bullying of individuals or groups of
individuals;
17. For military, warfare, nuclear industries or applications,
weapons development, or any use in connection with activities
that may cause death, personal injury, or severe physical or
environmental damage;
18. For commercial use only: To train, improve, or fine-tune any
other machine learning model, artificial intelligence system,
or competing model, except for Derivatives of LTX-2 as
expressly permitted under this Agreement;
19. To circumvent, disable, or interfere with any technical
limitations, safety features, content filters, or use
restrictions implemented in LTX-2 by Licensor;
20. To use LTX-2 or Derivatives of LTX-2 in any product, service,
or application that directly competes with Licensor's
commercial products or services, or is designed to replace or
substitute Licensor's offerings in the market, without
obtaining a separate commercial license from Licensor.
+183 -31
View File
@@ -1,49 +1,201 @@
# ComfyUI-LTXVideo
ComfyUI-LTXVideo is a collection of custom nodes for ComfyUI designed to integrate the LTXVideo diffusion model. These nodes enable workflows for text-to-video, image-to-video, and video-to-video generation. The main LTXVideo repository can be found [here](https://github.com/Lightricks/LTX-Video).
## 22.12.2024
[![GitHub](https://img.shields.io/badge/LTX-Repo-blue?logo=github)](https://github.com/Lightricks/LTX-2)
[![Website](https://img.shields.io/badge/Website-LTX-181717?logo=google-chrome)](https://ltx.io/model)
[![Model](https://img.shields.io/badge/HuggingFace-Model-orange?logo=huggingface)](https://huggingface.co/Lightricks/LTX-2.3)
[![LTXV Trainer](https://img.shields.io/badge/LTX-Trainer%20Repo-9146FF)](https://github.com/Lightricks/LTX-2/tree/main/packages/ltx-trainer)
[![Demo](https://img.shields.io/badge/Demo-Try%20Now-brightgreen?logo=vercel)](https://app.ltx.studio/ltx-2-playground/i2v)
[![Paper](https://img.shields.io/badge/Paper-arXiv-B31B1B?logo=arxiv)](https://videos.ltx.io/LTX-2/grants/LTX_2_Technical_Report_compressed.pdf)
[![Discord](https://img.shields.io/badge/Join-Discord-5865F2?logo=discord)](https://discord.gg/ltxplatform)
Fixed a bug which caused the model to produce artifacts on short negative prompts when using a native CLIP Loader node.
## 19.12.2024 ⭐ Update ⭐
1. Improved model - removes "strobing texture" artifacts and generates better motion. Download from [here](https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.1.safetensors).
2. STG support
3. Integrated image degradation system for improved motion generation.
3. Additional initial latent optional input to chain latents for high res generation.
4. Image captioning in image to video [flow](assets/ltxvideo-i2v.json).
A collection of powerful custom nodes that extend ComfyUI's capabilities for the LTX-2 video generation model.
## Installation
LTX-2 is built into ComfyUI core ([see it here](https://github.com/comfyanonymous/ComfyUI/tree/master/comfy/ldm/lightricks)), making it readily accessible to all ComfyUI users. This repository hosts additional nodes and workflows to help you get the most out of LTX-2's advanced features.
Installation via [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) is preferred. Simply search for `ComfyUI-LTXVideo` in the list of nodes and follow installation instructions.
**To learn more about LTX-2** See the [main LTX-2 repository](https://github.com/Lightricks/LTX-2) for model details and additional resources.
### Manual installation
1. Install ComfyUI
2. Clone this repository to `custom-nodes` folder in your ComfyUI installation directory.
3. Install the required packages:
```bash
cd custom_nodes/ComfyUI-LTXVideo && pip install -r requirements.txt
## Prerequisites
Before you begin using an LTX-2 workflow in ComfyUI, make sure you have:
* ComfyUI installed (Download here](https://www.comfy.org/download)
* CUDA-compatible GPU with 32GB+ VRAM
* 100GB+ free disk space for models and cache
## Quick Start 🚀
We recommend using the LTX-2 workflows available in Comfy Manager.
1. Open ComfyUI
2. Click the Manager button (or press Ctrl+M)
3. Select Install Custom Nodes
4. Search for “LTXVideo”
5. Click Install
6. Wait for installation to complete
7. Restart ComfyUI
The nodes will appear in your node menu under the “LTXVideo” category. Required models will be downloaded on first use.
## Example Workflows
The ComfyUI-LTXVideo installation includes several example workflows.
You can see them all at:
```
For portable ComfyUI installations, run
```
.\python_embeded\python.exe -m pip install -r .\ComfyUI\custom_nodes\ComfyUI-LTXVideo\requirements.txt
ComfyUI/custom_nodes/ComfyUI-LTXVideo/example_workflows/
```
### Models
LTX-2.3 Workflows:
1. Download [ltx-video-2b-v0.9.1.safetensors](https://huggingface.co/Lightricks/LTX-Video/blob/main/ltx-video-2b-v0.9.1.safetensors) from Hugging Face and place it under `models/checkpoints`.
2. Install one of the t5 text encoders, for example [google_t5-v1_1-xxl_encoderonly](https://huggingface.co/mcmonkey/google_t5-v1_1-xxl_encoderonly/tree/main). You can install it using ComfyUI Model Manager.
* [`Text/image to video full/distilled model; single stage`](./example_workflows/2.3/LTX-2.3_T2V_I2V_Single_Stage_Distilled_Full.json)
* [`Text/image to video distilled model; two stages (with upsampling)`](./example_workflows/2.3/LTX-2.3_T2V_I2V_Two_Stage_Distilled.json)
* [`IC-LoRA distilled model depth + human pose + edges`](./example_workflows/2.3/LTX-2.3_ICLoRA_Union_Control_Distilled.json)
* [`IC-LoRA distilled model I2V motion tracking`](./example_workflows/2.3/LTX-2.3_ICLoRA_Motion_Track_Distilled.json)
* [`IC-LoRA distilled model HDR`](./example_workflows/2.3/LTX-2.3_ICLoRA_HDR_Distilled.json)
* [`IC-LoRA distilled model Dub-It; two stages (with upsampling)`](./example_workflows/2.3/LTX-2.3_ICLoRA_DubIt_Two_Stage_Distilled.json)
* [`IC-LoRA distilled model pixel spatial upscaling`](./example_workflows/2.3/LTX-2.3_ICLoRA_Pixel_Spatial_Upscaler_Distilled.json)
* [`Text to audio distilled model; single stage`](./example_workflows/2.3/LTX-2.3_T2A_Single_Stage_Distilled.json)
## Example workflows
Older Workflows (LTX-2.0):
Note that to run the example workflows, you need to have some additional custom nodes, like [ComfyUI-VideoHelperSuite](https://github.com/kosinkadink/ComfyUI-VideoHelperSuite) and others, installed. You can do it by pressing "Install Missing Custom Nodes" button in ComfyUI Manager.
* [`Text to video full model`](./example_workflows/2.0/LTX-2_T2V_Full_wLora.json)
* [`Text to video distilled model (Fast)`](./example_workflows/2.0/LTX-2_T2V_Distilled_wLora.json)
* [`Image to video full model`](./example_workflows/2.0/LTX-2_I2V_Full_wLora.json)
* [`Image to video distilled model (Fast)`](./example_workflows/2.0/LTX-2_I2V_Distilled_wLora.json)
* [`Video to video detailer`](./example_workflows/2.0/LTX-2_V2V_Detailer.json)
* [`IC-LoRA distilled model (depth + human pose + edges)`](./example_workflows/2.0/LTX-2_ICLoRA_All_Distilled.json)
* [`IC-LoRA distilled model with downscaled reference latents`](./example_workflows/2.0/LTX-2_ICLoRA_All_Distilled_ref0.5.json)
### Image-to-video
## Union IC-LoRA Model
[Download workflow](assets/ltxvideo-i2v.json)
![workflow](assets/ltxvideo-i2v.png)
We introduce a new **Union IC-LoRA** model that combines depth and edge (canny) control conditions into a single unified LoRA.
### Text-to-video
### Key Features
[Download workflow](assets/ltxvideo-t2v.json)
![workflow](assets/ltxvideo-t2v.png)
- **Unified Control**: A single LoRA that supports multiple control conditions (depth or edges).
- **Downsampled Latent Processing**: The union LoRA operates on a downsampled latent size, which reduces memory usage and significantly speeds up inference while maintaining quality.
### How It Works
The union LoRA is trained to understand and respond to both control signals (depth maps and edge maps) within a single model. The model learns to:
1. **Parse multiple conditions**: Identify which control signals are present in the input
2. **Process at reduced resolution**: Work on downsampled latents to improve efficiency
## HDR IC-LoRA
We provide an **HDR IC-LoRA** that upgrades SDR video into ACEScct HDR (same working space as pipelines `hdr_ic_lora`), suitable for grading, EXR export, and HLG masters. Use the tiled 2.5 graph [`LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json`](./example_workflows/2.5/LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json), or the 2.3 single-stage [`LTX-2.3_ICLoRA_HDR_Distilled.json`](./example_workflows/2.3/LTX-2.3_ICLoRA_HDR_Distilled.json).
### Key Features
- **Linear HDR output**: The LoRA produces frames in LogC3-compressed space; the `LTXVHDRDecodePostprocess` node decodes these back to linear HDR values.
- **SDR preview + raw HDR**: The node outputs both a Reinhard-tonemapped SDR preview and the raw linear HDR tensor for downstream use.
- **EXR export**: Optionally writes the linear HDR frames as a 16/32-bit EXR image sequence via **OpenImageIO** (`pip install openimageio`, also listed in `requirements.txt`). The exported EXR sequence is best viewed in [DJV](https://github.com/grizzlypeak3d/DJV) (or [DJV for macOS](https://djv.en.uptodown.com/mac/download)).
## Dub-It IC-LoRA
We provide a **Dub-It IC-LoRA** that dubs or rephrases speech in video. Given a source video and a text prompt containing the desired dialogue, it generates new lip movements and audio that match the target text while preserving the speaker's identity.
### Key Features
- **Multilingual dubbing**: Translate speech into another language - the model regenerates lips and audio to match.
- **Same-language rephrasing**: Change what the speaker says while keeping the original language.
- **Two-stage pipeline**: Stage 1 generates the video and audio at base resolution; Stage 2 upscales while freezing the audio.
- **Speaker identity preservation**: Reference audio tokens provide speaker context so the generated voice stays consistent.
## Pixel Spatial Upscaler IC-LoRA
We provide **Pixel Spatial Upscaler** IC-LoRAs that creatively upscale low-resolution video by synthesizing fine detail rather than simply interpolating pixels. Given a low-resolution reference clip, the model re-renders it at 2× or 4× resolution with generative spatial detail — making it a creative upsampler, not a pixel-accurate refiner.
### Key Features
- **2× and 4× variants**: Choose the 2× upscaler for moderate upscaling or the 4× upscaler for larger resolution jumps.
- **Generative detail synthesis**: The model synthesizes texture and structure from the reference rather than faithfully preserving every pixel.
- **Draft-then-upscale workflow**: Generate at a low base resolution (e.g. ~280p) to lock in composition and motion, then run the upscaler for the final high-resolution output.
- **Tunable fidelity**: LoRA strength, guidance, and step count control how closely the output follows the reference — lower values stay closer to the source; higher values allow more creative detail.
## Text-to-Audio (T2A)
LTX-2 is a single joint audio/video transformer, but it can generate audio on its own. The `LTXVAudioOnlyModel` node puts the model into audio-only mode for text-to-audio, with no video output.
### Key Features
- **Audio-only sampling**: The node sets the model's `run_vx`, `a2v_cross_attn` and `v2a_cross_attn` flags off, so the audio is denoised with no dependence on the video latent and the video stream is skipped. This matches the reference single-stage T2A pipeline's `video=None` behavior.
- **Minimal dummy video latent**: The model splits its input positionally into `[video, audio]`, so the sampler still needs a video latent at index 0. Use the `LTXVAudioOnlyEmptyVideoLatent` node (a fixed 64x64 single-frame placeholder, no params to tweak) joined with the audio latent via `LTXVConcatAVLatent`; with `LTXVAudioOnlyModel` active it is never attended to and adds negligible cost.
- **Audio decode**: `LTXVAudioVAEDecode` extracts the audio directly from the joint latent, then save it with a standard built-in audio node (for example `Save Audio (FLAC)`).
## Required Models
Download the following models:
**LTX-2.3 Model Checkpoint** - Choose and download one of the models to `COMFYUI_ROOT_FOLDER/models/checkpoints` folder.
* [`ltx-2.3-22b-dev.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-22b-dev.safetensors)
* [`ltx-2.3-22b-distilled-1.1.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-22b-distilled-1.1.safetensors)
**Spatial Upscaler** - Required for current two-stage pipeline implementations in this repository. Download to `COMFYUI_ROOT_FOLDER/models/latent_upscale_models` folder.
* [`ltx-2.3-spatial-upscaler-x2-1.1.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-spatial-upscaler-x2-1.1.safetensors)
* [`ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors)
**Temporal Upscaler** - Required for current two-stage pipeline implementations in this repository. Download to `COMFYUI_ROOT_FOLDER/models/latent_upscale_models` folder.
* [`ltx-2.3-temporal-upscaler-x2-1.0.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-temporal-upscaler-x2-1.0.safetensors)
**Distilled LoRA** - Required for current two-stage pipeline implementations in this repository (except DistilledPipeline and ICLoraPipeline). Download to `COMFYUI_ROOT_FOLDER/models/loras` folder.
* [`ltx-2.3-22b-distilled-lora-384-1.1.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-22b-distilled-lora-384-1.1.safetensors)
**Gemma Text Encoder** Download all files from the repository to `COMFYUI_ROOT_FOLDER/models/text_encoders/gemma-3-12b-it-qat-q4_0-unquantized`.
* [`Gemma 3`](https://huggingface.co/google/gemma-3-12b-it-qat-q4_0-unquantized)
**LoRAs** Choose and download to `COMFYUI_ROOT_FOLDER/models/loras` folder.
* [`ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control/blob/main/ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors)
* [`ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control/blob/main/ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors)
* [`ltx-2.3-22b-ic-lora-hdr-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-HDR)
* [`ltx-2.3-22b-ic-lora-dubit-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-DubIt)
* [`ltx-2-19b-ic-lora-detailer.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer/blob/main/ltx-2-19b-ic-lora-detailer.safetensors)
* [`ltx-2-19b-ic-lora-pose-control.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/blob/main/ltx-2-19b-ic-lora-pose-control.safetensors)
* [`ltx-2-19b-lora-camera-control-dolly-in.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In/blob/main/ltx-2-19b-lora-camera-control-dolly-in.safetensors)
* [`ltx-2-19b-lora-camera-control-dolly-left.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/blob/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors)
* [`ltx-2-19b-lora-camera-control-dolly-out.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out/blob/main/ltx-2-19b-lora-camera-control-dolly-out.safetensors)
* [`ltx-2-19b-lora-camera-control-dolly-right.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right/blob/main/ltx-2-19b-lora-camera-control-dolly-right.safetensors)
* [`ltx-2-19b-lora-camera-control-jib-down.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down/blob/main/ltx-2-19b-lora-camera-control-jib-down.safetensors)
* [`ltx-2-19b-lora-camera-control-jib-up.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up/blob/main/ltx-2-19b-lora-camera-control-jib-up.safetensors)
* [`ltx-2-19b-lora-camera-control-static.safetensors`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static/blob/main/ltx-2-19b-lora-camera-control-static.safetensors)
* [`ltx-2.3-22b-ic-lora-instant-shave-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Instant-Shave/blob/main/ltx-2.3-22b-ic-lora-instant-shave-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-colorization-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Colorization/blob/main/ltx-2.3-22b-ic-lora-colorization-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-cross-eyed-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Cross-Eyed/blob/main/ltx-2.3-22b-ic-lora-cross-eyed-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-day-to-night-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Day-To-Night/blob/main/ltx-2.3-22b-ic-lora-day-to-night-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-deblur-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Deblur/blob/main/ltx-2.3-22b-ic-lora-deblur-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-decompression-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Decompression/blob/main/ltx-2.3-22b-ic-lora-decompression-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-In-Outpainting/blob/main/ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-water-simulation-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Water-Simulation/blob/main/ltx-2.3-22b-ic-lora-water-simulation-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Ingredients/blob/main/ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x4-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler/blob/main/ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x4-0.9.safetensors)
* [`ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x2-0.9.safetensors`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler/blob/main/ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x2-0.9.safetensors)
## Advanced Techniques
### Tiled Fusion Sampler
`LTXVTiledFusionSampler` (🅛🅣🅧 Tiled Fusion Sampler) denoises a canvas larger
than the model's trained window by running each denoise step on overlapping
spatial tiles that share one latent canvas and one noise field. It is meant for
IC-LoRA video-to-video where `LTXAddVideoICLoRAGuide` has already attached the
guide. Pair it with `LTXVGetTilingSizes` (🅛🅣🅧 Get Tiling Sizes) for legal
canvas / tile / `tile_frames` values. See
[tiled_fusion_sampler.md](./tiled_fusion_sampler.md).
Do not confuse it with `LTXVTiledSampler`, which samples tiles independently.
Example graphs (2.5 distilled):
* [`V2V upscale`](./example_workflows/2.5/LTX-2.5_V2V_TiledFusion_Upscale.json) — detail-refine at FullHD / 4K / 8K
* [`V2V native 4K/8K`](./example_workflows/2.5/LTX-2.5_V2V_TiledFusion_Native_4K_8K.json) — FullHD stage 1, then 4K or 8K
* [`V2V SDR→HDR`](./example_workflows/2.5/LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json) — same ladder with the HDR IC-LoRA
Prompt those graphs for look and style only (lighting, palette, sharpness, grade). Do not name specific objects in the scene: after upscale, named details can appear in every spatial tile.
### Low VRAM
* For systems with low VRAM you can use the model loader nodes from [low_vram_loaders.py](./low_vram_loaders.py). Those nodes ensure the correct order of execution and perform the model offloading such that generation fits in 32 GB VRAM.
* Use --reserve-vram ComfyUI parameter: `python -m main --reserve-vram 5` (or other number in GB).
* For complete information about using LTX-2 models, workflows, and nodes in ComfyUI, please visit our [Open Source documentation](https://docs.ltx.video/open-source-model/integration-tools/comfy-ui).
+133 -6
View File
@@ -1,8 +1,135 @@
from .loader_node import LTXVLoader # noqa: F401
from .nodes_registry import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .stg import LTXVApplySTG, STGGuiderNode # noqa: F401
from .t5_encoder import LTXVCLIPModelLoader # noqa: F401
from .transformer import LTXVModelConfigurator, LTXVShiftSigmas # noqa: F401
from .audio_only import LTXVAudioOnlyEmptyVideoLatent, LTXVAudioOnlyModel
from .conditioning_loader import LTXVLoadConditioning
from .decoder_noise import DecoderNoise
from .dynamic_conditioning import DynamicConditioning
from .easy_samplers import (
LinearOverlapLatentTransition,
LTXVBaseSampler,
LTXVExtendSampler,
LTXVInContextSampler,
LTXVNormalizingSampler,
)
from .gemma_api_conditioning import GemmaAPITextEncode
from .gemma_encoder import LTXVGemmaCLIPModelLoader, LTXVGemmaEnhancePrompt
from .guide import LTXVImgToVideoAdvanced
from .guiders import GuiderParametersNode, MultimodalGuiderNode
from .hdr_nodes import (
LTXVHDRDecodePostprocess,
LTXVLoadEXRSequence,
LTXVSaveHLG,
LTXVSDRToHDRWorkingSpace,
LTXVVAEForceFloat32,
)
from .iclora import (
LTXAddVideoICLoRAGuide,
LTXAddVideoICLoRAGuideAdvanced,
LTXICLoRALoaderModelOnly,
LTXVSetAudioRefTokens,
)
from .latent_norm import LTXVAdainLatent
from .latents import (
LTXVImgToVideoConditionOnly,
LTXVSelectLatents,
LTXVSetVideoLatentNoiseMasks,
)
from .looping_sampler import LTXVLoopingSampler, MultiPromptProvider
from .low_vram_loaders import (
LowVRAMAudioVAELoader,
LowVRAMCheckpointLoader,
LowVRAMLatentUpscaleModelLoader,
)
from .nodes_registry import NODE_CLASS_MAPPINGS as RUNTIME_NODE_CLASS_MAPPINGS
from .nodes_registry import (
NODE_DISPLAY_NAME_MAPPINGS as RUNTIME_NODE_DISPLAY_NAME_MAPPINGS,
)
from .nodes_registry import NODES_DISPLAY_NAME_PREFIX, camel_case_to_spaces
from .pyramid_blending import LTXVLaplacianPyramidBlend
from .q8_nodes import LTXVQ8LoraModelLoader, LTXVQ8Patch
from .sparse_tracks import LTXVDrawTracks, LTXVSparseTrackEditor
from .stg import (
LTXVApplySTG,
STGAdvancedPresetsNode,
STGGuiderAdvancedNode,
STGGuiderNode,
)
from .tiled_fusion_sampler import LTXVTiledFusionSampler
from .tiled_fusion_sizes import LTXVGetTilingSizes
from .tiled_sampler import LTXVTiledSampler
from .tiled_vae_decode import LTXVTiledVAEDecode
from .utiltily_nodes import FloatToInt, ImageToCPU
from .vanish_nodes import LTXVDilateVideoMask, LTXVInpaintPreprocess
# Static node mappings, required for ComfyUI-Manager mapping to work
NODE_CLASS_MAPPINGS = {
"Set VAE Decoder Noise": DecoderNoise,
"LTXVLinearOverlapLatentTransition": LinearOverlapLatentTransition,
"LTXVImgToVideoAdvanced": LTXVImgToVideoAdvanced,
"LTXVAdainLatent": LTXVAdainLatent,
"LTXVImgToVideoConditionOnly": LTXVImgToVideoConditionOnly,
"LTXVApplySTG": LTXVApplySTG,
"LTXVBaseSampler": LTXVBaseSampler,
"LTXVInContextSampler": LTXVInContextSampler,
"LTXVExtendSampler": LTXVExtendSampler,
"LTXVNormalizingSampler": LTXVNormalizingSampler,
"LTXQ8Patch": LTXVQ8Patch,
"LTXVQ8LoraModelLoader": LTXVQ8LoraModelLoader,
"LTXVSelectLatents": LTXVSelectLatents,
"LTXVSetVideoLatentNoiseMasks": LTXVSetVideoLatentNoiseMasks,
"LTXVTiledSampler": LTXVTiledSampler,
"LTXVTiledFusionSampler": LTXVTiledFusionSampler,
"LTXVGetTilingSizes": LTXVGetTilingSizes,
"LTXVLoopingSampler": LTXVLoopingSampler,
"LTXVTiledVAEDecode": LTXVTiledVAEDecode,
"MultimodalGuider": MultimodalGuiderNode,
"GuiderParameters": GuiderParametersNode,
"STGAdvancedPresets": STGAdvancedPresetsNode,
"STGGuiderAdvanced": STGGuiderAdvancedNode,
"STGGuiderNode": STGGuiderNode,
"LTXVMultiPromptProvider": MultiPromptProvider,
"ImageToCPU": ImageToCPU,
"LTXFloatToInt": FloatToInt,
"LTXVGemmaCLIPModelLoader": LTXVGemmaCLIPModelLoader,
"LTXVGemmaEnhancePrompt": LTXVGemmaEnhancePrompt,
"GemmaAPITextEncode": GemmaAPITextEncode,
"DynamicConditioning": DynamicConditioning,
"LowVRAMCheckpointLoader": LowVRAMCheckpointLoader,
"LowVRAMAudioVAELoader": LowVRAMAudioVAELoader,
"LowVRAMLatentUpscaleModelLoader": LowVRAMLatentUpscaleModelLoader,
"LTXAddVideoICLoRAGuide": LTXAddVideoICLoRAGuide,
"LTXAddVideoICLoRAGuideAdvanced": LTXAddVideoICLoRAGuideAdvanced,
"LTXICLoRALoaderModelOnly": LTXICLoRALoaderModelOnly,
"LTXVSetAudioRefTokens": LTXVSetAudioRefTokens,
"LTXVLoadConditioning": LTXVLoadConditioning,
"LTXVDrawTracks": LTXVDrawTracks,
"LTXVSparseTrackEditor": LTXVSparseTrackEditor,
"LTXVDilateVideoMask": LTXVDilateVideoMask,
"LTXVInpaintPreprocess": LTXVInpaintPreprocess,
"LTXVLaplacianPyramidBlend": LTXVLaplacianPyramidBlend,
"LTXVHDRDecodePostprocess": LTXVHDRDecodePostprocess,
"LTXVSDRToHDRWorkingSpace": LTXVSDRToHDRWorkingSpace,
"LTXVLoadEXRSequence": LTXVLoadEXRSequence,
"LTXVVAEForceFloat32": LTXVVAEForceFloat32,
"LTXVSaveHLG": LTXVSaveHLG,
"LTXVAudioOnlyModel": LTXVAudioOnlyModel,
"LTXVAudioOnlyEmptyVideoLatent": LTXVAudioOnlyEmptyVideoLatent,
}
# Consistent display names between static and dynamic node mappings in nodes_registry.py,
# to prevent ComfyUI initializing them with default display names.
NODE_DISPLAY_NAME_MAPPINGS = {
name: f"{NODES_DISPLAY_NAME_PREFIX} {camel_case_to_spaces(name)}"
for name in NODE_CLASS_MAPPINGS.keys()
}
# Update with runtime mappings (these will override static mappings if there are any differences)
NODE_CLASS_MAPPINGS.update(RUNTIME_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(RUNTIME_NODE_DISPLAY_NAME_MAPPINGS)
WEB_DIRECTORY = "./web"
# Export so that ComfyUI can pick them up.
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"WEB_DIRECTORY",
]
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ec16f1372ece0cd63395edf29d9997599580df3703d7fd30025fd0870ad5532
size 1205996
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e508763410d009f524ed22c1c420e798904afe92098130a4d155d8318ef6b3a
size 1112804
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8681ff86edb43d0a12472175d5503a590910b24d5ec5d5d5b78eed079960eac0
size 998115
+89
View File
@@ -0,0 +1,89 @@
"""Audio-only (text-to-audio) mode for the LTX-2 audio/video model.
The LTX-2 transformer in ComfyUI core (``comfy/ldm/lightricks/av_model.py``)
already supports an audio-only path: every block reads ``run_vx``,
``a2v_cross_attn`` and ``v2a_cross_attn`` from ``transformer_options`` and skips
the video self-attention/FFN and the audio<->video cross-attention when they are
``False``. These flags default to ``True``, so the model normally runs jointly.
``LTXVAudioOnlyModel`` flips those flags, which is the equivalent of the
reference pipeline's ``video=None``: the audio is denoised with no dependence on
the video latent, and the video stream is skipped for speed.
Note: the model splits its input positionally into ``[video, audio]``, so the
sampler still needs a (tiny) dummy video latent at index 0. Use
``LTXVAudioOnlyEmptyVideoLatent`` (a fixed 64x64 single-frame placeholder) joined
with the audio latent via ``LTXVConcatAVLatent``. With ``LTXVAudioOnlyModel``
active that dummy video is never attended to.
"""
import comfy.model_management
import torch
from comfy.model_patcher import ModelPatcher
from .nodes_registry import comfy_node
@comfy_node(name="LTXVAudioOnlyModel")
class LTXVAudioOnlyModel:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (
"MODEL",
{
"tooltip": "The LTX-2 audio/video model to run in audio-only mode."
},
),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "patch"
CATEGORY = "Lightricks/audio"
DESCRIPTION = (
"Run the LTX-2 audio/video model in audio-only mode for text-to-audio. "
"Skips the video stream and disables the audio<->video cross-attention, so "
"the generated audio does not depend on the video latent. The sampler still "
"needs a minimal dummy video latent (EmptyLTXVLatentVideo at 64x64 length 1, "
"joined with LTXVConcatAVLatent); it is never attended to. Remove or bypass "
"this node to restore normal joint audio+video generation."
)
def patch(self, model: ModelPatcher):
new_model = model.clone()
transformer_options = new_model.model_options["transformer_options"]
transformer_options["run_vx"] = False
transformer_options["a2v_cross_attn"] = False
transformer_options["v2a_cross_attn"] = False
return (new_model,)
@comfy_node(name="LTXVAudioOnlyEmptyVideoLatent")
class LTXVAudioOnlyEmptyVideoLatent:
@classmethod
def INPUT_TYPES(s):
return {"required": {}}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "generate"
CATEGORY = "Lightricks/audio"
DESCRIPTION = (
"Fixed minimal placeholder video latent for audio-only (text-to-audio) "
"generation - a single 64x64 frame. LTX-2 needs a video latent at input "
"index 0, but with LTXV Audio Only Model it is never attended to, so the "
"dimensions are fixed (and hidden) to avoid confusion. Join it with the "
"audio latent via LTXVConcatAVLatent."
)
def generate(self):
# LTX-2 video latent shape: (batch, 128 channels, frames, H/32, W/32).
# Fixed to batch 1, a single frame, 64x64 -> (1, 128, 1, 2, 2).
latent = torch.zeros(
[1, 128, 1, 2, 2],
device=comfy.model_management.intermediate_device(),
)
return ({"samples": latent},)
+163
View File
@@ -0,0 +1,163 @@
import hashlib
from pathlib import Path
from typing import Any
import folder_paths
import safetensors
import torch
from comfy_api.latest import io
from .nodes_registry import comfy_node
# Pipeline HDR IC-LoRA scene embeddings (`hdr_ic_lora._load_video_context`).
_PIPELINE_VIDEO_KEYS = ("video_context", "video_prompt_embeds")
_PIPELINE_AUDIO_KEYS = ("audio_context", "audio_prompt_embeds")
# LTX-AV text encode layout: video 4096 + audio 2048 on the last dim.
_VIDEO_CONTEXT_DIM = 4096
_AUDIO_CONTEXT_DIM = 2048
_AV_CONTEXT_DIM = _VIDEO_CONTEXT_DIM + _AUDIO_CONTEXT_DIM
def _unsqueeze_batch(t: torch.Tensor, *, source: str, kind: str) -> torch.Tensor:
"""Ensure conditioning tensors are ``[B, T, D]`` (pipeline files are often ``[T, D]``)."""
if t.ndim == 2:
return t.unsqueeze(0)
if t.ndim == 3:
return t
raise ValueError(
f"{source}: {kind} embedding must be 2D [T, D] or 3D [B, T, D]; "
f"got shape {tuple(t.shape)}"
)
def _load_pipeline_scene_embedding(f: Any, source: str) -> list[list[Any]]:
"""Load ``video_context`` (+ optional ``audio_context``) into Comfy CONDITIONING.
Matches ``ltx_pipelines.hdr_ic_lora``: the safetensors replaces text encoding.
For AV Comfy models, video (4096) and audio (2048) contexts are concatenated on
the last dim — same layout as a fully processed LTX-AV text encode.
"""
keys = list(f.keys())
video_key = next((k for k in _PIPELINE_VIDEO_KEYS if k in keys), None)
if video_key is None:
return []
video = _unsqueeze_batch(f.get_tensor(video_key), source=source, kind=video_key)
video_dim = int(video.shape[-1])
audio_key = next((k for k in _PIPELINE_AUDIO_KEYS if k in keys), None)
if video_dim == _AV_CONTEXT_DIM:
# Already video∥audio on the last dim (rare export); use as-is.
tensor = video
elif video_dim == _VIDEO_CONTEXT_DIM:
if audio_key is None:
raise ValueError(
f"{source}: {video_key} last dim is {_VIDEO_CONTEXT_DIM} but no "
f"audio_context / audio_prompt_embeds found. AV conditioning needs "
f"{_VIDEO_CONTEXT_DIM}+{_AUDIO_CONTEXT_DIM}="
f"{_AV_CONTEXT_DIM} on the last dim."
)
audio = _unsqueeze_batch(f.get_tensor(audio_key), source=source, kind=audio_key)
audio_dim = int(audio.shape[-1])
if audio_dim != _AUDIO_CONTEXT_DIM:
raise ValueError(
f"{source}: {audio_key} last dim must be {_AUDIO_CONTEXT_DIM}; "
f"got {audio_dim} with shape {tuple(audio.shape)}"
)
if audio.shape[:-1] != video.shape[:-1]:
raise ValueError(
f"{source}: audio/video context shape mismatch "
f"(excluding last dim): {tuple(audio.shape)} vs {tuple(video.shape)}"
)
tensor = torch.cat([video, audio], dim=-1)
else:
raise ValueError(
f"{source}: {video_key} last dim must be {_VIDEO_CONTEXT_DIM} "
f"(or pre-concatenated {_AV_CONTEXT_DIM}); got {video_dim} with shape "
f"{tuple(video.shape)}. Check that video/audio keys are not swapped."
)
return [[tensor, {}]]
@comfy_node(name="LTXVLoadConditioning")
class LTXVLoadConditioning(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
files = folder_paths.get_filename_list("embeddings")
if not files:
files = [""]
return io.Schema(
node_id="LTXVLoadConditioning",
display_name="🅛🅣🅧 LTXV Load Conditioning",
category="lightricks/LTXV",
description=(
"Load precomputed text conditioning from models/embeddings. "
"Supports Comfy conditioning_data_* files and pipeline scene "
"embeddings (video_context / audio_context), e.g. HDR IC-LoRA."
),
inputs=[
io.Combo.Input("file_name", options=sorted(files)),
io.Combo.Input("device", options=["cpu", "gpu"]),
],
outputs=[
io.Conditioning.Output(),
],
)
@classmethod
def execute(cls, file_name: str, device: str) -> io.NodeOutput:
file_path = folder_paths.get_full_path("embeddings", file_name)
if not Path(file_path).exists():
raise FileNotFoundError(f"Conditioning file not found: {file_path}")
target_device = "cpu"
if device == "gpu":
target_device = "cuda" if torch.cuda.is_available() else "cpu"
conditioning: list[list[Any]] = []
with safetensors.safe_open(
file_path, framework="pt", device=target_device
) as f:
tensor_keys = [k for k in f.keys() if k.startswith("conditioning_data_")]
if tensor_keys:
for tensor_key in sorted(tensor_keys):
idx = tensor_key.replace("conditioning_data_", "")
tensor = f.get_tensor(tensor_key)
options: dict[str, Any] = {}
mask_key = f"attention_mask_{idx}"
if mask_key in f.keys():
options["attention_mask"] = f.get_tensor(mask_key)
conditioning.append([tensor, options])
else:
conditioning = _load_pipeline_scene_embedding(f, source=file_name)
if not conditioning:
raise ValueError(
f"No conditioning data found in file: {file_name}. "
"Expected conditioning_data_* or video_context / video_prompt_embeds."
)
return io.NodeOutput(conditioning)
@classmethod
def fingerprint_inputs(cls, file_name: str, device: str) -> str:
file_path = folder_paths.get_full_path("embeddings", file_name)
with open(file_path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
@classmethod
def validate_inputs(cls, file_name: str, device: str) -> bool | str:
if not file_name:
return "No files found. Please save a conditioning first."
try:
file_path = folder_paths.get_full_path("embeddings", file_name)
if not Path(file_path).exists():
return f"File not found: {file_name}"
except Exception:
return f"Invalid file: {file_name}"
return True
+57
View File
@@ -0,0 +1,57 @@
from copy import copy
from .nodes_registry import comfy_node
@comfy_node(name="Set VAE Decoder Noise")
class DecoderNoise:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"vae": ("VAE",),
"timestep": (
"FLOAT",
{
"default": 0.05,
"min": 0.0,
"max": 1.0,
"step": 0.001,
"tooltip": "The timestep used for decoding the noise.",
},
),
"scale": (
"FLOAT",
{
"default": 0.025,
"min": 0.0,
"max": 1.0,
"step": 0.001,
"tooltip": "The scale of the noise added to the decoder.",
},
),
"seed": (
"INT",
{
"default": 42,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"tooltip": "The random seed used for creating the noise.",
},
),
}
}
FUNCTION = "add_noise"
RETURN_TYPES = ("VAE",)
CATEGORY = "lightricks/LTXV"
def add_noise(self, vae, timestep, scale, seed):
result = copy(vae)
if hasattr(result, "first_stage_model"):
result.first_stage_model.decode_timestep = timestep
result.first_stage_model.decode_noise_scale = scale
result._decode_timestep = timestep
result.decode_noise_scale = scale
result.seed = seed
return (result,)
+57
View File
@@ -0,0 +1,57 @@
import torch
from .nodes_registry import comfy_node
@comfy_node(name="DynamicConditioning")
class DynamicConditioning:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"power": ("FLOAT", {"default": 1.3, "min": 1, "max": 2, "step": 0.01}),
"only_first_frame": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply"
CATEGORY = "lightricks/LTXV"
INIT = False
def apply(self, model, power, only_first_frame):
self.only_first_frame = only_first_frame
self.power = power
model = model.clone()
model.set_model_denoise_mask_function(self.forward)
return (model,)
def find_step(self, sigma: torch.Tensor, step_sigmas: torch.Tensor):
for i, step_sigma in enumerate(step_sigmas):
if step_sigma <= sigma:
return i
return len(step_sigmas) - 1
def forward(
self, sigma: torch.Tensor, denoise_mask: torch.Tensor, extra_options: dict
):
model = extra_options["model"]
step_sigmas = extra_options["sigmas"]
step = self.find_step(sigma, step_sigmas)
# In order to apply power multiple times, this is the same as applying power number of times equal to step
power = self.power**step
denoise_mask = denoise_mask.clone()
if self.only_first_frame:
num_channels = model.model_patcher.model.diffusion_model.in_channels
denoise_mask[:, :num_channels, :1] **= power
else:
denoise_mask **= power
# make sure to update the denoise mask in the model, to get correct timestep values for all tokens
for k in model.conds:
if "positive" in k or "negative" in k:
for cond in model.conds[k]:
if "model_conds" in cond and "denoise_mask" in cond["model_conds"]:
cond["model_conds"]["denoise_mask"].cond = denoise_mask
# print(f"DynamicConditioning: power: {power}, step: {step}, sigma: {sigma}, step_sigmas: {step_sigmas}")
return denoise_mask
+1051
View File
File diff suppressed because it is too large Load Diff
+473
View File
@@ -0,0 +1,473 @@
import math
from typing import Optional
import comfy.ldm.common_dit
import comfy.ops
import torch
from comfy.ldm.lightricks.model import (
CrossAttention,
FeedForward,
LTXFrequenciesPrecision,
LTXRopeType,
generate_freq_grid_np,
)
from torch import nn
try:
# New core (since ComfyUI PR #15056) replaced interleaved_freqs_cis/split_freqs_cis
# with a single freqs_cis_matrix helper.
from comfy.ldm.lightricks.model import freqs_cis_matrix
_USE_FREQS_CIS_MATRIX = True
except ImportError:
# Legacy core: cos/sin freqs consumed as a (cos, sin, split) tuple.
from .rope_utils import interleaved_freqs_cis, split_freqs_cis
_USE_FREQS_CIS_MATRIX = False
from .pos_embedding_exp_values import POS_EMBEDDING_EXP_VALUES
class BasicTransformerBlock1D(nn.Module):
r"""
A basic Transformer block.
Parameters:
dim (`int`): The number of channels in the input and output.
num_attention_heads (`int`): The number of heads to use for multi-head attention.
attention_head_dim (`int`): The number of channels in each head.
dropout (`float`, *optional*, defaults to 0.0): The dropout probability to use.
activation_fn (`str`, *optional*, defaults to `"geglu"`): Activation function to be used in feed-forward.
attention_bias (:
obj: `bool`, *optional*, defaults to `False`): Configure if the attentions should contain a bias parameter.
upcast_attention (`bool`, *optional*):
Whether to upcast the attention computation to float32. This is useful for mixed precision training.
norm_elementwise_affine (`bool`, *optional*, defaults to `True`):
Whether to use learnable elementwise affine parameters for normalization.
standardization_norm (`str`, *optional*, defaults to `"layer_norm"`): The type of pre-normalization to use. Can be `"layer_norm"` or `"rms_norm"`.
norm_eps (`float`, *optional*, defaults to 1e-5): Epsilon value for normalization layers.
qk_norm (`str`, *optional*, defaults to None):
Set to 'layer_norm' or `rms_norm` to perform query and key normalization.
final_dropout (`bool` *optional*, defaults to False):
Whether to apply a final dropout after the last feed-forward layer.
ff_inner_dim (`int`, *optional*): Dimension of the inner feed-forward layer. If not provided, defaults to `dim * 4`.
ff_bias (`bool`, *optional*, defaults to `True`): Whether to use bias in the feed-forward layer.
attention_out_bias (`bool`, *optional*, defaults to `True`): Whether to use bias in the attention output layer.
use_rope (`bool`, *optional*, defaults to `False`): Whether to use Rotary Position Embeddings (RoPE).
ffn_dim_mult (`int`, *optional*, defaults to 4): Multiplier for the inner dimension of the feed-forward layer.
"""
def __init__(
self,
dim,
n_heads,
d_head,
context_dim=None,
attn_precision=None,
apply_gated_attention=False,
dtype=None,
device=None,
operations=None,
):
super().__init__()
# Define 3 blocks. Each block has its own normalization layer.
# 1. Self-Attn
self.attn1 = CrossAttention(
query_dim=dim,
heads=n_heads,
dim_head=d_head,
context_dim=None,
apply_gated_attention=apply_gated_attention,
dtype=dtype,
device=device,
operations=operations,
)
# 3. Feed-forward
self.ff = FeedForward(
dim,
dim_out=dim,
glu=True,
dtype=dtype,
device=device,
operations=operations,
)
def forward(self, hidden_states, attention_mask=None, pe=None) -> torch.FloatTensor:
# Notice that normalization is always applied before the real computation in the following blocks.
# 1. Normalization Before Self-Attention
norm_hidden_states = comfy.ldm.common_dit.rms_norm(hidden_states)
norm_hidden_states = norm_hidden_states.squeeze(1)
# 2. Self-Attention
attn_output = self.attn1(norm_hidden_states, mask=attention_mask, pe=pe)
hidden_states = attn_output + hidden_states
if hidden_states.ndim == 4:
hidden_states = hidden_states.squeeze(1)
# 3. Normalization before Feed-Forward
norm_hidden_states = comfy.ldm.common_dit.rms_norm(hidden_states)
# 4. Feed-forward
ff_output = self.ff(norm_hidden_states)
hidden_states = ff_output + hidden_states
if hidden_states.ndim == 4:
hidden_states = hidden_states.squeeze(1)
return hidden_states
class Embeddings1DConnector(nn.Module):
_supports_gradient_checkpointing = True
def __init__(
self,
in_channels=128,
cross_attention_dim=2048,
attention_head_dim=128,
num_attention_heads=30,
num_layers=2,
positional_embedding_theta=10000.0,
positional_embedding_max_pos=[1],
causal_temporal_positioning=False,
num_learnable_registers: Optional[int] = 128,
apply_gated_attention=False,
dtype=None,
device=None,
operations=None,
split_rope=False,
double_precision_rope=False,
**kwargs,
):
super().__init__()
self.dtype = dtype
self.out_channels = in_channels
self.num_attention_heads = num_attention_heads
self.inner_dim = num_attention_heads * attention_head_dim
self.causal_temporal_positioning = causal_temporal_positioning
self.positional_embedding_theta = positional_embedding_theta
self.positional_embedding_max_pos = positional_embedding_max_pos
self.split_rope = split_rope
self.double_precision_rope = double_precision_rope
self.transformer_1d_blocks = nn.ModuleList(
[
BasicTransformerBlock1D(
self.inner_dim,
num_attention_heads,
attention_head_dim,
context_dim=cross_attention_dim,
apply_gated_attention=apply_gated_attention,
dtype=dtype,
device=device,
operations=operations,
)
for _ in range(num_layers)
]
)
inner_dim = num_attention_heads * attention_head_dim
self.num_learnable_registers = num_learnable_registers
if self.num_learnable_registers:
self.learnable_registers = nn.Parameter(
torch.rand(
self.num_learnable_registers, inner_dim, dtype=dtype, device=device
)
* 2.0
- 1.0
)
def get_fractional_positions(self, indices_grid):
fractional_positions = torch.stack(
[
indices_grid[:, i] / self.positional_embedding_max_pos[i]
for i in range(1)
],
dim=-1,
)
return fractional_positions
def precompute_freqs(self, indices_grid, spacing):
source_dtype = indices_grid.dtype
dtype = (
torch.float32
if source_dtype in (torch.bfloat16, torch.float16)
else source_dtype
)
fractional_positions = self.get_fractional_positions(indices_grid)
indices = (
generate_freq_grid_np(
self.positional_embedding_theta,
indices_grid.shape[1],
self.inner_dim,
)
if self.double_precision_rope
else self.generate_freq_grid(spacing, dtype, fractional_positions.device)
).to(device=fractional_positions.device)
if spacing == "exp_2":
freqs = (
(indices * fractional_positions.unsqueeze(-1))
.transpose(-1, -2)
.flatten(2)
)
else:
freqs = (
(indices * (fractional_positions.unsqueeze(-1) * 2 - 1))
.transpose(-1, -2)
.flatten(2)
)
return freqs
def generate_freq_grid(self, spacing, dtype, device):
dim = self.inner_dim
theta = self.positional_embedding_theta
n_pos_dims = 1
n_elem = 2 * n_pos_dims # 2 for cos and sin e.g. x 3 = 6
start = 1
end = theta
if spacing == "exp":
# Workaround float precision issues when calculating positional embeddings exponents to ensure compatibility with JAX.
indices = torch.tensor(POS_EMBEDDING_EXP_VALUES, dtype=dtype, device=device)
elif spacing == "exp_2":
indices = 1.0 / theta ** (torch.arange(0, dim, n_elem, device=device) / dim)
indices = indices.to(dtype=dtype)
elif spacing == "linear":
indices = torch.linspace(
start, end, dim // n_elem, device=device, dtype=dtype
)
elif spacing == "sqrt":
indices = torch.linspace(
start**2, end**2, dim // n_elem, device=device, dtype=dtype
).sqrt()
indices = indices * math.pi / 2
return indices
def precompute_freqs_cis(self, indices_grid, spacing="exp"):
dim = self.inner_dim
n_elem = 2 # 2 because of cos and sin
freqs = self.precompute_freqs(indices_grid, spacing)
if self.split_rope:
expected_freqs = dim // 2
current_freqs = freqs.shape[-1]
pad_size = expected_freqs - current_freqs
else:
pad_size = dim % n_elem
if _USE_FREQS_CIS_MATRIX:
# New core takes (rotation_matrix, split_pe); let it build the matrix so
# this cannot drift from the transformer again.
return freqs_cis_matrix(
freqs, pad_size, self.split_rope, self.num_attention_heads, self.dtype
)
# Legacy core: returns (cos, sin, split_rope).
if self.split_rope:
cos_freq, sin_freq = split_freqs_cis(
freqs, pad_size, self.num_attention_heads
)
else:
cos_freq, sin_freq = interleaved_freqs_cis(freqs, pad_size)
return cos_freq.to(self.dtype), sin_freq.to(self.dtype), self.split_rope
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: Optional[torch.Tensor] = None,
):
"""
The [`Transformer2DModel`] forward method.
Args:
hidden_states (`torch.LongTensor` of shape `(batch size, num latent pixels)` if discrete, `torch.FloatTensor` of shape `(batch size, channel, height, width)` if continuous):
Input `hidden_states`.
indices_grid (`torch.LongTensor` of shape `(batch size, 3, num latent pixels)`):
attention_mask ( `torch.Tensor`, *optional*):
An attention mask of shape `(batch, key_tokens)` is applied to `encoder_hidden_states`. If `1` the mask
is kept, otherwise if `0` it is discarded. Mask will be converted into a bias, which adds large
negative values to the attention scores corresponding to "discard" tokens.
Returns:
If `return_dict` is True, an [`~models.transformer_2d.Transformer2DModelOutput`] is returned, otherwise a
`tuple` where the first element is the sample tensor.
"""
# 1. Input
if self.num_learnable_registers:
# replace all padded tokens with learnable registers
assert (
hidden_states.shape[1] % self.num_learnable_registers == 0
), f"Hidden states sequence length {hidden_states.shape[1]} must be divisible by num_learnable_registers {self.num_learnable_registers}."
num_registers_duplications = (
hidden_states.shape[1] // self.num_learnable_registers
)
learnable_registers = torch.tile(
self.learnable_registers, (num_registers_duplications, 1)
).to(hidden_states.device)
attention_mask_binary = (
attention_mask.squeeze(1).squeeze(1).unsqueeze(-1) >= -9000.0
).int()
non_zero_hidden_states = hidden_states[
:, attention_mask_binary.squeeze().bool(), :
]
non_zero_nums = non_zero_hidden_states.shape[1]
pad_length = hidden_states.shape[1] - non_zero_nums
adjusted_hidden_states = torch.nn.functional.pad(
non_zero_hidden_states, pad=(0, 0, 0, pad_length), value=0
)
flipped_mask = torch.flip(attention_mask_binary, dims=[1])
hidden_states = (
flipped_mask * adjusted_hidden_states
+ (1 - flipped_mask) * learnable_registers
)
attention_mask = torch.full_like(
attention_mask,
0.0,
dtype=attention_mask.dtype,
device=attention_mask.device,
)
indices_grid = torch.arange(
hidden_states.shape[1], dtype=torch.float32, device=hidden_states.device
)
indices_grid = indices_grid[None, None, :]
freqs_cis = self.precompute_freqs_cis(indices_grid)
# 2. Blocks
for block_idx, block in enumerate(self.transformer_1d_blocks):
hidden_states = block(
hidden_states, attention_mask=attention_mask, pe=freqs_cis
)
# 3. Output
# if self.output_scale is not None:
# hidden_states = hidden_states / self.output_scale
hidden_states = comfy.ldm.common_dit.rms_norm(hidden_states)
return hidden_states, attention_mask
# ---------------------------------------------------------------------------
# Loading helpers
# ---------------------------------------------------------------------------
_PREFIX_BASE = "model.diffusion_model."
def load_embeddings_connector(
sd,
connector_prefix,
connector_config,
dtype=torch.bfloat16,
rope_type=LTXRopeType.INTERLEAVED,
frequencies_precision=LTXFrequenciesPrecision.FLOAT32,
pe_max_pos=None,
):
sd_connector = {
k[len(connector_prefix) :]: v
for k, v in sd.items()
if k.startswith(connector_prefix)
}
if len(sd_connector) == 0:
return None
operations = comfy.ops.pick_operations(dtype, dtype, disable_fast_fp8=True)
connector = Embeddings1DConnector(
num_attention_heads=connector_config["num_attention_heads"],
attention_head_dim=connector_config["attention_head_dim"],
num_layers=connector_config["num_layers"],
apply_gated_attention=connector_config.get("apply_gated_attention", False),
dtype=dtype,
operations=operations,
positional_embedding_max_pos=pe_max_pos if pe_max_pos is not None else [1],
split_rope=rope_type == LTXRopeType.SPLIT,
double_precision_rope=frequencies_precision == LTXFrequenciesPrecision.FLOAT64,
)
connector.load_state_dict(sd_connector)
return connector
def load_video_embeddings_connector(sd, transformer_config, dtype=torch.bfloat16):
rope_type = LTXRopeType.from_dict(transformer_config)
frequencies_precision = LTXFrequenciesPrecision.from_dict(transformer_config)
pe_max_pos = transformer_config.get("connector_positional_embedding_max_pos", [1])
video_only_connector_prefix = f"{_PREFIX_BASE}embeddings_connector."
av_connector_prefix = f"{_PREFIX_BASE}video_embeddings_connector."
prefix = (
av_connector_prefix
if f"{_PREFIX_BASE}audio_adaln_single.linear.weight" in sd
else video_only_connector_prefix
)
connector_config = {
"num_attention_heads": transformer_config.get(
"connector_num_attention_heads", 30
),
"attention_head_dim": transformer_config.get(
"connector_attention_head_dim", 128
),
"num_layers": transformer_config.get("connector_num_layers", 2),
"apply_gated_attention": transformer_config.get(
"connector_apply_gated_attention", False
),
}
return load_embeddings_connector(
sd,
prefix,
connector_config,
dtype,
rope_type,
frequencies_precision,
pe_max_pos,
)
def load_audio_embeddings_connector(sd, transformer_config, dtype=torch.bfloat16):
rope_type = LTXRopeType.from_dict(transformer_config)
frequencies_precision = LTXFrequenciesPrecision.from_dict(transformer_config)
pe_max_pos = transformer_config.get("connector_positional_embedding_max_pos", [1])
connector_config = {
"num_attention_heads": transformer_config.get(
"audio_connector_num_attention_heads",
transformer_config.get("connector_num_attention_heads", 30),
),
"attention_head_dim": transformer_config.get(
"audio_connector_attention_head_dim",
transformer_config.get("connector_attention_head_dim", 128),
),
"num_layers": transformer_config.get(
"audio_connector_num_layers",
transformer_config.get("connector_num_layers", 2),
),
"apply_gated_attention": transformer_config.get(
"connector_apply_gated_attention", False
),
}
return load_embeddings_connector(
sd,
f"{_PREFIX_BASE}audio_embeddings_connector.",
connector_config,
dtype,
rope_type,
frequencies_precision,
pe_max_pos,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
# LTX-2.5 example workflows
These graphs generate video (and usually audio) with the **LTX-2.5 distilled** model. They share the same subgraph layout — load models, set inputs, preprocess, sample, decode — so you can switch files without relearning the graph.
All of them use the distilled transformer (`ltx-2.5-22b-distilled-transformer-bf16.safetensors`): a few sampling steps, meant for fast iteration. Two-stage graphs add a 2× spatial upscale and a short refine pass; single-stage graphs skip that.
Default values are tuned for lower-VRAM machines. If you have more memory, you can speed things up by increasing the **decode tile size** — see the Decode notes inside each graph (fewer, larger tiles run faster but need more VRAM).
## Getting started
This assumes ComfyUI is already installed. If not, see the [ComfyUI download page](https://www.comfy.org/download), the [ComfyUI setup guide](https://docs.ltx.io/open-source-model/integration-tools/comfy-ui), and the [system requirements](https://docs.ltx.io/open-source-model/getting-started/system-requirements).
1. Open ComfyUI.
2. Load a workflow from this folder (**Workflow** → **Open**, or drag the `.json` onto the canvas). The built-in ComfyUI **Templates** search for **LTX-2.5** is the same family of graphs if you prefer that entry point.
3. Open the **Workflow Overview** panel. On first use it lists **Missing Models**.
4. Click **Download all** to fetch the files into the right `ComfyUI/models/` folders. You only do this once — later runs reuse them.
5. Fill in the left-hand **Inputs** (prompt, optional image, duration, and anything the graph asks for such as audio or a reference video).
6. Click **Run**.
Weights also live on the [LTX-2.5 Hugging Face repo](https://huggingface.co/Lightricks/LTX-2.5) if you want to download them by hand. Each graph’s **Model Links** note lists the exact files it needs, including any IC-LoRA.
Prompting tips: [prompting guide](https://docs.ltx.io/open-source-model/usage-guides/prompting-guide). A beginner walkthrough of text-to-video: [Text-to-Video workflow](https://docs.ltx.io/open-source-model/usage-guides/text-to-video).
Frame count must be `1 + a multiple of 8`. Duration in the graphs is converted from fps × seconds and may land slightly off the number you typed.
## Workflows
### Generation
| Workflow | What it does |
| -------- | ------------ |
| [LTX-2.5_T2V_I2V_Two_Stage_Distilled.json](./LTX-2.5_T2V_I2V_Two_Stage_Distilled.json) | **Start here.** Text-to-video, optional first-frame image. Stage 1 at base resolution, 2× spatial upscale, then a 3-step refine. Generates video and audio together. |
| [LTX-2.5_T2V_I2V_Single_Stage_Distilled.json](./LTX-2.5_T2V_I2V_Single_Stage_Distilled.json) | Same text / image inputs, one distilled pass, no upscaler. Faster and lighter; lower spatial detail than two-stage. |
| [LTX-2.5_A2V_Two_Stage_Distilled.json](./LTX-2.5_A2V_Two_Stage_Distilled.json) | Audio-to-video. Encodes an input clip (with start + duration trim), **freezes** those tokens in both stages, and muxes the original waveform into the output (no audio decode). Optional first-frame image. |
| [LTX-2.5_T2A_Single_Stage_Distilled.json](./LTX-2.5_T2A_Single_Stage_Distilled.json) | Text-to-audio only. No video VAE or upscaler. |
### IC-LoRA (control and edit)
These keep the distilled 2.5 backbone and add an IC-LoRA so a guide (video, image sheet, tracks, or mask) steers generation.
| Workflow | What it does |
| -------- | ------------ |
| [LTX-2.5_ICLoRA_Union_Control_Distilled.json](./LTX-2.5_ICLoRA_Union_Control_Distilled.json) | Video-to-video from a **depth / canny / pose** annotator on a reference clip. Depth is wired by default; switch the annotator inside the graph. |
| [LTX-2.5_V2V_ICLoRA_Single_Stage_Distilled.json](./LTX-2.5_V2V_ICLoRA_Single_Stage_Distilled.json) | Video-to-video using the source frames as IC-LoRA guides. Ships with the Instant Shave LoRA as the example; swap the LoRA for other identity/edit LoRAs. Original audio is frozen through. |
| [LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json](./HDR_workflows/LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json) | **SDR→HDR**: SDR MP4 → ACEScct IDT → HDR IC-LoRA → EXR + HLG. |
| [LTX-2.5_ICLoRA_Ingredients_Single_Stage_Distilled.json](./LTX-2.5_ICLoRA_Ingredients_Single_Stage_Distilled.json) | Generate from a **reference sheet** (characters, props, wardrobe, location laid out in one image). Describe each element in the prompt by its place on the sheet. |
| [LTX-2.5_ICLoRA_Motion_Track_Distilled.json](./LTX-2.5_ICLoRA_Motion_Track_Distilled.json) | Image-to-video with **sparse motion tracks** you draw on the first frame. Optional still as the opening frame. |
| [LTX-2.5_ICLoRA_Inpaint_Two_Stage_Distilled.json](./LTX-2.5_ICLoRA_Inpaint_Two_Stage_Distilled.json) | Fill masked regions of a reference video (two-stage). Source audio can stay frozen. |
| [LTX-2.5_ICLoRA_Inpaint_HDR_Two_Stage_Distilled.json](./HDR_workflows/LTX-2.5_ICLoRA_Inpaint_HDR_Two_Stage_Distilled.json) | Same inpaint, but **HDR→HDR**: EXR sequence + mask video → EXR + HLG out. |
| [LTX-2.5_ICLoRA_Outpaint_Two_Stage_Distilled.json](./LTX-2.5_ICLoRA_Outpaint_Two_Stage_Distilled.json) | Extend the canvas of a reference video (two-stage). Same in/outpaint LoRA as inpaint. |
| [LTX-2.5_I2V_HDR_Two_Stage_Distilled.json](./HDR_workflows/LTX-2.5_I2V_HDR_Two_Stage_Distilled.json) | **HDR still → HDR video**: EXR still → float32 VAE → EXR + HLG out. |
### Tiled Fusion (large-canvas IC-LoRA)
These keep one latent canvas and fuse overlapping spatial tiles after every denoise step (`LTXVTiledFusionSampler`). Use them when the canvas is larger than the LoRA window. They share the same subgraph layout as the other 2.5 graphs (Load Models, Inputs, Preprocess, Generate, Decode). **Get Tiling Sizes** and the canvas resize live inside **Preprocess**; the size combos (`tile_size` / `initial_canvas_size` = qHD / HD / FullHD, `output_size` = FullHD / 4K / 8K) are widgets on that subgraph. Source frames come from Load Video, or from Load EXR still on the native HDR→HDR graph. The node emits model-legal canvases (multiples of 32, `tile_frames` = 8n+1); a **Tiling sizes** preview sits below Preprocess. There is no first-frame still / `use image input` / I2V bypass. `use_tiled_encode` on the guide node stays **false**; `use_streaming` is on so each temporal window gets a fresh IC-LoRA encode.
Upscale prompts should describe **look and style** (lighting, palette, film stock, sharpness, grade), not named objects, people, or props — anything you name can stamp into every spatial tile after upscale. Native and HDR: the stage-1 prompt describes the **change** you want on the input video or HDR plate (plus enhance / API / [prompting guide](https://docs.ltx.io/open-source-model/usage-guides/prompting-guide)); the stage-2 prompt is look/style only, because only that pass uses spatial tiling. The negative prompt is used as written.
| Workflow | What it does |
| -------- | ------------ |
| [LTX-2.5_V2V_TiledFusion_Upscale.json](./LTX-2.5_V2V_TiledFusion_Upscale.json) | **V2V upscale.** Preprocess resizes the guide to `output_size` (FullHD / 4K / 8K), then 8 fused steps with the detail refiner. Tiles come from `tile_size` (HD). |
| [LTX-2.5_V2V_TiledFusion_Native_4K_8K.json](./LTX-2.5_V2V_TiledFusion_Native_4K_8K.json) | **V2V native / dynamic 4K/8K.** Stage 1 at `initial_canvas_size` FullHD (8 steps, Day-To-Night IC-LoRA). Latent ×2 (×4 when `output_size` is 8K), then 3 fused steps with a fresh prompt and the original clip resized to output. Optional second IC-LoRA on stage 2. |
| [LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json](./LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json) | Same recipe as Native, with the HDR IC-LoRA. SDR MP4 → ACEScct, then `LTXVHDRDecodePostprocess` (EXR + HLG). |
| [LTX-2.5_V2V_TiledFusion_HDR_to_HDR.json](./LTX-2.5_V2V_TiledFusion_HDR_to_HDR.json) | **Native HDR→HDR refine.** EXR still (ACEScct, float32 VAE, `img_compression` 0) with the detail refiner instead of the HDR IC-LoRA. Same 4K/8K tiled ladder; EXR + HLG out. |
## Which workflow should I use?
```text
Do you only need audio (no picture)?
├─ YES → LTX-2.5_T2A_Single_Stage_Distilled.json
│
Do you have an audio file the video should follow?
├─ YES → LTX-2.5_A2V_Two_Stage_Distilled.json
│ (optional still as the first frame)
│
Do you have an existing video to edit or follow?
├─ YES → What kind of control?
│ ├─ Depth / edges / pose from the clip
│ │ → LTX-2.5_ICLoRA_Union_Control_Distilled.json
│ ├─ Keep the footage, change appearance (identity / style LoRA)
│ │ → LTX-2.5_V2V_ICLoRA_Single_Stage_Distilled.json
│ ├─ Upgrade SDR footage to HDR (ACEScct IC-LoRA)
│ │ → HDR_workflows/LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json
│ ├─ Upscale an existing clip at full HD / 4K / 8K (tiled fusion)
│ │ → LTX-2.5_V2V_TiledFusion_Upscale.json
│ ├─ Viewport-style 4K/8K ladder (full HD, then ×2 / ×4)
│ │ → LTX-2.5_V2V_TiledFusion_Native_4K_8K.json
│ ├─ Upgrade SDR footage to HDR (ACEScct IC-LoRA)
│ │ → LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json (tiled 4K/8K ladder)
│ │ → ../2.3/LTX-2.3_ICLoRA_HDR_Distilled.json (single-stage)
│ ├─ Refine HDR stills / EXR (native ACEScct I/O, refine LoRA)
│ │ → LTX-2.5_V2V_TiledFusion_HDR_to_HDR.json
│ ├─ Fill a masked region
│ │ → LTX-2.5_ICLoRA_Inpaint_Two_Stage_Distilled.json (SDR)
│ │ → HDR_workflows/LTX-2.5_ICLoRA_Inpaint_HDR_Two_Stage_Distilled.json (HDR EXR)
│ └─ Grow the frame / canvas
│ → LTX-2.5_ICLoRA_Outpaint_Two_Stage_Distilled.json
│
Do you have stills rather than a video?
├─ YES → HDR EXR still → video?
│ ├─ YES → HDR_workflows/LTX-2.5_I2V_HDR_Two_Stage_Distilled.json
│ └─ NO → A sheet of characters / props / locations?
│ ├─ YES → LTX-2.5_ICLoRA_Ingredients_Single_Stage_Distilled.json
│ └─ NO → Draw motion paths on a first frame?
│ ├─ YES → LTX-2.5_ICLoRA_Motion_Track_Distilled.json
│ └─ NO → Text-to-video with an optional opening still
│ (see below)
│
Text-to-video (optional image)?
├─ Want 2× spatial upscale and a refine pass?
│ └─ YES → LTX-2.5_T2V_I2V_Two_Stage_Distilled.json (recommended)
└─ Want the fastest / lightest run?
└─ YES → LTX-2.5_T2V_I2V_Single_Stage_Distilled.json
```
Two-stage is the better default when you care about spatial detail. Single-stage is for previews and tighter VRAM.
## Comparison
| Workflow | Stages | Upsample | Main conditioning | Best for |
| -------- | ------ | -------- | ----------------- | -------- |
| T2V / I2V two-stage | 2 | 2× spatial | Text + optional image | Default generation |
| T2V / I2V single-stage | 1 | — | Text + optional image | Fast previews |
| A2V two-stage | 2 | 2× spatial | Audio (frozen) + optional image | Video that must match a soundtrack |
| T2A single-stage | 1 | — | Text | Audio-only clips |
| Union Control | 1 | — | Reference video (depth / canny / pose) | Structure-following v2v |
| V2V IC-LoRA | 1 | — | Source video (+ frozen audio) | Appearance edits on existing footage |
| [SDR→HDR IC-LoRA](./HDR_workflows/LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json) | 1 | — | SDR video (+ frozen audio) | SDR→ACEScct HDR; EXR + HLG out |
| Ingredients | 1 | — | Reference sheet | Cast / props / location from one image |
| Motion Track | 1 | — | Image + drawn tracks | Directed motion from a still |
| Inpaint two-stage | 2 | 2× spatial | Video + mask (+ frozen audio) | Replacing a region |
| Outpaint two-stage | 2 | 2× spatial | Video + target size (+ frozen audio) | Extending the frame |
| [I2V HDR two-stage](./HDR_workflows/LTX-2.5_I2V_HDR_Two_Stage_Distilled.json) | 2 | 2× spatial | EXR still (ACEScct) | HDR still → HDR video; EXR + HLG out |
| [Inpaint HDR two-stage](./HDR_workflows/LTX-2.5_ICLoRA_Inpaint_HDR_Two_Stage_Distilled.json) | 2 | 2× spatial | EXR sequence + mask video | HDR inpaint; EXR + HLG out |
| Tiled Fusion upscale | 1 | — | Source video as IC-LoRA guide | Detail-refine at full HD / 4K / 8K |
| Tiled Fusion native 4K/8K | 2 | 2× or 4× latent | Stage 1 Day-To-Night + stage 2 original@x2 | Large-canvas V2V ladder |
| Tiled Fusion SDR→HDR | 2 | 2× or 4× latent | Same as native, HDR IC-LoRA | SDR→ACEScct 4K/8K; EXR + HLG |
| Tiled Fusion HDR→HDR | 2 | 2× or 4× latent | HDR EXR still + refine IC-LoRA | Native ACEScct refine; EXR + HLG |
Native HDR I/O: [LTX-2.5_V2V_TiledFusion_HDR_to_HDR.json](./LTX-2.5_V2V_TiledFusion_HDR_to_HDR.json) — `LTXVLoadEXRSequence` → ACEScct → `LTXVVAEForceFloat32` → refine IC-LoRA tiled fusion → `LTXVHDRDecodePostprocess` (`transfer=acescct`, optional EXR) → `LTXVSaveHLG` from `hdr_linear`. `img_compression = 0`. Same idea as pipelines `--hdr` on Distilled / IC-LoRA.
SDR→HDR IC-LoRA (separate from native EXR HDR): [LTX-2.3_ICLoRA_HDR_Distilled.json](../2.3/LTX-2.3_ICLoRA_HDR_Distilled.json) for a single-stage pass, or [LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json](./LTX-2.5_V2V_TiledFusion_SDR_to_HDR.json) for the tiled 4K/8K ladder. `LoadVideo` → `LTXVSDRToHDRWorkingSpace` (`srgb_gamma`) → HDR IC-LoRA guide → float32 VAE decode → `LTXVHDRDecodePostprocess` (`transfer=acescct`, EXR=`acescct`) → HLG. Sampler **euler**; original audio frozen/muxed. Same idea as pipelines `hdr_ic_lora --text-embeddings`.
For native HDR, set **`img_compression = 0`** (Python `--hdr` EXR conditioning skips JPEG/CRF; CRF on ACEScct darkens highlights). Turn **prompt enhancer off** when matching goldens. I2V stage-2 distilled sigmas should be `0.909375, 0.725, 0.421875, 0.0` (not `0.85, …`). Inpaint keeps its own 2-step stage-2 schedule.
HDR graphs live under [`HDR_workflows/`](./HDR_workflows/).
Native HDR I/O: `LTXVLoadEXRSequence` → float32 VAE → `LTXVHDRDecodePostprocess` (`transfer=acescct`, optional EXR) → `LTXVSaveHLG` from `hdr_linear`. Same idea as pipelines `--hdr` on Distilled / IC-LoRA.
SDR→HDR IC-LoRA (separate from native EXR HDR): [LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json](./HDR_workflows/LTX-2.5_ICLoRA_SDR_to_HDR_Distilled.json) — `LoadVideo` → `LTXVSDRToHDRWorkingSpace` (`srgb_gamma`) → `Resize Image/Mask` (scale to multiple 32) → **fixed scene embeddings** (`LTXVLoadConditioning`, not a free-text prompt) → HDR IC-LoRA guide → float32 VAE decode → `LTXVHDRDecodePostprocess` (`transfer=acescct`, EXR=`acescg`) → HLG. Sampler **euler**; original audio frozen/muxed. Same idea as pipelines `hdr_ic_lora --text-embeddings`. Conditioning fps snaps to 60 when source fps is above 30 (playback fps unchanged for decode/export).
For native HDR, set **`img_compression = 0`** (Python `--hdr` EXR conditioning skips JPEG/CRF; CRF on ACEScct darkens highlights). Turn **prompt enhancer off** when matching goldens. I2V stage-2 distilled sigmas should be `0.909375, 0.725, 0.421875, 0.0` (not `0.85, …`). Inpaint keeps its own 2-step stage-2 schedule.
Python / pipeline equivalents of these ideas live in [pipeline-selection.md](https://github.com/Lightricks/LTX-2/blob/main/packages/ltx-pipelines/docs/pipeline-selection.md) in the LTX-2 repo.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6557896dab6ca788224de10cf66bb3b46fce990b921f4bb9783c2f43de77c209
size 1761966
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f32314bb85cde1b2fcc978a71d0fd93242c1132c397989a3fa1f4486574a3ff
size 1760295
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4cfb677a93331ab9614c820ab4d7436eac0f54a4685a6bf8f9083000ecc66f6f
size 6625560
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64a49c9e6d5c8d9426afa1b3b3fdbac6cbf491e7e65cc21bdf2c677743a3982d
size 1130439
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa96b9f0b71a388b30b24b64734779b35562d6ab2b8cd377781a5d5a4a388b10
size 5226932
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96c2a8f737e1e49583e56b1d0f17e487876b1e424b049434efe8132914eb3e64
size 1968386
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6666a36c4acf4ac1b1f0e85df38551ab51616593b4b93d08502f08223d18f307
size 250432
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a04e0a13b03d31c119e3d988bd840dec04b9d15c737e1e8951dc293add136a2a
size 3334888
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a89b03aa9d353dd28441cacd22258deac24ca6c8ff689471e2a968cadd2d8a26
size 35624
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d64dea45a90dc0a0cecd0317b84bc84979bb3b2074733d5157c53c792a665db
size 3653150
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c24a1501f188175ac3a3b42fa43814188a3ee38ae264a6859e796a5e2851274
size 237417
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a5ced045ce8bd87b6b6e9d5e9eedb7ffc89d271509780765134aeb0555b427d3
size 2738806
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:166913c9ea536e9b48a292312b9c380957d7836ea0facb984caacf3fd69208bd
size 461785
+220
View File
@@ -0,0 +1,220 @@
"""
API-based text encoding that returns CONDITIONING for LTX-2.
Replaces the CLIP encoding step entirely using an external API.
"""
import io
import json
import logging
import pickle
import folder_paths
import requests
from safetensors import safe_open
from .nodes_registry import comfy_node
logger = logging.getLogger(__name__)
LTXV_API_BASE_URL = "https://api.ltx.video"
UPDATE_MESSAGE = (
"Note: If this error persists, the node might be outdated. "
"Please update ComfyUI-LTXVideo to the latest version."
)
INVALID_API_KEY_MESSAGE = (
"Invalid API key. Please generate a new API key at: https://console.ltx.video/"
)
MISSING_MODEL_SELECTOR_MESSAGE = (
"Cannot identify the text encoder from the provided model file."
)
MODEL_ID_METADATA_KEY = "encrypted_wandb_properties"
GEMMA_SOURCE_CHECKPOINT_METADATA_KEY = "gemma_source_checkpoint"
MODEL_FOLDERS = ("checkpoints", "diffusion_models")
def model_filename_list() -> list[str]:
"""Every model the node can read a text-encoder selector from, de-duplicated.
A name present in both folders is listed once; resolve_model_path resolves
it the same way round, so the entry always refers to the same file the
combo offered.
"""
names = []
for folder in MODEL_FOLDERS:
for name in folder_paths.get_filename_list(folder):
if name not in names:
names.append(name)
return names
def resolve_model_path(ckpt_name: str) -> str:
"""Full path of ckpt_name, looked up in the folder order the combo used."""
for folder in MODEL_FOLDERS:
path = folder_paths.get_full_path(folder, ckpt_name)
if path is not None:
return path
raise ValueError(
f"Model file '{ckpt_name}' was not found in any of: {', '.join(MODEL_FOLDERS)}."
)
def parse_gemma_source_checkpoint(raw: str) -> dict[str, str]:
"""Parse the ``gemma_source_checkpoint`` safetensors metadata JSON string."""
try:
obj = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(MISSING_MODEL_SELECTOR_MESSAGE) from exc
if not isinstance(obj, dict):
raise ValueError(MISSING_MODEL_SELECTOR_MESSAGE)
ltx_version, gemma_version = obj.get("ltx_version"), obj.get("gemma_version")
if not ltx_version or not gemma_version:
raise ValueError(MISSING_MODEL_SELECTOR_MESSAGE)
return {"ltx_version": str(ltx_version), "gemma_version": str(gemma_version)}
def prompt_embedding_selector_from_metadata(
metadata: dict[str, str] | None,
) -> dict[str, str | dict[str, str]]:
"""Build the XOR selector for ``/v1/prompt-embedding``.
LTX-2.5+ checkpoints identify the text encoder via ``gemma_source_checkpoint``
(``ltx_version`` + ``gemma_version``). Pre-2.5 checkpoints use
``encrypted_wandb_properties`` as ``model_id``. Prefer the 2.5+ selector when
both are present: OS 2.5 files may still carry an unusable ``model_id``.
"""
if metadata:
raw_gemma = metadata.get(GEMMA_SOURCE_CHECKPOINT_METADATA_KEY)
if raw_gemma:
return {"model": parse_gemma_source_checkpoint(raw_gemma)}
model_id = metadata.get(MODEL_ID_METADATA_KEY)
if model_id:
return {"model_id": model_id}
raise ValueError(MISSING_MODEL_SELECTOR_MESSAGE)
def extract_prompt_embedding_selector(
ckpt_name: str,
) -> dict[str, str | dict[str, str]]:
with safe_open(
resolve_model_path(ckpt_name),
framework="pt",
device="cpu",
) as f:
return prompt_embedding_selector_from_metadata(f.metadata())
@comfy_node(name="GemmaAPITextEncode")
class GemmaAPITextEncode:
"""
Encodes text prompts using the LTX Video API, returning CONDITIONING for LTX-2 models.
This node replaces the local CLIP encoding step by sending the prompt to an external API
for processing. It requires an API key and selects the text encoder from checkpoint
metadata: ``model`` (LTX-2.5+) or ``model_id`` (LTX-2.3).
Inputs:
- api_key: Authentication key for the LTX Video API
- prompt: Text prompt to encode
- ckpt_name: Checkpoint file containing model metadata
Returns:
- CONDITIONING: Encoded prompt conditioning ready for LTX-2 video generation
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"api_key": (
"STRING",
{
"default": "",
"placeholder": "API_KEY",
"multiline": False,
"tooltip": "API key for authentication",
},
),
"prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "Text prompt to encode",
},
),
"enhance_prompt": (
"BOOLEAN",
{
"default": True,
"tooltip": "When enabled, the prompt is enhanced using Gemma 3 before encoding",
},
),
"ckpt_name": (
model_filename_list(),
{
"tooltip": "The model to read the API text-encoder selector from. "
"Either a checkpoint or a diffusion model."
},
),
},
}
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode"
CATEGORY = "api node/text/Lightricks"
def encode(
self, api_key: str, prompt: str, ckpt_name: str, enhance_prompt: bool = False
):
if not api_key:
raise ValueError("API key is required")
if not prompt.strip():
raise ValueError("Text prompt cannot be empty")
if not ckpt_name or not ckpt_name.strip():
raise ValueError("Model path is required")
selector = extract_prompt_embedding_selector(ckpt_name)
payload = {
"prompt": prompt,
"enhance_prompt": enhance_prompt,
**selector,
}
if "model_id" in selector:
selector_desc = f"model_id: {str(selector['model_id'])[:50]}..."
else:
selector_desc = f"model: {selector['model']}"
logger.info(
f"Calling API to encode prompt: {prompt[:50]}... with {selector_desc}"
)
try:
response = requests.post(
f"{LTXV_API_BASE_URL}/v1/prompt-embedding",
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=60,
)
if response.status_code == 401:
raise RuntimeError(INVALID_API_KEY_MESSAGE)
if response.status_code != 200:
raise RuntimeError(
f"API request failed with status {response.status_code}: {response.text}\n"
f"{UPDATE_MESSAGE}"
)
conditioning = pickle.load(io.BytesIO(response.content))
logger.info("Successfully received conditioning from API")
return (conditioning,)
except Exception as e:
raise RuntimeError(f"API request failed: {str(e)}\n {UPDATE_MESSAGE}")
+62
View File
@@ -0,0 +1,62 @@
{
"architectures": [
"Gemma3ForConditionalGeneration"
],
"boi_token_index": 255999,
"eoi_token_index": 256000,
"eos_token_id": [
1,
106
],
"image_token_index": 262144,
"initializer_range": 0.02,
"mm_tokens_per_image": 256,
"model_type": "gemma3",
"text_config": {
"attention_bias": false,
"attention_dropout": 0.0,
"attn_logit_softcapping": null,
"cache_implementation": "hybrid",
"final_logit_softcapping": null,
"head_dim": 256,
"hidden_activation": "gelu_pytorch_tanh",
"hidden_size": 3840,
"initializer_range": 0.02,
"intermediate_size": 15360,
"max_position_embeddings": 131072,
"model_type": "gemma3_text",
"num_attention_heads": 16,
"num_hidden_layers": 48,
"num_key_value_heads": 8,
"query_pre_attn_scalar": 256,
"rms_norm_eps": 1e-06,
"rope_local_base_freq": 10000,
"rope_scaling": {
"factor": 8.0,
"rope_type": "linear"
},
"rope_theta": 1000000,
"sliding_window": 1024,
"sliding_window_pattern": 6,
"torch_dtype": "bfloat16",
"use_cache": true,
"vocab_size": 262208
},
"torch_dtype": "bfloat16",
"transformers_version": "4.51.0",
"vision_config": {
"attention_dropout": 0.0,
"hidden_act": "gelu_pytorch_tanh",
"hidden_size": 1152,
"image_size": 896,
"intermediate_size": 4304,
"layer_norm_eps": 1e-06,
"model_type": "siglip_vision_model",
"num_attention_heads": 16,
"num_channels": 3,
"num_hidden_layers": 27,
"patch_size": 14,
"torch_dtype": "bfloat16",
"vision_use_head": false
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"do_convert_rgb": null,
"do_normalize": true,
"do_pan_and_scan": null,
"do_rescale": true,
"do_resize": true,
"image_mean": [
0.5,
0.5,
0.5
],
"image_processor_type": "Gemma3ImageProcessor",
"image_seq_length": 256,
"image_std": [
0.5,
0.5,
0.5
],
"pan_and_scan_max_num_crops": null,
"pan_and_scan_min_crop_size": null,
"pan_and_scan_min_ratio_to_activate": null,
"processor_class": "Gemma3Processor",
"resample": 2,
"rescale_factor": 0.00392156862745098,
"size": {
"height": 896,
"width": 896
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"image_seq_length": 256,
"processor_class": "Gemma3Processor"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+666
View File
@@ -0,0 +1,666 @@
import logging
from pathlib import Path
from typing import List, Optional, Tuple
import comfy.model_management
import comfy.sd
import comfy.supported_models_base
import folder_paths
import torch
from PIL import Image
from transformers import (
AutoImageProcessor,
AutoTokenizer,
Gemma3Config,
Gemma3ForConditionalGeneration,
Gemma3Processor,
)
from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
from transformers.models.auto.modeling_auto import MODEL_MAPPING_NAMES
from .nodes_registry import comfy_node
from .text_embeddings_connectors import load_text_embeddings_pipeline
logger = logging.getLogger(__name__)
def _load_system_prompt(filename: str) -> str:
"""Load system prompt from file at module level."""
try:
prompt_path = Path(__file__).parent / "system_prompts" / filename
if prompt_path.exists():
return prompt_path.read_text(encoding="utf-8").strip()
except Exception as e:
logger.warning(f"Could not load {filename}: {e}")
return ""
DEFAULT_T2V_SYSTEM_PROMPT = _load_system_prompt("gemma_t2v_system_prompt.txt")
DEFAULT_I2V_SYSTEM_PROMPT = _load_system_prompt("gemma_i2v_system_prompt.txt")
def tensor_to_pil(tensor: torch.Tensor) -> Image.Image:
"""Convert ComfyUI image tensor to PIL Image."""
if tensor.dim() == 4:
tensor = tensor[0]
numpy_image = (tensor.cpu().numpy() * 255).astype("uint8")
return Image.fromarray(numpy_image)
class LTXVGemmaTokenizer:
def __init__(self, tokenizer_path: str, max_length: int = 1024):
self.tokenizer = AutoTokenizer.from_pretrained(
tokenizer_path, local_files_only=True, model_max_length=max_length
)
# Gemma expects left padding for chat-style prompts; for plain text it doesn't matter much.
self.tokenizer.padding_side = "left"
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
self.max_length = max_length
def tokenize_with_weights(self, text: str, return_word_ids: bool = False):
text = text.strip()
encoded = self.tokenizer(
text,
padding="max_length",
max_length=self.max_length,
truncation=True,
return_tensors="pt",
)
input_ids = encoded.input_ids
attention_mask = encoded.attention_mask
tuples = [
(token_id, attn, i)
for i, (token_id, attn) in enumerate(zip(input_ids[0], attention_mask[0]))
]
out = {"gemma": tuples}
if not return_word_ids:
out = {k: [(t, w) for t, w, _ in v] for k, v in out.items()}
return out
class LTXVGemmaTextEncoderModel(torch.nn.Module):
def __init__(
self,
model: Gemma3ForConditionalGeneration,
feature_extractor, # FeatureExtractorV1/V2
embeddings_processor, # VideoEmbeddingsProcessor or AVEmbeddingsProcessor
processor: Gemma3Processor | None = None,
dtype=torch.bfloat16,
device="cpu",
):
super().__init__()
self.model = model
self.processor = processor
self.feature_extractor = feature_extractor.to(dtype=dtype)
self.embeddings_processor = embeddings_processor.to(dtype=dtype)
self.dtypes = set([dtype])
# Cache an estimate of memory required to load/keep the model on device
# weights size + small overhead
self._model_memory_required = (
comfy.model_management.module_size(self.model) + 256 * 1024 * 1024
)
def set_clip_options(self, options):
pass
def reset_clip_options(self):
pass
def forward(self, input_ids, attention_mask, padding_side="right"):
# Block 1: Run Gemma model
outputs = self.model(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
)
all_layer_hiddens = torch.stack(outputs.hidden_states, dim=-1) # [B, T, D, L]
# Block 2: Feature extraction
features = self.feature_extractor(
all_layer_hiddens, attention_mask, padding_side
)
return features # dict with "video" and optionally "audio"
def encode_token_weights(self, token_weight_pairs):
token_pairs = token_weight_pairs["gemma"]
input_ids = torch.tensor(
[[t[0] for t in token_pairs]], device=self.model.device
)
attention_mask = torch.tensor(
[[w[1] for w in token_pairs]], device=self.model.device
)
self.to(self.model.device)
features = self(input_ids, attention_mask, padding_side="left")
# Convert binary mask -> additive mask for processor
encoded_input_dtype = next(iter(features.values())).dtype
connector_attention_mask = (attention_mask - 1).to(encoded_input_dtype).reshape(
(attention_mask.shape[0], 1, -1, attention_mask.shape[-1])
) * torch.finfo(encoded_input_dtype).max
# Block 3: Embeddings processor
encoded, mask = self.embeddings_processor.create_embeddings(
features, connector_attention_mask
)
return encoded, None, {"attention_mask": mask}
def load_sd(self, sd):
return self.model.load_state_dict(sd, strict=False)
def memory_required(self, input_shape):
# Return a conservative estimate in bytesed(input_shape)
return self._model_memory_required
def ltxv_gemma_tokenizer(tokenizer_path, max_length=256):
class _LTXVGemmaTokenizer(LTXVGemmaTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
super().__init__(tokenizer_path, max_length=max_length)
return _LTXVGemmaTokenizer
def ltxv_gemma_clip(encoder_path, ltxv_path, processor=None, dtype=None):
class _LTXVGemmaTextEncoderModel(LTXVGemmaTextEncoderModel):
def __init__(self, device="cpu", dtype=dtype, model_options={}):
dtype = torch.bfloat16 # TODO: make this configurable
gemma_model = Gemma3ForConditionalGeneration.from_pretrained(
encoder_path,
local_files_only=True,
torch_dtype=dtype,
)
feature_extractor, embeddings_processor = load_text_embeddings_pipeline(
ltxv_path,
dtype=dtype,
fallback_proj_path=encoder_path / "proj_linear.safetensors",
)
super().__init__(
model=gemma_model,
feature_extractor=feature_extractor,
embeddings_processor=embeddings_processor,
processor=processor,
dtype=dtype,
device=device,
)
return _LTXVGemmaTextEncoderModel
def gemma_model_dir(gemma_path: str) -> Path:
"""Resolve the Gemma model directory from a selected weights file."""
model_dir = Path(folder_paths.get_full_path("text_encoders", gemma_path)).parent
if not (model_dir / "config.json").exists():
raise FileNotFoundError(
f"No config.json found for the selected Gemma model ({model_dir}). "
"Ensure the model's config, tokenizer and processor files are present."
)
return model_dir
@comfy_node(name="LTXVGemmaCLIPModelLoader", description="Gemma 3 Model Loader")
class LTXVGemmaCLIPModelLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"gemma_path": (
folder_paths.get_filename_list("text_encoders"),
{"tooltip": "The name of the text encoder model to load."},
),
"ltxv_path": (
folder_paths.get_filename_list("checkpoints"),
{"tooltip": "The name of the ltxv model to load."},
),
"max_length": (
"INT",
{"default": 1024, "min": 16, "max": 131072, "step": 8},
),
}
}
RETURN_TYPES = ("CLIP",)
RETURN_NAMES = ("clip",)
FUNCTION = "load_model"
CATEGORY = "lightricks/LTXV"
TITLE = "LTXV Gemma CLIP Loader"
OUTPUT_NODE = False
def load_model(self, gemma_path: str, ltxv_path: str, max_length: int):
model_dir = gemma_model_dir(gemma_path)
tokenizer_class = ltxv_gemma_tokenizer(model_dir, max_length=max_length)
processor = None
try:
image_processor = AutoImageProcessor.from_pretrained(
str(model_dir),
local_files_only=True,
)
processor = Gemma3Processor(
image_processor=image_processor,
tokenizer=tokenizer_class().tokenizer,
)
logger.info(f"Loaded processor from {model_dir} - enhancement enabled")
except Exception as e:
logger.warning(f"Could not load processor from {model_dir}: {e}")
clip_dtype = torch.bfloat16
ltxv_full_path = folder_paths.get_full_path("checkpoints", ltxv_path)
clip_target = comfy.supported_models_base.ClipTarget(
tokenizer=tokenizer_class,
clip=ltxv_gemma_clip(
model_dir, ltxv_full_path, processor=processor, dtype=clip_dtype
),
)
return (comfy.sd.CLIP(clip_target),)
_UNICODE_REPLACEMENTS = str.maketrans(
"\u2018\u2019\u201c\u201d\u2014\u2013\u00a0\u2032\u2212", "''\"\"-- '-"
)
def clean_response(text):
text = text.translate(_UNICODE_REPLACEMENTS)
# Remove leading non-letter characters
for i, char in enumerate(text):
if char.isalpha():
return text[i:]
return text
@comfy_node(name="LTXVGemmaEnhancePrompt", description="Gemma 3 Prompt Enhancer")
class LTXVGemmaEnhancePrompt:
"""Enhance prompts using Gemma 3 model. Supports T2V and I2V modes."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip": ("CLIP",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"system_prompt": (
"STRING",
{
"multiline": True,
"default": DEFAULT_T2V_SYSTEM_PROMPT,
},
),
"max_tokens": (
"INT",
{"default": 512, "min": 32, "max": 1024, "step": 16},
),
"bypass_i2v": ("BOOLEAN", {"default": False}),
},
"optional": {
"image": ("IMAGE",),
"seed": (
"INT",
{"default": 42, "min": 0, "max": 0xFFFFFFFF},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("enhanced_prompt",)
FUNCTION = "enhance"
CATEGORY = "lightricks/LTXV"
TITLE = "LTXV Gemma Enhance Prompt"
OUTPUT_NODE = True
DESCRIPTION = (
"Enhance text prompts using Gemma 3 VLLM for improved video generation."
)
def enhance(
self,
clip,
prompt: str,
system_prompt: str,
max_tokens: int,
bypass_i2v: bool,
image: Optional[torch.Tensor] = None,
seed: int = 42,
):
if not isinstance(seed, int):
seed = 42
clip.load_model()
encoder = clip.cond_stage_model
if not hasattr(encoder, "processor") or encoder.processor is None:
if hasattr(encoder, "gemma3_12b"):
model, processor = transformers_gemma3_from_encoder(encoder)
else:
raise ValueError(
"Processor not loaded - enhancement not available. "
"Ensure your model directory has chat_template.json, processor_config.json, "
"and preprocessor_config.json files."
)
elif isinstance(encoder, LTXVGemmaTextEncoderModel):
model = encoder.model
processor = encoder.processor
# Determine mode: use I2V if image is provided and not bypassed
use_i2v = image is not None and not bypass_i2v
# Auto-select the appropriate system prompt if user is using default T2V prompt
if use_i2v and system_prompt.strip() == DEFAULT_T2V_SYSTEM_PROMPT.strip():
system_prompt = DEFAULT_I2V_SYSTEM_PROMPT
logger.info("Auto-selected I2V system prompt for image-to-video mode")
if not system_prompt or not system_prompt.strip():
raise ValueError(
"system_prompt is required and cannot be empty or whitespace-only"
)
if use_i2v:
pil_image = tensor_to_pil(image)
enhanced_prompt = enhance_i2v(
processor=processor,
model=model,
prompt=prompt,
image=pil_image,
system_prompt=system_prompt,
max_new_tokens=max_tokens,
seed=seed,
)
else:
enhanced_prompt = enhance_t2v(
processor=processor,
model=model,
prompt=prompt,
system_prompt=system_prompt,
max_new_tokens=max_tokens,
seed=seed,
)
enhanced_prompt = clean_response(enhanced_prompt)
return (enhanced_prompt,)
def _enhance(
processor: Gemma3Processor,
model: Gemma3ForConditionalGeneration,
messages: list,
image: Optional[Image.Image] = None,
max_new_tokens: int = 512,
seed: int = 42,
) -> str:
"""Common enhancement logic for both T2V and I2V modes."""
if processor is None:
raise ValueError("Processor not loaded - enhancement not available")
text = processor.tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
model_inputs = processor(
text=text,
images=image,
return_tensors="pt",
).to(model.device)
pad_token_id = (
processor.tokenizer.pad_token_id
if processor.tokenizer.pad_token_id is not None
else 0
)
model_inputs = _pad_inputs_for_attention_alignment(model_inputs, pad_token_id)
with (
torch.inference_mode(),
torch.random.fork_rng(devices=[model.device]),
torch.autocast(device_type=model.device.type, dtype=model.dtype),
):
torch.manual_seed(seed)
outputs = model.generate(
**model_inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
)
generated_ids = outputs[0][len(model_inputs.input_ids[0]) :]
enhanced_prompt = processor.tokenizer.decode(
generated_ids, skip_special_tokens=True
)
return enhanced_prompt
def enhance_t2v(
processor: Gemma3Processor,
model: Gemma3ForConditionalGeneration,
prompt: str,
system_prompt: str,
max_new_tokens: int = 512,
seed: int = 42,
) -> str:
"""Enhance a text prompt for T2V generation."""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"User Raw Input Prompt: {prompt}."},
]
return _enhance(
processor, model, messages, max_new_tokens=max_new_tokens, seed=seed
)
def enhance_i2v(
processor: Gemma3Processor,
model: Gemma3ForConditionalGeneration,
prompt: str,
image: Image.Image,
system_prompt: str,
max_new_tokens: int = 512,
seed: int = 42,
) -> str:
"""Enhance a text prompt for I2V generation using a reference image."""
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": f"User Raw Input Prompt: {prompt}."},
],
},
]
return _enhance(
processor,
model,
messages,
image=image,
max_new_tokens=max_new_tokens,
seed=seed,
)
def _cat_with_padding(
tensor: torch.Tensor,
padding_length: int,
value: int | float,
) -> torch.Tensor:
"""Left-pad a tensor (prepend the padding) with the given value.
Decoder-only LLMs must be LEFT-padded for generation: right-padding makes the
trailing pad the sequence end, so generate() reads next-token logits from a
masked pad position instead of the real last token, producing degenerate output.
"""
return torch.cat(
[
torch.full(
(1, padding_length),
value,
dtype=tensor.dtype,
device=tensor.device,
),
tensor,
],
dim=1,
)
def _pad_inputs_for_attention_alignment(model_inputs, pad_token_id, alignment: int = 8):
"""Pad sequence length to multiple of alignment for Flash Attention compatibility.
Flash Attention within SDPA requires sequence lengths aligned to 8 bytes.
This pads input_ids, attention_mask, and token_type_ids (if present) to prevent
'p.attn_bias_ptr is not correctly aligned' errors.
"""
seq_len = model_inputs.input_ids.shape[1]
padded_len = ((seq_len + alignment - 1) // alignment) * alignment
padding_length = padded_len - seq_len
if padding_length > 0:
model_inputs["input_ids"] = _cat_with_padding(
model_inputs.input_ids, padding_length, pad_token_id
)
model_inputs["attention_mask"] = _cat_with_padding(
model_inputs.attention_mask, padding_length, 0
)
if (
"token_type_ids" in model_inputs
and model_inputs["token_type_ids"] is not None
):
model_inputs["token_type_ids"] = _cat_with_padding(
model_inputs["token_type_ids"], padding_length, 0
)
return model_inputs
def _locate_model_within_model(super_model, model_name):
class_name = MODEL_MAPPING_NAMES.get(model_name, None)
if class_name is None:
return None
for module in super_model.modules():
if module.__class__.__name__ == class_name:
return module
return None
def _locate_unique_parameter_owner_by_leaf(
root: torch.nn.Module,
leaf_param_name: str,
must_have_in_path: Optional[str] = None,
) -> Optional[Tuple[torch.nn.Module, str, torch.nn.Parameter, str]]:
modules = dict(root.named_modules())
candidates: List[Tuple[torch.nn.Module, str, torch.nn.Parameter, str]] = []
for full_name, p in root.named_parameters(recurse=True):
parts = full_name.split(".")
leaf = parts[-1]
if leaf != leaf_param_name:
continue
if must_have_in_path is not None and must_have_in_path not in parts:
continue
owner_path = ".".join(parts[:-1])
owner = modules.get(owner_path, root if owner_path == "" else None)
if owner is None:
continue
candidates.append((owner, leaf, p, full_name))
if not candidates:
return None
return candidates[0]
def transformers_gemma3_from_encoder(encoder):
jsons_path = Path(__file__).parent / "gemma_configs"
config = Gemma3Config.from_json_file(jsons_path / "gemma3cfg.json")
with torch.device("meta"):
metamodel = Gemma3ForConditionalGeneration(config)
t_model_name = config.text_config.model_type
t_model = _locate_model_within_model(metamodel, t_model_name)
if t_model is None:
raise ValueError(
"Can't locate text model while converting text encoder to Gemma3ForConditionalGeneration"
)
t_model.load_state_dict(
encoder.gemma3_12b.transformer.model.state_dict(), assign=True, strict=False
)
v_tower_name = config.vision_config.model_type
v_tower = _locate_model_within_model(metamodel, v_tower_name)
if v_tower is None:
raise ValueError(
"Can't locate vision model while converting text encoder to Gemma3ForConditionalGeneration"
)
v_model = v_tower.vision_model
v_model.load_state_dict(
encoder.gemma3_12b.transformer.vision_model.state_dict(),
assign=True,
strict=False,
)
metamodel.multi_modal_projector.load_state_dict(
encoder.gemma3_12b.transformer.multi_modal_projector.state_dict(),
assign=True,
strict=False,
)
config = config.text_config
dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)
base = config.rope_local_base_freq
device = encoder.device
positions_length = len(v_model.embeddings.position_ids[0])
position_ids = torch.arange(
positions_length, dtype=torch.long, device="cpu"
).unsqueeze(0)
v_model.embeddings.register_buffer("position_ids", position_ids)
embed_scale = torch.tensor(config.hidden_size**0.5, device=device)
t_model.embed_tokens.register_buffer("embed_scale", embed_scale)
local_rope_freqs = 1.0 / (
base
** (
torch.arange(0, dim, 2, dtype=torch.int64).to(
device=device, dtype=torch.float
)
/ dim
)
)
t_model.rotary_emb_local.register_buffer("inv_freq", local_rope_freqs)
rope_freqs, _ = ROPE_INIT_FUNCTIONS[config.rope_scaling["rope_type"]](
config, device
)
t_model.rotary_emb.register_buffer("inv_freq", rope_freqs)
lm_head_requires_grad = False
loc_result = _locate_unique_parameter_owner_by_leaf(
metamodel, leaf_param_name="weight", must_have_in_path="lm_head"
)
if loc_result is None:
raise ValueError(
"Can't locate lm_head while converting text encoder to Gemma3ForConditionalGeneration"
)
lm_head_owner, lm_head_attr, _, _ = loc_result
real_w = t_model.embed_tokens.weight
setattr(
lm_head_owner,
lm_head_attr,
torch.nn.Parameter(real_w, requires_grad=lm_head_requires_grad),
)
metamodel.to(device)
tokenizer_class = ltxv_gemma_tokenizer(jsons_path, max_length=1024)
image_processor = AutoImageProcessor.from_pretrained(
str(jsons_path),
local_files_only=True,
)
processor = Gemma3Processor(
image_processor=image_processor,
tokenizer=tokenizer_class().tokenizer,
)
return metamodel, processor
+134
View File
@@ -0,0 +1,134 @@
import comfy
import comfy_extras.nodes_lt as nodes_lt
import comfy_extras.nodes_post_processing as post_processing
import nodes
from .nodes_registry import comfy_node
def blur_internal(image, blur_radius):
if blur_radius > 0:
# https://docs.opencv.org/2.4/modules/imgproc/doc/filtering.html#getgaussiankernel
# sigma = 0.3 * blur_radius + 0.5 is what is recommended in the OpenCV doc for the
# relationship between sigma and kernel size 2*blur_radius + 1, however we want somewhat weaker
# blurring, so we use 0.3 * blur_radius instead, reducing the sigma value by 0.5
sigma = 0.3 * blur_radius
image = post_processing.Blur.execute(image, blur_radius, sigma)[0]
return image
@comfy_node(name="LTXVImgToVideoAdvanced")
class LTXVImgToVideoAdvanced:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"image": ("IMAGE",),
"width": (
"INT",
{
"default": 768,
"min": 64,
"max": nodes.MAX_RESOLUTION,
"step": 32,
},
),
"height": (
"INT",
{
"default": 512,
"min": 64,
"max": nodes.MAX_RESOLUTION,
"step": 32,
},
),
"length": (
"INT",
{"default": 97, "min": 9, "max": nodes.MAX_RESOLUTION, "step": 8},
),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}),
"crf": (
"INT",
{
"default": 29,
"min": 0,
"max": 51,
"step": 1,
"tooltip": "CRF value for the video. Higher values mean more motion, lower values mean higher quality.",
},
),
"blur_radius": (
"INT",
{
"default": 0,
"min": 0,
"max": 7,
"step": 1,
"tooltip": "Blur kernel radius size. Higher values mean more motion, lower values mean higher quality.",
},
),
"interpolation": (
[
"lanczos",
"bislerp",
"nearest",
"bilinear",
"bicubic",
"area",
"nearest-exact",
],
{"default": "lanczos"},
),
"crop": (["center", "disabled"], {"default": "disabled"}),
"strength": ("FLOAT", {"default": 0.9, "min": 0, "max": 1}),
}
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("positive", "negative", "latent")
CATEGORY = "conditioning/video_models"
FUNCTION = "generate"
DESCRIPTION = (
"Adds a conditioning frame or a video at index 0. "
"This node is used to add a keyframe or a video segment which should appear in the "
"generated video at index 0. It resizes the image to the correct size "
"and applies preprocessing to it."
)
def generate(
self,
positive,
negative,
vae,
image,
width,
height,
length,
batch_size,
crf,
blur_radius,
interpolation,
crop,
strength,
):
image = comfy.utils.common_upscale(
image.movedim(-1, 1), width, height, interpolation, crop=crop
).movedim(1, -1)
image = nodes_lt.LTXVPreprocess().execute(image, crf)[0]
image = blur_internal(image, blur_radius)
return nodes_lt.LTXVImgToVideo().execute(
positive=positive,
negative=negative,
vae=vae,
image=image,
width=width,
height=height,
length=length,
batch_size=batch_size,
strength=strength,
)
+2
View File
@@ -0,0 +1,2 @@
from .multimodal_guider import MultimodalGuiderNode # noqa: F401
from .parameters import GuiderParametersNode # noqa: F401
+311
View File
@@ -0,0 +1,311 @@
from typing import List
import comfy.samplers
import torch
from comfy.model_patcher import ModelPatcher
from comfy.patcher_extension import CallbacksMP
from ..nodes_registry import comfy_node
from ..stg import STGBlockWrapper, STGFlag
from .parameters import GuiderParameters, Modality
class MultimodalGuider(comfy.samplers.CFGGuider):
def __init__(
self,
model: ModelPatcher,
parameters: dict[str, GuiderParameters],
skip_blocks: List[int],
):
model = model.clone()
self.current_step = 0
self.last_denoised_v = None
self.last_denoised_a = None
model.add_callback_with_key(
CallbacksMP.ON_PRE_RUN, "mm_guider_on_pre_run", self.reset_current_step
)
super().__init__(model)
self.stg_flag = STGFlag(
do_skip=False,
skip_layers=skip_blocks,
)
self.patch_model(model, self.stg_flag)
self.parameters = parameters
def reset_current_step(self, model_patcher=None):
self.current_step = 0
self.last_denoised_v = None
self.last_denoised_a = None
@classmethod
def patch_model(cls, model: ModelPatcher, stg_flag: STGFlag):
transformer_blocks = cls.get_transformer_blocks(model)
for i, block in enumerate(transformer_blocks):
model.set_model_patch_replace(
STGBlockWrapper(block, stg_flag, i), "dit", "double_block", i
)
@staticmethod
def get_transformer_blocks(model: ModelPatcher):
diffusion_model = model.get_model_object("diffusion_model")
key = "diffusion_model.transformer_blocks"
if diffusion_model.__class__.__name__ == "LTXVTransformer3D":
key = "diffusion_model.transformer.transformer_blocks"
return model.get_model_object(key)
def set_conds(self, positive, negative):
self.inner_set_conds({"positive": positive, "negative": negative})
def calc_stg_indexes(
self,
run_vx: bool,
run_ax: bool,
audio_ptb: bool,
video_ptb: bool,
):
stg_indexes = set()
num_self_attns = 0
if run_vx:
num_self_attns += 1
if run_ax:
num_self_attns += 1
video_attn_idx = 0
audio_attn_idx = 0 if num_self_attns == 1 else 2
if video_ptb:
stg_indexes.add(video_attn_idx)
if audio_ptb:
stg_indexes.add(audio_attn_idx)
return list(stg_indexes)
def unpack_latents(self, x: torch.Tensor):
latent_shapes = (
self.conds.get("positive", {})[0]
.get("model_conds", {})
.get("latent_shapes", None)
.cond
)
vx, ax = comfy.utils.unpack_latents(x, latent_shapes)
return vx, ax
def pack_latents(self, vx: torch.Tensor, ax: torch.Tensor):
x, latent_shapes = comfy.utils.pack_latents([vx, ax])
return x, latent_shapes
def predict_noise(
self,
x: torch.Tensor,
timestep: torch.Tensor,
model_options: dict = {},
seed=None,
):
# in CFGGuider.predict_noise, we call sampling_function(), which uses cfg_function() to compute pos & neg
# but we'd rather do a single batch of sampling pos, neg, and perturbed, so we call calc_cond_batch([perturbed,pos,neg]) directly
current_step = self.current_step
self.current_step = current_step + 1
positive_cond = self.conds.get("positive", None)
negative_cond = self.conds.get("negative", None)
audio_params: GuiderParameters = self.parameters.get(
Modality.AUDIO.value, GuiderParameters()
)
video_params: GuiderParameters = self.parameters.get(
Modality.VIDEO.value, GuiderParameters()
)
run_vx = not video_params.do_skip(current_step)
run_ax = not audio_params.do_skip(current_step)
run_a2v = video_params.do_cross_attn(current_step)
run_v2a = audio_params.do_cross_attn(current_step)
vx, ax = self.unpack_latents(x)
if not run_vx:
vx = self.last_denoised_v
if not run_ax:
ax = self.last_denoised_a
x, _ = self.pack_latents(vx, ax)
if not run_vx and not run_ax:
return x
try:
model_options["transformer_options"]["run_vx"] = run_vx
model_options["transformer_options"]["run_ax"] = run_ax
model_options["transformer_options"]["a2v_cross_attn"] = run_a2v
model_options["transformer_options"]["v2a_cross_attn"] = run_v2a
noise_pred_pos = comfy.samplers.calc_cond_batch(
self.inner_model,
[positive_cond],
x,
timestep,
model_options,
)[0]
finally:
del model_options["transformer_options"]["run_vx"]
del model_options["transformer_options"]["run_ax"]
del model_options["transformer_options"]["a2v_cross_attn"]
del model_options["transformer_options"]["v2a_cross_attn"]
v_noise_pred_pos, a_noise_pred_pos = self.unpack_latents(noise_pred_pos)
a_noise_pred_neg, v_noise_pred_neg = 0, 0
a_noise_pred_perturbed, v_noise_pred_perturbed = 0, 0
a_noise_pred_modality, v_noise_pred_modality = 0, 0
if any(params.do_uncond() for params in [audio_params, video_params]):
try:
model_options["transformer_options"]["run_vx"] = run_vx
model_options["transformer_options"]["run_ax"] = run_ax
model_options["transformer_options"]["a2v_cross_attn"] = run_a2v
model_options["transformer_options"]["v2a_cross_attn"] = run_v2a
noise_pred_neg = comfy.samplers.calc_cond_batch(
self.inner_model,
[negative_cond],
x,
timestep,
model_options,
)[0]
v_noise_pred_neg, a_noise_pred_neg = self.unpack_latents(noise_pred_neg)
finally:
del model_options["transformer_options"]["run_vx"]
del model_options["transformer_options"]["run_ax"]
del model_options["transformer_options"]["a2v_cross_attn"]
del model_options["transformer_options"]["v2a_cross_attn"]
if any(params.do_perturbed() for params in [audio_params, video_params]):
try:
stg_indexes = self.calc_stg_indexes(
run_vx,
run_ax and ax.numel() > 0,
audio_params.perturb_attn,
video_params.perturb_attn,
)
model_options["transformer_options"]["run_vx"] = run_vx
model_options["transformer_options"]["run_ax"] = run_ax
model_options["transformer_options"]["a2v_cross_attn"] = run_a2v
model_options["transformer_options"]["v2a_cross_attn"] = run_v2a
model_options["transformer_options"]["ptb_index"] = 0
model_options["transformer_options"]["stg_indexes"] = stg_indexes
self.stg_flag.do_skip = True
noise_pred_perturbed = comfy.samplers.calc_cond_batch(
self.inner_model,
[positive_cond],
x,
timestep,
model_options,
)[0]
v_noise_pred_perturbed, a_noise_pred_perturbed = self.unpack_latents(
noise_pred_perturbed
)
finally:
self.stg_flag.do_skip = False
del model_options["transformer_options"]["ptb_index"]
del model_options["transformer_options"]["run_vx"]
del model_options["transformer_options"]["run_ax"]
del model_options["transformer_options"]["a2v_cross_attn"]
del model_options["transformer_options"]["v2a_cross_attn"]
del model_options["transformer_options"]["stg_indexes"]
if any(params.do_modality() for params in [audio_params, video_params]):
try:
model_options["transformer_options"]["run_vx"] = run_vx
model_options["transformer_options"]["run_ax"] = run_ax
model_options["transformer_options"]["a2v_cross_attn"] = False
model_options["transformer_options"]["v2a_cross_attn"] = False
noise_pred_modality = comfy.samplers.calc_cond_batch(
self.inner_model,
[positive_cond],
x,
timestep,
model_options,
)[0]
v_noise_pred_modality, a_noise_pred_modality = self.unpack_latents(
noise_pred_modality
)
finally:
del model_options["transformer_options"]["a2v_cross_attn"]
del model_options["transformer_options"]["v2a_cross_attn"]
del model_options["transformer_options"]["run_vx"]
del model_options["transformer_options"]["run_ax"]
if run_vx:
vx = video_params.calculate(
v_noise_pred_pos,
v_noise_pred_neg,
v_noise_pred_perturbed,
v_noise_pred_modality,
)
else:
vx = self.last_denoised_v
if run_ax:
ax = audio_params.calculate(
a_noise_pred_pos,
a_noise_pred_neg,
a_noise_pred_perturbed,
a_noise_pred_modality,
)
else:
ax = self.last_denoised_a
x, _ = self.pack_latents(vx, ax)
# normally this would be done in cfg_function, but we skipped
# that for efficiency: we can compute the noise predictions in
# a single call to calc_cond_batch() (rather than two)
# so we replicate the hook here
for fn in model_options.get("sampler_post_cfg_function", []):
args = {
"denoised": x,
"cond": positive_cond,
"uncond": negative_cond,
"model": self.inner_model,
"uncond_denoised": noise_pred_neg,
"cond_denoised": noise_pred_pos,
"sigma": timestep,
"model_options": model_options,
"input": x,
# not in the original call in samplers.py:cfg_function, but made available for future hooks
"perturbed_cond": positive_cond,
"perturbed_cond_denoised": noise_pred_perturbed,
}
x = fn(args)
self.last_denoised_v, self.last_denoised_a = self.unpack_latents(x)
return x
@comfy_node(name="MultimodalGuider")
class MultimodalGuiderNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"parameters": ("GUIDER_PARAMETERS",),
"skip_blocks": (
"STRING",
{"default": "", "multiline": True},
),
}
}
RETURN_TYPES = ("GUIDER",)
FUNCTION = "get_guider"
CATEGORY = "lightricks/LTXV"
def get_guider(self, model, positive, negative, parameters, skip_blocks):
skip_blocks = [int(n.strip()) for n in skip_blocks.split(",") if n.strip()]
guider = MultimodalGuider(model, parameters, skip_blocks)
guider.set_conds(positive, negative)
guider.raw_conds = (positive, negative)
return (guider,)
+168
View File
@@ -0,0 +1,168 @@
import math
from enum import Enum
from ..nodes_registry import comfy_node
class Modality(Enum):
VIDEO = "VIDEO"
AUDIO = "AUDIO"
class GuiderParameters:
def __init__(
self,
cfg_scale: float = 1.0,
stg_scale: float = 0.0,
perturb_attn: bool = True,
rescale_scale: float = 0.0,
modality_scale: float = 1.0,
skip_step: int = 0,
cross_attn: bool = True,
cfg_zero_star: bool = False,
zero_init_sigma: float = 1.0,
):
self.cfg_scale = cfg_scale
self.stg_scale = stg_scale
self.perturb_attn = perturb_attn
self.rescale_scale = rescale_scale
self.modality_scale = modality_scale
self.skip_step = skip_step
self.cross_attn = cross_attn
self.cfg_zero_star = cfg_zero_star
self.zero_init_sigma = zero_init_sigma
def __str__(self):
return f"cfg_scale: {self.cfg_scale}, stg_scale: {self.stg_scale}, rescale_scale: {self.rescale_scale}, modality_scale: {self.modality_scale}"
def __repr__(self):
return f"cfg_scale: {self.cfg_scale}, stg_scale: {self.stg_scale}, rescale_scale: {self.rescale_scale}, modality_scale: {self.modality_scale}"
def calculate(
self, noise_pred_pos, noise_pred_neg, noise_pred_pertubed, noise_pred_modality
):
noise_pred = (
noise_pred_pos
+ (self.cfg_scale - 1) * (noise_pred_pos - noise_pred_neg)
+ self.stg_scale * (noise_pred_pos - noise_pred_pertubed)
+ (self.modality_scale - 1) * (noise_pred_pos - noise_pred_modality)
)
if self.rescale_scale != 0:
factor = noise_pred_pos.std() / noise_pred.std()
factor = self.rescale_scale * factor + (1 - self.rescale_scale)
noise_pred = noise_pred * factor
return noise_pred
def do_uncond(self):
return not math.isclose(self.cfg_scale, 1.0)
def do_perturbed(self):
return not math.isclose(self.stg_scale, 0.0)
def do_modality(self):
return not math.isclose(self.modality_scale, 1.0)
def do_skip(self, step: int) -> bool:
if self.skip_step == 0:
return False
return step % (self.skip_step + 1) != 0
def do_cross_attn(self, step: int) -> bool:
return self.cross_attn and not self.do_skip(step)
@comfy_node(name="GuiderParameters")
class GuiderParametersNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"modality": (
[m.value for m in Modality],
{
"default": Modality.VIDEO.value,
},
),
"cfg": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 100.0,
"step": 0.1,
"round": 0.01,
},
),
"stg": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.01},
),
"perturb_attn": (
"BOOLEAN",
{"default": True},
),
"rescale": (
"FLOAT",
{"default": 0.7, "min": 0.0, "max": 100.0, "step": 0.01},
),
"modality_scale": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.01},
),
"skip_step": (
"INT",
{"default": 0, "min": 0, "max": 100, "step": 1},
),
"cross_attn": (
"BOOLEAN",
{"default": True},
),
},
"optional": {
"parameters": (
"GUIDER_PARAMETERS",
{"default": None},
),
},
}
RETURN_TYPES = ("GUIDER_PARAMETERS",)
FUNCTION = "get_parameters"
CATEGORY = "lightricks/LTXV"
def get_parameters(
self,
modality,
cfg,
stg,
perturb_attn,
rescale,
modality_scale,
skip_step,
cross_attn,
parameters=None,
):
parameters = parameters.copy() if parameters is not None else {}
if modality in parameters:
raise ValueError(f"Modality {modality} already exists in parameters")
parameters.update(
{
modality: GuiderParameters(
cfg,
stg,
perturb_attn,
rescale,
modality_scale,
skip_step,
cross_attn,
)
}
)
return (parameters,)
+251
View File
@@ -0,0 +1,251 @@
"""ACEScct working-space + primaries math for HDR Comfy paths.
Kept in lockstep with ``ltx_core.hdr`` / ``ltx_core.color.primaries`` on
ltx-2-internal: ACEScct curve constants match ``ltx_core.hdr``, and primaries
matrices / EXR chromaticities come from ``colour-science`` with Bradford CAT
(same source OpenColorIO-Config-ACES uses for its utility CLFs).
``colour-science`` is required (same as ``ltx_core.color.primaries``).
Used by HDR Comfy nodes in ``hdr_nodes`` (EXR ↔ VAE, SDR→HDR IC-LoRA ingest
via ``SDRColorSpace`` / sRGB EOTF → ACEScct). File I/O helpers live in ``hdr_io``.
"""
from __future__ import annotations
import enum
import logging
import colour
import torch
from torch import Tensor
logger = logging.getLogger("LTXVideo.hdr_color")
# ACEScct (AMPAS S-2016-001) — same constants as ``ltx_core.hdr``
_ACESCCT_A_LIN = 10.5402377416545
_ACESCCT_B_LIN = 0.0729055341958355
_ACESCCT_X_BRK = 0.0078125
_ACESCCT_Y_BRK = 0.155251141552511
_ACESCCT_LOG_M = 17.52
_ACESCCT_LOG_B = 9.72
_CAT = "Bradford"
_CS_ACESCG = colour.RGB_COLOURSPACES["ACEScg"]
_CS_REC709 = colour.RGB_COLOURSPACES["ITU-R BT.709"]
_CS_REC2020 = colour.RGB_COLOURSPACES["ITU-R BT.2020"]
def _rgb_to_rgb(src: colour.RGB_Colourspace, dst: colour.RGB_Colourspace) -> Tensor:
"""Linear RGB→RGB matrix from colour-science, as float32."""
return torch.as_tensor(
colour.matrix_RGB_to_RGB(src, dst, chromatic_adaptation_transform=_CAT),
dtype=torch.float32,
)
def _exr_chroma(cs: colour.RGB_Colourspace) -> tuple[float, ...]:
"""EXR ``chromaticities`` order: Rxy, Gxy, Bxy, Wxy."""
r, g, b = cs.primaries
w = cs.whitepoint
return (
float(r[0]),
float(r[1]),
float(g[0]),
float(g[1]),
float(b[0]),
float(b[1]),
float(w[0]),
float(w[1]),
)
_ACESCG_TO_SRGB = _rgb_to_rgb(_CS_ACESCG, _CS_REC709)
_SRGB_TO_ACESCG = torch.linalg.inv(_ACESCG_TO_SRGB.double()).to(torch.float32)
_REC709_TO_2020 = _rgb_to_rgb(_CS_REC709, _CS_REC2020)
_ACESCG_TO_2020 = _rgb_to_rgb(_CS_ACESCG, _CS_REC2020)
_CHROMA_REC709 = _exr_chroma(_CS_REC709)
_CHROMA_ACESCG = _exr_chroma(_CS_ACESCG)
_MATRICES_SOURCE = "colour-science"
# Public aliases (parity with ``ltx_core.color.primaries`` exports).
ACESCG_TO_SRGB = _ACESCG_TO_SRGB
SRGB_TO_ACESCG = _SRGB_TO_ACESCG
REC709_TO_2020 = _REC709_TO_2020
ACESCG_TO_2020 = _ACESCG_TO_2020
_CHROMATICITIES = {
"rec709": _CHROMA_REC709,
"acescg": _CHROMA_ACESCG,
}
class Primaries(enum.Enum):
"""Authoring / working-space colour primaries (linear light)."""
REC709 = "rec709"
ACESCG = "acescg"
@property
def exr_chromaticities(self) -> tuple[float, ...]:
"""EXR ``chromaticities`` header (R/G/B/W x,y)."""
return _CHROMATICITIES[self.value]
@property
def matrix_to_rec2020(self) -> Tensor:
"""3x3 linear map from this basis to Rec.2020 (for HLG)."""
return _REC709_TO_2020 if self is Primaries.REC709 else _ACESCG_TO_2020
def to(self, target: Primaries, video: Tensor) -> Tensor:
"""Convert linear light from this basis into ``target``. Identity when same."""
if self is target:
return video
if (self, target) == (Primaries.REC709, Primaries.ACESCG):
return _apply_matrix(video, _SRGB_TO_ACESCG)
if (self, target) == (Primaries.ACESCG, Primaries.REC709):
return _apply_matrix(video, _ACESCG_TO_SRGB)
raise ValueError(f"No primaries conversion from {self!r} to {target!r}.")
def to_rec2020(self, video: Tensor) -> Tensor:
"""Linear light in this basis → Rec.2020 (HLG master space)."""
return _apply_matrix(video, self.matrix_to_rec2020)
class HDRColorSpace(enum.Enum):
"""Source / EXR authoring colour space (matches pipelines ``--hdr``)."""
SRGB_LINEAR = "srgb_linear"
ACESCG = "acescg"
ACESCCT = "acescct"
@property
def is_log_working(self) -> bool:
return self is HDRColorSpace.ACESCCT
@property
def source_primaries(self) -> Primaries:
if self in (HDRColorSpace.ACESCG, HDRColorSpace.ACESCCT):
return Primaries.ACESCG
return Primaries.REC709
def exr_output_tags(self) -> tuple[Primaries, str]:
"""``(primaries, colorSpace tag)`` for EXR headers."""
if self is HDRColorSpace.ACESCCT:
return Primaries.ACESCG, "ACEScct"
if self is HDRColorSpace.ACESCG:
return Primaries.ACESCG, "ACEScg"
return Primaries.REC709, "sRGB"
def _apply_matrix(video: Tensor, mat: Tensor) -> Tensor:
"""Apply 3x3 on channel-last ``[..., H, W, 3]`` or channel-first ``[..., 3, H, W]``.
Comfy IMAGE tensors are channel-last; ``ltx_core`` often uses channel-first.
"""
mat = mat.to(device=video.device, dtype=video.dtype)
if video.shape[-1] == 3:
return video @ mat.T
if video.shape[-3] == 3:
return torch.einsum("...chw,dc->...dhw", video, mat)
raise ValueError(f"Expected RGB in last or -3 dim; got shape {tuple(video.shape)}")
# sRGB EOTF (IEC 61966-2-1) — same constants as ``ltx_core.hdr``.
_SRGB_A = 0.055
_SRGB_LINEAR_THRESHOLD = 0.04045
_SRGB_LINEAR_SLOPE = 12.92
_SRGB_GAMMA = 2.4
def compress_acescct(hdr: Tensor) -> Tensor:
"""Linear HDR [0, ∞) → ACEScct [0, 1]."""
x = torch.clamp(hdr, min=0.0)
log_part = (torch.log2(torch.clamp(x, min=1e-12)) + _ACESCCT_LOG_B) / _ACESCCT_LOG_M
lin_part = _ACESCCT_A_LIN * x + _ACESCCT_B_LIN
return torch.clamp(torch.where(x > _ACESCCT_X_BRK, log_part, lin_part), 0.0, 1.0)
def decompress_acescct(ct: Tensor) -> Tensor:
"""ACEScct [0, 1] → linear ACEScg [0, ∞)."""
ct = torch.clamp(ct, 0.0, 1.0)
lin_from_log = torch.pow(2.0, ct * _ACESCCT_LOG_M - _ACESCCT_LOG_B)
lin_from_lin = (ct - _ACESCCT_B_LIN) / _ACESCCT_A_LIN
return torch.where(ct > _ACESCCT_Y_BRK, lin_from_log, lin_from_lin)
def srgb_eotf_to_linear(srgb_01: Tensor) -> Tensor:
"""sRGB-encoded [0, 1] → display-linear Rec.709/sRGB (IEC 61966-2-1 EOTF)."""
x = torch.clamp(srgb_01.float(), 0.0, 1.0)
return torch.where(
x <= _SRGB_LINEAR_THRESHOLD,
x / _SRGB_LINEAR_SLOPE,
torch.pow((x + _SRGB_A) / (1.0 + _SRGB_A), _SRGB_GAMMA),
)
def to_acescct_working_space(linear_rgb: Tensor, source_primaries: Primaries) -> Tensor:
"""Scene-linear RGB → ACEScct [0, 1] (channel-last or channel-first)."""
native = source_primaries.to(Primaries.ACESCG, linear_rgb.float())
return compress_acescct(native.clamp(min=0.0))
def acescct_to_linear(
working: Tensor, out_primaries: Primaries = Primaries.REC709
) -> Tensor:
"""ACEScct [0, 1] → scene-linear in ``out_primaries``."""
linear_acescg = decompress_acescct(working.float())
return Primaries.ACESCG.to(out_primaries, linear_acescg).clamp(min=0.0)
class SDRColorSpace(enum.Enum):
"""SDR ingest colour space for mapping float RGB into ACEScct.
Matches pipelines ``SDRColorSpace`` / ``hdr_ic_lora`` IDT:
* ``srgb_gamma`` — display-referred sRGB (apply EOTF, then ACEScct).
* ``srgb`` — scene-linear Rec.709 / sRGB primaries (no EOTF).
* ``acescg`` — scene-linear ACEScg → ACEScct.
* ``acescct`` — already ACEScct log codes; clamp passthrough.
"""
SRGB = "srgb"
SRGB_GAMMA = "srgb_gamma"
ACESCG = "acescg"
ACESCCT = "acescct"
def to_working_space(self, video: Tensor) -> Tensor:
"""Map SDR float RGB into ACEScct ``[0, 1]`` (channel-last or channel-first)."""
if not video.is_floating_point():
raise ValueError(
f"SDRColorSpace.to_working_space expects float RGB; got dtype={video.dtype}. "
"Normalize integer pixels before calling."
)
video = video.float()
if self is SDRColorSpace.ACESCCT:
return video.clamp(0.0, 1.0)
if self is SDRColorSpace.SRGB_GAMMA:
return to_acescct_working_space(
srgb_eotf_to_linear(video), Primaries.REC709
)
if self is SDRColorSpace.SRGB:
return to_acescct_working_space(video, Primaries.REC709)
if self is SDRColorSpace.ACESCG:
return to_acescct_working_space(video, Primaries.ACESCG)
raise ValueError(f"Unsupported SDR color space: {self!r}")
def images_to_acescct(images: Tensor, color_space: HDRColorSpace) -> Tensor:
"""Convert loaded EXR frames ``[F,H,W,3]`` into ACEScct ``[0,1]`` for the VAE."""
images = images.float()
if color_space.is_log_working:
return images.clamp(0.0, 1.0)
return to_acescct_working_space(images, color_space.source_primaries)
def sdr_images_to_acescct(images: Tensor, color_space: SDRColorSpace) -> Tensor:
"""Convert SDR frames ``[F,H,W,C]`` into ACEScct ``[0,1]`` for HDR IC-LoRA."""
rgb = images[..., :3]
working = color_space.to_working_space(rgb)
if images.shape[-1] > 3:
return torch.cat([working, images[..., 3:].float()], dim=-1)
return working
+394
View File
@@ -0,0 +1,394 @@
"""HDR file I/O helpers: EXR read/write, HLG encode, path utilities.
Shared by Comfy HDR nodes (ingest + delivery). Color math lives in ``hdr_color``;
node classes live in ``hdr_nodes``.
"""
from __future__ import annotations
import logging
import math
import os
import re
from pathlib import Path
import folder_paths
import numpy as np
import torch
from torch import Tensor
from .hdr_color import Primaries
logger = logging.getLogger("LTXVideo.hdr_io")
# BT.2020 / HLG (ARIB STD-B67) encode constants — from ltx_core.color.hlg
_HLG_A, _HLG_B, _HLG_C = 0.17883277, 0.28466892, 0.55991073
_AV_COLOR_PRIMARIES_BT2020 = 9
_AV_COLOR_TRC_ARIB_STD_B67 = 18
_AV_COLORSPACE_BT2020_NCL = 9
_AV_COLOR_RANGE_MPEG = 1
_KR_2020, _KG_2020, _KB_2020 = 0.2627, 0.6780, 0.0593
_BT2020_RGB_TO_YUV = torch.tensor(
[
[_KR_2020, _KG_2020, _KB_2020],
[-_KR_2020 / 1.8814, -_KG_2020 / 1.8814, 0.5],
[0.5, -_KG_2020 / 1.4746, -_KB_2020 / 1.4746],
],
dtype=torch.float32,
)
def _resolve_path(path: str) -> Path:
p = Path(path).expanduser()
if p.is_absolute() and p.exists():
return p
# Prefer Comfy input dir for relative paths.
candidate = Path(folder_paths.get_input_directory()) / path
if candidate.exists():
return candidate
if p.exists():
return p.resolve()
raise FileNotFoundError(
f"HDR path not found: {path!r} (tried {candidate} and {p.resolve()})"
)
def _natural_sort_key(name: str) -> tuple:
"""Sort key so ``frame_9.exr`` precedes ``frame_10.exr`` (not lexicographic)."""
parts = re.split(r"(\d+)", name)
key: list = []
for part in parts:
if part.isdigit():
key.append((0, int(part)))
else:
key.append((1, part.casefold()))
return tuple(key)
def _list_exr_files(path: Path) -> list[Path]:
"""Resolve a still ``.exr`` or an EXR folder to a sorted frame list."""
if path.is_file():
if path.suffix.lower() != ".exr":
raise ValueError(f"Expected a .exr file; got {path}")
return [path]
if path.is_dir():
# Case-insensitive match (Linux is case-sensitive; macOS usually is not).
files = sorted(
{p for p in path.iterdir() if p.is_file() and p.suffix.lower() == ".exr"},
key=lambda p: _natural_sort_key(p.name),
)
if not files:
raise RuntimeError(f"No .exr frames in {path}")
return files
raise ValueError(f"Path must be a .exr file or a directory of EXRs; got {path}")
def _silent_audio(num_frames: int, frame_rate: float, sample_rate: int = 44100) -> dict:
"""Stereo silence long enough for the LTX audio VAE mel path.
EXR plates have no soundtrack; V2V still freezes an audio latent. A 0-length
waveform crashes ``torchaudio.functional.resample`` / ``VAEEncodeAudio``.
"""
if frame_rate <= 0:
raise ValueError(f"frame_rate must be > 0; got {frame_rate}")
duration_s = max(float(num_frames) / float(frame_rate), 1.0)
# Keep a comfortable floor for mel/STFT (typical n_fft is 1024–2048).
n_samples = max(int(math.ceil(duration_s * sample_rate)), sample_rate, 8192)
return {
"waveform": torch.zeros(1, 2, n_samples, dtype=torch.float32),
"sample_rate": int(sample_rate),
}
def _read_exr(path: Path) -> Tensor:
"""Read one EXR as float32 ``[H,W,3]`` (unbounded linear or log codes as stored).
Requires OpenImageIO (same as ``ltx_pipelines`` media I/O). No OpenCV fallback.
"""
try:
import OpenImageIO as oiio
except ImportError as e:
raise RuntimeError(
"Reading EXR requires OpenImageIO. Install with: pip install openimageio"
) from e
buf = oiio.ImageBuf(str(path))
pixels = buf.get_pixels(oiio.FLOAT)
if pixels is None or buf.has_error:
raise RuntimeError(f"OpenImageIO failed on '{path}': {buf.geterror()}")
arr = np.ascontiguousarray(np.asarray(pixels, dtype=np.float32))
if arr.ndim == 2:
arr = np.repeat(arr[..., None], 3, axis=-1)
return torch.from_numpy(np.ascontiguousarray(arr[..., :3]))
def _save_exr_frame(
frame_hwc: Tensor,
path: Path,
*,
half_precision: bool,
primaries: Primaries | None,
color_space_tag: str | None,
) -> None:
"""Write one EXR via OpenImageIO (same backend as ``ltx_pipelines``).
Optional ``primaries`` / ``color_space_tag`` set EXR header attributes when
provided; ``exr_color_space=linear`` may omit them for legacy untagged plates.
"""
try:
import OpenImageIO as oiio
except ImportError as e:
raise RuntimeError(
"Saving EXR requires OpenImageIO. Install with: pip install openimageio"
) from e
rgb = frame_hwc.detach().float().cpu().numpy()
path.parent.mkdir(parents=True, exist_ok=True)
h, w, _ = rgb.shape
fmt = oiio.HALF if half_precision else oiio.FLOAT
spec = oiio.ImageSpec(w, h, 3, fmt)
spec.channelnames = ("R", "G", "B")
spec.attribute("compression", "zip")
if primaries is not None:
spec.attribute(
"chromaticities",
"float[8]",
primaries.exr_chromaticities,
)
if color_space_tag is not None:
spec.attribute("colorSpace", color_space_tag)
out = oiio.ImageOutput.create(str(path))
if out is None:
raise RuntimeError(
f"OpenImageIO cannot create writer for {path}. "
"Ensure OpenImageIO is built with OpenEXR support."
)
try:
if not out.open(str(path), spec):
raise RuntimeError(
f"Failed to access EXR output file at '{path}': {out.geterror()}"
)
# OIIO converts to HALF from float32 buffer when spec is HALF.
if not out.write_image(np.ascontiguousarray(rgb, dtype=np.float32)):
raise RuntimeError(f"Failed to write EXR '{path}': {out.geterror()}")
finally:
out.close()
def _hlg_inverse_oetf_scalar(v: float) -> float:
return (
(v * v) / 3.0 if v <= 0.5 else (math.exp((v - _HLG_C) / _HLG_A) + _HLG_B) / 12.0
)
def _linear_to_hlg_signal(
rgb_fhwc: Tensor,
primaries: Primaries = Primaries.REC709,
white_signal: float = 0.75,
) -> Tensor:
"""Scene-linear RGB ``[F,H,W,3]`` in ``primaries`` → HLG signal in BT.2020.
Maps with ``primaries.matrix_to_rec2020`` then clamps (same as
``ltx_core.color.hlg.HlgGpuConverter``). Prefer ACEScg→Rec.2020 directly
over ACEScg→Rec.709→Rec.2020 so OOG negatives are not crushed early.
"""
white_x = _hlg_inverse_oetf_scalar(white_signal)
roll_k = white_x / (1.0 - white_x)
mat = primaries.matrix_to_rec2020.to(device=rgb_fhwc.device, dtype=rgb_fhwc.dtype)
lin = torch.nan_to_num((rgb_fhwc @ mat.T).clamp(min=0.0), nan=0.0, neginf=0.0)
x = torch.where(
lin <= 1.0,
lin * white_x,
1.0 - (1.0 - white_x) * torch.exp(-roll_k * (lin - 1.0)),
)
sig = torch.where(
x <= 1.0 / 12.0,
torch.sqrt((3.0 * x).clamp(min=0.0)),
_HLG_A * torch.log((12.0 * x - _HLG_B).clamp(min=1e-12)) + _HLG_C,
)
return sig.clamp(0.0, 1.0)
def _rgb_hlg_to_yuv420p10(
rgb_fhwc: Tensor,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""HLG RGB ``[F,H,W,3]`` → planar uint16 Y,U,V (limited / BT.2020)."""
if rgb_fhwc.shape[-2] % 2 or rgb_fhwc.shape[-3] % 2:
raise ValueError(f"HLG encode needs even H and W; got {tuple(rgb_fhwc.shape)}")
rgb = rgb_fhwc.movedim(-1, -3).contiguous() # F,3,H,W
mat = _BT2020_RGB_TO_YUV.to(device=rgb.device, dtype=rgb.dtype)
pixels = rgb.movedim(-3, -1)
yuv = pixels.flatten(-3, -2) @ mat.T
yuv = yuv.unflatten(-2, rgb.shape[-2:]).movedim(-1, -3)
y = yuv[:, :1]
uv = torch.nn.functional.avg_pool2d(
yuv[:, 1:3].contiguous(), kernel_size=2, stride=2
)
# MPEG limited 10-bit
y = y.mul(219 * 4).add_(16 * 4)
uv = uv.mul(224 * 4).add_(128 * 4)
y16 = y[:, 0].round_().clamp_(0, 1023).to(torch.uint16).cpu().numpy()
u16 = uv[:, 0].round_().clamp_(0, 1023).to(torch.uint16).cpu().numpy()
v16 = uv[:, 1].round_().clamp_(0, 1023).to(torch.uint16).cpu().numpy()
return y16, u16, v16
def _encode_hlg_mp4(
frames_linear: Tensor,
out_path: Path,
fps: float,
audio: dict | None = None,
*,
primaries: Primaries = Primaries.REC709,
) -> None:
"""Encode scene-linear ``[F,H,W,3]`` (in ``primaries``) to BT.2020/HLG/10-bit HEVC.
Optional Comfy ``AUDIO`` dict ``{waveform, sample_rate}`` is muxed as AAC.
The AAC stream is registered *before* video packets (PyAV requires this).
"""
from fractions import Fraction
import av
hlg = _linear_to_hlg_signal(frames_linear.float(), primaries=primaries)
y, u, v = _rgb_hlg_to_yuv420p10(hlg)
f, height, width = int(y.shape[0]), int(y.shape[1]), int(y.shape[2])
out_path.parent.mkdir(parents=True, exist_ok=True)
container = av.open(str(out_path), mode="w", options={"movflags": "+faststart"})
audio_stream = None
try:
stream = container.add_stream(
"libx265", rate=Fraction(fps).limit_denominator(1000)
)
stream.width = width
stream.height = height
stream.pix_fmt = "yuv420p10le"
stream.codec_tag = "hvc1"
threads = max(1, min(os.cpu_count() or 8, 16))
stream.options = {
"crf": "12",
"preset": "medium",
"x265-params": (
"colorprim=bt2020:transfer=arib-std-b67:colormatrix=bt2020nc:range=limited:"
f"repeat-headers=1:info=0:pools={threads}:frame-threads=4"
),
}
ctx = stream.codec_context
ctx.thread_count = threads
ctx.color_primaries = _AV_COLOR_PRIMARIES_BT2020
ctx.color_trc = _AV_COLOR_TRC_ARIB_STD_B67
ctx.colorspace = _AV_COLORSPACE_BT2020_NCL
ctx.color_range = _AV_COLOR_RANGE_MPEG
if audio is not None:
audio_stream = _prepare_aac_stream(container, audio)
for i in range(f):
frame = av.VideoFrame(width, height, "yuv420p10le")
for plane, src in zip(frame.planes, (y[i], u[i], v[i]), strict=True):
dest = np.frombuffer(plane, dtype=np.uint16).reshape(
plane.height, plane.line_size // 2
)
dest[:, : src.shape[1]] = src
frame.colorspace = _AV_COLORSPACE_BT2020_NCL
frame.color_range = _AV_COLOR_RANGE_MPEG
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
if audio is not None and audio_stream is not None:
_mux_comfy_audio_aac(container, audio_stream, audio)
finally:
container.close()
def _comfy_audio_stereo_n2(audio: dict) -> tuple[Tensor, int]:
"""Return stereo int16-ready float samples ``[N, 2]`` and sample rate."""
waveform = audio.get("waveform")
sample_rate = int(audio.get("sample_rate", 0) or 0)
if waveform is None or sample_rate <= 0:
raise ValueError("AUDIO must include waveform and sample_rate")
samples = waveform.detach().float().cpu()
if samples.ndim == 3:
samples = samples[0]
if samples.ndim != 2:
raise ValueError(
f"Expected AUDIO waveform [C,N] or [B,C,N]; got {tuple(waveform.shape)}"
)
if samples.shape[0] == 2:
samples_cn = samples
elif samples.shape[1] == 2:
samples_cn = samples.T
elif samples.shape[0] == 1:
samples_cn = samples.repeat(2, 1)
else:
raise ValueError(
f"AUDIO must be mono or stereo; got shape {tuple(samples.shape)}"
)
return samples_cn.T.contiguous(), sample_rate
def _prepare_aac_stream(container, audio: dict):
"""Register a stereo AAC stream before any packets are muxed."""
from fractions import Fraction
_, sample_rate = _comfy_audio_stereo_n2(audio)
audio_stream = container.add_stream("aac", rate=sample_rate)
audio_stream.codec_context.sample_rate = sample_rate
audio_stream.codec_context.layout = "stereo"
audio_stream.codec_context.time_base = Fraction(1, sample_rate)
return audio_stream
def _mux_comfy_audio_aac(container, audio_stream, audio: dict) -> None:
"""Mux Comfy AUDIO onto an already-added AAC stream (after video flush)."""
import av
samples_n2, sample_rate = _comfy_audio_stereo_n2(audio)
samples_i16 = (samples_n2.clamp(-1.0, 1.0) * 32767.0).to(torch.int16)
frame_in = av.AudioFrame.from_ndarray(
samples_i16.reshape(1, -1).numpy(),
format="s16",
layout="stereo",
)
frame_in.sample_rate = sample_rate
cc = audio_stream.codec_context
resampler = av.audio.resampler.AudioResampler(
format=cc.format or "fltp",
layout=cc.layout or "stereo",
rate=cc.sample_rate or sample_rate,
)
next_pts = 0
for rframe in resampler.resample(frame_in):
if rframe.pts is None:
rframe.pts = next_pts
next_pts += rframe.samples
for packet in audio_stream.encode(rframe):
container.mux(packet)
for rframe in resampler.resample(None):
if rframe.pts is None:
rframe.pts = next_pts
next_pts += rframe.samples
for packet in audio_stream.encode(rframe):
container.mux(packet)
for packet in audio_stream.encode(None):
container.mux(packet)
def _trim_to_8k1(images: Tensor) -> Tensor:
"""Keep longest legal ``8k+1`` prefix (VAE temporal scale 8)."""
n = images.shape[0]
legal = ((n - 1) // 8) * 8 + 1
if legal < 1:
raise ValueError(f"Need at least 1 frame; got {n}")
if legal != n:
logger.warning(
"Trimmed EXR sequence from %d to %d frames (must be 8k+1)", n, legal
)
return images[:legal]
+675
View File
@@ -0,0 +1,675 @@
"""ComfyUI HDR nodes: ingest, decode postprocess, and delivery.
Stage-oriented layout (shared ACEScct working space):
- Ingest: ``LTXVSDRToHDRWorkingSpace``, ``LTXVLoadEXRSequence``
- Runtime: ``LTXVVAEForceFloat32``
- Delivery: ``LTXVHDRDecodePostprocess``, ``LTXVSaveHLG``
Color math: ``hdr_color``. File I/O helpers: ``hdr_io``.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import folder_paths
import torch
from torch import Tensor
from .hdr_color import (
HDRColorSpace,
Primaries,
SDRColorSpace,
compress_acescct,
images_to_acescct,
sdr_images_to_acescct,
)
from .hdr_io import (
_encode_hlg_mp4,
_list_exr_files,
_read_exr,
_resolve_path,
_save_exr_frame,
_silent_audio,
_trim_to_8k1,
)
from .nodes_registry import comfy_node
logger = logging.getLogger("LTXVideo.hdr_nodes")
_EXR_COLOR_SPACE_CHOICES = ["linear", "acescct", "srgb_linear", "acescg"]
_SDR_COLOR_SPACE_CHOICES = [c.value for c in SDRColorSpace]
_COLOR_SPACE_CHOICES = [c.value for c in HDRColorSpace]
class LogC3:
"""ARRI LogC3 (EI 800) HDR compression.
Maps linear [0, inf) -> [0, 1] via the camera log curve, then scales to
[-1, 1] for VAE input.
"""
A = 5.555556
B = 0.052272
C = 0.247190
D = 0.385537
E = 5.367655
F = 0.092809
CUT = 0.010591
def compress(self, hdr: Tensor) -> Tensor:
x = torch.clamp(hdr, min=0.0)
log_part = self.C * torch.log10(self.A * x + self.B) + self.D
lin_part = self.E * x + self.F
logc = torch.where(x >= self.CUT, log_part, lin_part)
logc = torch.clamp(logc, 0.0, 1.0)
return logc * 2.0 - 1.0
def decompress(self, z: Tensor) -> Tensor:
logc = torch.clamp((z + 1.0) / 2.0, 0.0, 1.0)
cut_log = self.E * self.CUT + self.F
lin_from_log = (torch.pow(10.0, (logc - self.D) / self.C) - self.B) / self.A
lin_from_lin = (logc - self.F) / self.E
return torch.where(logc >= cut_log, lin_from_log, lin_from_lin)
# ---------------------------------------------------------------------------
# Transform registry — singleton (stateless)
# ---------------------------------------------------------------------------
_LOGC3 = LogC3()
def _hdr_decompress(decoded_01: Tensor, transfer: str = "acescct") -> Tensor:
"""Decompress VAE-decoded ``[0,1]`` image to linear HDR ``[0, inf)``.
ComfyUI's VAE decode returns images in ``[0, 1]`` via ``(raw + 1) / 2``.
* ``acescct`` — ACEScct working codes (SDR→HDR IC-LoRA + native HDR).
* ``logc3`` — legacy LogC3 SDR→HDR LoRA only.
"""
transfer = (transfer or "acescct").lower()
if transfer == "acescct":
from .hdr_color import decompress_acescct
# Decoded IMAGE is already ACEScct working-space [0, 1].
return decompress_acescct(decoded_01.float())
if transfer != "logc3":
raise ValueError(
f"Unsupported HDR transfer {transfer!r}; use 'logc3' or 'acescct'."
)
raw = decoded_01.float() * 2.0 - 1.0
return _LOGC3.decompress(raw)
def _linear_to_srgb(x: Tensor) -> Tensor:
"""Convert linear [0, 1] to sRGB [0, 1]."""
return torch.where(
x <= 0.0031308,
12.92 * x,
1.055 * torch.pow(x.clamp(min=0.0031308), 1.0 / 2.4) - 0.055,
).clamp(0.0, 1.0)
# ---------------------------------------------------------------------------
# ComfyUI Nodes
# ---------------------------------------------------------------------------
@comfy_node(name="LTXVSDRToHDRWorkingSpace")
class LTXVSDRToHDRWorkingSpace:
"""Map SDR float RGB into ACEScct ``[0, 1]`` for HDR IC-LoRA conditioning.
Place **before** resize when the source is display sRGB so
the EOTF is not applied to bilinear-filtered codes.
Default ``srgb_gamma`` matches typical ``LoadVideo`` / MP4 decode.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"color_space": (
_SDR_COLOR_SPACE_CHOICES,
{
"default": "srgb_gamma",
"tooltip": (
"SDR ingest IDT. srgb_gamma = display sRGB (EOTF then "
"ACEScct); srgb = scene-linear Rec.709; acescg / acescct "
"for float EXR-style plates."
),
},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("acescct",)
FUNCTION = "convert"
CATEGORY = "Lightricks/HDR"
DESCRIPTION = (
"SDR → ACEScct working space for HDR IC-LoRA (pipelines SDRColorSpace). "
"Wire LoadVideo images through this before resize / IC-LoRA guide."
)
def convert(self, image: torch.Tensor, color_space: str = "srgb_gamma") -> tuple:
space = SDRColorSpace(color_space)
return (sdr_images_to_acescct(image, space),)
def _exr_payload_for_color_space(
image: Tensor,
hdr_linear: Tensor,
transfer: str,
exr_color_space: str,
) -> tuple[Tensor, Primaries | None, str | None]:
"""Build EXR RGB frames and optional primaries / colorSpace tags.
``linear`` keeps the legacy 2.3 behaviour (raw decompressed buffer, no tags).
Other values match pipelines ``--hdr`` EXR authoring.
"""
space = (exr_color_space or "linear").lower()
transfer = (transfer or "acescct").lower()
rgb_in = image[..., :3].float()
hdr = hdr_linear[..., :3].float()
if space == "linear":
return hdr, None, None
if space == "acescct":
if transfer == "acescct":
frames = rgb_in.clamp(0.0, 1.0)
else:
# LogC3 linear is treated as Rec.709 scene-linear for ACEScct authoring.
frames = compress_acescct(
Primaries.REC709.to(Primaries.ACESCG, hdr).clamp(min=0.0)
)
return frames, Primaries.ACESCG, "ACEScct"
if space == "acescg":
if transfer == "acescct":
frames = hdr # decompress_acescct → ACEScg linear
else:
frames = Primaries.REC709.to(Primaries.ACESCG, hdr).clamp(min=0.0)
return frames, Primaries.ACESCG, "ACEScg"
if space == "srgb_linear":
if transfer == "acescct":
frames = Primaries.ACESCG.to(Primaries.REC709, hdr).clamp(min=0.0)
else:
frames = hdr.clamp(min=0.0)
return frames, Primaries.REC709, "sRGB"
raise ValueError(
f"Unsupported exr_color_space {exr_color_space!r}; "
f"use one of {_EXR_COLOR_SPACE_CHOICES}."
)
@comfy_node(name="LTXVHDRDecodePostprocess")
class LTXVHDRDecodePostprocess:
"""Decompress HDR from VAE output, tonemap for preview, optionally save EXR.
Place after VAE Decode. Recovers linear HDR from the VAE ``[0,1]`` image and
tonemaps to SDR for display. When ``save_exr`` is enabled, writes an EXR
sequence (legacy ``linear``, or tagged ``acescct`` / ``srgb_linear`` /
``acescg``).
For BT.2020/HLG masters, wire ``hdr_linear`` into ``LTXVSaveHLG``.
Outputs:
tonemapped: SDR preview [0, 1] after Reinhard tonemap + sRGB gamma.
hdr_linear: Raw linear HDR values [0, inf) for further processing.
With ``transfer=acescct`` this is ACEScg scene-linear; with
``logc3`` it is LogC3/Rec.709-style scene-linear.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
},
"optional": {
"transfer": (
["acescct", "logc3"],
{
"default": "acescct",
"tooltip": (
"Working-space curve of the VAE decode. "
"Use acescct for SDR→HDR IC-LoRA (after "
"LTXVSDRToHDRWorkingSpace) and for native HDR EXR; "
"logc3 only for legacy LogC3 HDR LoRA checkpoints."
),
},
),
"exposure": (
"FLOAT",
{
"default": 0.0,
"min": -10.0,
"max": 10.0,
"step": 0.1,
"display": "slider",
"tooltip": (
"Exposure in stops (EV). 0 = no change, "
"+1 = 2x brighter, -1 = half brightness. "
"Affects the tonemapped preview only."
),
},
),
"save_exr": (
"BOOLEAN",
{
"default": False,
"tooltip": "Save HDR frames as an EXR sequence.",
},
),
"exr_color_space": (
_EXR_COLOR_SPACE_CHOICES,
{
"default": "acescg",
"tooltip": (
"EXR contents when save_exr is on. "
"Default 'acescg' is tagged ACEScg scene-linear. "
"'acescct' is ACEScct log; 'srgb_linear' is tagged "
"Rec.709 linear; 'linear' is legacy raw decompress "
"(untagged). All paths use OpenImageIO."
),
},
),
"output_dir": (
"STRING",
{
"default": "output/hdr_exr",
"tooltip": (
"Directory for EXR frames (relative to ComfyUI "
"output directory, or absolute path)."
),
},
),
"filename_prefix": (
"STRING",
{"default": "frame"},
),
"half_precision": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"Save EXR as float16 (half). Smaller files, "
"negligible quality loss for most workflows."
),
},
),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("tonemapped", "hdr_linear")
OUTPUT_NODE = True
FUNCTION = "postprocess"
CATEGORY = "Lightricks/HDR"
DESCRIPTION = (
"Decompresses VAE-decoded HDR (ACEScct or legacy LogC3), tonemaps for "
"preview, and optionally writes EXR (linear / ACEScct / sRGB linear / "
"ACEScg). Wire hdr_linear into LTXVSaveHLG for a BT.2020/HLG master."
)
def postprocess(
self,
image: torch.Tensor,
transfer: str = "acescct",
exposure: float = 0.0,
save_exr: bool = False,
exr_color_space: str = "acescg",
output_dir: str = "output/hdr_exr",
filename_prefix: str = "frame",
half_precision: bool = True,
) -> tuple:
hdr = _hdr_decompress(image, transfer=transfer)
hdr = torch.clamp(hdr, min=0.0, max=1e4)
# Reinhard tonemap with exposure (stops → linear multiplier)
exposure_mult = 2.0**exposure
hdr_exposed = hdr * exposure_mult
tonemapped_linear = (hdr_exposed / (1.0 + hdr_exposed)).clamp(0.0, 1.0)
tonemapped = _linear_to_srgb(tonemapped_linear)
if save_exr:
frames, primaries, tag = _exr_payload_for_color_space(
image, hdr, transfer, exr_color_space
)
self._save_exr_frames_tagged(
frames,
output_dir,
filename_prefix,
half_precision,
primaries=primaries,
color_space_tag=tag,
)
return (tonemapped, hdr)
@staticmethod
def _resolve_exr_dir(output_dir: str) -> str:
import folder_paths
if not os.path.isabs(output_dir):
output_dir = os.path.join(folder_paths.get_output_directory(), output_dir)
os.makedirs(output_dir, exist_ok=True)
return output_dir
@classmethod
def _save_exr_frames_tagged(
cls,
frames: torch.Tensor,
output_dir: str,
filename_prefix: str,
half_precision: bool,
*,
primaries: Primaries | None,
color_space_tag: str | None,
) -> None:
output_dir = cls._resolve_exr_dir(output_dir)
for i, frame in enumerate(frames):
_save_exr_frame(
frame,
Path(output_dir) / f"{filename_prefix}_{i:05d}.exr",
half_precision=half_precision,
primaries=primaries,
color_space_tag=color_space_tag,
)
logger.info(
"Saved %d EXR frame(s) to %s (%s%s)",
frames.shape[0],
output_dir,
"float16" if half_precision else "float32",
f", {color_space_tag}" if color_space_tag else ", untagged linear",
)
@comfy_node(name="LTXVLoadEXRSequence")
class LTXVLoadEXRSequence:
"""Load an EXR still or folder and convert to ACEScct ``[0,1]`` IMAGE for the VAE.
Declares the source colour space the same way as pipelines ``--hdr``:
``srgb_linear`` / ``acescg`` (compress to ACEScct) or ``acescct`` (passthrough).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": (
"STRING",
{
"default": "",
"tooltip": (
"Path to a .exr still or a directory of *.exr frames. "
"I2V: single still is fine. V2V: use a folder with 8k+1 frames. "
"Relative paths are resolved under ComfyUI's input folder."
),
},
),
"color_space": (_COLOR_SPACE_CHOICES, {"default": "srgb_linear"}),
"frame_rate": (
"FLOAT",
{
"default": 24.0,
"min": 1.0,
"max": 120.0,
"step": 0.001,
"tooltip": "Required for EXR folders (no container fps).",
},
),
},
"optional": {
"frame_start": ("INT", {"default": 0, "min": 0, "max": 100000}),
"frame_cap": (
"INT",
{
"default": 0,
"min": 0,
"max": 100000,
"tooltip": "Max frames to load (0 = all). Still trimmed to 8k+1.",
},
),
"trim_to_8k1": (
"BOOLEAN",
{
"default": True,
"tooltip": "Trim to longest 8k+1 prefix required by the video VAE.",
},
),
},
}
RETURN_TYPES = ("IMAGE", "FLOAT", "INT", "INT", "INT", "AUDIO")
RETURN_NAMES = ("images", "frame_rate", "width", "height", "frame_count", "audio")
FUNCTION = "load"
CATEGORY = "Lightricks/HDR"
DESCRIPTION = (
"Native HDR input: load EXR → ACEScct [0,1] frames for IC-LoRA / VAE encode. "
"Also emits silent AUDIO matching duration so V2V graphs that freeze audio still wire. "
"Native EXR path only (not SDR→HDR IC-LoRA ingest). "
"Downstream: set LTXVPreprocess img_compression=0 — Python --hdr EXR conditioning "
"skips JPEG/CRF; compressing ACEScct codes darkens HDR highlights."
)
def load(
self,
path: str,
color_space: str,
frame_rate: float,
frame_start: int = 0,
frame_cap: int = 0,
trim_to_8k1: bool = True,
):
root = _resolve_path(path)
files = _list_exr_files(root)
files = files[frame_start:]
if frame_cap > 0:
files = files[:frame_cap]
if not files:
raise ValueError("No EXR frames left after frame_start / frame_cap.")
cs = HDRColorSpace(color_space)
frames = [_read_exr(f) for f in files]
shapes = {tuple(f.shape) for f in frames}
if len(shapes) != 1:
raise ValueError(
"EXR frames must share the same H×W×C; got "
+ ", ".join(str(s) for s in sorted(shapes))
)
images = torch.stack(frames, dim=0)
images = images_to_acescct(images, cs)
if trim_to_8k1:
images = _trim_to_8k1(images)
h, w = int(images.shape[1]), int(images.shape[2])
n = int(images.shape[0])
if n < 1:
raise ValueError(f"No EXR frames loaded from {root}")
audio = _silent_audio(n, float(frame_rate))
logger.info(
"Loaded %d EXR frame(s) from %s as %s → ACEScct (%dx%d @ %.3f fps); "
"silent audio %d samples @ %d Hz",
n,
root if root.is_dir() else (root.parent if root.is_file() else root),
cs.value,
w,
h,
frame_rate,
int(audio["waveform"].shape[-1]),
int(audio["sample_rate"]),
)
return (images, float(frame_rate), w, h, n, audio)
@comfy_node(name="LTXVVAEForceFloat32")
class LTXVVAEForceFloat32:
"""Force the video VAE to encode/decode in float32 (native HDR requirement).
This node does **not** clone the VAE into a separate float32 copy for the
output. It mutates the same object that was passed in (``vae_dtype`` and
``first_stage_model`` weights). The output socket is that same instance.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"vae": ("VAE",),
}
}
RETURN_TYPES = ("VAE",)
FUNCTION = "patch"
CATEGORY = "Lightricks/HDR"
DESCRIPTION = (
"Forces float32 on the video VAE (vae_dtype + first_stage_model) for "
"native HDR. IMPORTANT: this does NOT clone a float32 VAE for the "
"output — it mutates the input object in place and returns the same "
"instance. If you also wire that loader's VAE (or this output) into "
"another branch, those nodes share the mutated model: they may see "
"float16 or float32 depending on execution order (a race with the "
"original dtype). Prefer a single chain VAELoader → this node → all "
"encode/decode / IC-LoRA guide uses."
)
def patch(self, vae):
# In-place: Comfy Module.to(dtype) mutates weights; a wrapper copy would
# still share first_stage_model and waste peak memory for no isolation.
vae.vae_dtype = torch.float32
model = getattr(vae, "first_stage_model", None)
if model is not None:
try:
model.to(dtype=torch.float32)
except Exception as exc:
raise RuntimeError(
"LTXVVAEForceFloat32 could not cast VAE weights to float32 "
f"({type(exc).__name__}: {exc}). Native HDR requires float32."
) from exc
logger.info("VAE dtype forced to float32 for native HDR")
return (vae,)
@comfy_node(name="LTXVSaveHLG")
class LTXVSaveHLG:
"""Encode scene-linear ``hdr_linear`` frames to a BT.2020 / HLG 10-bit HEVC master.
Wire the ``hdr_linear`` output of ``LTXVHDRDecodePostprocess`` here (not the
tonemapped preview). Matches pipelines ``encode_video`` HLG delivery.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"hdr_linear": (
"IMAGE",
{
"tooltip": (
"Scene-linear HDR from LTXVHDRDecodePostprocess. "
"With transfer=acescct this is ACEScg linear — leave "
"linear_primaries=acescg. For logc3, use rec709."
),
},
),
"frame_rate": (
"FLOAT",
{"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.001},
),
"filename_prefix": ("STRING", {"default": "hdr/ltxv_hlg"}),
},
"optional": {
"linear_primaries": (
["acescg", "rec709"],
{
"default": "acescg",
"tooltip": (
"Primaries of hdr_linear. ACEScct decode "
"(SDR→HDR IC-LoRA or native HDR) → acescg. "
"Legacy LogC3 → rec709."
),
},
),
"audio": (
"AUDIO",
{
"tooltip": (
"Optional track to mux into the HLG MP4 (e.g. Decode AUDIO). "
"Omit for video-only."
),
},
),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save"
CATEGORY = "Lightricks/HDR"
DESCRIPTION = (
"Writes a BT.2020/HLG 10-bit HEVC master from scene-linear hdr_linear "
"(from LTXVHDRDecodePostprocess). Maps linear_primaries → Rec.2020 "
"directly (no Rec.709 hop), then HLG. Optional AUDIO is muxed as AAC."
)
def save(
self,
hdr_linear: Tensor,
frame_rate: float,
filename_prefix: str,
linear_primaries: str = "acescg",
audio: dict | None = None,
):
if hdr_linear.ndim != 4 or hdr_linear.shape[-1] < 3:
raise ValueError(f"Expected IMAGE [F,H,W,C]; got {tuple(hdr_linear.shape)}")
# ACEScg/Rec.709 → Rec.2020 inside HLG encode (Primaries.to_rec2020 /
# matrix_to_rec2020). No Rec.709 intermediate that would crush OOG early.
linear = hdr_linear[..., :3].float()
prim = (linear_primaries or "acescg").lower()
if prim == "acescg":
src_primaries = Primaries.ACESCG
elif prim == "rec709":
src_primaries = Primaries.REC709
else:
raise ValueError(
f"Unsupported linear_primaries {linear_primaries!r}; use acescg or rec709."
)
# Even dims for 4:2:0
h, w = linear.shape[1], linear.shape[2]
if h % 2 or w % 2:
linear = linear[:, : h - (h % 2), : w - (w % 2), :]
full_output_folder, filename, counter, _subfolder, _ = (
folder_paths.get_save_image_path(
filename_prefix,
folder_paths.get_output_directory(),
linear.shape[2],
linear.shape[1],
)
)
hlg_path = Path(full_output_folder) / f"{filename}_{counter:05d}_hlg.mp4"
_encode_hlg_mp4(
linear,
hlg_path,
fps=float(frame_rate),
audio=audio,
primaries=src_primaries,
)
logger.info(
"Wrote HLG 10-bit master to %s%s",
hlg_path,
" (with audio)" if audio is not None else "",
)
return {}
+906
View File
@@ -0,0 +1,906 @@
import logging
import comfy
import comfy.samplers
import comfy.sd
import comfy.utils
import comfy_extras.nodes_lt as nodes_lt
import folder_paths
import node_helpers
import torch
from comfy_api.latest import io
from .latents import LTXVDilateLatent
from .nodes_registry import NODES_DISPLAY_NAME_PREFIX, comfy_node
from .tiled_fusion_plan import snap_frames
FRAME_IDX_TOOLTIP = (
"RoPE position of the reference, in pixel frames.\n"
"0 aligns the reference with the generated clip. Use for video references.\n"
">0 places the reference at a specific pixel-frame position in the clip.\n"
"-1 places the reference outside the generated frame range, while still using it "
"for attention conditioning. Use for single-image references.\n"
"For multi-frame references, >0 values are rounded down to 1 mod 8."
)
@comfy_node(name="LTXAddVideoICLoRAGuide")
class LTXAddVideoICLoRAGuide(io.ComfyNode):
PATCHIFIER = nodes_lt.SymmetricPatchifier(1, start_end=True)
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXAddVideoICLoRAGuide",
display_name=NODES_DISPLAY_NAME_PREFIX + " Add Video IC-LoRA Guide",
category="Lightricks/IC-LoRA",
description=(
"Adds one or more conditioning frames starting at the specified frame index. "
"Supports both single images and multi-frame videos. "
"The latent_downscale_factor resizes input to a fraction of the target size "
"(1 = original, 2 = half, 3 = third, etc.) for IC-LoRA on small grids. "
"For LTXVTiledFusionSampler temporal windows, turn on use_streaming and "
"set tile_frames to the same 8n+1 value as the sampler (97 for LTX)."
),
inputs=[
io.Conditioning.Input("positive"),
io.Conditioning.Input("negative"),
io.Vae.Input("vae"),
io.Latent.Input(
"latent",
tooltip="Video-only latent to condition. Must be a 5D video latent (batch, channels, frames, height, width).",
),
io.Image.Input("image"),
io.Int.Input(
"frame_idx",
default=0,
min=-9999,
max=9999,
tooltip=FRAME_IDX_TOOLTIP,
),
io.Float.Input(
"strength",
default=1.0,
min=0.0,
max=1.0,
step=0.01,
),
io.Float.Input(
"latent_downscale_factor",
default=1.0,
min=1.0,
max=10.0,
step=1.0,
tooltip="For IC-LoRA on small grid. 1 means original size, 2 means half size, 3 means third, etc.",
),
io.Combo.Input(
"crop",
options=["disabled", "center"],
default="disabled",
tooltip="Crop mode when resizing. 'center' crops to fit, 'disabled' stretches to fit.",
),
io.Boolean.Input(
"use_tiled_encode",
default=False,
tooltip="Enable tiled VAE encoding for large resolutions/long videos to reduce memory usage.",
),
io.Int.Input(
"tile_size",
default=256,
min=64,
max=512,
step=32,
tooltip="Spatial tile size for tiled encoding. Only used when use_tiled_encode is enabled.",
),
io.Int.Input(
"tile_overlap",
default=64,
min=16,
max=256,
step=16,
tooltip="Overlap between tiles for tiled encoding. Only used when use_tiled_encode is enabled.",
),
io.Boolean.Input(
"use_streaming",
optional=True,
default=False,
tooltip=(
"Off (default): encode the whole clip as one IC-LoRA guide. "
"On: re-encode one fresh guide per LTXVTiledFusionSampler "
"temporal window and write the plan the sampler reads. "
"Set tile_frames on this node and the sampler to the same "
"value (97 for LTX)."
),
),
io.Int.Input(
"tile_frames",
optional=True,
default=97,
min=0,
max=1000,
step=1,
tooltip=(
"Window length in pixel frames, used only when use_streaming "
"is on. Snapped to 8n+1. Must match LTXVTiledFusionSampler. "
"Ignored when use_streaming is off."
),
),
],
outputs=[
io.Conditioning.Output("positive"),
io.Conditioning.Output("negative"),
io.Latent.Output("latent"),
],
)
@classmethod
def resolve_frame_idx(
cls,
positive,
frame_idx,
latent_length,
image_length,
guide_latent,
scale_factors,
):
"""Map frame_idx to the position append_keyframe should use.
A negative value is a RoPE position before the clip, not an index counted back
from its end, so it bypasses get_latent_index. That is what lets a one-frame
reference sit at -1: adjacent to the first generated frame without landing in
its slot, which is the only position a one-frame guide can occupy that makes it
indistinguishable from the frame being generated. The length assert is
meaningless there -- the guide is outside the clip by construction.
"""
if frame_idx < 0:
return frame_idx
frame_idx, latent_idx = nodes_lt.LTXVAddGuide.get_latent_index(
positive, latent_length, image_length, frame_idx, scale_factors
)
assert (
latent_idx + guide_latent.shape[2] <= latent_length
), "Conditioning frames exceed the length of the latent sequence."
return frame_idx
@classmethod
def encode(
cls,
vae,
latent_width,
latent_height,
images,
scale_factors,
latent_downscale_factor,
crop,
use_tiled_encode,
tile_size,
tile_overlap,
):
time_scale_factor, width_scale_factor, height_scale_factor = scale_factors
num_frames_to_keep = (
(images.shape[0] - 1) // time_scale_factor
) * time_scale_factor + 1
images = images[:num_frames_to_keep]
# Divide target size by latent_downscale_factor
target_width = int(latent_width * width_scale_factor / latent_downscale_factor)
target_height = int(
latent_height * height_scale_factor / latent_downscale_factor
)
pixels = comfy.utils.common_upscale(
images.movedim(-1, 1),
target_width,
target_height,
"bilinear",
crop=crop,
).movedim(1, -1)
encode_pixels = pixels[:, :, :, :3]
if use_tiled_encode:
guide_latent = vae.encode_tiled(
encode_pixels,
tile_x=tile_size,
tile_y=tile_size,
overlap=tile_overlap,
)
else:
guide_latent = vae.encode(encode_pixels)
return encode_pixels, guide_latent
@classmethod
def encode_windowed_guides(
cls,
positive,
negative,
vae,
latent_image,
noise_mask,
image,
latent_length,
latent_width,
latent_height,
scale_factors,
strength,
crop,
tile_frames,
attention_strength=1.0,
attention_mask=None,
frame_idx=0,
use_tiled_encode=False,
):
"""Re-encode each fusion window as its own clip, or return None.
Mid-clip causal latents cannot be sliced into windows, so each window's
pixels are encoded as a clip starting at 0. The shared plan is written
onto the conditioning so LTXVTiledFusionSampler pairs window w with
guide block w. Returns None when the clip fits one window.
"""
from .iclora_attention import append_guide_attention_entry
from .tiled_fusion_plan import attach_temporal_plan, temporal_plan
t_scale = scale_factors[0]
tf_lat = max(1, (tile_frames - 1) // t_scale + 1)
if not (0 < tf_lat < latent_length):
return None
if int(frame_idx) != 0:
raise ValueError(
"use_streaming encodes each window as a fresh clip at frame 0; "
f"frame_idx={frame_idx} is not supported. Use the whole-clip path "
"(use_streaming off) for stills or mid-clip placement."
)
if use_tiled_encode:
raise ValueError(
"use_streaming cannot use tiled VAE encode; tiled guide encodes "
"imprint a grid into the conditioning. Leave use_tiled_encode off."
)
keep = ((image.shape[0] - 1) // t_scale) * t_scale + 1
image = image[:keep]
ts = temporal_plan(latent_length, tf_lat)
span_px = (tf_lat - 1) * t_scale + 1
need = ts[-1] * t_scale + span_px
if image.shape[0] < need:
raise ValueError(
f"windowed guide needs {need} frames for {latent_length} "
f"content latents, found {image.shape[0]}"
)
for w, t0 in enumerate(ts):
span = image[t0 * t_scale : t0 * t_scale + span_px]
_, guide_latent = cls.encode(
vae,
latent_width,
latent_height,
span,
scale_factors,
1.0,
crop,
False,
256,
64,
)
shape = list(guide_latent.shape[2:])
toks = shape[0] * shape[1] * shape[2]
positive, negative, latent_image, noise_mask = (
nodes_lt.LTXVAddGuide.append_keyframe(
positive,
negative,
0,
latent_image,
noise_mask,
guide_latent,
strength,
scale_factors,
guide_mask=None,
latent_downscale_factor=1.0,
causal_fix=True,
)
)
# Pixel mask is (1, 1, F, H, W) after normalize_mask. F==1 broadcasts;
# otherwise it must match this window's encoded span, not the full clip.
window_mask = attention_mask
if window_mask is not None and window_mask.shape[2] != 1:
start = t0 * t_scale
window_mask = window_mask[:, :, start : start + span_px]
positive = append_guide_attention_entry(
positive,
toks,
shape,
attention_strength=attention_strength,
attention_mask=window_mask,
)
negative = append_guide_attention_entry(
negative,
toks,
shape,
attention_strength=attention_strength,
attention_mask=window_mask,
)
logging.info(
"window %d/%d: guide frames %d-%d -> %s",
w + 1,
len(ts),
t0 * t_scale,
t0 * t_scale + span_px - 1,
tuple(shape),
)
plan = {
"tile_frames": int(tile_frames),
"tf_lat": int(tf_lat),
"n_content": int(latent_length),
"ts": [int(v) for v in ts],
}
positive = attach_temporal_plan(positive, plan)
negative = attach_temporal_plan(negative, plan)
logging.info("%d per-window guides (t0 = %s)", len(ts), plan["ts"])
return io.NodeOutput(
positive, negative, {"samples": latent_image, "noise_mask": noise_mask}
)
@classmethod
def maybe_encode_windowed(
cls,
positive,
negative,
vae,
latent_image,
noise_mask,
image,
latent_length,
latent_width,
latent_height,
scale_factors,
strength,
crop,
use_streaming,
tile_frames,
latent_downscale_factor=1.0,
attention_strength=1.0,
attention_mask=None,
frame_idx=0,
use_tiled_encode=False,
):
"""Run the windowed encode only when use_streaming is on. Else None."""
if not use_streaming:
return None
if latent_downscale_factor != 1:
raise ValueError(
"use_streaming requires latent_downscale_factor=1; "
"windowed fusion guides cannot be dilated."
)
tf = snap_frames(int(tile_frames) if tile_frames and tile_frames > 0 else 97)
return cls.encode_windowed_guides(
positive,
negative,
vae,
latent_image,
noise_mask,
image,
latent_length,
latent_width,
latent_height,
scale_factors,
strength,
crop,
tf,
attention_strength=attention_strength,
attention_mask=attention_mask,
frame_idx=frame_idx,
use_tiled_encode=use_tiled_encode,
)
@classmethod
def execute(
cls,
positive,
negative,
vae,
latent,
image,
frame_idx,
strength,
latent_downscale_factor,
crop,
use_tiled_encode,
tile_size,
tile_overlap,
use_streaming=False,
tile_frames=0,
) -> io.NodeOutput:
scale_factors = vae.downscale_index_formula
latent_image = latent["samples"]
noise_mask = nodes_lt.get_noise_mask(latent)
_, _, latent_length, latent_height, latent_width = latent_image.shape
windowed = cls.maybe_encode_windowed(
positive,
negative,
vae,
latent_image,
noise_mask,
image,
latent_length,
latent_width,
latent_height,
scale_factors,
strength,
crop,
use_streaming,
tile_frames,
latent_downscale_factor=latent_downscale_factor,
frame_idx=frame_idx,
use_tiled_encode=use_tiled_encode,
)
if windowed is not None:
return windowed
time_scale_factor = scale_factors[0]
num_frames_to_keep = (
(image.shape[0] - 1) // time_scale_factor
) * time_scale_factor + 1
causal_fix = frame_idx == 0 or num_frames_to_keep == 1
if not causal_fix:
image = torch.cat([image[:1], image], dim=0)
image, guide_latent = cls.encode(
vae,
latent_width,
latent_height,
image,
scale_factors,
latent_downscale_factor,
crop,
use_tiled_encode,
tile_size,
tile_overlap,
)
if not causal_fix:
guide_latent = guide_latent[:, :, 1:, :, :]
image = image[1:]
# Record original (pre-dilation) guide latent shape for spatial mask downsampling
guide_orig_shape = list(guide_latent.shape[2:]) # [F, H_small, W_small]
guide_mask = None
# Dilate the latent if latent_downscale_factor > 1
if latent_downscale_factor > 1:
if (
latent_width % latent_downscale_factor != 0
or latent_height % latent_downscale_factor != 0
):
raise ValueError(
f"Latent spatial size {latent_width}x{latent_height} must be divisible by latent_downscale_factor {latent_downscale_factor}"
)
dilated = LTXVDilateLatent().dilate_latent(
{"samples": guide_latent},
horizontal_scale=int(latent_downscale_factor),
vertical_scale=int(latent_downscale_factor),
)[0]
guide_mask = dilated["noise_mask"]
guide_latent = dilated["samples"]
# Pre-filter token count = product of (potentially dilated) spatial dims
iclora_tokens_added = (
guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4]
)
frame_idx = cls.resolve_frame_idx(
positive, frame_idx, latent_length, len(image), guide_latent, scale_factors
)
positive, negative, latent_image, noise_mask = (
nodes_lt.LTXVAddGuide.append_keyframe(
positive,
negative,
frame_idx,
latent_image,
noise_mask,
guide_latent,
strength,
scale_factors,
guide_mask=guide_mask,
latent_downscale_factor=latent_downscale_factor,
causal_fix=causal_fix,
)
)
# Track this guide in guide_attention_entries for per-reference attention control.
from .iclora_attention import append_guide_attention_entry
positive = append_guide_attention_entry(
positive, iclora_tokens_added, guide_orig_shape
)
negative = append_guide_attention_entry(
negative, iclora_tokens_added, guide_orig_shape
)
return io.NodeOutput(
positive, negative, {"samples": latent_image, "noise_mask": noise_mask}
)
@comfy_node(name="LTXAddVideoICLoRAGuideAdvanced")
class LTXAddVideoICLoRAGuideAdvanced(LTXAddVideoICLoRAGuide):
"""Extended IC-LoRA guide node with per-guide attention strength control.
Same as LTXAddVideoICLoRAGuide, but adds attention_strength and
attention_mask inputs to control how strongly this guide's conditioning
influences generation via self-attention.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXAddVideoICLoRAGuideAdvanced",
display_name=NODES_DISPLAY_NAME_PREFIX
+ " Add Video IC-LoRA Guide Advanced",
category="Lightricks/IC-LoRA",
description=(
"Adds IC-LoRA guide conditioning with per-guide attention strength control. "
"Same as LTXAddVideoICLoRAGuide, but allows controlling how strongly this "
"guide influences generation via self-attention, optionally with a spatial mask. "
"use_streaming + matching tile_frames re-encodes one guide per Tiled Fusion window."
),
inputs=[
io.Conditioning.Input("positive"),
io.Conditioning.Input("negative"),
io.Vae.Input("vae"),
io.Latent.Input(
"latent",
tooltip="Video-only latent to condition. Must be a 5D video latent.",
),
io.Image.Input("image"),
io.Int.Input(
"frame_idx",
default=0,
min=-9999,
max=9999,
tooltip=FRAME_IDX_TOOLTIP,
),
io.Float.Input("strength", default=1.0, min=0.0, max=1.0, step=0.01),
io.Float.Input(
"latent_downscale_factor",
default=1.0,
min=1.0,
max=10.0,
step=1.0,
tooltip="For IC-LoRA on small grid. 1 = original size, 2 = half, etc.",
),
io.Combo.Input(
"crop",
options=["disabled", "center"],
default="disabled",
),
io.Boolean.Input("use_tiled_encode", default=False),
io.Int.Input("tile_size", default=256, min=64, max=512, step=32),
io.Int.Input("tile_overlap", default=64, min=16, max=256, step=16),
io.Float.Input(
"attention_strength",
default=1.0,
min=0.0,
max=1.0,
step=0.01,
tooltip=(
"Controls how strongly this guide influences generation via "
"self-attention. 1.0 = full conditioning (default), 0.0 = ignore. "
"When an attention_mask is also provided, this multiplies the mask values."
),
),
io.Mask.Input(
"attention_mask",
optional=True,
tooltip=(
"Optional pixel-space spatial mask. Shape (F, H, W) or (H, W). "
"Values in [0, 1]. Controls per-region conditioning influence. "
"Multiplied by attention_strength."
),
),
io.Boolean.Input(
"use_streaming",
optional=True,
default=False,
tooltip=(
"Off (default): whole-clip guide. On: one fresh encode per "
"fusion temporal window. Must match the sampler's tile_frames."
),
),
io.Int.Input(
"tile_frames",
optional=True,
default=97,
min=0,
max=1000,
step=1,
tooltip=(
"Used only when use_streaming is on. Snapped to 8n+1. "
"97 for LTX. Must match the sampler."
),
),
],
outputs=[
io.Conditioning.Output("positive"),
io.Conditioning.Output("negative"),
io.Latent.Output("latent"),
],
)
@classmethod
def execute(
cls,
positive,
negative,
vae,
latent,
image,
frame_idx,
strength,
latent_downscale_factor,
crop,
use_tiled_encode,
tile_size,
tile_overlap,
attention_strength=1.0,
attention_mask=None,
use_streaming=False,
tile_frames=0,
) -> io.NodeOutput:
from .iclora_attention import normalize_mask
from .latents import LTXVDilateLatent
scale_factors = vae.downscale_index_formula
latent_image = latent["samples"]
noise_mask = nodes_lt.get_noise_mask(latent)
_, _, latent_length, latent_height, latent_width = latent_image.shape
windowed = cls.maybe_encode_windowed(
positive,
negative,
vae,
latent_image,
noise_mask,
image,
latent_length,
latent_width,
latent_height,
scale_factors,
strength,
crop,
use_streaming,
tile_frames,
latent_downscale_factor=latent_downscale_factor,
attention_strength=attention_strength,
attention_mask=normalize_mask(attention_mask),
frame_idx=frame_idx,
use_tiled_encode=use_tiled_encode,
)
if windowed is not None:
return windowed
time_scale_factor = scale_factors[0]
num_frames_to_keep = (
(image.shape[0] - 1) // time_scale_factor
) * time_scale_factor + 1
causal_fix = frame_idx == 0 or num_frames_to_keep == 1
if not causal_fix:
image = torch.cat([image[:1], image], dim=0)
image, guide_latent = cls.encode(
vae,
latent_width,
latent_height,
image,
scale_factors,
latent_downscale_factor,
crop,
use_tiled_encode,
tile_size,
tile_overlap,
)
if not causal_fix:
guide_latent = guide_latent[:, :, 1:, :, :]
image = image[1:]
guide_orig_shape = list(guide_latent.shape[2:])
guide_mask = None
if latent_downscale_factor > 1:
if (
latent_width % latent_downscale_factor != 0
or latent_height % latent_downscale_factor != 0
):
raise ValueError(
f"Latent spatial size {latent_width}x{latent_height} must be "
f"divisible by latent_downscale_factor {latent_downscale_factor}"
)
dilated = LTXVDilateLatent().dilate_latent(
{"samples": guide_latent},
horizontal_scale=int(latent_downscale_factor),
vertical_scale=int(latent_downscale_factor),
)[0]
guide_mask = dilated["noise_mask"]
guide_latent = dilated["samples"]
iclora_tokens_added = (
guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4]
)
frame_idx = cls.resolve_frame_idx(
positive, frame_idx, latent_length, len(image), guide_latent, scale_factors
)
positive, negative, latent_image, noise_mask = (
nodes_lt.LTXVAddGuide.append_keyframe(
positive,
negative,
frame_idx,
latent_image,
noise_mask,
guide_latent,
strength,
scale_factors,
guide_mask=guide_mask,
latent_downscale_factor=latent_downscale_factor,
causal_fix=causal_fix,
)
)
from .iclora_attention import append_guide_attention_entry
norm_mask = normalize_mask(attention_mask)
positive = append_guide_attention_entry(
positive,
iclora_tokens_added,
guide_orig_shape,
attention_strength=attention_strength,
attention_mask=norm_mask,
)
negative = append_guide_attention_entry(
negative,
iclora_tokens_added,
guide_orig_shape,
attention_strength=attention_strength,
attention_mask=norm_mask,
)
return io.NodeOutput(
positive, negative, {"samples": latent_image, "noise_mask": noise_mask}
)
@comfy_node(name="LTXICLoRALoaderModelOnly")
class LTXICLoRALoaderModelOnly(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXICLoRALoaderModelOnly",
display_name=NODES_DISPLAY_NAME_PREFIX + " IC-LoRA Loader Model Only",
category="Lightricks/IC-LoRA",
description="Loads a LoRA model and extracts the latent_downscale_factor from the safetensors metadata.",
inputs=[
io.Model.Input("model"),
io.Combo.Input(
"lora_name",
options=folder_paths.get_filename_list("loras"),
),
io.Float.Input(
"strength_model",
default=1.0,
min=-100.0,
max=100.0,
step=0.01,
),
],
outputs=[
io.Model.Output("model"),
io.Float.Output("latent_downscale_factor"),
],
)
@classmethod
def execute(cls, model, lora_name, strength_model) -> io.NodeOutput:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
# Load lora and extract metadata
lora, metadata = comfy.utils.load_torch_file(
lora_path, safe_load=True, return_metadata=True
)
# Extract latent_downscale_factor from metadata
try:
latent_downscale_factor = float(metadata["reference_downscale_factor"])
except (KeyError, ValueError, TypeError):
latent_downscale_factor = 1.0
logging.warning(
"Failed to extract reference_downscale_factor from metadata for %s, using 1.0",
lora_path,
)
if strength_model == 0:
return io.NodeOutput(model, latent_downscale_factor)
model_lora, _ = comfy.sd.load_lora_for_models(
model, None, lora, strength_model, 0
)
return io.NodeOutput(model_lora, latent_downscale_factor)
def _patchify_audio_latent(latent):
"""Reshape audio latent (b, c, t, f) → ref_audio token dict (b, t, c*f)."""
b, c, t, f = latent.shape
ref_tokens = latent.permute(0, 2, 1, 3).reshape(b, t, c * f)
return {"tokens": ref_tokens}
@comfy_node(name="LTXVSetAudioRefTokens")
class LTXVSetAudioRefTokens(io.ComfyNode):
"""Provide speaker identity context for audio generation.
Patchifies the audio latent and attaches it as reference tokens on both
positive and negative conditioning. The model prepends these tokens with
negative temporal positions so they serve as identity context without
being part of the generated output.
Also outputs a frozen copy of the audio latent (noise_mask=0) for
direct use in stage 2 without re-encoding.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXVSetAudioRefTokens",
display_name=NODES_DISPLAY_NAME_PREFIX + " Set Audio Ref Tokens",
category="Lightricks/IC-LoRA",
description=(
"Provides speaker identity context for audio generation by attaching "
"reference audio tokens to the conditioning. The tokens are prepended "
"with negative temporal positions so the model treats them as context "
"rather than generation targets."
),
inputs=[
io.Conditioning.Input(
"positive",
tooltip="Positive conditioning to attach the reference audio tokens to.",
),
io.Conditioning.Input(
"negative",
tooltip="Negative conditioning to attach the reference audio tokens to.",
),
io.Latent.Input(
"audio_latent",
tooltip="Encoded audio latent from LTXV Audio VAE Encode.",
),
],
outputs=[
io.Conditioning.Output(
display_name="positive",
tooltip="Positive conditioning with reference audio tokens attached.",
),
io.Conditioning.Output(
display_name="negative",
tooltip="Negative conditioning with reference audio tokens attached.",
),
io.Latent.Output(
display_name="frozen_audio",
tooltip="Audio latent with noise_mask=0, fully frozen during denoising.",
),
],
)
@classmethod
def execute(cls, positive, negative, audio_latent) -> io.NodeOutput:
latent = audio_latent["samples"]
ref_audio = _patchify_audio_latent(latent)
positive = node_helpers.conditioning_set_values(
positive, {"ref_audio": ref_audio}
)
negative = node_helpers.conditioning_set_values(
negative, {"ref_audio": ref_audio}
)
frozen = audio_latent.copy()
b, c, t, f = latent.shape
frozen["noise_mask"] = torch.zeros((b, 1, t, 1), dtype=torch.float32)
return io.NodeOutput(positive, negative, frozen)
+80
View File
@@ -0,0 +1,80 @@
"""Helpers for per-guide attention strength tracking in conditioning metadata."""
def _get_guide_attention_entries(conditioning):
"""Read the current guide_attention_entries list from conditioning."""
for t in conditioning:
entries = t[1].get("guide_attention_entries", None)
if entries is not None:
return entries
return []
def _set_guide_attention_entries(conditioning, entries):
"""Write guide_attention_entries into conditioning (immutable update)."""
import node_helpers
return node_helpers.conditioning_set_values(
conditioning, {"guide_attention_entries": entries}
)
def append_guide_attention_entry(
conditioning,
pre_filter_count,
latent_shape,
attention_strength=1.0,
attention_mask=None,
):
"""Append a new guide attention entry to conditioning metadata.
Called by guide-adding nodes after appending tokens.
Args:
conditioning: ComfyUI conditioning list.
pre_filter_count: Token count for this guide (before grid_mask filtering).
latent_shape: [F, H, W] of the pre-dilation guide latent.
attention_strength: Scalar in [0, 1]. 1.0 = full attention (default).
attention_mask: Optional pixel-space mask tensor, shape (1, 1, F, H, W).
Returns:
Updated conditioning.
"""
existing_entries = _get_guide_attention_entries(conditioning)
entries = [*existing_entries]
entries.append(
{
"pre_filter_count": pre_filter_count,
"strength": attention_strength,
"pixel_mask": attention_mask,
"latent_shape": latent_shape,
}
)
return _set_guide_attention_entries(conditioning, entries)
def set_last_guide_attention_strength(conditioning, strength):
"""Override the attention strength of the most recently appended guide entry.
Separate from a guide's denoise strength: one says how clean the reference is
kept, the other how strongly the rest of the sequence may attend to it.
"""
entries = _get_guide_attention_entries(conditioning)
if not entries:
return conditioning
updated = [*entries[:-1], {**entries[-1], "strength": strength}]
return _set_guide_attention_entries(conditioning, updated)
def normalize_mask(mask):
"""Normalize a ComfyUI MASK to (1, 1, F, H, W) for downstream processing.
ComfyUI MASK type is typically (B, H, W) or (H, W).
"""
if mask is None:
return None
if mask.dim() == 2: # (H, W) → single frame
return mask.unsqueeze(0).unsqueeze(0).unsqueeze(0)
elif mask.dim() == 3: # (F, H, W) → video mask
return mask.unsqueeze(0).unsqueeze(0)
return mask
-99
View File
@@ -1,99 +0,0 @@
import io
import av
import comfy.latent_formats
import comfy.model_base
import comfy.model_management
import comfy.model_patcher
import comfy.sd
import comfy.supported_models_base
import comfy.utils
import numpy as np
import torch
from ltx_video.models.autoencoders.vae_encode import get_vae_size_scale_factor
def encode_single_frame(output_file, image_array: np.ndarray, crf):
container = av.open(output_file, "w", format="mp4")
try:
stream = container.add_stream(
"h264", rate=1, options={"crf": str(crf), "preset": "veryfast"}
)
stream.height = image_array.shape[0]
stream.width = image_array.shape[1]
av_frame = av.VideoFrame.from_ndarray(image_array, format="rgb24").reformat(
format="yuv420p"
)
container.mux(stream.encode(av_frame))
container.mux(stream.encode())
finally:
container.close()
def decode_single_frame(video_file):
container = av.open(video_file)
try:
stream = next(s for s in container.streams if s.type == "video")
frame = next(container.decode(stream))
finally:
container.close()
return frame.to_ndarray(format="rgb24")
def videofy(image: torch.Tensor, crf=29):
if crf == 0:
return image
image_array = (image * 255.0).byte().cpu().numpy()
with io.BytesIO() as output_file:
encode_single_frame(output_file, image_array, crf)
video_bytes = output_file.getvalue()
with io.BytesIO(video_bytes) as video_file:
image_array = decode_single_frame(video_file)
tensor = torch.tensor(image_array, dtype=image.dtype, device=image.device) / 255.0
return tensor
def pad_tensor(tensor, target_len):
dim = 2
repeat_factor = target_len - tensor.shape[dim] # Ceiling division
last_element = tensor.select(dim, -1).unsqueeze(dim)
padding = last_element.repeat(1, 1, repeat_factor, 1, 1)
return torch.cat([tensor, padding], dim=dim)
def encode_media_conditioning(
init_media, vae, width, height, frames_number, image_compression, initial_latent
):
pixels = comfy.utils.common_upscale(
init_media.movedim(-1, 1), width, height, "bilinear", ""
).movedim(1, -1)
encode_pixels = pixels[:, :, :, :3]
if image_compression > 0:
for i in range(encode_pixels.shape[0]):
image = videofy(encode_pixels[i], image_compression)
encode_pixels[i] = image
encoded_latents = vae.encode(encode_pixels).float()
video_scale_factor, _, _ = get_vae_size_scale_factor(vae.first_stage_model)
video_scale_factor = video_scale_factor if frames_number > 1 else 1
target_len = (frames_number // video_scale_factor) + 1
encoded_latents = encoded_latents[:, :, :target_len]
if initial_latent is None:
initial_latent = encoded_latents
else:
if encoded_latents.shape[2] > initial_latent.shape[2]:
initial_latent = pad_tensor(initial_latent, encoded_latents.shape[2])
initial_latent[:, :, : encoded_latents.shape[2], ...] = encoded_latents
init_image_frame_number = init_media.shape[0]
if init_image_frame_number == 1:
result = pad_tensor(initial_latent, target_len)
elif init_image_frame_number % 8 != 1:
result = pad_tensor(initial_latent, target_len)
else:
result = initial_latent
return result
+72
View File
@@ -0,0 +1,72 @@
import copy
import torch
from .nodes_registry import comfy_node
@comfy_node(name="LTXVAdainLatent")
class LTXVAdainLatent:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"latents": ("LATENT",),
"reference": ("LATENT",),
"factor": (
"FLOAT",
{
"default": 1.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"round": 0.01,
},
),
},
"optional": {
"per_frame": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "batch_normalize"
CATEGORY = "Lightricks/latents"
def batch_normalize(self, latents, reference, factor, per_frame=False):
latents_copy = copy.deepcopy(latents)
t = latents_copy["samples"] # B x C x F x H x W
if per_frame:
if reference["samples"].size(2) == 1:
print("Reference has only one frame, using it for all frames")
reference["samples"] = reference["samples"].repeat(
1, 1, t.size(2), 1, 1
)
elif t.size(2) > reference["samples"].size(2):
raise ValueError("Latents have more frames than reference")
for i in range(t.size(0)): # batch
for c in range(t.size(1)):
if not per_frame:
r_sd, r_mean = torch.std_mean(
reference["samples"][i, c], dim=None
) # index by original dim order
i_sd, i_mean = torch.std_mean(t[i, c], dim=None)
t[i, c] = ((t[i, c] - i_mean) / i_sd) * r_sd + r_mean
else:
for f in range(t.size(2)):
r_sd, r_mean = torch.std_mean(
reference["samples"][i, c, f], dim=None
) # index by original dim order
i_sd, i_mean = torch.std_mean(t[i, c, f], dim=None)
t[i, c, f] = ((t[i, c, f] - i_mean) / i_sd) * r_sd + r_mean
latents_copy["samples"] = torch.lerp(latents["samples"], t, factor)
return (latents_copy,)
+688
View File
@@ -0,0 +1,688 @@
import comfy.utils
import comfy_extras.nodes_lt as nodes_lt
import numpy as np
import torch
from comfy.ldm.lightricks.vae.audio_vae import LATENT_DOWNSAMPLE_FACTOR
from comfy.nested_tensor import NestedTensor
from .iclora_attention import set_last_guide_attention_strength
from .nodes_registry import comfy_node
@comfy_node(name="LTXVSelectLatents")
class LTXVSelectLatents:
"""
Selects a range of frames from a video latent.
Features:
- Supports positive and negative indexing
- Preserves batch processing capabilities
- Handles noise masks if present
- Maintains 5D tensor format
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"samples": ("LATENT",),
"start_index": (
"INT",
{"default": 0, "min": -9999, "max": 9999, "step": 1},
),
"end_index": (
"INT",
{"default": -1, "min": -9999, "max": 9999, "step": 1},
),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "select_latents"
CATEGORY = "latent/video"
DESCRIPTION = (
"Selects a range of frames from the video latent. "
"start_index and end_index define a closed interval (inclusive of both endpoints)."
)
def select_latents(self, samples: dict, start_index: int, end_index: int) -> tuple:
"""
Selects a range of frames from the video latent.
Args:
samples (dict): Video latent dictionary
start_index (int): Starting frame index (supports negative indexing)
end_index (int): Ending frame index (supports negative indexing)
Returns:
tuple: Contains modified latent dictionary with selected frames
Raises:
ValueError: If indices are invalid
"""
try:
s = samples.copy()
video_latent = s["samples"]
batch, channels, frames, height, width = video_latent.shape
# Handle negative indices
start_idx = frames + start_index if start_index < 0 else start_index
end_idx = frames + end_index if end_index < 0 else end_index
# Validate and clamp indices
start_idx = max(0, min(start_idx, frames - 1))
end_idx = max(0, min(end_idx, frames - 1))
if start_idx > end_idx:
start_idx = min(start_idx, end_idx)
# Select frames while maintaining 5D format
s["samples"] = video_latent[:, :, start_idx : end_idx + 1, :, :]
# Handle noise mask if present
if "noise_mask" in s and s["noise_mask"] is not None:
s["noise_mask"] = s["noise_mask"][:, :, start_idx : end_idx + 1, :, :]
return (s,)
except Exception as e:
print(f"[LTXVSelectLatents] Error: {str(e)}")
raise
@comfy_node(name="LTXVSetVideoLatentNoiseMasks")
class LTXVSetVideoLatentNoiseMasks:
"""
Applies multiple masks to a video latent.
Features:
- Supports multiple input mask formats (2D, 3D, 4D)
- Automatically handles fewer masks than frames by reusing the last mask
- Resizes masks to match latent dimensions
- Preserves batch processing capabilities
Input Formats:
- 2D mask: Single mask [H, W]
- 3D mask: Multiple masks [M, H, W]
- 4D mask: Multiple masks with channels [M, C, H, W]
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"samples": ("LATENT",),
"masks": ("MASK",),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "set_mask"
CATEGORY = "latent/video"
DESCRIPTION = (
"Applies multiple masks to a video latent. "
"masks can be 2D, 3D, or 4D tensors. "
"If there are fewer masks than frames, the last mask will be reused."
)
def set_mask(self, samples: dict, masks: torch.Tensor) -> tuple:
"""
Applies masks to video latent frames.
Args:
samples (dict): Video latent dictionary containing 'samples' tensor
masks (torch.Tensor): Mask tensor in various possible formats
- 2D: [H, W] single mask
- 3D: [M, H, W] multiple masks
- 4D: [M, C, H, W] multiple masks with channels
Returns:
tuple: Contains modified latent dictionary with applied masks
Raises:
ValueError: If mask dimensions are unsupported
RuntimeError: If tensor operations fail
"""
try:
s = samples.copy()
video_latent = s["samples"]
batch_size, channels, num_frames, height, width = video_latent.shape
# Initialize noise_mask if not present or resize if dimensions don't match
if "noise_mask" not in s:
s["noise_mask"] = torch.zeros(
(batch_size, 1, num_frames, height, width),
dtype=video_latent.dtype,
device=video_latent.device,
)
else:
existing_shape = s["noise_mask"].shape
# Check if noise_mask has the right number of frames
if existing_shape[2] != num_frames:
s["noise_mask"] = torch.zeros(
(batch_size, 1, num_frames, height, width),
dtype=video_latent.dtype,
device=video_latent.device,
)
# Process masks
masks_reshaped = self._reshape_masks(masks)
M = masks_reshaped.shape[0]
resized_masks = self._resize_masks(masks_reshaped, height, width)
# Apply masks efficiently
self._apply_masks(s["noise_mask"], resized_masks, num_frames, M)
return (s,)
except Exception as e:
print(f"[LTXVSetVideoLatentNoiseMasks] Error: {str(e)}")
raise
def _reshape_masks(self, masks: torch.Tensor) -> torch.Tensor:
"""Reshapes input masks to consistent 4D format."""
original_shape = tuple(masks.shape)
ndims = masks.ndim
if ndims == 2:
return masks.unsqueeze(0).unsqueeze(0)
elif ndims == 3:
return masks.reshape(masks.shape[0], 1, masks.shape[1], masks.shape[2])
elif ndims == 4:
return masks.reshape(masks.shape[0], 1, masks.shape[2], masks.shape[3])
else:
raise ValueError(
f"Unsupported 'masks' dimension: {original_shape}. "
"Must be 2D (H,W), 3D (M,H,W), or 4D (M,C,H,W)."
)
def _resize_masks(
self, masks: torch.Tensor, height: int, width: int
) -> torch.Tensor:
"""Resizes all masks to match latent dimensions."""
return torch.nn.functional.interpolate(
masks, size=(height, width), mode="bilinear", align_corners=False
)
def _apply_masks(
self,
noise_mask: torch.Tensor,
resized_masks: torch.Tensor,
num_frames: int,
M: int,
) -> None:
"""Applies resized masks to all frames."""
for f in range(num_frames):
mask_idx = min(f, M - 1) # Reuse last mask if we run out
noise_mask[:, :, f] = resized_masks[mask_idx]
@comfy_node(name="LTXVDilateLatent")
class LTXVDilateLatent:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"latent": ("LATENT",),
"horizontal_scale": (
"INT",
{"default": 1, "min": 1, "max": 100, "step": 1},
),
"vertical_scale": (
"INT",
{"default": 1, "min": 1, "max": 100, "step": 1},
),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "dilate_latent"
CATEGORY = "latent/video"
DESCRIPTION = "Dilates a latent by a grid size."
def dilate_latent(
self, latent: dict, horizontal_scale: int, vertical_scale: int
) -> tuple:
if horizontal_scale == 1 and vertical_scale == 1:
return (latent,)
samples = latent["samples"]
mask = latent.get("noise_mask", None)
dilated_shape = samples.shape[:3] + (
samples.shape[3] * vertical_scale,
samples.shape[4] * horizontal_scale,
)
dilated_samples = torch.zeros(
dilated_shape,
device=samples.device,
dtype=samples.dtype,
requires_grad=False,
)
dilated_samples[..., ::vertical_scale, ::horizontal_scale] = samples
dilated_mask_shape = (
dilated_samples.shape[0],
1,
dilated_samples.shape[2],
dilated_samples.shape[3],
dilated_samples.shape[4],
)
dilated_mask = torch.full(
dilated_mask_shape,
-1.0,
device=samples.device,
dtype=samples.dtype,
requires_grad=False,
)
dilated_mask[..., ::vertical_scale, ::horizontal_scale] = (
mask if mask is not None else 1.0
)
latent = {"samples": dilated_samples, "noise_mask": dilated_mask}
return (latent,)
def add_latent_guide(
*,
vae,
positive,
negative,
latent,
guiding_latent,
latent_idx,
strength,
attention_mask=None,
):
"""Pin an already-encoded latent as a guide, via ComfyUI's native node.
ComfyUI ships LTXVAddLatentGuide itself (Comfy-Org/ComfyUI#16176), so the
implementation lives there rather than being duplicated here; this is only the
call-shape adapter the pack's samplers want -- keyword arguments and a plain
tuple instead of a NodeOutput.
Deliberately does not forward pixel_frame_offset: that input exists only in
Lightricks' ComfyUI fork, and the pack has to keep working against a stock
install.
"""
positive, negative, latent = nodes_lt.LTXVAddLatentGuide.execute(
positive=positive,
negative=negative,
vae=vae,
latent=latent,
guiding_latent=guiding_latent,
latent_idx=latent_idx,
strength=strength,
attention_mask=attention_mask,
)
if attention_mask is None:
# The core node records `strength` as the guide's attention strength too, so a
# weakly pinned guide would also become weakly attended. The implementation this
# replaced always left attention at full strength, and the samplers calling this
# pass strengths well below 1.0 (0.15 in the tiled sampler, 0.5 when extending),
# so keep the two decoupled rather than silently reweighting their attention.
positive = set_last_guide_attention_strength(positive, 1.0)
negative = set_last_guide_attention_strength(negative, 1.0)
return positive, negative, latent
@comfy_node(name="LTXVImgToVideoConditionOnly")
class LTXVImgToVideoConditionOnly:
"""
Applies image conditioning to the first frames of a video latent.
Features:
- Takes existing latent and applies image conditioning
- Automatically resizes image to match latent dimensions
- Creates noise mask for strength control
- Supports bypass mode
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"vae": ("VAE",),
"image": ("IMAGE",),
"latent": ("LATENT",),
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0}),
},
"optional": {
"bypass": (
"BOOLEAN",
{"default": False, "tooltip": "Bypass the conditioning."},
),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
CATEGORY = "conditioning/video_models"
FUNCTION = "generate"
DESCRIPTION = (
"Applies image conditioning to the first frames of an existing latent. "
"Creates a noise mask to control conditioning strength."
)
def encode_image(self, image, shape, vae):
time_scale_factor, height_scale_factor, width_scale_factor = (
vae.downscale_index_formula
)
batch, channels, frames, height, width = shape
width *= width_scale_factor
height *= height_scale_factor
# Resize image to match latent dimensions
if image.shape[1] != height or image.shape[2] != width:
pixels = comfy.utils.common_upscale(
image.movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
else:
pixels = image
# Encode image (only RGB channels)
encode_pixels = pixels[:, :, :, :3]
t = vae.encode(encode_pixels)
return t
def generate(self, image, vae, latent, strength, bypass=False):
if bypass:
return (latent,)
samples = latent["samples"]
t = self.encode_image(image, samples.shape, vae)
# Apply encoded image to first frames
samples[:, :, : t.shape[2]] = t
# Create noise mask for conditioning
conditioning_latent_frames_mask = torch.ones(
(1, 1, samples.shape[2], 1, 1),
dtype=torch.float32,
device=samples.device,
)
conditioning_latent_frames_mask[:, :, : t.shape[2]] = 1.0 - strength
return ({"samples": samples, "noise_mask": conditioning_latent_frames_mask},)
def get_video_latent_blend_coefficients(
video_frame_index_start,
video_frame_index_end,
video_frame_count,
slope_len=3,
):
"""
Returns a blend coefficient list shaped such that:
- It is 0.0 outside the range [video_frame_index_start, video_frame_index_end]
- It ramps up from 0.0 to 1.0 starting at video_frame_index_start-slope_len to video_frame_index_start
- It stays at 1.0 during [video_frame_index_start, video_frame_index_end]
- It ramps down from 1.0 to 0.0 starting at video_frame_index_end to video_frame_index_end+slope_len
- The 'slope_len' parameter controls the sharpness (in frames) of the slope.
"""
coeffs = [0.0] * video_frame_count
# Clamp arguments to safe range
video_frame_index_start = max(
0, min(video_frame_count - 1, video_frame_index_start)
)
video_frame_index_end = max(
video_frame_index_start, min(video_frame_count - 1, video_frame_index_end)
)
slope_len = max(1, slope_len)
# Ramp up
rampl_start = max(0, video_frame_index_start - slope_len)
for i in range(rampl_start, video_frame_index_start):
# Linear ramp, but could be smoother (sigmoid/cosine)
coeffs[i] = (i - rampl_start + 1) / slope_len
# Plateau
for i in range(video_frame_index_start, video_frame_index_end + 1):
coeffs[i] = 1.0
# Ramp down
rampr_end = min(video_frame_count, video_frame_index_end + slope_len + 1)
for i in range(video_frame_index_end + 1, rampr_end):
coeffs[i] = 1.0 - ((i - (video_frame_index_end + 1) + 1) / slope_len)
coeffs[i] = max(0.0, coeffs[i])
import numpy as np
num_coeffs = len(coeffs)
pixel_frame_length = (num_coeffs - 1) * 8 + 1
xp = np.array([0] + list(range(1, pixel_frame_length, 8)))
fp = np.array(coeffs)
# Calculate the upsampled coefficients using np.interp
pixel_frame_positions = np.arange(pixel_frame_length)
pixel_frame_coefficients = np.interp(pixel_frame_positions, xp, fp).tolist()
return coeffs, pixel_frame_coefficients
@comfy_node(description="LTXV Set Audio Video Mask By Time")
class LTXVSetAudioVideoMaskByTime:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"av_latent": ("LATENT",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"model": ("MODEL",),
"vae": ("VAE",),
"audio_vae": ("VAE",),
"start_time": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2000.0}),
"end_time": ("FLOAT", {"default": 10.0, "min": 0.0, "max": 2000.0}),
"video_fps": ("FLOAT", {"default": 24.0, "min": 0.0, "max": 500.0}),
"mask_video": ("BOOLEAN", {"default": True}),
"mask_audio": ("BOOLEAN", {"default": True}),
"mask_init_value_video": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 1.0},
),
"mask_init_value_audio": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 1.0},
),
"slope_len": (
"INT",
{"default": 3, "min": 1, "max": 100, "step": 1},
),
},
"optional": {
"spatial_mask": (
"MASK",
{"default": None, "tooltip": "Spatial mask."},
),
},
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "FLOAT", "FLOAT")
RETURN_NAMES = (
"positive",
"negative",
"av_latent",
"video_latent_blend_coefficients",
"video_pixel_blend_coefficients",
)
FUNCTION = "run"
CATEGORY = "utility"
DESCRIPTION = "Sets the audio and video mask by time."
def run(
self,
av_latent,
positive,
negative,
model,
vae,
audio_vae,
start_time,
end_time,
video_fps,
mask_video,
mask_audio,
mask_init_value_video,
mask_init_value_audio,
slope_len,
spatial_mask=None,
):
from comfy.ldm.lightricks.av_model import LTXAVModel
if model.model.diffusion_model.__class__.__name__ != "LTXAVModel":
raise ValueError()
ltxav: LTXAVModel = model.model.diffusion_model
# Extract configuration from the audio VAE
sampling_rate = audio_vae.autoencoder.sampling_rate
mel_hop_length = audio_vae.autoencoder.mel_hop_length
audio_latents_per_second = (
sampling_rate / mel_hop_length / LATENT_DOWNSAMPLE_FACTOR
)
time_scale_factor = vae.downscale_index_formula[0]
video_latents_per_second = video_fps / time_scale_factor
if not isinstance(av_latent["samples"], NestedTensor):
raise ValueError("av_latent must be a NestedTensor")
video_samples, audio_samples = ltxav.separate_audio_and_video_latents(
av_latent["samples"].tensors,
None,
)
video_mask = torch.full(
(
video_samples.shape[0],
video_samples.shape[1],
video_samples.shape[2],
video_samples.shape[3],
video_samples.shape[4],
),
fill_value=mask_init_value_video,
)
audio_mask = torch.full(
(
audio_samples.shape[0],
audio_samples.shape[1],
audio_samples.shape[2],
audio_samples.shape[3],
),
fill_value=mask_init_value_audio,
)
if spatial_mask is not None:
if spatial_mask.ndim == 3:
spatial_mask = spatial_mask.unsqueeze(0)
if spatial_mask.ndim == 2:
spatial_mask = spatial_mask.unsqueeze(0).unsqueeze(0)
spatial_mask = torch.nn.functional.interpolate(
spatial_mask,
size=(video_samples.shape[3], video_samples.shape[4]),
mode="bilinear",
align_corners=False,
)
video_latent_frame_count = video_samples.shape[2]
audio_latent_frame_count = audio_samples.shape[2]
video_pixel_frame_count = (video_latent_frame_count - 1) * time_scale_factor + 1
xp = np.array(
[0]
+ list(
range(1, video_pixel_frame_count + time_scale_factor, time_scale_factor)
)
)
video_pixel_frame_start_raw = int(round(start_time * video_fps))
# video_frame_index_start = index of the value in xp rounding up
video_latent_frame_index_start = np.searchsorted(
xp, video_pixel_frame_start_raw, side="left"
)
video_pixel_frame_end_raw = int(round(end_time * video_fps))
# video_frame_index_end = index of the value in xp rounding down
video_latent_frame_index_end = (
np.searchsorted(xp, video_pixel_frame_end_raw, side="right") - 1
)
audio_latent_frame_index_start = int(
round(start_time * audio_latents_per_second)
)
audio_latent_frame_index_end = (
int(round(end_time * audio_latents_per_second)) + 1
)
# clamping
video_latent_frame_index_start = max(0, video_latent_frame_index_start)
video_latent_frame_index_end = min(
video_latent_frame_index_end, video_latent_frame_count
)
audio_latent_frame_index_start = max(0, audio_latent_frame_index_start)
audio_latent_frame_index_end = min(
audio_latent_frame_index_end, audio_latent_frame_count
)
print(
"noise mask start and end indices: video (%d %d), audio (%d %d), video fps: %f, video_latents_per_second: %f, audio_latents_per_second: %f, "
"video_latent_frame_count: %d, video_pixel_frame_count: %d, video_pixel_frame_start_raw: %d, video_pixel_frame_end_raw: %d, start_time: %f, end_time: %f"
% (
video_latent_frame_index_start,
video_latent_frame_index_end,
audio_latent_frame_index_start,
audio_latent_frame_index_end,
video_fps,
video_latents_per_second,
audio_latents_per_second,
video_latent_frame_count,
video_pixel_frame_count,
video_pixel_frame_start_raw,
video_pixel_frame_end_raw,
start_time,
end_time,
)
)
if mask_video:
if spatial_mask is not None:
video_mask[
:,
:,
video_latent_frame_index_start:video_latent_frame_index_end,
:,
:,
] = spatial_mask
else:
video_mask[
:, :, video_latent_frame_index_start:video_latent_frame_index_end
] = 1.0
if mask_audio:
audio_mask[
:, :, audio_latent_frame_index_start:audio_latent_frame_index_end
] = 1.0
if "noise_mask" in av_latent:
base_mask = av_latent["noise_mask"].tensors[0].clone()
if (
base_mask.shape[0]
== base_mask.shape[1]
== 1
== base_mask.shape[3]
== base_mask.shape[4]
):
for frame in range(base_mask.shape[2]):
video_mask[:, :, frame, :, :] *= base_mask[0, 0, frame, 0, 0]
av_latent["noise_mask"] = NestedTensor(
ltxav.recombine_audio_and_video_latents(video_mask, audio_mask)
)
video_latent_blend_coefficients, video_pixel_blend_coefficients = (
get_video_latent_blend_coefficients(
video_latent_frame_index_start,
video_latent_frame_index_end,
video_latent_frame_count,
slope_len=slope_len,
)
)
return (
positive,
negative,
av_latent,
video_latent_blend_coefficients,
video_pixel_blend_coefficients,
)
-179
View File
@@ -1,179 +0,0 @@
import json
from pathlib import Path
import comfy
import comfy.model_management
import comfy.model_patcher
import folder_paths
import safetensors.torch
import torch
from ltx_video.models.autoencoders.causal_video_autoencoder import (
CausalVideoAutoencoder,
)
from ltx_video.models.transformers.symmetric_patchifier import SymmetricPatchifier
from ltx_video.models.transformers.transformer3d import Transformer3DModel
from safetensors import safe_open
from .model import LTXVModel, LTXVModelConfig, LTXVTransformer3D
from .nodes_registry import comfy_node
from .vae import LTXVVAE
@comfy_node(name="LTXVLoader")
class LTXVLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"ckpt_name": (
folder_paths.get_filename_list("checkpoints"),
{"tooltip": "The name of the checkpoint (model) to load."},
),
"dtype": (["bfloat16", "float32"], {"default": "bfloat16"}),
}
}
RETURN_TYPES = ("MODEL", "VAE")
RETURN_NAMES = ("model", "vae")
FUNCTION = "load"
CATEGORY = "lightricks/LTXV"
TITLE = "LTXV Loader"
OUTPUT_NODE = False
def load(self, ckpt_name, dtype):
dtype_map = {"bfloat16": torch.bfloat16, "float32": torch.float32}
load_device = comfy.model_management.get_torch_device()
offload_device = comfy.model_management.unet_offload_device()
ckpt_path = Path(folder_paths.get_full_path("checkpoints", ckpt_name))
vae_config = None
unet_config = None
with safe_open(ckpt_path, framework="pt", device="cpu") as f:
metadata = f.metadata()
if metadata is not None:
config_metadata = metadata.get("config", None)
if config_metadata is not None:
config_metadata = json.loads(config_metadata)
vae_config = config_metadata.get("vae", None)
unet_config = config_metadata.get("transformer", None)
weights = safetensors.torch.load_file(ckpt_path, device="cpu")
vae = self._load_vae(weights, vae_config)
num_latent_channels = vae.first_stage_model.config.latent_channels
model = self._load_unet(
load_device,
offload_device,
weights,
num_latent_channels,
dtype=dtype_map[dtype],
config=unet_config,
)
return (model, vae)
def _load_vae(self, weights, config=None):
if not config:
config = {
"_class_name": "CausalVideoAutoencoder",
"dims": 3,
"in_channels": 3,
"out_channels": 3,
"latent_channels": 128,
"blocks": [
["res_x", 4],
["compress_all", 1],
["res_x_y", 1],
["res_x", 3],
["compress_all", 1],
["res_x_y", 1],
["res_x", 3],
["compress_all", 1],
["res_x", 3],
["res_x", 4],
],
"scaling_factor": 1.0,
"norm_layer": "pixel_norm",
"patch_size": 4,
"latent_log_var": "uniform",
"use_quant_conv": False,
"causal_decoder": False,
}
vae_prefix = "vae."
vae = LTXVVAE.from_config_and_state_dict(
vae_class=CausalVideoAutoencoder,
config=config,
state_dict={
key.removeprefix(vae_prefix): value
for key, value in weights.items()
if key.startswith(vae_prefix)
},
)
return vae
def _load_unet(
self,
load_device,
offload_device,
weights,
num_latent_channels,
dtype,
config=None,
):
if not config:
config = {
"_class_name": "Transformer3DModel",
"_diffusers_version": "0.25.1",
"_name_or_path": "PixArt-alpha/PixArt-XL-2-256x256",
"activation_fn": "gelu-approximate",
"attention_bias": True,
"attention_head_dim": 64,
"attention_type": "default",
"caption_channels": 4096,
"cross_attention_dim": 2048,
"double_self_attention": False,
"dropout": 0.0,
"in_channels": 128,
"norm_elementwise_affine": False,
"norm_eps": 1e-06,
"norm_num_groups": 32,
"num_attention_heads": 32,
"num_embeds_ada_norm": 1000,
"num_layers": 28,
"num_vector_embeds": None,
"only_cross_attention": False,
"out_channels": 128,
"project_to_2d_pos": True,
"upcast_attention": False,
"use_linear_projection": False,
"qk_norm": "rms_norm",
"standardization_norm": "rms_norm",
"positional_embedding_type": "rope",
"positional_embedding_theta": 10000.0,
"positional_embedding_max_pos": [20, 2048, 2048],
"timestep_scale_multiplier": 1000,
}
transformer = Transformer3DModel.from_config(config)
unet_prefix = "model.diffusion_model."
transformer.load_state_dict(
{
key.removeprefix(unet_prefix): value
for key, value in weights.items()
if key.startswith(unet_prefix)
}
)
transformer.to(dtype).to(load_device).eval()
patchifier = SymmetricPatchifier(1)
diffusion_model = LTXVTransformer3D(transformer, patchifier, None, None, None)
model = LTXVModel(
LTXVModelConfig(num_latent_channels, dtype=dtype),
model_type=comfy.model_base.ModelType.FLOW,
device=comfy.model_management.get_torch_device(),
)
model.diffusion_model = diffusion_model
patcher = comfy.model_patcher.ModelPatcher(model, load_device, offload_device)
return patcher
+293
View File
@@ -0,0 +1,293 @@
# LTXVLoopingSampler Documentation
## Overview
The **LTXVLoopingSampler** is a unified ComfyUI node designed for generating long, high-resolution videos through versatile guidance and conditioningi techniques, as well as temporal and spatial tiling. It overcomes memory and computational limitations by breaking video generation into manageable chunks, enabling the creation of videos that would otherwise be impossible to generate in a single model run.
It provides a multitude of features (autoregressive long vidoes, keyframes, guidance using IC-LoRA video modalities, normalization, 'long memory' with conditioning on negative positional encodings, and more ) all in one place.
The **MultiPromptProvider** utility node for providing dynamic prompts per temporal tile.
---
## LTXVLoopingSampler
### Core Concept
The LTXVLoopingSampler addresses two fundamental challenges in video generation:
1. **Temporal Length**: Generating very long videos by dividing them into overlapping temporal segments
2. **Spatial Resolution**: Creating high-resolution frames by splitting them into overlapping spatial regions
Each "tile" is processed independently and then seamlessly blended with its neighbors to create the final coherent video output.
### Key Features
- **🎬 Temporal Tiling**: Generates long videos by processing overlapping time segments
- **🖼️ Spatial Tiling**: Creates high-resolution output by dividing frames into spatial regions
- **🎯 Multiple Conditioning Modes**: Supports image conditioning, guiding latents, and negative index conditioning
- **⚡ Memory Efficient**: Processes one spatial tile at a time to minimize memory usage
- **🔄 Seamless Blending**: Uses weighted blending for smooth transitions between tiles
- **🎲 Advanced Seeding**: Configurable per-tile seeding for reproducible results
### Interface Reference
#### Required Parameters
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| **model** | MODEL | - | - | The diffusion model for video generation |
| **vae** | VAE | - | - | VAE for encoding/decoding between pixel and latent space |
| **noise** | NOISE | - | - | Noise generator for the sampling process |
| **sampler** | SAMPLER | - | - | Sampling algorithm (e.g., Euler, DPM++) |
| **sigmas** | SIGMAS | - | - | Noise schedule defining the denoising steps |
| **guider** | GUIDER | - | - | Conditioning guider (must be STGGuiderAdvanced to work with LTXVLoopingSampler) |
| **latents** | LATENT | - | - | Input latent tensor defining video dimensions and length. Non empty latents can be passed here to enable a partial denoising use case. |
#### Temporal Configuration
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| **temporal_tile_size** | INT | 80 | 24-1000 (step: 8) | Frames per temporal tile (in pixel space). The first tile will be one frame longer than this number, and all new tiles will add `temporal_tile_size-temporal_overlap` new frames each. |
| **temporal_overlap** | INT | 24 | 16-80 (step: 8) | Overlapping frames between consecutive tiles |
| **temporal_overlap_cond_strength** | FLOAT | 0.5 | 0.0-1.0 | How strongly the final frames of the previous tile condition the new tile generation |
#### Spatial Configuration
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| **horizontal_tiles** | INT | 1 | 1-6 | Number of horizontal spatial divisions |
| **vertical_tiles** | INT | 1 | 1-6 | Number of vertical spatial divisions |
| **spatial_overlap** | INT | 1 | 1-8 | Overlapping latent 'pixels' between spatial tiles (latent space) |
#### Conditioning Controls
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| **guiding_strength** | FLOAT | 1.0 | 0.0-1.0 | Influence of guiding latents on generation, if provided. |
| **cond_image_strength** | FLOAT | 1.0 | 0.0-1.0 | Influence of conditioning images on first image in classical i2v setup, and of all keyframes. |
#### Optional Advanced Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| **optional_cond_images** | IMAGE | None | Images for I2V conditioning (resized to match output using center crop operation) |
| **optional_guiding_latents** | LATENT | None | Latents for guided generation (usually used with IC-LoRA loaded) |
| **adain_factor** | FLOAT | 0.0 (0.0-1.0) | AdaIn normalization strength to prevent oversaturation. AdaIn is applied on each new temporal tile, with respect to the latent statistics of the first temporal tile. |
| **optional_positive_conditionings** | CONDITIONING | None | Per-tile prompts (from MultiPromptProvider) |
| **optional_negative_index_latents** | LATENT | None | Context latents for long-term coherence |
| **guiding_start_step** | INT | 0 (0-1000) | Step to begin applying guiding latents, if provided. |
| **guiding_end_step** | INT | 1000 (0-1000) | Step to stop applying guiding latents, if provided. |
| **optional_cond_image_indices** | STRING | "0" | Keyframe indices for conditioning (comma-separated list of indices) |
| **optional_normalizing_latents** | LATENT | None | Reference latents for output normalization. If provided, used in the AdaIn operation, each spatio-temporal tile with its counterpart, instead of using the first temporal tile as a normalization refernece. |
### Output
| Output | Type | Description |
|--------|------|-------------|
| **denoised_output** | LATENT | Complete video latents after tiling and blending |
### Generation Modes
#### 🎨 Text-to-Video (T2V)
Pure text-driven video generation starting from noise.
**Setup:**
- Don't connect `optional_cond_images`
- Provide text conditioning through the guider
- Video generates from pure noise
#### 🖼️ Image-to-Video (I2V)
Video generation conditioned on input images.
**Setup:**
- Connect a single image to `optional_cond_images` and set `optional_cond_image_indices` to "0".
- Images are automatically resized to match output dimensions
#### 🗝️ Keyframe-Based Generation
Keyframe-based generation allows you to guide the video at specific frames using conditioning images (keyframes). This is useful for tasks like animating a sequence between important visual states or ensuring the video matches certain reference images at chosen points.
- Use `optional_cond_images` to provide one or more keyframe images.
- Use `optional_cond_image_indices` to specify which frames should be conditioned on these images. This should be a comma-separated list of frame indices (e.g., `"0, 32, 64"`).
- Each image in `optional_cond_images` will be applied to the corresponding frame index in `optional_cond_image_indices`.
- The node will automatically resize and center-crop the images to match the output dimensions.
**Example:**
- To condition the first and last frames of a 64-frame video, set:
- `optional_cond_images`: [start_image, end_image]
- `optional_cond_image_indices`: `"0, 63"`
This will ensure the generated video starts and ends with the provided keyframes, smoothly interpolating between them.
**Tip:** You can combine keyframe conditioning with text prompts and guiding latents for even more control over the video generation process.
#### 🎯 Guided Generation
Advanced control using guiding latents (often with IC-LoRA).
**Setup:**
- Connect guiding latents to `optional_guiding_latents`
- Adjust `guiding_strength` (0.0-1.0)
- Set `guiding_start_step` and `guiding_end_step` for control over the effect of the guidance during the denoising steps.
#### 📏 High-Resolution Generation
Create videos larger than base model resolution using spatial tiling.
**Configuration Example:**
```
horizontal_tiles: 2
vertical_tiles: 2
spatial_overlap: 2
```
This creates a 2x2 grid of spatial tiles with 2-pixel overlap for seamless blending.
#### ⏱️ Long Video Generation
Generate extended sequences using temporal tiling.
**How It Works:**
- Each temporal tile is conditioned on the **end frames** of the previous tile
- This creates seamless motion continuity across tile boundaries
- The `temporal_overlap` defines how many frames from the previous tile are used for conditioning
**Best Practices:**
- Use `temporal_overlap` = 1/3 of `temporal_tile_size`
- Higher `temporal_overlap_cond_strength` = stronger temporal consistency
- Set `adain_factor` = 0.1-0.3 for long sequences to prevent oversaturation
- Consider using different prompts per tile via MultiPromptProvider
#### 🧩 Negative Index Latents for Enhanced Coherence
You can provide `optional_negative_index_latents` to further improve long video consistency. This feature allows each temporal chunk to attend to an additional latent (or set of latents), typically derived from another image or video, to guide the generation process and maintain global coherence.
- **How it works:**
Each temporal chunk is conditioned on the provided negative index latent(s) using *negative positional embeddings*. This mechanism helps the model reference global context or style cues throughout the video, reducing drift and improving overall consistency, especially in long sequences.
- **Usage:**
- Connect your reference latent(s) to the `optional_negative_index_latents` input.
- **Typical applications:**
- Maintaining a consistent subject or style across long videos
- Referencing a global template or anchor image
- Enforcing scene or character coherence
**Tip:** You can combine negative index latents with keyframes, guiding latents, and text prompts for maximum control.
### Technical Implementation
#### Temporal Tiling Algorithm
The node processes video in overlapping temporal chunks, with each tile conditioned on the preceding tile's output:
1. **First Tile (0 → temporal_tile_size)**
- Uses `LTXVInContextSampler` (with guidance) or `LTXVBaseSampler` (without)
- Establishes the foundation for the video sequence
- Generated from pure noise (T2V) or conditioning images (I2V / keyframes)
2. **Subsequent Tiles**
- Uses `LTXVExtendSampler` to extend from previous tile's results
- **Conditioning Mechanism**: Each new tile is conditioned on the **final frames** (overlap region) of the preceding tile
- The overlap region from the previous tile provides temporal continuity and motion consistency
- `temporal_overlap_cond_strength` controls how strongly the previous tile's end frames influence the new generation
- Each tile advances by `(temporal_tile_size - temporal_overlap)` frames, creating seamless temporal progression
- Guiding latents, if provided, are used outside of the overlap region.
- Conditioning images (`optional_cond_images`), if provided, are used to condition the generation as well.
#### Spatial Tiling Algorithm
For high-resolution generation, frames are divided spatially:
1. **Tile Calculation**: Frame divided into overlapping regions
2. **Independent Processing**: Each spatial region processed separately
3. **Weight Generation**: Linear blending weights created for overlap regions
4. **Final Compositing**: Weighted accumulation produces seamless result
#### Seed Management
Each tile receives a unique, deterministic seed:
```
tile_seed = base_seed + temporal_offset + spatial_offset + custom_offset (hidedn interface)
```
This ensures:
- Reproducible results across runs
- Consistent randomness per tile
- Ability to re-generate specific tiles
#### Memory Optimization Strategies
- **Sequential Spatial Processing**: Only one spatial tile in memory at a time
- **Temporal Chunking**: Long videos processed in manageable segments
- **Weighted Accumulation**: Final output built incrementally
- **Garbage Collection**: Intermediate results freed after use
### Configuration Guidelines
#### Temporal Parameters
- **Short Videos (< 5 seconds)**: `temporal_tile_size: 40-80`
- **Medium Videos (5-15 seconds)**: `temporal_tile_size: 80-120`
- **Long Videos (> 15 seconds)**: `temporal_tile_size: 120-200`
- **Overlap Rule**: Use 25-30% of tile size for smooth transitions
#### Spatial Parameters
- **Standard Resolution**: `horizontal_tiles: 1, vertical_tiles: 1`
- **2K Generation**: `horizontal_tiles: 2, vertical_tiles: 1-2`
- **4K Generation**: `horizontal_tiles: 2-3, vertical_tiles: 2-3`
- **Overlap Minimum**: At least 1 pixel, increase for better blending
#### Quality Settings
- **High Quality**: Lower `adain_factor` (0.0-0.1), higher overlap
- **Fast Generation**: Higher `adain_factor` (0.2-0.5), lower overlap
- **Temporal Consistency**: Higher `temporal_overlap_cond_strength`
### Common Use Cases
#### Creating Cinematic Sequences
```
temporal_tile_size: 80
temporal_overlap: 24
horizontal_tiles: 2
vertical_tiles: 1
adain_factor: 0.1
```
#### Long-Form Content Generation
```
temporal_tile_size: 120
temporal_overlap: 40
adain_factor: 0.2
+ MultiPromptProvider for narrative progression
```
#### High-Resolution Showcase Videos
```
horizontal_tiles: 3
vertical_tiles: 2
spatial_overlap: 3
temporal_tile_size: 60
```
### Troubleshooting
#### Common Issues
- **Visible Seams**: Increase spatial/temporal overlap
- **Oversaturation**: Increase `adain_factor`
- **Memory Errors**: Reduce tile sizes or use fewer spatial tiles
- **Inconsistent Motion**: Increase `temporal_overlap` and `temporal_overlap_cond_strength`
- **Temporal Discontinuities**: The end frames of each tile condition the next tile - ensure sufficient overlap
- **Color Shifts**: Use `optional_normalizing_latents` for reference and/or increase `adain_factor`
#### Performance Tips
- Process spatial tiles sequentially to minimize memory usage
- Use appropriate tile sizes for your hardware capabilities
- Consider temporal overlap vs. generation speed trade-offs
---
## MultiPromptProvider
A companion utility node that enables dynamic prompt changes across temporal tiles.
- Connect multiple pipe-separated prompts (`"prompt1|prompt2|prompt3"`) to create evolving narratives throughout video generation.
- Each prompt is used in one temporal_tile in `LTXVLoopingSampler`.
- If not enough prompts are provided, the last one is repeated.
- If too many prompts provided, the last ones are ignored.
---
+974
View File
@@ -0,0 +1,974 @@
import copy
from dataclasses import dataclass
import comfy
import torch
from .easy_samplers import LTXVBaseSampler, LTXVExtendSampler, LTXVInContextSampler
from .latents import LTXVDilateLatent, LTXVSelectLatents
from .nodes_registry import comfy_node
@dataclass
class TileConfig:
"""Configuration for spatial tile processing."""
tile_latents: dict
tile_guiding_latents: dict
tile_negative_index_latents: dict
tile_keyframes: torch.Tensor
keyframe_per_tile_indices: list
tile_normalizing_latents: dict
tile_height: int
tile_width: int
v: int
h: int
vertical_tiles: int
horizontal_tiles: int
first_seed: int
@dataclass
class SamplingConfig:
"""Configuration for sampling parameters."""
temporal_tile_size: int
temporal_overlap: int
temporal_overlap_cond_strength: float
cond_image_strength: float
guiding_strength: float
adain_factor: float
optional_negative_index: int
optional_negative_index_strength: float
optional_positive_conditionings: list
time_scale_factor: int
width_scale_factor: int
height_scale_factor: int
per_tile_seed_offsets: list
guiding_start_step: int
guiding_end_step: int
@dataclass
class ModelConfig:
"""Configuration for model components."""
model: object
vae: object
noise: object
sampler: object
sigmas: object
guider: object
@comfy_node(
name="LTXVLoopingSampler",
)
class LTXVLoopingSampler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL", {"tooltip": "The diffusion model to use."}),
"vae": ("VAE", {"tooltip": "The VAE to use."}),
"noise": ("NOISE", {"tooltip": "The noise to use."}),
"sampler": ("SAMPLER", {"tooltip": "The sampler to use."}),
"sigmas": ("SIGMAS", {"tooltip": "The sigmas to use."}),
"guider": (
"GUIDER",
{"tooltip": "The guider to use, must be a STGGuiderAdvanced."},
),
"latents": (
"LATENT",
{
"tooltip": "The latents to use for creating the long video, they can be guiding latents or empty latents when no guidance is used."
},
),
"temporal_tile_size": (
"INT",
{
"default": 80,
"min": 24,
"max": 1000,
"step": 8,
"tooltip": "The size of the temporal tile to use for the sampling, in pixel frames, in addition to the overlapping region.",
},
),
"temporal_overlap": (
"INT",
{
"default": 24,
"min": 16,
"max": 80,
"step": 8,
"tooltip": "The overlap between the temporal tiles, in pixel frames.",
},
),
"guiding_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on guiding latents, when optional_guiding_latents are provided.",
},
),
"temporal_overlap_cond_strength": (
"FLOAT",
{
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on the latents from the previous temporal tile.",
},
),
"cond_image_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on the optional_cond_images.",
},
),
"horizontal_tiles": (
"INT",
{
"default": 1,
"min": 1,
"max": 6,
"tooltip": "Number of horizontal spatial tiles.",
},
),
"vertical_tiles": (
"INT",
{
"default": 1,
"min": 1,
"max": 6,
"tooltip": "Number of vertical spatial tiles.",
},
),
"spatial_overlap": (
"INT",
{
"default": 1,
"min": 1,
"max": 8,
"tooltip": "Overlap between spatial tiles.",
},
),
},
"optional": {
"optional_cond_images": (
"IMAGE",
{
"tooltip": "The image to use for conditioning the first frame in the video (i2v setup). If not provided, the first frame will be unconditioned (t2v setup). The image will be resized to the size of the first frame."
},
),
"optional_guiding_latents": (
"LATENT",
{
"tooltip": "The latents to use for guiding the sampling, typically with an IC-LoRA."
},
),
"adain_factor": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the AdaIn operation used to fix the statistics of each new generated temporal tile, to prevent accumulated oversaturation.",
},
),
"optional_positive_conditionings": (
"CONDITIONING",
{
"tooltip": "Optional way to provide changing positive prompts, one per temporal tile, using the MultiPromptProvider node."
},
),
"optional_negative_index_latents": (
"LATENT",
{
"tooltip": "Special optional latents to condition on a negative index before each new temporal tile as a way to provide long term context during video generation."
},
),
"guiding_start_step": (
"INT",
{
"default": 0,
"min": 0,
"max": 1000,
"tooltip": "The step at which the guiding latents start to be used.",
},
),
"guiding_end_step": (
"INT",
{
"default": 1000,
"min": 0,
"max": 1000,
"tooltip": "The step at which the guiding latents stop to be used.",
},
),
"optional_cond_image_indices": (
"STRING",
{
"default": "0",
"tooltip": "The indices of the keyframes to use for the sampling, separated by commas. The indices are counted from the end of the video.",
},
),
"optional_normalizing_latents": (
"LATENT",
{
"tooltip": "The latents to use for normalizing the output latents, they will be used to normalize the output latents to the same statistics as the input latents."
},
),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("denoised_output",)
FUNCTION = "sample"
CATEGORY = "sampling"
def _extract_latent_spatial_tile(self, latent_dict, v_start, v_end, h_start, h_end):
"""Extract spatial tile from a latent dictionary."""
if latent_dict is None:
return None
tile_samples = latent_dict["samples"][:, :, :, v_start:v_end, h_start:h_end]
if "noise_mask" in latent_dict and latent_dict["noise_mask"] is not None:
tile_masks = latent_dict["noise_mask"][
:, :, :, v_start:v_end, h_start:h_end
]
return {"samples": tile_samples, "noise_mask": tile_masks}
else:
return {"samples": tile_samples}
def _extract_spatial_tile(
self,
latents,
optional_guiding_latents,
optional_negative_index_latents,
optional_normalizing_latents,
optional_keyframes,
v_start,
v_end,
h_start,
h_end,
height_scale_factor,
width_scale_factor,
):
"""Extract spatial tiles from all inputs for a given spatial region."""
# Extract spatial tile from latents
tile_latents = self._extract_latent_spatial_tile(
latents, v_start, v_end, h_start, h_end
)
# Extract spatial tile from guiding latents if provided
tile_guiding_latents = self._extract_latent_spatial_tile(
optional_guiding_latents, v_start, v_end, h_start, h_end
)
# Extract spatial tile from negative index latents if provided
tile_negative_index_latents = self._extract_latent_spatial_tile(
optional_negative_index_latents, v_start, v_end, h_start, h_end
)
# Extract spatial tile from normalizing latents if provided
tile_normalizing_latents = self._extract_latent_spatial_tile(
optional_normalizing_latents, v_start, v_end, h_start, h_end
)
if optional_keyframes is not None:
# Scale coordinates for image
img_h_start = v_start * height_scale_factor
img_h_end = v_end * height_scale_factor
img_w_start = h_start * width_scale_factor
img_w_end = h_end * width_scale_factor
tile_keyframes = optional_keyframes[
:, img_h_start:img_h_end, img_w_start:img_w_end, :
]
else:
tile_keyframes = None
return (
tile_latents,
tile_guiding_latents,
tile_negative_index_latents,
tile_keyframes,
tile_normalizing_latents,
)
def _process_temporal_chunks(
self,
tile_config: TileConfig,
sampling_config: SamplingConfig,
model_config: ModelConfig,
):
"""Process all temporal chunks for a single spatial tile."""
chunk_index = 0
tile_out_latents = None
first_tile_out_latents = None
for i_temporal_tile, (start_index, end_index) in enumerate(
zip(
range(
0,
tile_config.tile_latents["samples"].shape[2]
+ sampling_config.temporal_tile_size
- sampling_config.temporal_overlap,
sampling_config.temporal_tile_size
- sampling_config.temporal_overlap,
),
range(
sampling_config.temporal_tile_size,
tile_config.tile_latents["samples"].shape[2]
+ sampling_config.temporal_tile_size
- sampling_config.temporal_overlap,
sampling_config.temporal_tile_size
- sampling_config.temporal_overlap,
),
)
):
if tile_config.tile_guiding_latents is not None:
guiding_latent_chunk = LTXVSelectLatents().select_latents(
tile_config.tile_guiding_latents,
start_index,
min(
end_index - 1,
tile_config.tile_guiding_latents["samples"].shape[2] - 1,
),
)[0]
else:
guiding_latent_chunk = None
latent_chunk = LTXVSelectLatents().select_latents(
tile_config.tile_latents,
start_index,
min(end_index - 1, tile_config.tile_latents["samples"].shape[2] - 1),
)[0]
print(
"Processing temporal chunk at index",
start_index,
"to",
min(end_index - 1, tile_config.tile_latents["samples"].shape[2] - 1),
)
seed_offset = self._get_per_tile_value(
sampling_config.per_tile_seed_offsets, i_temporal_tile
)
if tile_config.tile_normalizing_latents is not None:
normalizing_latent_chunk = LTXVSelectLatents().select_latents(
tile_config.tile_normalizing_latents,
start_index,
min(
end_index - 1,
tile_config.tile_normalizing_latents["samples"].shape[2] - 1,
),
)[0]
normalize_per_frame = True
print(
"Normalizing latents provided, normalizing per frame and channel with factor",
sampling_config.adain_factor,
)
else:
normalizing_latent_chunk = first_tile_out_latents
normalize_per_frame = False
print(
"No normalizing latents provided, normalizing per channel using first chunk with factor",
sampling_config.adain_factor,
)
model_config.noise.seed = self._calculate_tile_seed(
tile_config.first_seed,
start_index,
tile_config.vertical_tiles,
tile_config.horizontal_tiles,
tile_config.v,
tile_config.h,
seed_offset,
)
# Handle optional positive conditionings
new_guider = self._prepare_guider_for_chunk(
model_config.guider,
sampling_config.optional_positive_conditionings,
chunk_index,
)
this_chunk_keyframe_indices = [
in_tile_index
for (tile_index, in_tile_index) in tile_config.keyframe_per_tile_indices
if tile_index == i_temporal_tile
]
if this_chunk_keyframe_indices and tile_config.tile_keyframes is not None:
this_chunk_keyframes = torch.cat(
[
tile_config.tile_keyframes[i_keyframe].unsqueeze(0)
for (i_keyframe, (tile_index, in_tile_index)) in enumerate(
tile_config.keyframe_per_tile_indices
)
if tile_index == i_temporal_tile
]
)
print(
f"Chunk {i_temporal_tile} keyframe indices: {this_chunk_keyframe_indices}"
)
else:
this_chunk_keyframes = None
print(f"Chunk {i_temporal_tile} has no keyframes")
this_chunk_keyframe_indices = ",".join(
[str(i) for i in this_chunk_keyframe_indices]
)
if start_index == 0:
if tile_config.tile_guiding_latents is not None:
tile_out_latents = LTXVInContextSampler().sample(
vae=model_config.vae,
guider=new_guider,
sampler=model_config.sampler,
sigmas=model_config.sigmas,
noise=model_config.noise,
guiding_latents=guiding_latent_chunk,
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
num_frames=-1,
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
cond_image_strength=sampling_config.cond_image_strength,
guiding_strength=sampling_config.guiding_strength,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
else:
tile_out_latents = LTXVBaseSampler().sample(
model=model_config.model,
vae=model_config.vae,
noise=model_config.noise,
sampler=model_config.sampler,
sigmas=model_config.sigmas,
guider=new_guider,
num_frames=(
min(
sampling_config.temporal_tile_size,
tile_config.tile_latents["samples"].shape[2],
)
- 1
)
* sampling_config.time_scale_factor
+ 1,
width=tile_config.tile_width
* sampling_config.width_scale_factor,
height=tile_config.tile_height
* sampling_config.height_scale_factor,
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
crop="center",
crf=30,
strength=sampling_config.cond_image_strength,
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
first_tile_out_latents = copy.deepcopy(tile_out_latents)
else:
tile_out_latents = LTXVExtendSampler().sample(
model=model_config.model,
vae=model_config.vae,
sampler=model_config.sampler,
sigmas=model_config.sigmas,
noise=model_config.noise,
latents=tile_out_latents,
num_new_frames=(
latent_chunk["samples"].shape[2]
- sampling_config.temporal_overlap
)
* sampling_config.time_scale_factor,
frame_overlap=sampling_config.temporal_overlap
* sampling_config.time_scale_factor,
guider=new_guider,
strength=sampling_config.temporal_overlap_cond_strength,
guiding_strength=sampling_config.guiding_strength,
cond_image_strength=sampling_config.cond_image_strength,
optional_guiding_latents=guiding_latent_chunk,
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
optional_reference_latents=normalizing_latent_chunk,
normalize_per_frame=normalize_per_frame,
adain_factor=sampling_config.adain_factor,
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
chunk_index += 1
return tile_out_latents
def _create_spatial_weights(
self,
tile_shape,
v,
h,
horizontal_tiles,
vertical_tiles,
spatial_overlap,
device,
dtype,
):
"""Create blending weights for spatial tiles."""
tile_weights = torch.ones(tile_shape, device=device, dtype=dtype)
# Apply horizontal blending weights
if h > 0: # Left overlap
h_blend = torch.linspace(0, 1, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :, :spatial_overlap] *= h_blend.view(1, 1, 1, 1, -1)
if h < horizontal_tiles - 1: # Right overlap
h_blend = torch.linspace(1, 0, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :, -spatial_overlap:] *= h_blend.view(1, 1, 1, 1, -1)
# Apply vertical blending weights
if v > 0: # Top overlap
v_blend = torch.linspace(0, 1, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :spatial_overlap, :] *= v_blend.view(1, 1, 1, -1, 1)
if v < vertical_tiles - 1: # Bottom overlap
v_blend = torch.linspace(1, 0, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, -spatial_overlap:, :] *= v_blend.view(1, 1, 1, -1, 1)
return tile_weights
def _calculate_tile_seed(
self,
first_seed,
start_index,
vertical_tiles,
horizontal_tiles,
v,
h,
seed_offset,
):
"""Calculate the seed value for a specific temporal and spatial tile."""
return (
first_seed
+ start_index * (vertical_tiles * horizontal_tiles)
+ v * horizontal_tiles
+ h
+ seed_offset
)
def _get_per_tile_value(self, value_list, tile_index):
"""Get a value from a per-tile configuration list, using the last value if the list is shorter."""
return value_list[min(tile_index, len(value_list) - 1)]
def _parse_comma_separated_string(
self, config_string, default_value, converter_func, optional_total_size=None
):
"""Parse a comma-separated configuration string into a list with type conversion."""
if config_string == "":
config_string = default_value
values = [converter_func(item.strip()) for item in config_string.split(",")]
def handle_negative_index(value):
if value < 0:
return value + optional_total_size
return value
if optional_total_size is not None:
values = [handle_negative_index(value) for value in values]
return values
def _prepare_guider_for_chunk(
self, guider, optional_positive_conditionings, chunk_index
):
"""Prepare the guider for a specific chunk, handling optional positive conditionings."""
if optional_positive_conditionings is not None:
new_guider = copy.copy(guider)
positive, negative = guider.raw_conds
# Use the conditioning at chunk_index, or the last one if we've run out
conditioning_index = min(
chunk_index, len(optional_positive_conditionings) - 1
)
new_guider.set_conds(
optional_positive_conditionings[conditioning_index],
negative,
)
new_guider.raw_conds = (
optional_positive_conditionings[conditioning_index],
negative,
)
return new_guider
else:
return guider
def _calculate_keyframe_per_tile_indices(
self, keyframe_indices, temporal_tile_size, temporal_overlap, num_frames
):
"""
Calculate which temporal tile each keyframe falls into.
Returns a list of tuples (temporal_tile_index, in_tile_index) for each keyframe.
- temporal_tile_index: which temporal tile the keyframe falls in
- in_tile_index: index within that specific temporal tile
First tile: frames [0, temporal_tile_size - 8] (size = temporal_tile_size - 8 + 1)
Subsequent tiles follow the pattern from _process_temporal_chunks:
- Tile n starts at: n * (temporal_tile_size - temporal_overlap)
- Tile n ends at: temporal_tile_size + n * (temporal_tile_size - temporal_overlap) - 1
For subsequent tiles, keyframes with in_tile_index < temporal_overlap
don't count as falling in that tile.
"""
result = []
for keyframe_index in keyframe_indices:
if keyframe_index >= num_frames:
print(
f"Keyframe index {keyframe_index} is greater than num_frames {num_frames}, skipping"
)
continue
# First tile (tile 0): covers frames [0, temporal_tile_size - 8]
if keyframe_index < temporal_tile_size - 7:
result.append((0, keyframe_index))
continue
# Find which subsequent tile this keyframe could fall into
# Tile n starts at: n * (temporal_tile_size - temporal_overlap) - 7
# Tile n ends at: temporal_tile_size + n * (temporal_tile_size - temporal_overlap) - 1 - 7
tile_step = temporal_tile_size - temporal_overlap
tile_index = 1
while True:
tile_start = tile_index * tile_step - 7
tile_end = temporal_tile_size + tile_index * tile_step - 1 - 7
print(
f"Tile {tile_index} starts at {tile_start} and ends at {tile_end}"
)
# Check if keyframe falls within this tile's range
if keyframe_index <= tile_end:
in_tile_index = (
keyframe_index - tile_start - 7
) # this extra -7 is needed because the first latent is re-interpreted inside the temporal tile as 1 pixel frame
# For tiles > 0, if in_tile_index < temporal_overlap,
# the keyframe doesn't "fall" in this tile - assign to previous tile
if in_tile_index < temporal_overlap:
tile_index -= 1
if tile_index == 0:
# Previous tile is the first tile
in_tile_index = keyframe_index
else:
# Previous tile starts at (tile_index) * tile_step
prev_start = tile_start - tile_step
in_tile_index = (
keyframe_index - prev_start - 7
) # same reason as above
result.append((tile_index, in_tile_index))
break
tile_index += 1
return result
def sample(
self,
model,
vae,
noise,
sampler,
sigmas,
guider,
latents,
guiding_strength,
adain_factor,
temporal_tile_size,
temporal_overlap,
temporal_overlap_cond_strength,
horizontal_tiles,
vertical_tiles,
spatial_overlap,
optional_cond_images=None,
cond_image_strength=1.0,
optional_guiding_latents=None,
optional_negative_index_latents=None,
optional_negative_index_strength=1.0, # hidden interface
optional_positive_conditionings=None,
guiding_start_step=0,
guiding_end_step=1000,
optional_cond_image_indices="0",
optional_normalizing_latents=None,
per_tile_seed_offsets="0", # hidden interface
):
# Get dimensions and prepare for spatial tiling
samples = latents["samples"]
if (
isinstance(samples, comfy.nested_tensor.NestedTensor)
and len(samples.tensors) == 2
):
raise ValueError(
"LoopingSampler currently does not support Audio Visual latents. please only use video latents."
)
batch, channels, frames, height, width = samples.shape
time_scale_factor, width_scale_factor, height_scale_factor = (
vae.downscale_index_formula
)
temporal_tile_size = temporal_tile_size // time_scale_factor
temporal_overlap = temporal_overlap // time_scale_factor
first_seed = noise.seed
per_tile_seed_offsets = self._parse_comma_separated_string(
per_tile_seed_offsets, "0", int
)
keyframe_indices = self._parse_comma_separated_string(
optional_cond_image_indices,
"0",
int,
optional_total_size=frames * time_scale_factor - 7,
)
keyframe_per_tile_indices = self._calculate_keyframe_per_tile_indices(
keyframe_indices,
temporal_tile_size * time_scale_factor,
temporal_overlap * time_scale_factor,
frames * time_scale_factor - 7,
)
print(f"Keyframe per tile indices: {keyframe_per_tile_indices}")
if optional_cond_images is not None:
optional_keyframes = (
comfy.utils.common_upscale(
optional_cond_images.movedim(-1, 1),
width * width_scale_factor,
height * height_scale_factor,
"bilinear",
crop="center",
)
.movedim(1, -1)
.clamp(0, 1)
)
else:
optional_keyframes = None
if optional_guiding_latents is not None:
guide = optional_guiding_latents["samples"]
assert (
samples.shape[2] == guide.shape[2]
), "The number of frames in the latents and optional_guiding_latents must be the same"
assert (
samples.shape[3] % guide.shape[3] == 0
and samples.shape[4] % guide.shape[4] == 0
), "The ratio of the height and width of the latents and optional_guiding_latents must be an integer"
grid_size_h = samples.shape[3] // guide.shape[3]
grid_size_w = samples.shape[4] // guide.shape[4]
optional_guiding_latents = LTXVDilateLatent().dilate_latent(
optional_guiding_latents, grid_size_w, grid_size_h
)[0]
# Calculate tile sizes with overlap
base_tile_height = (
height + (vertical_tiles - 1) * spatial_overlap
) // vertical_tiles
base_tile_width = (
width + (horizontal_tiles - 1) * spatial_overlap
) // horizontal_tiles
# Output tensors will be initialized after first tile to get correct temporal dimension
final_output = None
weights = None
# Process each spatial tile
for v in range(vertical_tiles):
for h in range(horizontal_tiles):
# Calculate tile boundaries
h_start = h * (base_tile_width - spatial_overlap)
v_start = v * (base_tile_height - spatial_overlap)
# Adjust end positions for edge tiles
h_end = (
min(h_start + base_tile_width, width)
if h < horizontal_tiles - 1
else width
)
v_end = (
min(v_start + base_tile_height, height)
if v < vertical_tiles - 1
else height
)
# Calculate actual tile dimensions
tile_height = v_end - v_start
tile_width = h_end - h_start
print(f"Processing spatial tile at row {v}, col {h}:")
print(f" Position: ({v_start}:{v_end}, {h_start}:{h_end})")
print(f" Size: {tile_height}x{tile_width}")
# Extract spatial tiles from all inputs
(
tile_latents,
tile_guiding_latents,
tile_negative_index_latents,
tile_keyframes,
tile_normalizing_latents,
) = self._extract_spatial_tile(
latents,
optional_guiding_latents,
optional_negative_index_latents,
optional_normalizing_latents,
optional_keyframes,
v_start,
v_end,
h_start,
h_end,
height_scale_factor,
width_scale_factor,
)
# Process all temporal chunks for this spatial tile
tile_config = TileConfig(
tile_latents=tile_latents,
tile_guiding_latents=tile_guiding_latents,
tile_negative_index_latents=tile_negative_index_latents,
tile_keyframes=tile_keyframes,
keyframe_per_tile_indices=keyframe_per_tile_indices,
tile_normalizing_latents=tile_normalizing_latents,
tile_height=tile_height,
tile_width=tile_width,
v=v,
h=h,
vertical_tiles=vertical_tiles,
horizontal_tiles=horizontal_tiles,
first_seed=first_seed,
)
sampling_config = SamplingConfig(
temporal_tile_size=temporal_tile_size,
temporal_overlap=temporal_overlap,
temporal_overlap_cond_strength=temporal_overlap_cond_strength,
cond_image_strength=cond_image_strength,
guiding_strength=guiding_strength,
adain_factor=adain_factor,
optional_negative_index=(
-1 * tile_negative_index_latents["samples"].shape[2]
if tile_negative_index_latents is not None
else -1
),
optional_negative_index_strength=optional_negative_index_strength,
optional_positive_conditionings=optional_positive_conditionings,
time_scale_factor=time_scale_factor,
width_scale_factor=width_scale_factor,
height_scale_factor=height_scale_factor,
per_tile_seed_offsets=per_tile_seed_offsets,
guiding_start_step=guiding_start_step,
guiding_end_step=guiding_end_step,
)
model_config = ModelConfig(
model=model,
vae=vae,
noise=noise,
sampler=sampler,
sigmas=sigmas,
guider=guider,
)
tile_out_latents = self._process_temporal_chunks(
tile_config,
sampling_config,
model_config,
)
# Initialize output tensors on first tile (to get correct temporal dimension)
if final_output is None:
out_temporal = tile_out_latents["samples"].shape[2]
final_output = torch.zeros(
batch,
channels,
out_temporal,
height,
width,
device=samples.device,
dtype=samples.dtype,
)
weights = torch.zeros_like(final_output)
# Move tile samples to output device and create weight mask
tile_samples = tile_out_latents["samples"].to(final_output.device)
tile_weights = self._create_spatial_weights(
tile_samples.shape,
v,
h,
horizontal_tiles,
vertical_tiles,
spatial_overlap,
final_output.device,
final_output.dtype,
)
# Add weighted tile to final output
final_output[:, :, :, v_start:v_end, h_start:h_end] += (
tile_samples * tile_weights
)
weights[:, :, :, v_start:v_end, h_start:h_end] += tile_weights
# Normalize by weights
final_output = final_output / (weights + 1e-8)
out_latents = {"samples": final_output}
noise.seed = first_seed
return (out_latents,)
@comfy_node(
name="MultiPromptProvider",
)
class MultiPromptProvider:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompts": (
"STRING",
{
"multiline": True,
"dynamicPrompts": True,
"tooltip": "Prompts to encode, one per line. Each prompt will be encoded separately. Each prompt will be used in one temporal_tile in LTXVLoopingSampler.",
},
),
"clip": ("CLIP", {"tooltip": "CLIP model to encode the prompts."}),
},
}
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditionings",)
FUNCTION = "get_prompt_list"
CATEGORY = "prompt"
def get_prompt_list(self, prompts, clip):
prompt_list = prompts.split("|")
prompt_list = [prompt.strip() for prompt in prompt_list]
encoded_prompt_list = [
clip.encode_from_tokens_scheduled(clip.tokenize(prompt))
for prompt in prompt_list
]
return (encoded_prompt_list,)
+127
View File
@@ -0,0 +1,127 @@
"""
Low VRAM loader nodes for sequential model loading.
These nodes add a 'dependencies' input to control execution order,
ensuring models load one after another instead of simultaneously.
This reduces peak VRAM usage in memory-constrained environments.
"""
import comfy.utils
import folder_paths
import nodes
from comfy.ldm.lightricks.vae.audio_vae import AudioVAE
from comfy_api.latest import io
from comfy_extras.nodes_hunyuan import LatentUpscaleModelLoader
class AnyType(str):
"""Custom type that matches any ComfyUI type for dependency inputs."""
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
class LowVRAMCheckpointLoader(nodes.CheckpointLoaderSimple):
"""Load Checkpoint with dependency input for sequential loading in low VRAM scenarios."""
@classmethod
def INPUT_TYPES(s) -> dict:
base_types = super().INPUT_TYPES()
base_types["optional"] = {
"dependencies": (
any_type,
{
"tooltip": "Connect any output from a previous loader to ensure sequential loading."
},
),
}
return base_types
CATEGORY = "LTXV/loaders"
DESCRIPTION = (
"Loads a diffusion model checkpoint with dependency support. "
"Connect 'dependencies' to a previous loader's output to ensure sequential loading and reduce peak VRAM usage."
)
FUNCTION = "load_checkpoint_sequentially"
def load_checkpoint_sequentially(
self,
ckpt_name: str,
dependencies: object = None, # noqa: ARG002 - required for ComfyUI optional inputs
) -> tuple:
return super().load_checkpoint(ckpt_name)
class LowVRAMAudioVAELoader:
"""Load LTXV Audio VAE with dependency input for sequential loading in low VRAM scenarios."""
@classmethod
def INPUT_TYPES(s) -> dict:
return {
"required": {
"ckpt_name": (
folder_paths.get_filename_list("checkpoints"),
{"tooltip": "Audio VAE checkpoint to load."},
),
},
"optional": {
"dependencies": (
any_type,
{
"tooltip": "Connect any output from a previous loader to ensure sequential loading."
},
),
},
}
RETURN_TYPES = ("VAE",)
RETURN_NAMES = ("audio_vae",)
CATEGORY = "LTXV/loaders"
DESCRIPTION = (
"Loads an LTXV Audio VAE checkpoint with dependency support. "
"Connect 'dependencies' to a previous loader's output to ensure sequential loading and reduce peak VRAM usage."
)
FUNCTION = "load_audio_vae_sequentially"
def load_audio_vae_sequentially(
self,
ckpt_name: str,
dependencies: object = None, # noqa: ARG002 - required for ComfyUI optional inputs
) -> tuple:
ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", ckpt_name)
sd, metadata = comfy.utils.load_torch_file(ckpt_path, return_metadata=True)
audio_vae = AudioVAE(sd, metadata)
return (audio_vae,)
class LowVRAMLatentUpscaleModelLoader(LatentUpscaleModelLoader):
"""Load latent upsampler with dependency input for sequential loading in low VRAM scenarios."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LowVRAMLatentUpscaleModelLoader",
display_name="Low VRAMLoad Latent Upscale Model",
category="LTXV/loaders",
inputs=[
io.Combo.Input(
"model_name",
options=folder_paths.get_filename_list("latent_upscale_models"),
),
io.AnyType.Input(
"dependencies",
optional=True,
tooltip="Connect any output from a previous loader to ensure sequential loading.",
),
],
outputs=[
io.LatentUpscaleModel.Output(),
],
)
@classmethod
def execute(cls, model_name, dependencies=None) -> io.NodeOutput:
return LatentUpscaleModelLoader.execute(model_name=model_name)
-234
View File
@@ -1,234 +0,0 @@
import math
from contextlib import nullcontext
import comfy.latent_formats
import comfy.model_base
import comfy.model_management
import comfy.model_patcher
import comfy.model_sampling
import comfy.sd
import comfy.supported_models_base
import comfy.utils
import torch
import torch.nn as nn
from ltx_video.models.transformers.symmetric_patchifier import SymmetricPatchifier
from ltx_video.models.transformers.transformer3d import Transformer3DModel
class LTXVModelConfig:
def __init__(self, latent_channels, dtype):
self.unet_config = {}
self.unet_extra_config = {}
self.latent_format = comfy.latent_formats.LatentFormat()
self.latent_format.latent_channels = latent_channels
self.manual_cast_dtype = dtype
self.sampling_settings = {"multiplier": 1.0}
self.memory_usage_factor = 2.7
# denoiser is handled by extension
self.unet_config["disable_unet_model_creation"] = True
class LTXVSampling(torch.nn.Module, comfy.model_sampling.CONST):
def __init__(self, condition_mask, guiding_latent=None):
super().__init__()
self.condition_mask = condition_mask
self.guiding_latent = guiding_latent
self.set_parameters(shift=1.0, multiplier=1)
def set_parameters(self, shift=1.0, timesteps=1000, multiplier=1000):
self.shift = shift
self.multiplier = multiplier
ts = self.sigma((torch.arange(0, timesteps + 1, 1) / timesteps) * multiplier)
self.register_buffer("sigmas", ts)
@property
def sigma_min(self):
return self.sigmas[0]
@property
def sigma_max(self):
return self.sigmas[-1]
def timestep(self, sigma):
return sigma * self.multiplier
def sigma(self, timestep):
return timestep
def percent_to_sigma(self, percent):
if percent <= 0.0:
return 1.0
if percent >= 1.0:
return 0.0
return 1.0 - percent
def calculate_input(self, sigma, noise):
if self.guiding_latent is not None:
noise = (
noise * (1 - self.condition_mask)
+ self.guiding_latent * self.condition_mask
)
return noise
def noise_scaling(self, sigma, noise, latent_image, max_denoise=False):
self.condition_mask = self.condition_mask.to(latent_image.device)
scaled = latent_image * (1 - sigma) + noise * sigma
result = latent_image * self.condition_mask + scaled * (1 - self.condition_mask)
return result
def calculate_denoised(self, sigma, model_output, model_input):
sigma = sigma.view(sigma.shape[:1] + (1,) * (model_output.ndim - 1))
result = model_input - model_output * sigma
# In order to d * dT to be zero in euler step, we need to set result equal to input in first latent frame.
if self.guiding_latent is not None:
result = (
result * (1 - self.condition_mask)
+ self.guiding_latent * self.condition_mask
)
else:
result = (
result * (1 - self.condition_mask) + model_input * self.condition_mask
)
return result
class LTXVModel(comfy.model_base.BaseModel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.model_sampling = LTXVSampling(torch.zeros([1]))
def extra_conds(self, **kwargs):
out = super().extra_conds(**kwargs)
attention_mask = kwargs.get("attention_mask", None)
if attention_mask is not None:
out["attention_mask"] = comfy.conds.CONDRegular(attention_mask)
return out
class LTXVTransformer3D(nn.Module):
def __init__(
self,
transformer: Transformer3DModel,
patchifier: SymmetricPatchifier,
conditioning_mask,
latent_frame_rate,
vae_scale_factor,
):
super().__init__()
self.dtype = transformer.dtype
self.transformer = transformer
self.patchifier = patchifier
self.conditioning_mask = conditioning_mask
self.latent_frame_rate = latent_frame_rate
self.vae_scale_factor = vae_scale_factor
def indices_grid(
self,
latent_shape,
device,
):
use_rope = self.transformer.use_rope
scale_grid = (
(1 / self.latent_frame_rate, self.vae_scale_factor, self.vae_scale_factor)
if use_rope
else None
)
indices_grid = self.patchifier.get_grid(
orig_num_frames=latent_shape[2],
orig_height=latent_shape[3],
orig_width=latent_shape[4],
batch_size=latent_shape[0],
scale_grid=scale_grid,
device=device,
)
return indices_grid
def wrapped_transformer(
self,
latent,
timesteps,
context,
attention_mask,
indices_grid,
skip_layer_mask=None,
skip_layer_strategy=None,
img_hw=None,
aspect_ratio=None,
mixed_precision=True,
**kwargs,
):
# infer mask from context padding, assumes padding vectors are all zero.
latent = latent.to(self.transformer.dtype)
latent_patchified = self.patchifier.patchify(latent)
if mixed_precision:
context_manager = torch.autocast("cuda", dtype=torch.bfloat16)
else:
context_manager = nullcontext()
with context_manager:
noise_pred = self.transformer(
latent_patchified.to(self.transformer.dtype).to(
self.transformer.device
),
indices_grid.to(self.transformer.device),
encoder_hidden_states=context.to(self.transformer.device),
encoder_attention_mask=attention_mask,
timestep=timesteps,
skip_layer_mask=skip_layer_mask,
skip_layer_strategy=skip_layer_strategy,
return_dict=False,
)[0]
result = self.patchifier.unpatchify(
latents=noise_pred,
output_height=latent.shape[3],
output_width=latent.shape[4],
output_num_frames=latent.shape[2],
out_channels=latent.shape[1] // math.prod(self.patchifier.patch_size),
)
return result
def forward(
self,
x,
timesteps,
context,
attention_mask,
img_hw=None,
aspect_ratio=None,
**kwargs,
):
transformer_options = kwargs.get("transformer_options", {})
ptb_index = transformer_options.get("ptb_index", None)
mixed_precision = transformer_options.get("mixed_precision", False)
cond_or_uncond = transformer_options.get("cond_or_uncond", [])
skip_block_list = transformer_options.get("skip_block_list", [])
skip_layer_strategy = transformer_options.get("skip_layer_strategy", None)
mask = self.patchifier.patchify(self.conditioning_mask).squeeze(-1).to(x.device)
ndim_mask = mask.ndimension()
expanded_timesteps = timesteps.view(timesteps.size(0), *([1] * (ndim_mask - 1)))
timesteps_masked = expanded_timesteps * (1 - mask)
skip_layer_mask = None
if ptb_index is not None and ptb_index in cond_or_uncond:
skip_layer_mask = self.transformer.create_skip_layer_mask(
skip_block_list,
1,
len(cond_or_uncond),
len(cond_or_uncond) - 1 - cond_or_uncond.index(ptb_index),
)
result = self.wrapped_transformer(
x,
timesteps_masked,
context,
attention_mask,
indices_grid=self.indices_grid(x.shape, x.device),
mixed_precision=mixed_precision,
skip_layer_mask=skip_layer_mask,
skip_layer_strategy=skip_layer_strategy,
)
return result
+30 -2
View File
@@ -1,4 +1,5 @@
import re
from functools import wraps
from typing import Callable, Optional, Type
NODE_CLASS_MAPPINGS = {}
@@ -36,6 +37,29 @@ def register_node(node_class: Type, name: str, description: str) -> None:
NODE_DISPLAY_NAME_MAPPINGS[name] = description
def _is_v3_node(node_class: Type) -> bool:
"""Check if the node class is a v3 node (has define_schema method)."""
return hasattr(node_class, "define_schema") and callable(
getattr(node_class, "define_schema")
)
def _wrap_define_schema(node_class: Type, display_name: str) -> None:
"""Wrap the define_schema method to inject the display_name into the schema."""
original_define_schema = node_class.define_schema
@classmethod
@wraps(original_define_schema.__func__)
def wrapped_define_schema(cls):
schema = original_define_schema.__func__(cls)
# Only set display_name if not already set in the schema
if schema.display_name is None:
schema.display_name = display_name
return schema
node_class.define_schema = wrapped_define_schema
def comfy_node(
node_class: Optional[Type] = None,
name: Optional[str] = None,
@@ -82,6 +106,10 @@ def comfy_node(
description = _format_description(description, name, experimental, deprecated)
# For v3 nodes, wrap define_schema to inject the display_name
if _is_v3_node(node_class):
_wrap_define_schema(node_class, description)
register_node(node_class, name, description)
return node_class
@@ -99,7 +127,7 @@ def _format_description(
# If description is not provided, auto-generate one based on the class name
if description is None:
description = _camel_case_to_spaces(class_name)
description = camel_case_to_spaces(class_name)
# Strip the prefix if it's already there
prefix_len = len(NODES_DISPLAY_NAME_PREFIX)
@@ -118,7 +146,7 @@ def _format_description(
return description
def _camel_case_to_spaces(text: str) -> str:
def camel_case_to_spaces(text: str) -> str:
# Add space before each capital letter except the first one
spaced_text = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text)
# Handle sequences of uppercase letters followed by a lowercase letter
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
[
{
"name": "Custom"
},
{
"name": "13b Dynamic",
"skip_steps_sigma_threshold": 0.997,
"cfg_star_rescale": true,
"sigmas": [1.0, 0.9933, 0.9850, 0.9767, 0.9008, 0.6180],
"cfg_values": [1, 6, 8, 6, 1, 1],
"stg_scale_values": [0, 4, 4, 4, 2, 1],
"stg_rescale_values": [1, 0.5, 0.5, 1, 1, 1],
"stg_layers_indices": [[11, 25, 35, 39], [22, 35, 39], [28], [28], [28], [28]]
},
{
"name": "13b Balanced",
"skip_steps_sigma_threshold": 0.998,
"cfg_star_rescale": true,
"sigmas": [1.0, 0.9933, 0.9850, 0.9767, 0.9008, 0.6180],
"cfg_values": [1, 6, 8, 6, 1, 1],
"stg_scale_values": [0, 4, 4, 4, 2, 1],
"stg_rescale_values": [1, 0.5, 0.5, 1, 1, 1],
"stg_layers_indices": [[12], [12], [5], [5], [28], [29]]
},
{
"name": "13b Upscale",
"skip_steps_sigma_threshold": 0.997,
"cfg_star_rescale": true,
"sigmas": [1.0, 0.9933, 0.9850, 0.9767, 0.9008, 0.6180],
"cfg_values": [1, 1, 1, 1, 1, 1],
"stg_scale_values": [1, 1, 1, 1, 1, 1],
"stg_rescale_values": [1, 1, 1, 1, 1, 1],
"stg_layers_indices": [[42], [42], [42], [42], [42], [42]]
},
{
"name": "13b Distilled",
"skip_steps_sigma_threshold": 0.997,
"cfg_star_rescale": true,
"sigmas": [1.0],
"cfg_values": [1],
"stg_scale_values": [0],
"stg_rescale_values": [1],
"stg_layers_indices": [[25]]
},
{
"name": "2b",
"skip_steps_sigma_threshold": 0.997,
"cfg_star_rescale": true,
"sigmas": [1.0, 0.9933, 0.9850, 0.9767, 0.9008, 0.6180],
"cfg_values": [4, 4, 4, 4, 1, 1],
"stg_scale_values": [2, 2, 2, 2, 1, 0],
"stg_rescale_values": [1, 1, 1, 1, 1, 1],
"stg_layers_indices": [[14], [14], [14], [14], [14], [14]]
}
]
-22
View File
@@ -1,22 +0,0 @@
[project]
name = "comfyui-ltxvideo"
version = "0.1.0"
description = "Custom nodes for LTX-Video support in ComfyUI"
authors = [
{ name = "Andrew Kvochko", email = "akvochko@lightricks.com" }
]
requires-python = ">=3.10"
readme = "README.md"
license = { file = "LICENSE" }
dependencies = [
"ltx-video@git+https://github.com/Lightricks/LTX-Video@ltx-video-0.9.1",
"av>=10.0.0"
]
[project.optional-dependencies]
dev = [
"pre-commit>=4.0.1",
"pytest>=8.0.0",
"websocket-client==1.6.1",
"scikit-image==0.24.0"
]
+266
View File
@@ -0,0 +1,266 @@
import math
import comfy.model_management
import torch
import torch.nn.functional as F
from comfy_api.latest import io
from kornia.geometry.transform.pyramid import (
PyrUp,
build_laplacian_pyramid,
build_pyramid,
find_next_powerof_two,
is_powerof_two,
)
from torch import Tensor
from .nodes_registry import comfy_node
_CHUNK_SIZE = 8
_MASK_LOW_RES_LONG_SIDE = 64
def _pad_for_laplacian(image: torch.Tensor) -> tuple[torch.Tensor, tuple[int, int]]:
"""Pad an image tensor so H and W are powers of two (kornia requirement)."""
h, w = image.shape[2], image.shape[3]
pad_right = 0
pad_down = 0
if not (is_powerof_two(h) and is_powerof_two(w)):
pad_right = find_next_powerof_two(w) - w
pad_down = find_next_powerof_two(h) - h
image = F.pad(image, (0, pad_right, 0, pad_down), mode="reflect")
return image, (pad_right, pad_down)
def _gaussian_pyramid(
images: torch.Tensor,
max_level: int,
border_type: str = "reflect",
align_corners: bool = False,
) -> list[Tensor]:
h, w = images.shape[2], images.shape[3]
if not (is_powerof_two(w) and is_powerof_two(h)):
padding = (0, find_next_powerof_two(w) - w, 0, find_next_powerof_two(h) - h)
images = F.pad(images, padding, mode=border_type)
return build_pyramid(images, max_level, border_type, align_corners)
def _resize_preserving_aspect_ratio(
images: torch.Tensor, long_side: int, mode: str
) -> torch.Tensor:
h, w = images.shape[-2:]
current_long_side = max(h, w)
if current_long_side == long_side:
return images
scale = long_side / current_long_side
resized_h = max(1, int(round(h * scale)))
resized_w = max(1, int(round(w * scale)))
if mode == "nearest":
return F.interpolate(images, size=(resized_h, resized_w), mode=mode)
return F.interpolate(
images,
size=(resized_h, resized_w),
mode=mode,
align_corners=False,
)
def _apply_low_res_mask_dilation(
mask: torch.Tensor,
spatial_radius: int,
long_side: int = _MASK_LOW_RES_LONG_SIDE,
) -> torch.Tensor:
if spatial_radius <= 0:
return mask
original_size = mask.shape[-2:]
mask_low_res = _resize_preserving_aspect_ratio(
mask.float(), long_side, mode="bilinear"
)
mask_low_res = F.max_pool2d(
mask_low_res,
kernel_size=spatial_radius * 2 + 1,
stride=1,
padding=spatial_radius,
)
return F.interpolate(
mask_low_res,
size=original_size,
mode="bilinear",
align_corners=False,
)
def _pyramid_blend_chunk(
image1: torch.Tensor,
image2: torch.Tensor,
mask: torch.Tensor,
max_level: int = 7,
) -> torch.Tensor:
"""Blend a single chunk (already padded, already on device)."""
pyramid1 = build_laplacian_pyramid(image1, max_level=max_level)
pyramid2 = build_laplacian_pyramid(image2, max_level=max_level)
pyramid_mask = _gaussian_pyramid(mask, max_level=max_level)
pyr_up = PyrUp()
output = pyramid1[-1] * pyramid_mask[-1] + pyramid2[-1] * (1 - pyramid_mask[-1])
for i in range(len(pyramid1) - 2, -1, -1):
residual = pyramid1[i] * pyramid_mask[i] + pyramid2[i] * (1 - pyramid_mask[i])
output = pyr_up(output) + residual
return output
def _pyramid_blend(
image1: torch.Tensor,
image2: torch.Tensor,
mask: torch.Tensor,
max_level: int = 7,
device: torch.device | None = None,
output_device: torch.device | None = None,
) -> torch.Tensor:
if image1.shape != image2.shape:
raise ValueError(
f"input images must have the same size, {image1.shape} != {image2.shape}"
)
if image1.shape[0] != mask.shape[0]:
raise ValueError(
"image_a, image_b, and mask must have the same frame count for blending"
)
if image1.shape[-2:] != mask.shape[-2:]:
raise ValueError(
"image_a, image_b, and mask must have the same spatial resolution for blending"
)
_, padding = _pad_for_laplacian(image1[:1])
orig_h, orig_w = image1.shape[-2], image1.shape[-1]
padded_min = min(orig_h + padding[1], orig_w + padding[0])
max_level = min(max_level, int(math.log2(padded_min)))
if any(padding):
mask = torch.nn.functional.pad(
mask, (0, padding[0], 0, padding[1]), mode="reflect"
)
B = image1.shape[0]
results = []
for start in range(0, B, _CHUNK_SIZE):
end = min(start + _CHUNK_SIZE, B)
img1_chunk, _ = _pad_for_laplacian(image1[start:end])
img2_chunk, _ = _pad_for_laplacian(image2[start:end])
mask_chunk = mask[start:end]
if device is not None:
img1_chunk = img1_chunk.to(device)
img2_chunk = img2_chunk.to(device)
mask_chunk = mask_chunk.to(device)
out_chunk = _pyramid_blend_chunk(
img1_chunk, img2_chunk, mask_chunk, max_level=max_level
)
cropped = out_chunk[..., :orig_h, :orig_w].clamp(0, 1)
results.append(
cropped.to(output_device) if output_device is not None else cropped
)
return torch.cat(results, dim=0)
@comfy_node(
name="LTXVLaplacianPyramidBlend",
description="LTX Laplacian Pyramid Blend",
)
class LTXVLaplacianPyramidBlend(io.ComfyNode):
"""Blend two images seamlessly using Laplacian pyramid blending with a mask."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXVLaplacianPyramidBlend",
category="Lightricks/utility",
description="Blend two images seamlessly using Laplacian pyramid blending.",
inputs=[
io.Image.Input(
"image_a",
tooltip="First source image.",
),
io.Image.Input(
"image_b",
tooltip="Second source image.",
),
io.Mask.Input(
"mask",
tooltip="Blend mask (white = image_a, black = image_b).",
),
io.Boolean.Input(
"trim_to_shortest",
default=True,
tooltip="Trim image_a, image_b, and mask to the shortest sequence length before blending.",
),
io.Int.Input(
"mask_low_res_dilation",
default=5,
min=0,
max=15,
tooltip="Downscale the mask to long side 64, dilate it spatially, then resize it back before blending.",
),
],
outputs=[
io.Image.Output("image"),
],
)
@classmethod
def execute(
cls,
image_a: torch.Tensor,
image_b: torch.Tensor,
mask: torch.Tensor,
trim_to_shortest: bool,
mask_low_res_dilation: int,
) -> io.NodeOutput:
device = comfy.model_management.get_torch_device()
if mask.ndim == 4:
mask = mask[:, :, :, 0]
if mask.ndim == 2:
mask = mask.unsqueeze(0)
if image_a.shape[1:3] != image_b.shape[1:3]:
raise ValueError(
"image_a and image_b must have the same spatial resolution"
)
if image_a.shape[1:3] != mask.shape[1:3]:
raise ValueError(
"image_a, image_b, and mask must have the same spatial resolution"
)
if trim_to_shortest:
shortest = min(image_a.shape[0], image_b.shape[0], mask.shape[0])
image_a = image_a[:shortest]
image_b = image_b[:shortest]
mask = mask[:shortest]
elif not (image_a.shape[0] == image_b.shape[0] == mask.shape[0]):
raise ValueError(
"image_a, image_b, and mask must have the same frame count unless trim_to_shortest is enabled"
)
original = image_a.permute(0, 3, 1, 2)
target = image_b.permute(0, 3, 1, 2)
mask_4d = mask.unsqueeze(1).float()
mask_4d = _apply_low_res_mask_dilation(mask_4d, mask_low_res_dilation)
result = _pyramid_blend(
original,
target,
mask_4d,
max_level=7,
device=device,
output_device=comfy.model_management.intermediate_device(),
)
return io.NodeOutput(result.permute(0, 2, 3, 1))
+213
View File
@@ -0,0 +1,213 @@
import importlib.metadata
import logging
import comfy
import comfy.model_detection
import comfy.model_management
import comfy.model_patcher
import comfy.utils
import folder_paths
import torch
try:
from q8_kernels.functional.ops import hadamard_transform
from q8_kernels.integration.patch_transformer import (
patch_comfyui_native_avtransformer,
patch_comfyui_native_transformer,
)
Q8_AVAILABLE = True
except ImportError:
Q8_AVAILABLE = False
from .nodes_registry import comfy_node
def list_in_name(check_list, name):
return any([x in name for x in check_list])
def check_q8_available():
if not Q8_AVAILABLE:
raise ImportError(
"Q8 kernels are not available. To use this feature install the q8_kernels package from here:."
"https://github.com/Lightricks/LTX-Video-Q8-Kernels"
)
def check_deprecated():
q8_version = tuple(
int(x) for x in importlib.metadata.version("q8_kernels").split(".")[:3]
)
return q8_version >= (0, 1, 5)
@comfy_node(name="LTXQ8Patch")
class LTXVQ8Patch:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"use_fp8_attention": (
"BOOLEAN",
{"default": False, "tooltip": "Use FP8 attention."},
),
"quantization_preset": (
["0.9.8", "ltxv2", "full_bf16", "custom"],
{"default": "0.9.8"},
),
"quantize_self_attn": (
"BOOLEAN",
{"default": True, "tooltip": "Quantize Self Attention Layer"},
),
"quantize_cross_attn": (
"BOOLEAN",
{"default": True, "tooltip": "Quantize Cross Attention Layer"},
),
"quantize_ffn": (
"BOOLEAN",
{"default": True, "tooltip": "Quantize Feed Forward Layer"},
),
}
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "patch"
CATEGORY = "lightricks/LTXV"
TITLE = "LTXV Q8 Patcher"
PRESETS = {
"0.9.8": (True, True, True),
"ltxv2": (True, False, True),
"full_bf16": (False, False, False),
}
def patch(
self,
model,
use_fp8_attention,
quantization_preset,
quantize_self_attn,
quantize_cross_attn,
quantize_ffn,
):
check_q8_available()
m = model.clone()
transformer_key = "diffusion_model"
transformer = m.get_model_object(transformer_key)
is_av = transformer.__class__.__name__ == "LTXAVModel"
if is_av:
patcher = patch_comfyui_native_avtransformer
else:
patcher = patch_comfyui_native_transformer
if check_deprecated():
logging.info("This node is deprecated soon. Use new one.")
quant_audio_path = False
quant_type = "blockwise-fp8"
patcher(transformer, use_fp8_attention, True, quant_type, quant_audio_path)
else:
quantize_self_attn, quantize_cross_attn, quantize_ffn = (
LTXVQ8Patch.PRESETS.get(
quantization_preset,
(quantize_self_attn, quantize_cross_attn, quantize_ffn),
)
)
if getattr(transformer, "quantization_config", None) is not None:
if (quantize_self_attn, quantize_cross_attn, quantize_ffn) != getattr(
transformer, "quantization_config"
):
quantize_self_attn, quantize_cross_attn, quantize_ffn = getattr(
transformer, "quantization_config"
)
patcher(
transformer,
use_fp8_attention,
True,
quantize_self_attn,
quantize_cross_attn,
quantize_ffn,
)
setattr(
transformer,
"quantization_config",
(quantize_self_attn, quantize_cross_attn, quantize_ffn),
)
setattr(transformer, "is_q8_patched", True)
setattr(transformer, "use_fp8_attention", use_fp8_attention)
return (m,)
def idendity_quant_fn(x, t):
return x.to(dtype=t)
@comfy_node(name="LTXVQ8LoraModelLoader")
class LTXVQ8LoraModelLoader:
def __init__(self):
self.loaded_lora = None
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength_model": (
"FLOAT",
{"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01},
),
}
}
RETURN_TYPES = ("MODEL",)
CATEGORY = "lightricks/LTXV"
FUNCTION = "load_lora_model_only"
def load_lora(self, model, lora_name, strength_model):
quant_fn = hadamard_transform
transformer = model.get_model_object("diffusion_model")
is_patched_transformer = getattr(transformer, "is_q8_patched", False)
if not is_patched_transformer or not Q8_AVAILABLE:
raise ValueError(
"LTXV Q8 Patcher is not applied to the model. Please use LTXQ8Patch node before loading lora or install q8_kernels."
)
if strength_model == 0:
return model
quantize_self_attn, quantize_cross_attn, quantize_ffn = getattr(
transformer, "quantization_config"
)
skip_list = []
if not quantize_self_attn:
skip_list += ["attn1"]
if not quantize_cross_attn:
skip_list += ["attn2"]
if not quantize_ffn:
skip_list += ["ff"]
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
new_lora = {}
for k in lora:
device = lora[k].device
if lora[k].ndim == 2:
if "lora_A" in k and not list_in_name(skip_list, k):
new_lora[k] = quant_fn(
lora[k].to(device="cuda", dtype=torch.bfloat16),
out_type=torch.bfloat16,
).to(device)
else:
new_lora[k] = lora[k]
else:
new_lora[k] = lora[k]
self.loaded_lora = (lora_path, new_lora)
model_lora, _ = comfy.sd.load_lora_for_models(
model, None, new_lora, strength_model, 0
)
return model_lora
def load_lora_model_only(self, model, lora_name, strength_model):
return (self.load_lora(model, lora_name, strength_model),)
+10 -2
View File
@@ -1,2 +1,10 @@
av>=10.0.0
ltx-video@git+https://github.com/Lightricks/LTX-Video@ltx-video-0.9.1
# Required for HDR primaries (same as ltx-core); no baked-matrix fallback.
colour-science>=0.4.4
diffusers
einops
huggingface_hub>=0.25.2
kornia
ninja~=1.11.1.4
# Required for HDR EXR read/write (same as ltx-pipelines); no OpenCV EXR fallback.
openimageio
transformers>=4.50.0
+46
View File
@@ -0,0 +1,46 @@
"""RoPE frequency helpers used by the embeddings connector.
These were previously imported from ``comfy.ldm.lightricks.model``, but upstream
ComfyUI does not ship them (it folds the equivalent logic into
``freqs_cis_matrix``). They are kept here, local to this node, so the connector
keeps its exact original behavior without relying on fork-only additions to core
ComfyUI.
"""
import torch
def interleaved_freqs_cis(freqs, pad_size):
cos_freq = freqs.cos().repeat_interleave(2, dim=-1)
sin_freq = freqs.sin().repeat_interleave(2, dim=-1)
if pad_size != 0:
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
sin_padding = torch.zeros_like(cos_freq[:, :, :pad_size])
cos_freq = torch.cat([cos_padding, cos_freq], dim=-1)
sin_freq = torch.cat([sin_padding, sin_freq], dim=-1)
return cos_freq, sin_freq
def split_freqs_cis(freqs, pad_size, num_attention_heads):
cos_freq = freqs.cos()
sin_freq = freqs.sin()
if pad_size != 0:
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
sin_padding = torch.zeros_like(sin_freq[:, :, :pad_size])
cos_freq = torch.concatenate([cos_padding, cos_freq], axis=-1)
sin_freq = torch.concatenate([sin_padding, sin_freq], axis=-1)
B, T, half_HD = cos_freq.shape
cos_freq = cos_freq.reshape(
B, T, num_attention_heads, half_HD // num_attention_heads
)
sin_freq = sin_freq.reshape(
B, T, num_attention_heads, half_HD // num_attention_heads
)
cos_freq = torch.swapaxes(cos_freq, 1, 2)
sin_freq = torch.swapaxes(sin_freq, 1, 2)
return cos_freq, sin_freq
+381
View File
@@ -0,0 +1,381 @@
import base64
import json
from io import BytesIO
import numpy as np
import torch
from comfy_api.latest import io
from PIL import Image
from .nodes_registry import comfy_node
def _catmull_rom(p0: dict, p1: dict, p2: dict, p3: dict, t: float) -> dict[str, float]:
t2 = t * t
t3 = t2 * t
return {
"x": 0.5
* (
2 * p1["x"]
+ (-p0["x"] + p2["x"]) * t
+ (2 * p0["x"] - 5 * p1["x"] + 4 * p2["x"] - p3["x"]) * t2
+ (-p0["x"] + 3 * p1["x"] - 3 * p2["x"] + p3["x"]) * t3
),
"y": 0.5
* (
2 * p1["y"]
+ (-p0["y"] + p2["y"]) * t
+ (2 * p0["y"] - 5 * p1["y"] + 4 * p2["y"] - p3["y"]) * t2
+ (-p0["y"] + 3 * p1["y"] - 3 * p2["y"] + p3["y"]) * t3
),
}
def _interpolate_spline(
control_points: list[dict], num_samples: int
) -> list[dict[str, int]]:
"""Catmull-Rom spline interpolation matching the JS frontend logic."""
if len(control_points) == 0:
return []
if len(control_points) == 1:
p = control_points[0]
return [{"x": round(p["x"]), "y": round(p["y"])} for _ in range(num_samples)]
if len(control_points) == 2:
a, b = control_points
return [
{
"x": round(a["x"] + (b["x"] - a["x"]) * i / (num_samples - 1)),
"y": round(a["y"] + (b["y"] - a["y"]) * i / (num_samples - 1)),
}
for i in range(num_samples)
]
pts = [control_points[0], *control_points, control_points[-1]]
n_seg = len(pts) - 3
result = []
for i in range(num_samples):
g_t = (i / (num_samples - 1)) * n_seg
seg = min(int(g_t), n_seg - 1)
l_t = g_t - seg
p = _catmull_rom(pts[seg], pts[seg + 1], pts[seg + 2], pts[seg + 3], l_t)
result.append({"x": round(p["x"]), "y": round(p["y"])})
return result
@comfy_node(name="LTXVSparseTrackEditor", description="LTX Sparse Track Editor")
class LTXVSparseTrackEditor(io.ComfyNode):
"""Interactive spline editor for drawing sparse motion tracks.
Provides a canvas widget where users can draw and edit spline control
points on top of a reference image. Outputs interpolated track
coordinates compatible with LTXVDrawTracks.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXVSparseTrackEditor",
category="Lightricks/motion_tracking",
description=(
"Interactive spline editor for drawing sparse motion tracks "
"on a reference image."
),
inputs=[
io.Image.Input(
"image",
tooltip="Reference image displayed as the editor canvas background.",
),
io.String.Input(
"points_store",
default="[]",
tooltip="JSON array of spline control points managed by the editor widget.",
),
io.String.Input(
"coordinates",
default="[]",
tooltip="JSON array of interpolated track coordinates produced by the editor.",
),
io.Int.Input(
"points_to_sample",
default=121,
min=2,
max=10000,
tooltip="Number of points sampled along each spline curve.",
),
],
outputs=[
io.String.Output("tracks"),
],
is_output_node=True,
)
@classmethod
def execute(
cls,
image,
points_store: str,
coordinates: str,
points_to_sample: int,
) -> io.NodeOutput:
# Re-interpolate from control points so that changes to
# points_to_sample are always respected, regardless of JS sync.
try:
splines = json.loads(points_store) if points_store else []
except (json.JSONDecodeError, TypeError):
splines = []
if splines and isinstance(splines, list) and isinstance(splines[0], list):
interpolated = [_interpolate_spline(sp, points_to_sample) for sp in splines]
tracks = json.dumps(interpolated)
elif coordinates and coordinates != "[]":
tracks = coordinates
else:
tracks = "[]"
img_array = (image[0].cpu().numpy() * 255).astype(np.uint8)
img = Image.fromarray(img_array)
buf = BytesIO()
img.save(buf, format="JPEG", quality=75)
img_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return io.NodeOutput(tracks, ui={"bg_image": [img_b64]})
def _parse_tracks(raw: str) -> list[list[dict]]:
"""Parse tracks from a JSON string, handling nested/wrapped formats."""
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, list):
unwrapped = []
for item in parsed:
unwrapped.append(json.loads(item) if isinstance(item, str) else item)
parsed = unwrapped
tracks: list[list[dict]] = []
stack = [parsed]
while stack:
obj = stack.pop()
if isinstance(obj, list) and len(obj) > 0:
if isinstance(obj[0], dict) and "x" in obj[0] and "y" in obj[0]:
tracks.append(obj)
else:
stack.extend(obj)
return tracks
def _age_color_batch(ratios: torch.Tensor, device: torch.device) -> torch.Tensor:
"""Vectorised age-ratio -> RGB [0..1] mapping on GPU.
Gradient: blue -> green -> yellow -> red.
"""
colors = torch.zeros(ratios.shape[0], 3, device=device)
m1 = ratios <= 1 / 3
tr1 = ratios[m1] * 3
colors[m1, 1] = tr1
colors[m1, 2] = 1 - tr1
m2 = (ratios > 1 / 3) & (ratios <= 2 / 3)
tr2 = (ratios[m2] - 1 / 3) * 3
colors[m2, 0] = tr2
colors[m2, 1] = 1
m3 = ratios > 2 / 3
tr3 = (ratios[m3] - 2 / 3) * 3
colors[m3, 0] = 1
colors[m3, 1] = 1 - tr3
return colors
def _render_resolution(width: int, height: int, reference_short_side: int):
"""Compute the higher render resolution that preserves aspect ratio."""
if height <= width:
rw = int(width * reference_short_side / height)
rh = reference_short_side
else:
rw = reference_short_side
rh = int(height * reference_short_side / width)
scale_x = rw / width
scale_y = rh / height
return rw, rh, scale_x, scale_y
_MIN_RADIUS = 2
_MAX_RADIUS = 8
_MAX_TRAIL = 50
_REF_SHORT_SIDE = 1080
@comfy_node(name="LTXVDrawTracks", description="LTX Draw Sparse Tracks")
class LTXVDrawTracks(io.ComfyNode):
"""GPU-accelerated sparse track renderer.
Renders circles at a high reference resolution and downscales with
bilinear interpolation so circle sizes match the CPU version.
All work — rasterisation, compositing and resize — stays on GPU.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LTXVDrawTracks",
category="Lightricks/motion_tracking",
description=(
"GPU-accelerated sparse track renderer. Rasterises circles at "
"high resolution and downscales with bilinear interpolation."
),
inputs=[
io.String.Input(
"tracks",
multiline=True,
tooltip="JSON string of track coordinates (list of point lists with x/y keys).",
),
io.Int.Input(
"width",
default=512,
min=8,
max=8192,
step=8,
tooltip="Output image width in pixels.",
),
io.Int.Input(
"height",
default=512,
min=8,
max=8192,
step=8,
tooltip="Output image height in pixels.",
),
],
outputs=[
io.Image.Output(),
],
)
@classmethod
def execute(cls, tracks: str, width: int, height: int) -> io.NodeOutput:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
parsed = _parse_tracks(tracks)
if not parsed:
blank = torch.zeros(1, height, width, 3, device=device, dtype=torch.half)
return io.NodeOutput(blank)
num_tracks = len(parsed)
num_frames = max(len(t) for t in parsed)
rw, rh, sx, sy = _render_resolution(width, height, _REF_SHORT_SIDE)
point_xy = torch.zeros(num_tracks, num_frames, 2, device=device)
vis = torch.zeros(num_tracks, num_frames, dtype=torch.bool, device=device)
for i, trk in enumerate(parsed):
coords = torch.tensor(
[[p["x"] * sx, p["y"] * sy] for p in trk],
dtype=torch.float32,
device=device,
)
point_xy[i, : len(trk)] = coords
vis[i, : len(trk)] = True
max_d = 2 * _MAX_RADIUS + 3
half_d = max_d // 2
offsets = torch.arange(max_d, device=device) - half_d
oy, ox = torch.meshgrid(offsets, offsets, indexing="ij")
template_dist_sq = oy.float().square() + ox.float().square()
render_frames = torch.zeros(num_frames, rh, rw, 3, device=device)
for t in range(num_frames):
tau_min = max(0, t - _MAX_TRAIL)
window = t - tau_min + 1
active_xy = point_xy[:, tau_min : t + 1]
active_vis = vis[:, tau_min : t + 1]
ages = torch.arange(window - 1, -1, -1, device=device, dtype=torch.float32)
ratios = 1.0 - ages / _MAX_TRAIL
radii = _MIN_RADIUS + (_MAX_RADIUS - _MIN_RADIUS) * ratios
colors = _age_color_batch(ratios, device)
flat_xy = active_xy.reshape(-1, 2)
flat_vis = active_vis.reshape(-1)
flat_radii = radii.unsqueeze(0).expand(num_tracks, -1).reshape(-1)
flat_colors = colors.unsqueeze(0).expand(num_tracks, -1, -1).reshape(-1, 3)
idx = flat_vis.nonzero(as_tuple=True)[0]
if idx.shape[0] == 0:
continue
pts = flat_xy[idx]
r = flat_radii[idx]
c = flat_colors[idx]
flat_ages = ages.unsqueeze(0).expand(num_tracks, -1).reshape(-1)
sort_order = flat_ages[idx].argsort(descending=True)
pts = pts[sort_order]
r = r[sort_order]
c = c[sort_order]
_rasterise_circles(
render_frames[t], pts, r, c, template_dist_sq, half_d, max_d, rh, rw
)
out = torch.nn.functional.interpolate(
render_frames.permute(0, 3, 1, 2),
size=(height, width),
mode="bilinear",
align_corners=False,
).permute(0, 2, 3, 1)
out = out[..., [2, 1, 0]] # RGB -> BGR to match IC-LoRA training data format
return io.NodeOutput(out.half())
def _rasterise_circles(
frame: torch.Tensor,
pts: torch.Tensor,
radii: torch.Tensor,
colors: torch.Tensor,
template_dist_sq: torch.Tensor,
half_d: int,
max_d: int,
H: int,
W: int,
) -> None:
"""Stamp filled circles onto *frame* fully on-device.
Uses ``scatter_reduce_`` with ``'amax'`` to resolve overlaps in
painter's order (circles are expected oldest-first so the highest
index = newest wins).
"""
M = pts.shape[0]
if M == 0:
return
device = pts.device
# per-circle masks [M, D, D]
radii_sq = (radii * radii).view(M, 1, 1)
circle_masks = template_dist_sq.unsqueeze(0) <= radii_sq
# frame-space indices [M, D, D]
cx = pts[:, 0].round().long().view(M, 1, 1)
cy = pts[:, 1].round().long().view(M, 1, 1)
offsets_y = torch.arange(max_d, device=device).sub(half_d).view(1, max_d, 1)
offsets_x = torch.arange(max_d, device=device).sub(half_d).view(1, 1, max_d)
fy = (cy + offsets_y).expand(M, max_d, max_d) # [M, D, D]
fx = (cx + offsets_x).expand(M, max_d, max_d) # [M, D, D]
valid = circle_masks & (fy >= 0) & (fy < H) & (fx >= 0) & (fx < W)
flat_fy = fy[valid]
flat_fx = fx[valid]
flat_lin = (flat_fy * W + flat_fx).long()
# circle index per valid pixel (oldest=0 … newest=M-1)
j_map = torch.arange(M, device=device, dtype=torch.float32).view(M, 1, 1)
j_map = j_map.expand_as(valid)
flat_j = j_map[valid]
# priority map — highest index (newest) wins via 'amax' reduce
priority = torch.full((H * W,), -1.0, device=device)
priority.scatter_reduce_(0, flat_lin, flat_j, reduce="amax", include_self=False)
priority = priority.view(H, W).long()
has_circle = priority >= 0
frame[has_circle] = colors[priority[has_circle]]

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