Compare commits
101
Commits
pr-2024-12-22
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44c8a36b7c | ||
|
|
af31f726c0 | ||
|
|
7f22eb16eb | ||
|
|
db476dacc1 | ||
|
|
3bf3ca6259 | ||
|
|
5eb48fdcae | ||
|
|
bf2ca0264f | ||
|
|
e6fdfe6686 | ||
|
|
5722b53a08 | ||
|
|
71d8972bd2 | ||
|
|
61ee82b23b | ||
|
|
c75eacae23 | ||
|
|
f8387c893d | ||
|
|
d164816f8a | ||
|
|
cb1f8204dc | ||
|
|
35364168d9 | ||
|
|
dfb2786749 | ||
|
|
63a7f34e64 | ||
|
|
15d09abb5a | ||
|
|
b9446f5972 | ||
|
|
ac4d998390 | ||
|
|
9d1672beb6 | ||
|
|
3b9c5cde47 | ||
|
|
548a39373b | ||
|
|
aceeae9635 | ||
|
|
ec7fe89955 | ||
|
|
4f45fd6c22 | ||
|
|
2e2ac81759 | ||
|
|
229437c6b6 | ||
|
|
75e1ed26ab | ||
|
|
964be9688f | ||
|
|
d50612a64a | ||
|
|
2acf7af899 | ||
|
|
09b90d0f33 | ||
|
|
937831df0e | ||
|
|
b82708a54f | ||
|
|
d43181d572 | ||
|
|
5984491048 | ||
|
|
3686d832c6 | ||
|
|
6df1f3ff90 | ||
|
|
531512f728 | ||
|
|
c5f4724bc2 | ||
|
|
58a5c12c2d | ||
|
|
5c3d20803d | ||
|
|
82bd963cde | ||
|
|
bb70ecba66 | ||
|
|
49add6dddb | ||
|
|
f82614dc6d | ||
|
|
d153ca3f78 | ||
|
|
d008795913 | ||
|
|
cd5d371518 | ||
|
|
683d46186a | ||
|
|
6790484426 | ||
|
|
adb1f9c95c | ||
|
|
63c8a9285c | ||
|
|
b7a8a2cfc2 | ||
|
|
e56af6973c | ||
|
|
aa0e2f1709 | ||
|
|
2c0bb1b9b8 | ||
|
|
aebe760c30 | ||
|
|
e1d2cff331 | ||
|
|
08f892eace | ||
|
|
42f509b3e8 | ||
|
|
445e087a43 | ||
|
|
ce66eea33e | ||
|
|
8b2d38c2ec | ||
|
|
36fdaf500b | ||
|
|
35ec43af1f | ||
|
|
8761bf3c00 | ||
|
|
ee11be3ce2 | ||
|
|
ba2626426f | ||
|
|
688efe72d2 | ||
|
|
e87087e95a | ||
|
|
653a95bdd1 | ||
|
|
5862cc77e6 | ||
|
|
ca193cdfe8 | ||
|
|
6e9e6de056 | ||
|
|
b19dc8618f | ||
|
|
7875c1cf64 | ||
|
|
a877517b63 | ||
|
|
1c8909531c | ||
|
|
36516b4e71 | ||
|
|
e63938b095 | ||
|
|
d617d4bd31 | ||
|
|
fa234963e5 | ||
|
|
5f6848be70 | ||
|
|
bcfd123014 | ||
|
|
972af4120d | ||
|
|
466330e953 | ||
|
|
8ef49793d7 | ||
|
|
0992f16e0c | ||
|
|
864ffa6183 | ||
|
|
ddb96675f0 | ||
|
|
bdde649e25 | ||
|
|
e2a73515dd | ||
|
|
228f8cbcba | ||
|
|
eabd4b6416 | ||
|
|
53a474cbe4 | ||
|
|
55f2d9ebb8 | ||
|
|
95a63bbb6d | ||
|
|
4c5add5f46 |
@@ -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}"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
[](https://github.com/Lightricks/LTX-2)
|
||||
[](https://ltx.io/model)
|
||||
[](https://huggingface.co/Lightricks/LTX-2.3)
|
||||
[](https://github.com/Lightricks/LTX-2/tree/main/packages/ltx-trainer)
|
||||
[](https://app.ltx.studio/ltx-2-playground/i2v)
|
||||
[](https://videos.ltx.io/LTX-2/grants/LTX_2_Technical_Report_compressed.pdf)
|
||||
[](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)
|
||||

|
||||
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)
|
||||

|
||||
- **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
@@ -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
@@ -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
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e508763410d009f524ed22c1c420e798904afe92098130a4d155d8318ef6b3a
|
||||
size 1112804
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8681ff86edb43d0a12472175d5503a590910b24d5ec5d5d5b78eed079960eac0
|
||||
size 998115
|
||||
@@ -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},)
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
+10678
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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"image_seq_length": 256,
|
||||
"processor_class": "Gemma3Processor"
|
||||
}
|
||||
+2379611
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
from .multimodal_guider import MultimodalGuiderNode # noqa: F401
|
||||
from .parameters import GuiderParametersNode # noqa: F401
|
||||
@@ -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,)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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,)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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]]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user