Shriramnag commited on
Commit
a69cd16
·
verified ·
1 Parent(s): 7ab3cd0

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. LICENSE +381 -0
  2. README.md +123 -3
  3. packages/ltx-core/README.md +413 -0
  4. packages/ltx-core/pyproject.toml +55 -0
  5. packages/ltx-core/src/ltx_core/__init__.py +0 -0
  6. packages/ltx-core/src/ltx_core/batch_split.py +95 -0
  7. packages/ltx-core/src/ltx_core/block_streaming/__init__.py +19 -0
  8. packages/ltx-core/src/ltx_core/block_streaming/builder.py +351 -0
  9. packages/ltx-core/src/ltx_core/block_streaming/disk.py +148 -0
  10. packages/ltx-core/src/ltx_core/block_streaming/pool.py +75 -0
  11. packages/ltx-core/src/ltx_core/block_streaming/provider.py +147 -0
  12. packages/ltx-core/src/ltx_core/block_streaming/source.py +102 -0
  13. packages/ltx-core/src/ltx_core/block_streaming/utils.py +134 -0
  14. packages/ltx-core/src/ltx_core/block_streaming/wrapper.py +96 -0
  15. packages/ltx-core/src/ltx_core/components/__init__.py +10 -0
  16. packages/ltx-core/src/ltx_core/components/diffusion_steps.py +186 -0
  17. packages/ltx-core/src/ltx_core/components/guiders.py +364 -0
  18. packages/ltx-core/src/ltx_core/components/noisers.py +35 -0
  19. packages/ltx-core/src/ltx_core/components/patchifiers.py +353 -0
  20. packages/ltx-core/src/ltx_core/components/protocols.py +101 -0
  21. packages/ltx-core/src/ltx_core/components/schedulers.py +130 -0
  22. packages/ltx-core/src/ltx_core/conditioning/__init__.py +21 -0
  23. packages/ltx-core/src/ltx_core/conditioning/exceptions.py +4 -0
  24. packages/ltx-core/src/ltx_core/conditioning/item.py +20 -0
  25. packages/ltx-core/src/ltx_core/conditioning/mask_utils.py +210 -0
  26. packages/ltx-core/src/ltx_core/conditioning/types/__init__.py +15 -0
  27. packages/ltx-core/src/ltx_core/conditioning/types/attention_strength_wrapper.py +71 -0
  28. packages/ltx-core/src/ltx_core/conditioning/types/keyframe_cond.py +83 -0
  29. packages/ltx-core/src/ltx_core/conditioning/types/latent_cond.py +44 -0
  30. packages/ltx-core/src/ltx_core/conditioning/types/noise_mask_cond.py +45 -0
  31. packages/ltx-core/src/ltx_core/conditioning/types/reference_audio_cond.py +59 -0
  32. packages/ltx-core/src/ltx_core/conditioning/types/reference_video_cond.py +91 -0
  33. packages/ltx-core/src/ltx_core/guidance/__init__.py +15 -0
  34. packages/ltx-core/src/ltx_core/guidance/perturbations.py +79 -0
  35. packages/ltx-core/src/ltx_core/hdr.py +71 -0
  36. packages/ltx-core/src/ltx_core/loader/__init__.py +56 -0
  37. packages/ltx-core/src/ltx_core/loader/fuse_loras.py +162 -0
  38. packages/ltx-core/src/ltx_core/loader/helpers.py +61 -0
  39. packages/ltx-core/src/ltx_core/loader/kernels.py +79 -0
  40. packages/ltx-core/src/ltx_core/loader/module_ops.py +14 -0
  41. packages/ltx-core/src/ltx_core/loader/primitives.py +158 -0
  42. packages/ltx-core/src/ltx_core/loader/registry.py +84 -0
  43. packages/ltx-core/src/ltx_core/loader/sd_ops.py +139 -0
  44. packages/ltx-core/src/ltx_core/loader/sft_loader.py +66 -0
  45. packages/ltx-core/src/ltx_core/loader/single_gpu_model_builder.py +162 -0
  46. packages/ltx-core/src/ltx_core/modality_tiling.py +234 -0
  47. packages/ltx-core/src/ltx_core/model/__init__.py +8 -0
  48. packages/ltx-core/src/ltx_core/model/audio_vae/__init__.py +29 -0
  49. packages/ltx-core/src/ltx_core/model/audio_vae/attention.py +71 -0
  50. packages/ltx-core/src/ltx_core/model/audio_vae/audio_vae.py +508 -0
LICENSE ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LTX-2 Community License Agreement
2
+ License date: January 5, 2026
3
+
4
+
5
+ By using or distributing any portion or element of LTX-2, you agree
6
+ to be bound by this Agreement.
7
+
8
+ 1. Definitions.
9
+
10
+ "Agreement" means the terms and conditions for the license, use,
11
+ reproduction, and distribution of LTX-2 and the Complementary
12
+ Materials, as specified in this document.
13
+
14
+ "Control" means the direct or indirect ownership of more than
15
+ fifty percent (50%) of the voting securities or other ownership
16
+ interests, or the power to direct the management and policies of
17
+ such Entity through voting rights, contract, or otherwise.
18
+
19
+ "Data" means a collection of information and/or content extracted
20
+ from the dataset used with LTX-2, including to train, pretrain,
21
+ or otherwise evaluate LTX-2. The Data is not licensed under this
22
+ Agreement.
23
+
24
+ "Derivatives of LTX-2" means all modifications to LTX-2, works
25
+ based on LTX-2, or any other model which is created or initialized
26
+ by transfer of patterns of the weights, parameters, activations or
27
+ output of LTX-2, to the other model, in order to cause the other
28
+ model to perform similarly to LTX-2, including – but not limited
29
+ to - distillation methods entailing the use of intermediate data
30
+ representations or methods based on the generation of synthetic
31
+ data by LTX-2 for training the other model. For clarity, Derivatives
32
+ of LTX-2 include: (i) any fine-tuned or adapted weights, parameters,
33
+ or checkpoints derived from LTX-2; (ii) derivative model architectures
34
+ that incorporate or are based upon LTX-2's architecture; and
35
+ (iii) any modified or extended versions of the Complementary
36
+ Materials. All intellectual property rights in Derivatives of LTX-2
37
+ shall be subject to the terms of this Agreement, and you may not
38
+ claim exclusive ownership rights in any Derivatives of LTX-2 that
39
+ would restrict the rights granted herein.
40
+
41
+ "Entity" means any individual, corporation, partnership, limited
42
+ liability company, or other legal entity. For purposes of this
43
+ Agreement, an Entity shall be deemed to include, on an aggregative
44
+ basis, all subsidiaries, affiliates, and other companies under
45
+ common Control with such Entity. When determining whether an Entity
46
+ meets any threshold under this Agreement (including revenue
47
+ thresholds), all subsidiaries, affiliates, and companies under
48
+ common Control shall be considered collectively.
49
+
50
+ "Harm" includes but is not limited to physical, mental,
51
+ psychological, financial and reputational damage, pain, or loss.
52
+
53
+ "Licensor" or "Lightricks" means the owner that is granting the
54
+ license under this Agreement. For the purposes of this Agreement,
55
+ the Licensor is Lightricks Ltd.
56
+
57
+ "LTX-2" means the large language models, text/image/video/audio/3D
58
+ generation models, and multimodal large language models and their
59
+ software and algorithms, including trained model weights, parameters
60
+ (including optimizer states), machine-learning model code,
61
+ inference-enabling code, training-enabling code, fine-tuning
62
+ enabling code, accompanying source code, scripts, documentation,
63
+ tutorials, examples, and all other elements of the foregoing
64
+ distributed and made publicly available by Lightricks (including,
65
+ for example, at https://github.com/Lightricks/LTX-2) for the LTX-2
66
+ model released on January 5, 2026. This license is applicable to
67
+ all LTX-2 versions released since January 5, 2026, and all future
68
+ releases of LTX-2 under this license.
69
+
70
+ "Output" means the results of operating LTX-2 as embodied in
71
+ informational content resulting therefrom.
72
+
73
+ "you" (or "your") means an individual or legal Entity licensing
74
+ LTX-2 in accordance with this Agreement and/or making use of LTX-2
75
+ for whichever purpose and in any field of use, including usage of
76
+ LTX-2 in an end-use application - e.g. chatbot, translator, image
77
+ generator.
78
+
79
+ 2. Grant of License. Subject to the terms and conditions of this
80
+ Agreement, you are granted a non-exclusive, worldwide,
81
+ non-transferable and royalty-free limited license under Licensor's
82
+ intellectual property or other rights owned by Licensor embodied
83
+ in LTX-2 to use, reproduce, prepare, distribute, publicly display,
84
+ publicly perform, sublicense, copy, create derivative works of,
85
+ and make modifications to LTX-2, for any purpose, subject to the
86
+ restrictions set forth in Attachment A; provided however, that
87
+ Entities with annual revenues of at least $10,000,000 (the
88
+ "Commercial Entities") are required to obtain a paid commercial
89
+ use license in order to use LTX-2 and Derivatives of LTX-2,
90
+ subject to the terms and provisions of a different license (the
91
+ "Commercial Use Agreement"), as will be provided by the Licensor.
92
+ Commercial Entities interested in such a commercial license are
93
+ required to [contact Licensor](https://ltx.io/model/licensing).
94
+ Any commercial use of LTX-2 or Derivatives of LTX-2 by the
95
+ Commercial Entities not in accordance with this Agreement and/or
96
+ the Commercial Use Agreement is strictly prohibited and shall be
97
+ deemed a material breach of this Agreement. Such material breach
98
+ will be subject, in addition to any license fees owed to Licensor
99
+ for the period such Commercial Entity used LTX-2 (as will be
100
+ determined by Licensor), to liquidated damages, which will be paid
101
+ to Licensor immediately upon demand, in an amount equal to double
102
+ the amount that would otherwise have been paid by you for the
103
+ relevant period of time. Such amount reflects a reasonable estimation
104
+ of the losses and administrative costs incurred due to such breach.
105
+ You agree and understand that this remedy does not limit the Licensor's
106
+ right to pursue other remedies available at law or equity.
107
+
108
+ 3. Distribution and Redistribution. You may host for third parties
109
+ remote access purposes (e.g. software-as-a-service), reproduce
110
+ and distribute copies of LTX-2 or Derivatives of LTX-2 thereof in
111
+ any medium, with or without modifications, provided that you meet
112
+ the following conditions:
113
+
114
+ (a) Use-based restrictions as referenced in paragraph 4 and all
115
+ provisions of Attachment A MUST be included as an enforceable
116
+ provision by you in any type of legal agreement (e.g. a
117
+ license) governing the use and/or distribution of LTX-2 or
118
+ Derivatives of LTX-2, and you shall give notice to subsequent
119
+ users you distribute to, that LTX-2 or Derivatives of LTX-2
120
+ are subject to paragraph 4 and Attachment A in their entirety,
121
+ including all use restrictions and acceptable use policies;
122
+
123
+ (b) You must provide any third party recipients of LTX-2 or
124
+ Derivatives of LTX-2 a copy of this Agreement, including all
125
+ attachments and use policies. Any Derivative of LTX-2 (as
126
+ defined in Section 1, including but not limited to fine-tuned
127
+ weights, modified training code, models trained on Outputs, or
128
+ any other derivative) must be distributed exclusively under
129
+ the terms of this Agreement with a complete copy of this
130
+ license included;
131
+
132
+ (c) You must cause any modified files to carry prominent notices
133
+ stating that you changed the files;
134
+
135
+ (d) You must retain all copyright, patent, trademark, and
136
+ attribution notices excluding those notices that do not
137
+ pertain to any part of LTX-2, Derivatives of LTX-2.
138
+
139
+ You may add your own copyright statement to your modifications and
140
+ may provide additional or different license terms and conditions -
141
+ respecting paragraph 3(a) - for use, reproduction, or distribution
142
+ of your modifications, or for any such Derivatives of LTX-2 as a
143
+ whole, provided your use, reproduction, and distribution of LTX-2
144
+ otherwise complies with the conditions stated in this Agreement,
145
+ and you provide a complete copy of this Agreement with any such
146
+ use, reproduction and distribution of LTX-2 and any Derivatives
147
+ thereof.
148
+
149
+ 4. Use-based restrictions. The restrictions set forth in Attachment A
150
+ are considered Use-based restrictions. Therefore, you cannot use
151
+ LTX-2 and the Derivatives of LTX-2 in violation of the specified
152
+ restricted uses. You may use LTX-2 subject to this Agreement,
153
+ including only for lawful purposes and in accordance with the
154
+ Agreement. "Use" may include creating any content with, fine-tuning,
155
+ updating, running, training, evaluating and/or re-parametrizing
156
+ LTX-2. You shall require all of your users who use LTX-2 or a
157
+ Derivative of LTX-2 to comply with the terms of this paragraph 4.
158
+
159
+ 5. The Output You Generate. Except as set forth herein, Licensor
160
+ claims no rights in the Output you generate using LTX-2. You are
161
+ accountable for input you insert into LTX-2, the Output you
162
+ generate and its subsequent uses. No use of the Output can
163
+ contravene any provision as stated in the Agreement.
164
+
165
+ 6. Updates and Runtime Restrictions. To the maximum extent permitted
166
+ by law, Licensor reserves the right to restrict (remotely or
167
+ otherwise) usage of LTX-2 in violation of this Agreement, update
168
+ LTX-2 through electronic means, or modify the Output of LTX-2
169
+ based on updates. You shall undertake reasonable efforts to use
170
+ the latest version of LTX-2. Any use of the non-current version
171
+ of LTX-2 is done solely at your risk.
172
+
173
+ 7. Export Controls and Sanctions Compliance. You acknowledge that
174
+ LTX-2, Derivatives of LTX-2 may be subject to export control laws
175
+ and regulations, including but not limited to the U.S. Export
176
+ Administration Regulations and sanctions programs administered by
177
+ the Office of Foreign Assets Control (OFAC). You represent and
178
+ warrant that you and any users of LTX-2 are not (i) located in,
179
+ organized under the laws of, or ordinarily resident in any country
180
+ or territory subject to comprehensive sanctions; (ii) identified
181
+ on any U.S. government restricted party list, including the
182
+ Specially Designated Nationals and Blocked Persons List; or
183
+ (iii) otherwise prohibited from receiving LTX-2 under applicable
184
+ law. You shall not export, re-export, or transfer LTX-2, directly
185
+ or indirectly, in violation of any applicable export control or
186
+ sanctions laws or regulations. You agree to comply with all
187
+ applicable trade control laws and shall indemnify and hold
188
+ Licensor harmless from any claims arising from your failure to
189
+ comply with such laws.
190
+
191
+ 8. Trademarks and related. Nothing in this Agreement permits you to
192
+ make use of Licensor's trademarks, trade names, logos or to
193
+ otherwise suggest endorsement or misrepresent the relationship
194
+ between the parties; and any rights not expressly granted herein
195
+ are reserved by the Licensor.
196
+
197
+ 9. Disclaimer of Warranty. Unless required by applicable law or
198
+ agreed to in writing, Licensor provides LTX-2 on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
200
+ implied, including, without limitation, any warranties or
201
+ conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS
202
+ FOR A PARTICULAR PURPOSE. You are solely responsible for
203
+ determining the appropriateness of using or redistributing LTX-2
204
+ and Derivatives of LTX-2 and assume any risks associated with
205
+ your exercise of permissions under this Agreement.
206
+
207
+ 10. Limitation of Liability. In no event and under no legal theory,
208
+ whether in tort (including negligence), contract, or otherwise,
209
+ unless required by applicable law (such as deliberate and grossly
210
+ negligent acts) or agreed to in writing, shall Licensor be liable
211
+ to you for damages, including any direct, indirect, special,
212
+ incidental, or consequential damages of any character arising as
213
+ a result of this Agreement or out of the use or inability to use
214
+ LTX-2 (including but not limited to damages for loss of goodwill,
215
+ work stoppage, computer failure or malfunction, or any and all
216
+ other commercial damages or losses), even if Licensor has been
217
+ advised of the possibility of such damages.
218
+
219
+ 11. Accepting Warranty or Additional Liability. While redistributing
220
+ LTX-2 and Derivatives of LTX-2, you may, provided you do not
221
+ violate the terms of this Agreement, choose to offer and charge
222
+ a fee for, acceptance of support, warranty, indemnity, or other
223
+ liability obligations. However, in accepting such obligations,
224
+ you may act only on your own behalf and on your sole
225
+ responsibility, not on behalf of Licensor, and only if you agree
226
+ to indemnify, defend, and hold Licensor harmless for any liability
227
+ incurred by, or claims asserted against Licensor, by reason of
228
+ your accepting any such warranty or additional liability.
229
+
230
+ 12. Governing Law. This Agreement and all relations, disputes, claims
231
+ and other matters arising hereunder (including non-contractual
232
+ disputes or claims) will be governed exclusively by, and construed
233
+ exclusively in accordance with, the laws of the State of New York.
234
+ To the extent permitted by law, choice of laws rules and the
235
+ United Nations Convention on Contracts for the International Sale
236
+ of Goods will not apply. For the purposes of adjudicating any
237
+ action or proceeding to enforce the terms of this Agreement, you
238
+ hereby irrevocably consent to the exclusive jurisdiction of, and
239
+ venue in, the federal and state courts located in the County of
240
+ New York within the State of New York. The prevailing party in
241
+ any claim or dispute between the parties under this Agreement
242
+ will be entitled to reimbursement of its reasonable attorneys'
243
+ fees and costs. You hereby waive the right to a trial by jury,
244
+ to participate in a class or representative action (including in
245
+ arbitration), or to combine individual proceedings in court or
246
+ in arbitration without the consent of all parties.
247
+
248
+ 13. Term and Termination. This Agreement is effective upon your
249
+ acceptance and continues until terminated. Licensor may terminate
250
+ this Agreement immediately upon written notice to you if you
251
+ breach any provision of this Agreement, including but not limited
252
+ to violations of the use restrictions in Attachment A or
253
+ unauthorized commercial use. Upon termination: (a) all rights
254
+ granted to you under this Agreement will immediately cease;
255
+ (b) you must immediately cease all use of LTX-2 and Derivatives
256
+ of LTX-2; (c) you must delete or destroy all copies of LTX-2
257
+ and Derivatives of LTX-2 in your possession or control; and
258
+ (d) you must notify any third parties to whom you distributed
259
+ LTX-2 or Derivatives of LTX-2 of the termination. Sections 8-13,
260
+ and Section 15 shall survive termination of this Agreement.
261
+ Termination does not relieve you of any obligations incurred
262
+ prior to termination, including payment obligations under
263
+ Section 2. In addition, if You commence a lawsuit or other
264
+ proceedings (including a cross-claim or counterclaim in a lawsuit)
265
+ against Licensor or any person or entity alleging that LTX-2 or
266
+ any Output, or any portion of any of the foregoing, infringe any
267
+ intellectual property or other right owned or licensable by you,
268
+ then all licenses granted to you under this Agreement shall
269
+ terminate as of the date such lawsuit or other proceeding is filed.
270
+
271
+ 14. Disputes and Arbitration. All disputes arising in connection with
272
+ this Agreement shall be finally settled by arbitration under the
273
+ Rules of Arbitration of the International Chamber of Commerce
274
+ ("ICC Rules"), by one (1) arbitrator appointed in accordance with
275
+ the ICC Rules. The seat of arbitration shall be New York, NY, USA,
276
+ and the proceedings shall be conducted in English. The arbitrator
277
+ shall be empowered to grant any relief that a court could grant.
278
+ Judgment on the arbitration award may be entered by any court
279
+ having jurisdiction thereof. Each party waives its right to a
280
+ trial by jury and to participate in any class or representative
281
+ action.
282
+
283
+ 15. If any provision of this Agreement is held to be
284
+ invalid, illegal
285
+ or unenforceable, the remaining provisions shall be unaffected
286
+ thereby and remain valid as if such provision had not been set
287
+ forth herein.
288
+
289
+ END OF TERMS AND CONDITIONS
290
+
291
+ ATTACHMENT A: Use Restrictions
292
+
293
+ When using the Outputs, LTX-2 and any Derivatives thereof, you
294
+ will comply with the Acceptable Use Policy. In addition, you
295
+ agree not to use the Outputs, LTX-2 or its Derivatives in any
296
+ of the following ways:
297
+
298
+ 1. In any way that violates any applicable national, federal,
299
+ state, local or international law or regulation;
300
+
301
+ 2. For the purpose of exploiting, Harming or attempting to
302
+ exploit or Harm minors in any way;
303
+
304
+ 3. To generate or disseminate false information and/or content
305
+ with the purpose of Harming others;
306
+
307
+ 4. To generate or disseminate personal identifiable information
308
+ that can be used to Harm an individual;
309
+
310
+ 5. To generate or disseminate information and/or content (e.g.
311
+ images, code, posts, articles), and place the information
312
+ and/or content in any context (e.g. bot generating tweets)
313
+ without expressly and intelligibly disclaiming that the
314
+ information and/or content is machine generated;
315
+
316
+ 6. To defame, disparage or otherwise harass others;
317
+
318
+ 7. To impersonate or attempt to impersonate (e.g. deepfakes)
319
+ others without their consent;
320
+
321
+ 8. For fully automated decision making that adversely impacts an
322
+ individual's legal rights or otherwise creates or modifies a
323
+ binding, enforceable obligation;
324
+
325
+ 9. For any use intended to or which has the effect of
326
+ discriminating against or Harming individuals or groups based
327
+ on online or offline social behavior or known or predicted
328
+ personal or personality characteristics;
329
+
330
+ 10. To exploit any of the vulnerabilities of a specific group of
331
+ persons based on their age, social, physical or mental
332
+ characteristics, in order to materially distort the behavior
333
+ of a person pertaining to that group in a manner that causes
334
+ or is likely to cause that person or another person physical
335
+ or psychological Harm;
336
+
337
+ 11. For any use intended to or which has the effect of
338
+ discriminating against individuals or groups based on legally
339
+ protected characteristics or categories;
340
+
341
+ 12. To provide medical advice and medical results interpretation;
342
+
343
+ 13. To generate or disseminate information for the purpose to be
344
+ used for administration of justice, law enforcement,
345
+ immigration or asylum processes, such as predicting an
346
+ individual will commit fraud/crime commitment (e.g. by text
347
+ profiling, drawing causal relationships between assertions
348
+ made in documents, indiscriminate and arbitrarily-targeted use);
349
+
350
+ 14. To generate and/or disseminate malware (including – but not
351
+ limited to – ransomware) or any other content to be used for
352
+ the purpose of harming electronic systems;
353
+
354
+ 15. To engage in, promote, incite, or facilitate discrimination
355
+ or other unlawful or harmful conduct in the provision of
356
+ employment, employment benefits, credit, housing, or other
357
+ essential goods and services;
358
+
359
+ 16. To engage in, promote, incite, or facilitate the harassment,
360
+ abuse, threatening, or bullying of individuals or groups of
361
+ individuals;
362
+
363
+ 17. For military, warfare, nuclear industries or applications,
364
+ weapons development, or any use in connection with activities
365
+ that may cause death, personal injury, or severe physical or
366
+ environmental damage;
367
+
368
+ 18. For commercial use only: To train, improve, or fine-tune any
369
+ other machine learning model, artificial intelligence system,
370
+ or competing model, except for Derivatives of LTX-2 as
371
+ expressly permitted under this Agreement;
372
+
373
+ 19. To circumvent, disable, or interfere with any technical
374
+ limitations, safety features, content filters, or use
375
+ restrictions implemented in LTX-2 by Licensor;
376
+
377
+ 20. To use LTX-2 or Derivatives of LTX-2 in any product, service,
378
+ or application that directly competes with Licensor's
379
+ commercial products or services, or is designed to replace or
380
+ substitute Licensor's offerings in the market, without
381
+ obtaining a separate commercial license from Licensor.
README.md CHANGED
@@ -1,3 +1,123 @@
1
- ---
2
- license: apache-2.0
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LTX-2
2
+
3
+ [![Website](https://img.shields.io/badge/Website-LTX-181717?logo=google-chrome)](https://ltx.io)
4
+ [![Model](https://img.shields.io/badge/HuggingFace-Model-orange?logo=huggingface)](https://huggingface.co/Lightricks/LTX-2.3)
5
+ [![Demo](https://img.shields.io/badge/Demo-Try%20Now-brightgreen?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFKADAAQAAAABAAAAFAAAAACy3fD9AAACmElEQVQ4Ea1VP2haYRA/fRo0mESRIIqb2IwxuNUl0CGFQBC6OAWcikMottCpqYtDQIgdQsBFhAjZqiQhbhmySJBOgmNU0EGCg9r61Bivd0ffoykE0iQH37/77n7f3e/uqQFIPB7P/N3d3QeDwfAFEedZ91ghnyH5JM1m87dWq6UavF6vdTKZfDcajW/p4rE49+wIFMj33Gq1vlNo+kxg758KpiETqP/29vaXweVyqaS0aBfPXEfGFwTjWCwM+KBQoWA4HAJx/KDNvxcmTTGbzYAH8SljOp2C2+2GjY0NqNfrcHFxAXNzc2LDfCuKIq78KBdFOwsgGzidTnA4HHBzcwO9Xg8sFgtsbm7C3t4eVCoVaDQa0O12YXl5GUwmk5z5cZ/PB6PRCNrttgADFQUXFhbw8PAQVVXF3d1dJAeMx+P0zn0Jh8OYz+eRADCRSGAqlcLxeIz7+/u4tLSEjKUDZrNZ8U4mk0jR4fr6Op6enoru+voa0+k0rq2tYTAYxE6ng9QiSLRgrVZDv9+PFLkA6kUhT+GEC8C8XF5ewtHRkejICShiaDabwPvj42NJm3k7ODiQdDl9Fr0ocqJpdXUVIpEIdz7Y7XZRr6ysQDQahXK5LORvbW1p5rC9vQ2UifAooBqHuVxO0vt72tnZwWq1qqtisRgWCgU5ZzIZPDk50fdUUEmZvxTmAgKBgAxunT/fJpRKJWmhUCgEVDi4uroSG46kWCzC4uKitNVgMICzszOhSgA5fiJZhp4Lbbh1KARpbF65D/lx3vMdP05Vlkf5zKIDyukFJi7N6AVwNAhVsdlsM+LsjaZ56sq8kyQUqs4P6rsAKV49B4x4Padf7Y9Kv9+fEmiBQH8S4Gsa5v8EHpL9VwL7xH8BvwEcd4ccVf02KQAAAABJRU5ErkJggg==)](https://console.ltx.video/playground)
6
+ [![Paper](https://img.shields.io/badge/Paper-PDF-EC1C24?logo=adobeacrobatreader&logoColor=white)](https://arxiv.org/abs/2601.03233)
7
+ [![Discord](https://img.shields.io/badge/Join-Discord-5865F2?logo=discord)](https://discord.gg/ltxplatform)
8
+
9
+ **LTX-2** is the first DiT-based audio-video foundation model that contains all core capabilities of modern video generation in one model: synchronized audio and video, high fidelity, multiple performance modes, production-ready outputs, API access, and open access.
10
+
11
+ <div align="center">
12
+ <video src="https://github.com/user-attachments/assets/4414adc0-086c-43de-b367-9362eeb20228" width="70%" poster=""> </video>
13
+ </div>
14
+
15
+ ## 🚀 Quick Start
16
+
17
+ ```bash
18
+ # Clone the repository
19
+ git clone https://github.com/Lightricks/LTX-2.git
20
+ cd LTX-2
21
+
22
+ # Set up the environment
23
+ uv sync --frozen
24
+ source .venv/bin/activate
25
+ ```
26
+
27
+ ### Required Models
28
+
29
+ Download the following models from the [LTX-2.3 HuggingFace repository](https://huggingface.co/Lightricks/LTX-2.3):
30
+
31
+ **LTX-2.3 Model Checkpoint** (choose and download one of the following)
32
+ * [`ltx-2.3-22b-dev.safetensors`](https://huggingface.co/Lightricks/LTX-2.3/blob/main/ltx-2.3-22b-dev.safetensors) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-dev.safetensors)
33
+ * [`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) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-1.1.safetensors)
34
+
35
+ **Spatial Upscaler** - Required for current two-stage pipeline implementations in this repository
36
+ * [`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) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x2-1.1.safetensors)
37
+ * [`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) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors)
38
+
39
+ **Temporal Upscaler** - Supported by the model and will be required for future pipeline implementations
40
+ * [`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) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-temporal-upscaler-x2-1.0.safetensors)
41
+
42
+ **Distilled LoRA** - Required for current two-stage pipeline implementations in this repository (except DistilledPipeline, ICLoraPipeline, and LipDubPipeline)
43
+ * [`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) - [Download](https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-lora-384-1.1.safetensors)
44
+
45
+ **Gemma Text Encoder** (download all assets from the repository)
46
+ * [`Gemma 3`](https://huggingface.co/google/gemma-3-12b-it-qat-q4_0-unquantized/tree/main)
47
+
48
+ **LoRAs**
49
+ * [`LTX-2.3-22b-IC-LoRA-Union-Control`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control) - [Download](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control/resolve/main/ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors)
50
+ * [`LTX-2.3-22b-IC-LoRA-Motion-Track-Control`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control) - [Download](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control/resolve/main/ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors)
51
+ * [`LTX-2-19b-IC-LoRA-Detailer`](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer/resolve/main/ltx-2-19b-ic-lora-detailer.safetensors)
52
+ * [`LTX-2-19b-IC-LoRA-Pose-Control`](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors)
53
+ * [`LTX-2-19b-LoRA-Camera-Control-Dolly-In`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In/resolve/main/ltx-2-19b-lora-camera-control-dolly-in.safetensors)
54
+ * [`LTX-2-19b-LoRA-Camera-Control-Dolly-Left`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors)
55
+ * [`LTX-2-19b-LoRA-Camera-Control-Dolly-Out`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out/resolve/main/ltx-2-19b-lora-camera-control-dolly-out.safetensors)
56
+ * [`LTX-2-19b-LoRA-Camera-Control-Dolly-Right`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right/resolve/main/ltx-2-19b-lora-camera-control-dolly-right.safetensors)
57
+ * [`LTX-2-19b-LoRA-Camera-Control-Jib-Down`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down/resolve/main/ltx-2-19b-lora-camera-control-jib-down.safetensors)
58
+ * [`LTX-2-19b-LoRA-Camera-Control-Jib-Up`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up/resolve/main/ltx-2-19b-lora-camera-control-jib-up.safetensors)
59
+ * [`LTX-2-19b-LoRA-Camera-Control-Static`](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static) - [Download](https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static/resolve/main/ltx-2-19b-lora-camera-control-static.safetensors)
60
+ * [`LTX-2.3-22b-IC-LoRA-HDR`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-HDR) - HDR IC-LoRA and pre-computed text embeddings for `HDRICLoraPipeline`
61
+ * [`LTX-2.3-22b-IC-LoRA-LipDub`](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-LipDub) - [Download](https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-LipDub/resolve/main/ltx-2.3-22b-ic-lora-lipdub-0.9.safetensors)
62
+
63
+ ### Available Pipelines
64
+
65
+ * **[TI2VidTwoStagesPipeline](packages/ltx-pipelines/src/ltx_pipelines/ti2vid_two_stages.py)** - Production-quality text/image-to-video with 2x upsampling (recommended)
66
+ * **[TI2VidTwoStagesHQPipeline](packages/ltx-pipelines/src/ltx_pipelines/ti2vid_two_stages_hq.py)** - Same two-stage flow as above but uses the res_2s second-order sampler (fewer steps, better quality)
67
+ * **[TI2VidOneStagePipeline](packages/ltx-pipelines/src/ltx_pipelines/ti2vid_one_stage.py)** - Single-stage generation for quick prototyping
68
+ * **[DistilledPipeline](packages/ltx-pipelines/src/ltx_pipelines/distilled.py)** - Fastest inference with 8 predefined sigmas
69
+ * **[ICLoraPipeline](packages/ltx-pipelines/src/ltx_pipelines/ic_lora.py)** - Video-to-video and image-to-video transformations (uses distilled model.)
70
+ * **[KeyframeInterpolationPipeline](packages/ltx-pipelines/src/ltx_pipelines/keyframe_interpolation.py)** - Interpolate between keyframe images
71
+ * **[A2VidPipelineTwoStage](packages/ltx-pipelines/src/ltx_pipelines/a2vid_two_stage.py)** - Audio-to-video generation conditioned on an input audio file
72
+ * **[RetakePipeline](packages/ltx-pipelines/src/ltx_pipelines/retake.py)** - Regenerate a specific time region of an existing video
73
+ * **[HDRICLoraPipeline](packages/ltx-pipelines/src/ltx_pipelines/hdr_ic_lora.py)** - Video-to-video with HDR output (linear float frames via LogC3 inverse decode, suitable for EXR export and tonemapping)
74
+ * **[LipDubPipeline](packages/ltx-pipelines/src/ltx_pipelines/lipdub.py)** - Lip dubbing, rephrasing, matching speaker identity (distilled model, single IC-LoRA, Two stages).
75
+
76
+ ### ⚡ Optimization Tips
77
+
78
+ * **Use DistilledPipeline** - Fastest inference with only 8 predefined sigmas (8 steps stage 1, 4 steps stage 2)
79
+ * **Enable FP8 quantization** - Enables lower memory footprint: `--quantization fp8-cast` (CLI) or `quantization=QuantizationPolicy.fp8_cast()` (Python). Fp8-cast should be used with bf16 checkpoints, it shall downcast them on the fly. For Hopper GPUs with TensorRT-LLM, use `--quantization fp8-scaled-mm` for FP8 scaled matrix multiplication. Fp8-scaled-mm should be used with fp8 checkpoints.
80
+ * **Install attention optimizations** - Use xFormers (`uv sync --extra xformers`) or [Flash Attention 3](https://github.com/Dao-AILab/flash-attention) for Hopper GPUs
81
+ * **Use gradient estimation** - Reduce inference steps from 40 to 20-30 while maintaining quality (see [pipeline documentation](packages/ltx-pipelines/README.md#denoising-loop-optimization))
82
+ * **Skip memory cleanup** - If you have sufficient VRAM, disable automatic memory cleanup between stages for faster processing
83
+ * **Choose single-stage pipeline** - Use `TI2VidOneStagePipeline` for faster generation when high resolution isn't required
84
+
85
+ ## ✍️ Prompting for LTX-2
86
+
87
+ When writing prompts, focus on detailed, chronological descriptions of actions and scenes. Include specific movements, appearances, camera angles, and environmental details - all in a single flowing paragraph. Start directly with the action, and keep descriptions literal and precise. Think like a cinematographer describing a shot list. Keep within 200 words. For best results, build your prompts using this structure:
88
+
89
+ - Start with main action in a single sentence
90
+ - Add specific details about movements and gestures
91
+ - Describe character/object appearances precisely
92
+ - Include background and environment details
93
+ - Specify camera angles and movements
94
+ - Describe lighting and colors
95
+ - Note any changes or sudden events
96
+
97
+ For additional guidance on writing a prompt please refer to <https://ltx.video/blog/how-to-prompt-for-ltx-2>
98
+
99
+ ### Automatic Prompt Enhancement
100
+
101
+ LTX-2 pipelines support automatic prompt enhancement via an `enhance_prompt` parameter.
102
+
103
+ ## 🔌 ComfyUI Integration
104
+
105
+ To use our model with ComfyUI, please follow the instructions at <https://github.com/Lightricks/ComfyUI-LTXVideo/>.
106
+
107
+ ## 📦 Packages
108
+
109
+ This repository is organized as a monorepo with three main packages:
110
+
111
+ * **[ltx-core](packages/ltx-core/)** - Core model implementation, inference stack, and utilities
112
+ * **[ltx-pipelines](packages/ltx-pipelines/)** - High-level pipeline implementations for text-to-video, image-to-video, and other generation modes
113
+ * **[ltx-trainer](packages/ltx-trainer/)** - Training and fine-tuning tools for LoRA, full fine-tuning, and IC-LoRA
114
+
115
+ Each package has its own README and documentation. See the [Documentation](#-documentation) section below.
116
+
117
+ ## 📚 Documentation
118
+
119
+ Each package includes comprehensive documentation:
120
+
121
+ * **[LTX-Core README](packages/ltx-core/README.md)** - Core model implementation, inference stack, and utilities
122
+ * **[LTX-Pipelines README](packages/ltx-pipelines/README.md)** - High-level pipeline implementations and usage guides
123
+ * **[LTX-Trainer README](packages/ltx-trainer/README.md)** - Training and fine-tuning documentation with detailed guides
packages/ltx-core/README.md ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LTX-Core
2
+
3
+ The foundational library for the LTX-2 Audio-Video generation model. This package contains the raw model definitions, component implementations, and loading logic used by `ltx-pipelines` and `ltx-trainer`.
4
+
5
+ ## 📦 What's Inside?
6
+
7
+ - **`components/`**: Modular diffusion components (Schedulers, Guiders, Noisers, Patchifiers) following standard protocols
8
+ - **`conditioning/`**: Tools for preparing latent states and applying conditioning (image, video, keyframes)
9
+ - **`guidance/`**: Perturbation system for fine-grained control over attention mechanisms
10
+ - **`loader/`**: Utilities for loading weights from `.safetensors`, fusing LoRAs, and managing memory
11
+ - **`model/`**: PyTorch implementations of the LTX-2 Transformer, Video VAE, Audio VAE, Vocoder and Upscaler
12
+ - **`text_encoders/gemma`**: Gemma text encoder implementation with tokenizers, feature extractors, and separate encoders for audio-video and video-only generation
13
+ - **`quantization/`**: FP8 quantization backends (FP8-TensorRT-LLM scaled MM, FP8 cast) for reduced memory footprint.
14
+
15
+ ## 🚀 Quick Start
16
+
17
+ `ltx-core` provides the building blocks (models, components, and utilities) needed to construct inference flows. For ready-made inference pipelines use [`ltx-pipelines`](../ltx-pipelines/) or [`ltx-trainer`](../ltx-trainer/) for training.
18
+
19
+ ## 🔧 Installation
20
+
21
+ ```bash
22
+ # From the repository root
23
+ uv sync --frozen
24
+
25
+ # Or install as a package
26
+ pip install -e packages/ltx-core
27
+ ```
28
+
29
+ ## Building Blocks Overview
30
+
31
+ `ltx-core` provides modular components that can be combined to build custom inference flows:
32
+
33
+ ### Core Models
34
+
35
+ - **Transformer** ([`model/transformer/`](src/ltx_core/model/transformer/)): The asymmetric dual-stream LTX-2 transformer (14B-parameter video stream, 5B-parameter audio stream) with bidirectional cross-modal attention for joint audio-video processing. Expects inputs in [`Modality`](src/ltx_core/model/transformer/modality.py) format
36
+ - **Video VAE** ([`model/video_vae/`](src/ltx_core/model/video_vae/)): Encodes/decodes video pixels to/from latent space with temporal and spatial compression
37
+ - **Audio VAE** ([`model/audio_vae/`](src/ltx_core/model/audio_vae/)): Encodes/decodes audio spectrograms to/from latent space
38
+ - **Vocoder** ([`model/audio_vae/`](src/ltx_core/model/audio_vae/)): Neural vocoder that converts mel spectrograms to audio waveforms
39
+ - **Text Encoder** ([`text_encoders/`](src/ltx_core/text_encoders/)): Gemma 3-based multilingual encoder with multi-layer feature extraction and thinking tokens that produces separate embeddings for video and audio conditioning
40
+ - **Spatial Upscaler** ([`model/upsampler/`](src/ltx_core/model/upsampler/)): Upsamples latent representations for higher-resolution generation
41
+
42
+ ### Diffusion Components
43
+
44
+ - **Schedulers** ([`components/schedulers.py`](src/ltx_core/components/schedulers.py)): Noise schedules (LTX2Scheduler, LinearQuadratic, Beta) that control the denoising process
45
+ - **Guiders** ([`components/guiders.py`](src/ltx_core/components/guiders.py)): Guidance strategies (CFG, STG, APG) for controlling generation quality and adherence to prompts
46
+ - **Noisers** ([`components/noisers.py`](src/ltx_core/components/noisers.py)): Add noise to latents according to the diffusion schedule
47
+ - **Patchifiers** ([`components/patchifiers.py`](src/ltx_core/components/patchifiers.py)): Convert between spatial latents `[B, C, F, H, W]` and sequence format `[B, seq_len, dim]` for transformer processing
48
+
49
+ ### Conditioning & Control
50
+
51
+ - **Conditioning** ([`conditioning/`](src/ltx_core/conditioning/)): Tools for preparing and applying various conditioning types (image, video, keyframes)
52
+ - **Guidance** ([`guidance/`](src/ltx_core/guidance/)): Perturbation system for fine-grained control over attention mechanisms (e.g., skipping specific attention layers)
53
+
54
+ ### Utilities
55
+
56
+ - **Loader** ([`loader/`](src/ltx_core/loader/)): Model loading from `.safetensors`, LoRA fusion, weight remapping, and memory management
57
+ - **Quantization** ([`quantization/`](src/ltx_core/quantization/)): FP8 quantization backends for reduced memory footprint and faster inference
58
+
59
+ ### Loader
60
+
61
+ The `loader/` module provides `SingleGPUModelBuilder`, a frozen dataclass that loads a PyTorch model from `.safetensors` checkpoints and optionally fuses one or more LoRA adapters.
62
+
63
+ #### Basic usage
64
+
65
+ ```python
66
+ from ltx_core.loader import SingleGPUModelBuilder
67
+
68
+ builder = SingleGPUModelBuilder(
69
+ model_class_configurator=MyModelConfigurator,
70
+ model_path="/path/to/model.safetensors",
71
+ )
72
+ model = builder.build(device=torch.device("cuda"))
73
+ ```
74
+
75
+ #### Loading LoRA adapters
76
+
77
+ Use the `.lora()` method to attach one or more LoRA adapters before calling `.build()`:
78
+
79
+ ```python
80
+ from ltx_core.loader import SDOps
81
+
82
+ lora_sd_ops = SDOps(name="identity").with_matching() # or a model-specific key-renaming SDOps
83
+
84
+ builder = (
85
+ SingleGPUModelBuilder(
86
+ model_class_configurator=MyModelConfigurator,
87
+ model_path="/path/to/model.safetensors",
88
+ )
89
+ .lora("/path/to/lora_a.safetensors", 0.8, lora_sd_ops)
90
+ .lora("/path/to/lora_b.safetensors", 0.5, lora_sd_ops)
91
+ )
92
+ model = builder.build(device=torch.device("cuda"))
93
+ ```
94
+
95
+ #### Memory-efficient LoRA loading (`lora_load_device`)
96
+
97
+ By default, LoRA weights are loaded onto the **CPU** (`lora_load_device=torch.device("cpu")`). This means each LoRA adapter is kept in CPU memory and transferred to the GPU sequentially during weight fusion, which keeps peak GPU memory low even when fusing large adapters.
98
+
99
+ If all adapters fit comfortably in GPU memory you can skip the CPU staging by setting `lora_load_device` to the target CUDA device:
100
+
101
+ ```python
102
+ import torch
103
+ from ltx_core.loader import SingleGPUModelBuilder
104
+
105
+ # Load LoRA weights directly onto the GPU (faster, but uses more GPU memory)
106
+ builder = SingleGPUModelBuilder(
107
+ model_class_configurator=MyModelConfigurator,
108
+ model_path="/path/to/model.safetensors",
109
+ lora_load_device=torch.device("cuda"),
110
+ ).lora("/path/to/lora.safetensors", 1.0, lora_sd_ops)
111
+
112
+ model = builder.build(device=torch.device("cuda"))
113
+ ```
114
+
115
+ ### Quantization
116
+
117
+ The `quantization/` module provides FP8 quantization support for the LTX-2 transformer, significantly reducing memory usage while maintaining quality. Two backends are available:
118
+
119
+ #### FP8 Scaled MM (TensorRT-LLM)
120
+
121
+ Uses NVIDIA TensorRT-LLM's `cublas_scaled_mm` for efficient FP8 matrix multiplication. Weights are stored in FP8 format with per-tensor scaling, and inputs are quantized dynamically (or statically with calibration data).
122
+
123
+ **Requirements**: `uv sync --frozen --extra fp8-trtllm`
124
+
125
+ **Usage with QuantizationPolicy:**
126
+
127
+ ```python
128
+ from ltx_core.quantization import QuantizationPolicy
129
+
130
+ # Dynamic input quantization (no calibration needed)
131
+ policy = QuantizationPolicy.fp8_scaled_mm()
132
+
133
+ # Static input quantization with calibration file
134
+ policy = QuantizationPolicy.fp8_scaled_mm(calibration_amax_path="/path/to/amax.json")
135
+ ```
136
+
137
+ The policy provides `sd_ops` and `module_ops` that can be passed to the model builder:
138
+
139
+ ```python
140
+ from ltx_core.loader import SingleGPUModelBuilder
141
+
142
+ builder = SingleGPUModelBuilder(
143
+ model=model,
144
+ device=device,
145
+ sd_ops=policy.sd_ops,
146
+ module_ops=policy.module_ops,
147
+ )
148
+ builder.load(checkpoint_path)
149
+ ```
150
+
151
+ **Calibration File Format** (for static input quantization):
152
+
153
+ ```json
154
+ {
155
+ "amax_values": {
156
+ "transformer_blocks.0.attn.to_q.input_quantizer": 12.5,
157
+ "transformer_blocks.0.attn.to_k.input_quantizer": 8.3,
158
+ ...
159
+ }
160
+ }
161
+ ```
162
+
163
+ #### FP8 Cast
164
+
165
+ A simpler approach that casts weights to FP8 for storage and upcasts during inference:
166
+
167
+ ```python
168
+ policy = QuantizationPolicy.fp8_cast()
169
+ ```
170
+
171
+ For complete, production-ready pipeline implementations that combine these building blocks, see the [`ltx-pipelines`](../ltx-pipelines/) package.
172
+
173
+ ---
174
+
175
+ # Architecture Overview
176
+
177
+ This section provides a deep dive into the internal architecture of the LTX-2 Audio-Video generation model.
178
+
179
+ ## Table of Contents
180
+
181
+ 1. [High-Level Architecture](#high-level-architecture)
182
+ 2. [The Transformer](#the-transformer)
183
+ 3. [Video VAE](#video-vae)
184
+ 4. [Audio VAE](#audio-vae)
185
+ 5. [Text Encoding (Gemma)](#text-encoding-gemma)
186
+ 6. [Spatial Upscaler](#spatial-upsampler)
187
+ 7. [Data Flow](#data-flow)
188
+
189
+ ---
190
+
191
+ ## High-Level Architecture
192
+
193
+ LTX-2 is an **asymmetric dual-stream diffusion transformer** that jointly models the text-conditioned distribution of video and audio signals, capturing true joint dependencies (unlike sequential T2V→V2A pipelines).
194
+
195
+ ### Key Design Principles
196
+
197
+ - **Decoupled Latent Representations**: Separate modality-specific VAEs enable 3D RoPE (video) vs 1D RoPE (audio), independent compression optimization, and native V2A/A2V editing workflows
198
+ - **Asymmetric Dual-Stream**: 14B-parameter video stream (spatiotemporal dynamics) + 5B-parameter audio stream (1D temporal), sharing 48 transformer blocks but differing in width
199
+ - **Bidirectional Cross-Modal Attention**: 1D temporal RoPE enables sub-frame alignment, mapping visual cues to auditory events (lip-sync, foley, environmental acoustics)
200
+ - **Cross-Modality AdaLN**: Scaling/shift parameters conditioned on the other modality's hidden states for synchronization across differing diffusion timesteps/temporal resolutions
201
+
202
+ ```text
203
+ ┌─────────────────────────────────────────────────────────────┐
204
+ │ INPUT PREPARATION │
205
+ │ │
206
+ │ Video Pixels → Video VAE Encoder → Video Latents │
207
+ │ Audio Waveform → Audio VAE Encoder → Audio Latents │
208
+ │ Text Prompt → Gemma 3 Encoder → Text Embeddings │
209
+ └─────────────────────────────────────────────────────────────┘
210
+
211
+ ┌──────────────────────���──────────────────────────────────────┐
212
+ │ LTX-2 ASYMMETRIC DUAL-STREAM TRANSFORMER (48 Blocks) │
213
+ │ │
214
+ │ ┌──────────────────────┐ ┌──────────────────────┐ │
215
+ │ │ Video Stream (14B) │ │ Audio Stream (5B) │ │
216
+ │ │ │ │ │ │
217
+ │ │ 3D RoPE (x,y,t) │ │ 1D RoPE (temporal) │ │
218
+ │ │ │ │ │ │
219
+ │ │ Self-Attn │ │ Self-Attn │ │
220
+ │ │ Text Cross-Attn │ │ Text Cross-Attn │ │
221
+ │ │ │◄────►│ │ │
222
+ │ │ A↔V Cross-Attn │ │ A↔V Cross-Attn │ │
223
+ │ │ (1D temporal RoPE) │ │ (1D temporal RoPE) │ │
224
+ │ │ Cross-modality │ │ Cross-modality │ │
225
+ │ │ AdaLN │ │ AdaLN │ │
226
+ │ │ Feed-Forward │ │ Feed-Forward │ │
227
+ │ └──────────────────────┘ └──────────────────────┘ │
228
+ └─────────────────────────────────────────────────────────────┘
229
+
230
+ ┌─────────────────────────────────────────────────────────────┐
231
+ │ OUTPUT DECODING │
232
+ │ │
233
+ │ Video Latents → Video VAE Decoder → Video Pixels │
234
+ │ Audio Latents → Audio VAE Decoder → Mel Spectrogram │
235
+ │ Mel Spectrogram → Vocoder → Audio Waveform (24 kHz) │
236
+ └─────────────────────────────────────────────────────────────┘
237
+ ```
238
+
239
+ ---
240
+
241
+ ## The Transformer
242
+
243
+ The core of LTX-2 is an **asymmetric dual-stream diffusion transformer** with 48 layers that processes both video and audio tokens simultaneously. The architecture allocates 14B parameters to the video stream and 5B parameters to the audio stream, reflecting the different information densities of the two modalities.
244
+
245
+ ### Model Structure
246
+
247
+ **Source**: [`src/ltx_core/model/transformer/model.py`](src/ltx_core/model/transformer/model.py)
248
+
249
+ The `LTXModel` class implements the transformer. It supports both video-only and audio-video generation modes. For actual usage, see the [`ltx-pipelines`](../ltx-pipelines/) package which handles model loading and initialization.
250
+
251
+ ### Transformer Block Architecture
252
+
253
+ **Source**: [`src/ltx_core/model/transformer/transformer.py`](src/ltx_core/model/transformer/transformer.py)
254
+
255
+ Each dual-stream block performs four operations sequentially:
256
+
257
+ 1. **Self-Attention**: Within-modality attention for each stream
258
+ 2. **Text Cross-Attention**: Textual prompt conditioning for both streams
259
+ 3. **Audio-Visual Cross-Attention**: Bidirectional inter-modal exchange
260
+ 4. **Feed-Forward Network (FFN)**: Feature refinement
261
+
262
+ ```text
263
+ ┌─────────────────────────────────────────────────────────────┐
264
+ │ TRANSFORMER BLOCK │
265
+ │ │
266
+ │ VIDEO (14B): Input → RMSNorm → AdaLN → Self-Attn → │
267
+ │ RMSNorm → Text Cross-Attn → │
268
+ │ RMSNorm → AdaLN → A↔V Cross-Attn (1D RoPE) → │
269
+ │ RMSNorm → AdaLN → FFN → Output │
270
+ │ │
271
+ │ AUDIO (5B): Input → RMSNorm → AdaLN → Self-Attn → │
272
+ │ RMSNorm → Text Cross-Attn → │
273
+ │ RMSNorm → AdaLN → A↔V Cross-Attn (1D RoPE) → │
274
+ │ RMSNorm → AdaLN → FFN → Output │
275
+ │ │
276
+ │ RoPE: Video=3D (x,y,t), Audio=1D (t), Cross-Attn=1D (t) │
277
+ │ AdaLN: Timestep-conditioned, cross-modality for A↔V CA │
278
+ └─────────────────────────────────────────────────────────────┘
279
+ ```
280
+
281
+ ### Audio-Visual Cross-Attention Details
282
+
283
+ Bidirectional cross-attention enables tight temporal alignment: video and audio streams exchange information bidirectionally using 1D temporal RoPE (synchronization only, no spatial alignment). AdaLN gates condition on each modality's timestep for cross-modal synchronization.
284
+
285
+ ### Perturbations
286
+
287
+ The transformer supports [**perturbations**](src/ltx_core/guidance/perturbations.py) that selectively skip attention operations.
288
+
289
+ Perturbations allow you to disable specific attention mechanisms during inference, which is useful for guidance techniques like STG (Spatio-Temporal Guidance).
290
+
291
+ **Supported Perturbation Types**:
292
+
293
+ - `SKIP_VIDEO_SELF_ATTN`: Skip video self-attention
294
+ - `SKIP_AUDIO_SELF_ATTN`: Skip audio self-attention
295
+ - `SKIP_A2V_CROSS_ATTN`: Skip audio-to-video cross-attention
296
+ - `SKIP_V2A_CROSS_ATTN`: Skip video-to-audio cross-attention
297
+
298
+ Perturbations are used internally by guidance mechanisms like STG (Spatio-Temporal Guidance). For usage examples, see the [`ltx-pipelines`](../ltx-pipelines/) package.
299
+
300
+ ---
301
+
302
+ ## Video VAE
303
+
304
+ The Video VAE ([`src/ltx_core/model/video_vae/`](src/ltx_core/model/video_vae/)) encodes video pixels into latent representations and decodes them back.
305
+
306
+ ### Architecture
307
+
308
+ - **Encoder**: Compresses `[B, 3, F, H, W]` pixels → `[B, 128, F', H/32, W/32]` latents
309
+ - Where `F' = 1 + (F-1)/8` (frame count must satisfy `(F-1) % 8 == 0`)
310
+ - Example: `[B, 3, 33, 512, 512]` → `[B, 128, 5, 16, 16]`
311
+ - **Decoder**: Expands `[B, 128, F, H, W]` latents → `[B, 3, F', H*32, W*32]` pixels
312
+ - Where `F' = 1 + (F-1)*8`
313
+ - Example: `[B, 128, 5, 16, 16]` → `[B, 3, 33, 512, 512]`
314
+
315
+ The Video VAE is used internally by pipelines for encoding video pixels to latents and decoding latents back to pixels. For usage examples, see the [`ltx-pipelines`](../ltx-pipelines/) package.
316
+
317
+ ---
318
+
319
+ ## Audio VAE
320
+
321
+ The Audio VAE ([`src/ltx_core/model/audio_vae/`](src/ltx_core/model/audio_vae/)) processes audio spectrograms.
322
+
323
+ ### Audio VAE Architecture
324
+
325
+ Compact neural audio representation optimized for diffusion-based training. Natively supports stereo: processes two-channel mel-spectrograms (16 kHz input) with channel concatenation before encoding.
326
+
327
+ - **Encoder**: `[B, mel_bins, T]` → `[B, 8, T/4, 16]` latents (4× temporal downsampling, 8 channels, 16 mel bins in latent space, ~1/25s per token, 128-dim feature vector)
328
+ - **Decoder**: `[B, 8, T, 16]` → `[B, mel_bins, T*4]` mel spectrogram
329
+ - **Vocoder**: HiFi-GAN-based, modified for stereo synthesis and upsampling (16 kHz mel → 24 kHz waveform, doubled generator capacity for stereo)
330
+
331
+ **Downsampling**:
332
+
333
+ - Temporal: 4× (time steps)
334
+ - Frequency: Variable (input mel_bins → fixed 16 in latent space)
335
+
336
+ The Audio VAE is used internally by pipelines for encoding mel spectrograms to latents and decoding latents back to mel spectrograms. The vocoder converts mel spectrograms to audio waveforms. For usage examples, see the [`ltx-pipelines`](../ltx-pipelines/) package.
337
+
338
+ ---
339
+
340
+ ## Text Encoding (Gemma)
341
+
342
+ LTX-2 uses **Gemma 3** (Gemma 3-12B) as the multilingual text encoder backbone, located in [`src/ltx_core/text_encoders/gemma/`](src/ltx_core/text_encoders/gemma/). Advanced text understanding is critical not only for global language support but for the phonetic and semantic accuracy of generated speech.
343
+
344
+ ### Text Encoder Architecture
345
+
346
+ The text conditioning pipeline consists of three stages:
347
+
348
+ 1. **Gemma 3 Backbone**: Decoder-only LLM processes text tokens → embeddings across all layers `[B, T, D, L]`
349
+ 2. **Multi-Layer Feature Extractor**: Aggregates features from all decoder layers (not just final layer), applies mean-centered scaling, flattens to `[B, T, D×L]`, and projects via learnable matrix W (jointly optimized with LTX-2, LLM weights frozen)
350
+ 3. **Text Connector**: Bidirectional transformer blocks with learnable registers (replacing padded positions, also referred to as "thinking tokens" in the paper) for contextual mixing. Separate connectors for video and audio streams (`Embeddings1DConnector`)
351
+
352
+ **Encoders**:
353
+
354
+ - `AVGemmaTextEncoderModel`: Audio-video generation (two connectors → `AVGemmaEncoderOutput` with separate video/audio contexts)
355
+ - `VideoGemmaTextEncoderModel`: Video-only generation (single connector → `VideoGemmaEncoderOutput`)
356
+
357
+ ### System Prompts
358
+
359
+ System prompts are also used to enhance user's prompts.
360
+
361
+ - **Text-to-Video**: [`gemma_t2v_system_prompt.txt`](src/ltx_core/text_encoders/gemma/encoders/prompts/gemma_t2v_system_prompt.txt)
362
+ - **Image-to-Video**: [`gemma_i2v_system_prompt.txt`](src/ltx_core/text_encoders/gemma/encoders/prompts/gemma_i2v_system_prompt.txt)
363
+
364
+ **Important**: Video and audio receive **different** context embeddings, even from the same prompt. This allows better modality-specific conditioning and enables the model to synthesize speech that is synchronized with visual lip movement while being natural in cadence, accent, and emotional tone.
365
+
366
+ **Output Format**:
367
+
368
+ - Video context: `[B, seq_len, 4096]` - Video-specific text embeddings
369
+ - Audio context: `[B, seq_len, 2048]` - Audio-specific text embeddings
370
+
371
+ The text encoder is used internally by pipelines. For usage examples, see the [`ltx-pipelines`](../ltx-pipelines/) package.
372
+
373
+ ---
374
+
375
+ ## Upscaler
376
+
377
+ The Upscaler ([`src/ltx_core/model/upsampler/`](src/ltx_core/model/upsampler/)) upsamples latent representations for higher-resolution output.
378
+
379
+ The spatial upsampler is used internally by two-stage pipelines (e.g., [`TI2VidTwoStagesPipeline`](../ltx-pipelines/src/ltx_pipelines/ti2vid_two_stages.py), [`ICLoraPipeline`](../ltx-pipelines/src/ltx_pipelines/ic_lora.py)) to upsample low-resolution latents before final VAE decoding. For usage examples, see the [`ltx-pipelines`](../ltx-pipelines/) package.
380
+
381
+ ---
382
+
383
+ ## Data Flow
384
+
385
+ ### Complete Generation Pipeline
386
+
387
+ Here's how all the components work together conceptually ([`src/ltx_core/components/`](src/ltx_core/components/)):
388
+
389
+ **Pipeline Steps**:
390
+
391
+ 1. **Text Encoding**: Text prompt → Gemma encoder → separate video/audio embeddings
392
+ 2. **Latent Initialization**: Initialize noise latents in spatial format `[B, C, F, H, W]`
393
+ 3. **Patchification**: Convert spatial latents to sequence format `[B, seq_len, dim]` for transformer
394
+ 4. **Sigma Schedule**: Generate noise schedule (adapts to token count)
395
+ 5. **Denoising Loop**: Iteratively denoise using transformer predictions
396
+ - Create Modality inputs with per-token timesteps and RoPE positions
397
+ - Forward pass through transformer (conditional and unconditional for CFG)
398
+ - Apply guidance (CFG, STG, etc.)
399
+ - Update latents using diffusion step (Euler, etc.)
400
+ 6. **Unpatchification**: Convert sequence back to spatial format
401
+ 7. **VAE Decoding**: Decode latents to pixel space (with optional upsampling for two-stage)
402
+
403
+ - [`TI2VidTwoStagesPipeline`](../ltx-pipelines/src/ltx_pipelines/ti2vid_two_stages.py) - Two-stage text-to-video (recommended)
404
+ - [`ICLoraPipeline`](../ltx-pipelines/src/ltx_pipelines/ic_lora.py) - Video-to-video with IC-LoRA control
405
+ - [`DistilledPipeline`](../ltx-pipelines/src/ltx_pipelines/distilled.py) - Fast inference with distilled model
406
+ - [`KeyframeInterpolationPipeline`](../ltx-pipelines/src/ltx_pipelines/keyframe_interpolation.py) - Keyframe-based interpolation
407
+
408
+ See the [ltx-pipelines README](../ltx-pipelines/README.md) for usage examples.
409
+
410
+ ## 🔗 Related Projects
411
+
412
+ - **[ltx-pipelines](../ltx-pipelines/)** - High-level pipeline implementations for text-to-video, image-to-video, and video-to-video
413
+ - **[ltx-trainer](../ltx-trainer/)** - Training and fine-tuning tools
packages/ltx-core/pyproject.toml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "ltx-core"
3
+ version = "1.1.3"
4
+ description = "Core implementation of Lightricks' LTX-2 model"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "torch~=2.7",
9
+ "torchaudio",
10
+ "einops",
11
+ "numpy",
12
+ "transformers>=4.52",
13
+ "safetensors",
14
+ "accelerate",
15
+ "scipy>=1.14",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ xformers = ["xformers"]
20
+ fp8-trtllm = [
21
+ "tensorrt-llm==1.0.0",
22
+ "onnx>=1.16.0,<1.20.0",
23
+ "openmpi",
24
+ ]
25
+
26
+ [tool.uv]
27
+ conflicts = [
28
+ [
29
+ { extra = "xformers" },
30
+ { extra = "fp8-trtllm" },
31
+ ],
32
+ ]
33
+
34
+ [tool.uv.sources]
35
+ xformers = { index = "pytorch" }
36
+ tensorrt-llm = { index = "nvidia" }
37
+
38
+ [[tool.uv.index]]
39
+ name = "pytorch"
40
+ url = "https://download.pytorch.org/whl/cu129"
41
+ explicit = true
42
+
43
+ [[tool.uv.index]]
44
+ name = "nvidia"
45
+ url = "https://pypi.nvidia.com/"
46
+ explicit = true
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.9.8,<0.10.0"]
50
+ build-backend = "uv_build"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "scikit-image>=0.25.2",
55
+ ]
packages/ltx-core/src/ltx_core/__init__.py ADDED
File without changes
packages/ltx-core/src/ltx_core/batch_split.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch-splitting adapter for the transformer.
2
+ Wraps an ``X0Model`` (or ``BlockStreamingWrapper``) and splits batched inputs
3
+ into smaller chunks before forwarding, then concatenates the results. This
4
+ controls peak activation memory at the cost of more forward passes.
5
+ The adapter is transparent — it has the same ``forward`` signature as
6
+ ``X0Model`` and proxies attribute access to the wrapped model.
7
+ Example
8
+ -------
9
+ >>> from ltx_core.batch_split import BatchSplitAdapter
10
+ >>> adapter = BatchSplitAdapter(model, max_batch_size=1)
11
+ >>> # Receives B=4, runs 4xB=1 internally, returns B=4
12
+ >>> denoised_video, denoised_audio = adapter(video=v_b4, audio=a_b4, perturbations=ptb)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ import torch
20
+ from torch import nn
21
+
22
+ from ltx_core.guidance.perturbations import BatchedPerturbationConfig
23
+ from ltx_core.model.transformer.modality import Modality
24
+
25
+
26
+ def _split_perturbations(config: BatchedPerturbationConfig, sizes: list[int]) -> list[BatchedPerturbationConfig]:
27
+ """Split a ``BatchedPerturbationConfig`` along the batch dimension."""
28
+ it = iter(config.perturbations)
29
+ return [BatchedPerturbationConfig([next(it) for _ in range(s)]) for s in sizes]
30
+
31
+
32
+ def _merge_tensors(tensors: list[torch.Tensor | None]) -> torch.Tensor | None:
33
+ """Concatenate tensors along batch dim, or return None if all are None."""
34
+ non_none = [t for t in tensors if t is not None]
35
+ if not non_none:
36
+ return None
37
+ return torch.cat(non_none, dim=0)
38
+
39
+
40
+ class BatchSplitAdapter(nn.Module):
41
+ """Wraps a model and splits batched forward calls into smaller chunks.
42
+ Has the same ``forward`` signature as ``X0Model``:
43
+ ``(video, audio, perturbations) -> (denoised_video, denoised_audio)``.
44
+ Args:
45
+ model: The model to wrap (``X0Model``, ``BlockStreamingWrapper``, etc.).
46
+ max_batch_size: Maximum batch size per forward pass. Input batches
47
+ larger than this are split into sequential chunks.
48
+ """
49
+
50
+ def __init__(self, model: nn.Module, max_batch_size: int) -> None:
51
+ if max_batch_size < 1:
52
+ raise ValueError(f"max_batch_size must be >= 1, got {max_batch_size}")
53
+ super().__init__()
54
+ self._model = model
55
+ self._max_batch_size = max_batch_size
56
+
57
+ def _get_chunk_sizes(self, batch_size: int) -> list[int]:
58
+ full, remainder = divmod(batch_size, self._max_batch_size)
59
+ sizes = [self._max_batch_size] * full
60
+ if remainder:
61
+ sizes.append(remainder)
62
+ return sizes
63
+
64
+ def forward(
65
+ self,
66
+ video: Modality | None,
67
+ audio: Modality | None,
68
+ perturbations: BatchedPerturbationConfig,
69
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
70
+ batch_size = (video or audio).latent.shape[0]
71
+
72
+ if batch_size <= self._max_batch_size:
73
+ return self._model(video=video, audio=audio, perturbations=perturbations)
74
+
75
+ sizes = self._get_chunk_sizes(batch_size)
76
+ n = len(sizes)
77
+
78
+ v_chunks = video.split(sizes) if video is not None else [None] * n
79
+ a_chunks = audio.split(sizes) if audio is not None else [None] * n
80
+ p_chunks = _split_perturbations(perturbations, sizes)
81
+
82
+ chunk_results = [
83
+ self._model(video=vc, audio=ac, perturbations=pc)
84
+ for vc, ac, pc in zip(v_chunks, a_chunks, p_chunks, strict=True)
85
+ ]
86
+
87
+ results_v, results_a = zip(*chunk_results, strict=True)
88
+ return _merge_tensors(list(results_v)), _merge_tensors(list(results_a))
89
+
90
+ def __getattr__(self, name: str) -> Any: # noqa: ANN401
91
+ """Proxy attribute access to the wrapped model."""
92
+ try:
93
+ return super().__getattr__(name)
94
+ except AttributeError:
95
+ return getattr(self._model, name)
packages/ltx-core/src/ltx_core/block_streaming/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Block streaming: memory-efficient sequential-block inference.
2
+ Streams transformer blocks from safetensors to GPU one at a time.
3
+ Block weights are provided by a :class:`WeightsProvider` which handles
4
+ CPU-to-GPU copies, caching, and stream synchronization. Two weight
5
+ source strategies are available:
6
+ - **RAM streaming** (default): all blocks pre-loaded into pinned CPU
7
+ buffers with LoRA fusion at build time. Fast, higher CPU memory.
8
+ - **Disk streaming** (``cpu_slots < num_blocks``): blocks read from
9
+ disk on demand with FIFO eviction. Slower, lower CPU memory.
10
+ """
11
+
12
+ from ltx_core.block_streaming.builder import DISK_CPU_SLOTS, StreamingModelBuilder
13
+ from ltx_core.block_streaming.wrapper import BlockStreamingWrapper
14
+
15
+ __all__ = [
16
+ "DISK_CPU_SLOTS",
17
+ "BlockStreamingWrapper",
18
+ "StreamingModelBuilder",
19
+ ]
packages/ltx-core/src/ltx_core/block_streaming/builder.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Builder that constructs a BlockStreamingWrapper from safetensors checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Generic
9
+
10
+ import safetensors
11
+ import torch
12
+ from torch import nn
13
+
14
+ from ltx_core.block_streaming.disk import DiskBlockReader, DiskTensorReader, LoraSource
15
+ from ltx_core.block_streaming.pool import WeightPool
16
+ from ltx_core.block_streaming.provider import WeightsProvider
17
+ from ltx_core.block_streaming.source import DiskWeightSource, PinnedWeightSource, WeightSource
18
+ from ltx_core.block_streaming.utils import allocate_layout_views, derive_layout, make_block_key, resolve_attr
19
+ from ltx_core.block_streaming.wrapper import BlockStreamingWrapper
20
+ from ltx_core.loader.fuse_loras import aggregate_lora_products, fuse_lora_weights
21
+ from ltx_core.loader.helpers import create_meta_model, load_state_dict, read_model_config
22
+ from ltx_core.loader.module_ops import ModuleOps
23
+ from ltx_core.loader.primitives import (
24
+ LoraPathStrengthAndSDOps,
25
+ LoraStateDictWithStrength,
26
+ ModelBuilderProtocol,
27
+ StateDictLoader,
28
+ )
29
+ from ltx_core.loader.registry import DummyRegistry, Registry
30
+ from ltx_core.loader.sd_ops import SDOps
31
+ from ltx_core.loader.sft_loader import SafetensorsModelStateDictLoader
32
+ from ltx_core.model.model_protocol import ModelConfigurator, ModelType
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ DISK_CPU_SLOTS = 2
37
+ _DEFAULT_GPU_SLOTS = 2
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class StreamingModelBuilder(Generic[ModelType], ModelBuilderProtocol[ModelType]):
42
+ """Immutable builder for :class:`BlockStreamingWrapper`.
43
+ Reads block weights from safetensors on demand. ``cpu_slots`` and
44
+ ``gpu_slots`` control the memory/speed trade-off (see :meth:`build`).
45
+ Args:
46
+ model_class_configurator: Creates the model from a config dict.
47
+ model_path: One or more ``.safetensors`` checkpoint paths.
48
+ model_sd_ops: Key remapping applied to safetensors keys.
49
+ module_ops: Module-level mutations for the meta model.
50
+ loras: LoRA adapters fused into weights at load time.
51
+ model_loader: Strategy for reading checkpoint metadata.
52
+ registry: Shared cache for loaded state dicts.
53
+ blocks_attr: Dotted path to the ``nn.ModuleList`` (e.g.
54
+ ``"velocity_model.transformer_blocks"``).
55
+ blocks_prefix: State-dict key prefix for block weights
56
+ (e.g. ``"transformer_blocks"``).
57
+ state_dict_prefix: Wrapper offset prepended to keys when loading into
58
+ the meta model (e.g. ``"velocity_model."`` when wrapped by ``X0Model``).
59
+ model_wrapper: Optional callable wrapping the model
60
+ (e.g. ``X0Model``).
61
+ """
62
+
63
+ model_class_configurator: type[ModelConfigurator[ModelType]]
64
+ model_path: str | tuple[str, ...]
65
+ model_sd_ops: SDOps | None = None
66
+ module_ops: tuple[ModuleOps, ...] = field(default_factory=tuple)
67
+ loras: tuple[LoraPathStrengthAndSDOps, ...] = field(default_factory=tuple)
68
+ model_loader: StateDictLoader = field(default_factory=SafetensorsModelStateDictLoader)
69
+ registry: Registry = field(default_factory=DummyRegistry)
70
+
71
+ # Streaming-specific
72
+ blocks_attr: str = ""
73
+ blocks_prefix: str = ""
74
+ state_dict_prefix: str = ""
75
+ model_wrapper: Callable[[ModelType], nn.Module] | None = None
76
+
77
+ def with_sd_ops(self, sd_ops: SDOps | None) -> StreamingModelBuilder:
78
+ return replace(self, model_sd_ops=sd_ops)
79
+
80
+ def with_module_ops(self, module_ops: tuple[ModuleOps, ...]) -> StreamingModelBuilder:
81
+ return replace(self, module_ops=module_ops)
82
+
83
+ def with_loras(self, loras: tuple[LoraPathStrengthAndSDOps, ...]) -> StreamingModelBuilder:
84
+ return replace(self, loras=loras)
85
+
86
+ def model_config(self) -> dict:
87
+ """Read model configuration from the checkpoint metadata."""
88
+ return read_model_config(self.model_path, self.model_loader)
89
+
90
+ def meta_model(self, config: dict, module_ops: tuple[ModuleOps, ...]) -> ModelType:
91
+ """Create a model on the meta device and apply module operations."""
92
+ return create_meta_model(self.model_class_configurator, config, module_ops)
93
+
94
+ def build(
95
+ self,
96
+ target_device: torch.device,
97
+ dtype: torch.dtype,
98
+ cpu_slots_count: int | None = None,
99
+ gpu_slots_count: int | None = None,
100
+ **_kwargs: object,
101
+ ) -> BlockStreamingWrapper:
102
+ """Build and return a ready-to-use :class:`BlockStreamingWrapper`.
103
+ Args:
104
+ target_device: GPU device for compute.
105
+ dtype: Weight dtype (e.g. ``torch.bfloat16``).
106
+ cpu_slots_count: Number of pinned CPU buffer slots.
107
+ ``None`` = RAM streaming (all blocks pre-loaded with LoRA fusion).
108
+ gpu_slots_count: Number of GPU buffer slots.
109
+ ``None`` = ``_DEFAULT_GPU_SLOTS`` (2).
110
+ """
111
+ if not self.blocks_prefix:
112
+ raise ValueError("blocks_prefix must be non-empty for streaming")
113
+
114
+ config = read_model_config(self.model_path, self.model_loader)
115
+ meta_model: nn.Module = create_meta_model(self.model_class_configurator, config, self.module_ops)
116
+ if self.model_wrapper is not None:
117
+ meta_model = self.model_wrapper(meta_model)
118
+ meta_model.eval()
119
+
120
+ blocks = resolve_attr(meta_model, self.blocks_attr)
121
+
122
+ checkpoint_paths = list(self.model_path) if isinstance(self.model_path, tuple) else [self.model_path]
123
+ block_key_map, non_block_keys = _scan_checkpoint_keys(checkpoint_paths, self.model_sd_ops, self.blocks_prefix)
124
+
125
+ cpu_slots_count = cpu_slots_count if cpu_slots_count is not None else len(blocks)
126
+ gpu_slots_count = gpu_slots_count if gpu_slots_count is not None else _DEFAULT_GPU_SLOTS
127
+
128
+ if cpu_slots_count >= len(blocks):
129
+ source, lora_sources = self._build_pinned_source(
130
+ meta_model, target_device, dtype, cpu_slots_count, block_key_map, non_block_keys
131
+ )
132
+ else:
133
+ reader = DiskTensorReader(checkpoint_paths)
134
+ source, lora_sources = self._build_disk_source(
135
+ meta_model, target_device, dtype, cpu_slots_count, reader, block_key_map, non_block_keys
136
+ )
137
+
138
+ copy_stream = torch.cuda.Stream(device=target_device)
139
+ gpu_pool = WeightPool(
140
+ source.block_layout,
141
+ gpu_slots_count,
142
+ target_device,
143
+ reuse_barrier=lambda event: copy_stream.wait_event(event),
144
+ )
145
+ provider = WeightsProvider(gpu_pool, copy_stream, target_device, source, lora_sources, self.blocks_prefix)
146
+ return BlockStreamingWrapper(
147
+ model=meta_model,
148
+ blocks=blocks,
149
+ provider=provider,
150
+ target_device=target_device,
151
+ )
152
+
153
+ def _build_pinned_source(
154
+ self,
155
+ meta_model: nn.Module,
156
+ target_device: torch.device,
157
+ dtype: torch.dtype,
158
+ cpu_slots_count: int,
159
+ block_key_map: dict[int, list[tuple[str, str]]],
160
+ non_block_keys: list[tuple[str, str]],
161
+ ) -> tuple[WeightSource, list[LoraSource]]:
162
+ """Pre-load all blocks into pinned CPU buffers with LoRA fusion."""
163
+ model_sd = load_state_dict(
164
+ self.model_path, self.model_loader, self.registry, torch.device("cpu"), self.model_sd_ops
165
+ )
166
+
167
+ lora_sd_and_strengths = [
168
+ LoraStateDictWithStrength(
169
+ load_state_dict([lora.path], self.model_loader, self.registry, torch.device("cpu"), lora.sd_ops),
170
+ lora.strength,
171
+ )
172
+ for lora in self.loras
173
+ ]
174
+
175
+ for block_idx in block_key_map:
176
+ if block_idx >= cpu_slots_count:
177
+ raise ValueError(
178
+ f"Pinned source requires one CPU slot per block; "
179
+ f"got block index {block_idx} with only {cpu_slots_count} slots."
180
+ )
181
+
182
+ blocks = resolve_attr(meta_model, self.blocks_attr)
183
+ block_tensors: dict[str, torch.Tensor] = {}
184
+ for block_idx, entries in block_key_map.items():
185
+ block_params = dict(blocks[block_idx].named_parameters())
186
+ for _sft_key, param_name in entries:
187
+ key = make_block_key(self.blocks_prefix, block_idx, param_name)
188
+ block_tensors[key] = block_params[param_name]
189
+ blocks_layout = derive_layout(block_tensors, dtype)
190
+ pinned_blocks = allocate_layout_views(blocks_layout, pin_memory=True)
191
+
192
+ should_sync = False
193
+ for key, fused in fuse_lora_weights(model_sd, lora_sd_and_strengths, dtype=None, preserve_input_device=False):
194
+ if key in pinned_blocks:
195
+ pinned_blocks[key].copy_(fused, non_blocking=True)
196
+ model_sd.sd[key] = None
197
+ should_sync = True
198
+ else:
199
+ model_sd.sd[key] = fused
200
+ if should_sync:
201
+ torch.cuda.synchronize()
202
+
203
+ # Fill remaining pinned keys from the source state dict.
204
+ for key in blocks_layout:
205
+ if model_sd.sd[key] is None:
206
+ continue
207
+ pinned_blocks[key].copy_(model_sd.sd[key])
208
+ model_sd.sd[key] = None
209
+
210
+ pinned: dict[int, dict[str, torch.Tensor]] = {
211
+ block_idx: {
212
+ param_name: pinned_blocks[make_block_key(self.blocks_prefix, block_idx, param_name)]
213
+ for _sft_key, param_name in entries
214
+ }
215
+ for block_idx, entries in block_key_map.items()
216
+ }
217
+
218
+ non_block_sd: dict[str, torch.Tensor] = {
219
+ self.state_dict_prefix + model_key: model_sd.sd[model_key].to(device=target_device, dtype=dtype)
220
+ for _sft_key, model_key in non_block_keys
221
+ }
222
+
223
+ meta_model.load_state_dict(non_block_sd, strict=False, assign=True)
224
+
225
+ return PinnedWeightSource(pinned), []
226
+
227
+ def _build_disk_source(
228
+ self,
229
+ meta_model: nn.Module,
230
+ target_device: torch.device,
231
+ dtype: torch.dtype,
232
+ cpu_slots_count: int,
233
+ reader: DiskTensorReader,
234
+ block_key_map: dict[int, list[tuple[str, str]]],
235
+ non_block_keys: list[tuple[str, str]],
236
+ ) -> tuple[WeightSource, list[LoraSource]]:
237
+ """Create a DiskWeightSource backed by a DiskBlockReader for lazy loading.
238
+ Derives the shared pool layout from the meta model's block 0 — this
239
+ relies on module_ops (e.g. fp8_cast) leaving the meta param dtype in
240
+ sync with the post-sd_ops checkpoint dtype.
241
+ """
242
+ lora_sources = [LoraSource(lora.path, lora.sd_ops, lora.strength) for lora in self.loras]
243
+
244
+ self._load_non_block_weights(
245
+ reader,
246
+ non_block_keys,
247
+ meta_model,
248
+ target_device,
249
+ dtype,
250
+ sd_ops=self.model_sd_ops,
251
+ key_prefix=self.state_dict_prefix,
252
+ lora_sources=lora_sources,
253
+ )
254
+
255
+ blocks = resolve_attr(meta_model, self.blocks_attr)
256
+ layout = derive_layout(dict(blocks[0].named_parameters()), dtype)
257
+
258
+ cpu_pool = WeightPool(
259
+ layout,
260
+ cpu_slots_count,
261
+ torch.device("cpu"),
262
+ reuse_barrier=lambda event: event.synchronize(),
263
+ pin_memory=True,
264
+ )
265
+ block_reader = DiskBlockReader(
266
+ reader=reader,
267
+ block_key_map=block_key_map,
268
+ sd_ops=self.model_sd_ops,
269
+ blocks_prefix=self.blocks_prefix,
270
+ )
271
+ source = DiskWeightSource(cpu_pool, block_reader)
272
+ return source, lora_sources
273
+
274
+ # ------------------------------------------------------------------
275
+ # Helpers
276
+ # ------------------------------------------------------------------
277
+
278
+ @staticmethod
279
+ def _fuse_lora_delta(
280
+ model_key: str,
281
+ tensor: torch.Tensor,
282
+ lora_sources: list[LoraSource],
283
+ ) -> torch.Tensor:
284
+ """Add all matching LoRA deltas to *tensor* in-place via ``addmm_``."""
285
+ if not lora_sources or not model_key.endswith(".weight"):
286
+ return tensor
287
+ prefix = model_key[: -len(".weight")]
288
+ products = (
289
+ ab
290
+ for ab in (s.get_ab(prefix, device=tensor.device, dtype=tensor.dtype) for s in lora_sources)
291
+ if ab is not None
292
+ )
293
+ aggregate_lora_products(products, out=tensor)
294
+ return tensor
295
+
296
+ @staticmethod
297
+ @torch.inference_mode()
298
+ def _load_non_block_weights(
299
+ reader: DiskTensorReader,
300
+ non_block_keys: list[tuple[str, str]],
301
+ model: nn.Module,
302
+ device: torch.device,
303
+ dtype: torch.dtype,
304
+ sd_ops: SDOps | None = None,
305
+ key_prefix: str = "",
306
+ lora_sources: list[LoraSource] | None = None,
307
+ ) -> None:
308
+ """Load non-block weights into *model* on *device*."""
309
+ state_dict: dict[str, torch.Tensor] = {}
310
+ sources = lora_sources or []
311
+ for sft_key, model_key in non_block_keys:
312
+ tensor = reader.get_tensor(sft_key).to(device=device, dtype=dtype)
313
+ tensor = StreamingModelBuilder._fuse_lora_delta(model_key, tensor, sources)
314
+ if sd_ops is not None:
315
+ for kv in sd_ops.apply_to_key_value(model_key, tensor):
316
+ state_dict[key_prefix + kv.new_key] = kv.new_value
317
+ continue
318
+ state_dict[key_prefix + model_key] = tensor
319
+ model.load_state_dict(state_dict, strict=False, assign=True)
320
+
321
+
322
+ def _scan_checkpoint_keys(
323
+ checkpoint_paths: list[str],
324
+ sd_ops: SDOps | None,
325
+ blocks_prefix: str,
326
+ ) -> tuple[dict[int, list[tuple[str, str]]], list[tuple[str, str]]]:
327
+ """Partition checkpoint keys into per-block and non-block lists.
328
+ Opens the safetensors files for header-only key enumeration; no tensor data
329
+ is read.
330
+ """
331
+ block_key_map: dict[int, list[tuple[str, str]]] = {}
332
+ non_block_keys: list[tuple[str, str]] = []
333
+ prefix_dot = blocks_prefix + "."
334
+ for path in checkpoint_paths:
335
+ with safetensors.safe_open(path, framework="pt", device="cpu") as handle:
336
+ for sft_key in handle.keys(): # noqa: SIM118
337
+ model_key = sd_ops.apply_to_key(sft_key) if sd_ops else sft_key
338
+ if model_key is None:
339
+ continue
340
+ if model_key.startswith(prefix_dot):
341
+ rest = model_key[len(prefix_dot) :]
342
+ idx_str, _, param_name = rest.partition(".")
343
+ try:
344
+ block_idx = int(idx_str)
345
+ except ValueError:
346
+ non_block_keys.append((sft_key, model_key))
347
+ continue
348
+ block_key_map.setdefault(block_idx, []).append((sft_key, param_name))
349
+ else:
350
+ non_block_keys.append((sft_key, model_key))
351
+ return block_key_map, non_block_keys
packages/ltx-core/src/ltx_core/block_streaming/disk.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Safetensors I/O and LoRA fusion for block streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ import safetensors
8
+ import torch
9
+
10
+ from ltx_core.block_streaming.utils import allocate_layout_views, make_block_key
11
+ from ltx_core.loader.fuse_loras import LoraProduct
12
+ from ltx_core.loader.sd_ops import SDOps
13
+
14
+ _SAFETENSORS_DTYPE_TO_TORCH: dict[str, torch.dtype] = {
15
+ "F64": torch.float64,
16
+ "F32": torch.float32,
17
+ "F16": torch.float16,
18
+ "BF16": torch.bfloat16,
19
+ }
20
+
21
+
22
+ class DiskTensorReader:
23
+ """Key-based tensor accessor over one or more safetensors files."""
24
+
25
+ def __init__(self, paths: list[str]) -> None:
26
+ self._handles: list[safetensors.safe_open] = []
27
+ self._key_to_handle_idx: dict[str, int] = {}
28
+ for path in paths:
29
+ handle = safetensors.safe_open(path, framework="pt", device="cpu")
30
+ handle_idx = len(self._handles)
31
+ self._handles.append(handle)
32
+ for sft_key in handle.keys(): # noqa: SIM118
33
+ self._key_to_handle_idx[sft_key] = handle_idx
34
+
35
+ def get_tensor(self, key: str) -> torch.Tensor:
36
+ return self._handles[self._key_to_handle_idx[key]].get_tensor(key)
37
+
38
+ def close(self) -> None:
39
+ self._handles.clear()
40
+ self._key_to_handle_idx.clear()
41
+
42
+ def __contains__(self, key: str) -> bool:
43
+ return key in self._key_to_handle_idx
44
+
45
+ def __iter__(self) -> Iterator[str]:
46
+ return iter(self._key_to_handle_idx)
47
+
48
+
49
+ class DiskBlockReader:
50
+ """Reads one block at a time from safetensors into provided buffers."""
51
+
52
+ def __init__(
53
+ self,
54
+ reader: DiskTensorReader,
55
+ block_key_map: dict[int, list[tuple[str, str]]],
56
+ sd_ops: SDOps | None = None,
57
+ blocks_prefix: str = "",
58
+ ) -> None:
59
+ self._reader = reader
60
+ self._block_key_map = block_key_map
61
+ self._sd_ops = sd_ops
62
+ self._blocks_prefix = blocks_prefix
63
+
64
+ def read_into(self, target: dict[str, torch.Tensor], block_idx: int) -> None:
65
+ block_prefix = make_block_key(self._blocks_prefix, block_idx, "")
66
+ for sft_key, param_name in self._block_key_map[block_idx]:
67
+ tensor = self._reader.get_tensor(sft_key)
68
+ if self._sd_ops is None:
69
+ target[param_name].copy_(tensor)
70
+ continue
71
+ full_key = make_block_key(self._blocks_prefix, block_idx, param_name)
72
+ for result in self._sd_ops.apply_to_key_value(full_key, tensor):
73
+ if not result.new_key.startswith(block_prefix):
74
+ raise ValueError(
75
+ f"SDOps output key '{result.new_key}' is outside block {block_idx} "
76
+ f"(expected prefix '{block_prefix}'); cannot route to a per-block buffer."
77
+ )
78
+ target[result.new_key[len(block_prefix) :]].copy_(result.new_value)
79
+
80
+ def cleanup(self) -> None:
81
+ self._reader.close()
82
+
83
+
84
+ class LoraSource:
85
+ """Pinned-memory cache of matched LoRA A/B factors backed by a single buffer."""
86
+
87
+ def __init__(self, path: str, sd_ops: SDOps | None, strength: float) -> None:
88
+ self.strength = strength
89
+ self._pinned_ab: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
90
+
91
+ a_keys: dict[str, str] = {}
92
+ b_keys: dict[str, str] = {}
93
+ with safetensors.safe_open(path, framework="pt", device="cpu") as handle:
94
+ for sft_key in handle.keys(): # noqa: SIM118
95
+ model_key = sd_ops.apply_to_key(sft_key) if sd_ops is not None else sft_key
96
+ if model_key is None:
97
+ continue
98
+ if model_key.endswith(".lora_A.weight"):
99
+ a_keys[model_key[: -len(".lora_A.weight")]] = sft_key
100
+ elif model_key.endswith(".lora_B.weight"):
101
+ b_keys[model_key[: -len(".lora_B.weight")]] = sft_key
102
+
103
+ matched_prefixes = list(a_keys.keys() & b_keys.keys())
104
+
105
+ # Build the layout from safetensors header metadata only — no tensor data is read.
106
+ layout: dict[str, tuple[torch.Size, torch.dtype]] = {}
107
+ for prefix in matched_prefixes:
108
+ a_slice_view = handle.get_slice(a_keys[prefix])
109
+ b_slice_view = handle.get_slice(b_keys[prefix])
110
+ layout[f"{prefix}.A"] = (
111
+ torch.Size(a_slice_view.get_shape()),
112
+ _SAFETENSORS_DTYPE_TO_TORCH[a_slice_view.get_dtype()],
113
+ )
114
+ layout[f"{prefix}.B"] = (
115
+ torch.Size(b_slice_view.get_shape()),
116
+ _SAFETENSORS_DTYPE_TO_TORCH[b_slice_view.get_dtype()],
117
+ )
118
+
119
+ all_views = allocate_layout_views(layout, pin_memory=True)
120
+
121
+ for prefix in matched_prefixes:
122
+ a_view = all_views[f"{prefix}.A"]
123
+ b_view = all_views[f"{prefix}.B"]
124
+ a_view.copy_(handle.get_tensor(a_keys[prefix]))
125
+ b_view.copy_(handle.get_tensor(b_keys[prefix]))
126
+ self._pinned_ab[prefix] = (a_view, b_view)
127
+
128
+ def get_ab(
129
+ self,
130
+ param_prefix: str,
131
+ device: torch.device | None = None,
132
+ dtype: torch.dtype | None = None,
133
+ ) -> LoraProduct | None:
134
+ """Return the :class:`LoraProduct` for *param_prefix*, or ``None``."""
135
+ pair = self._pinned_ab.get(param_prefix)
136
+ if pair is None:
137
+ return None
138
+ a, b = pair
139
+ if device is not None and device.type == "cuda":
140
+ a = a.to(device=device, non_blocking=True)
141
+ b = b.to(device=device, non_blocking=True)
142
+ if dtype is not None:
143
+ a = a.to(dtype=dtype)
144
+ b = b.to(dtype=dtype)
145
+ return LoraProduct(a, b, self.strength)
146
+
147
+ def cleanup(self) -> None:
148
+ self._pinned_ab.clear()
packages/ltx-core/src/ltx_core/block_streaming/pool.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weight buffer pool for block streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from typing import Callable
7
+
8
+ import torch
9
+
10
+ from ltx_core.block_streaming.utils import allocate_layout_views
11
+ from ltx_core.loader.primitives import TensorLayout
12
+
13
+
14
+ class WeightPool:
15
+ """Fixed pool of pre-allocated weight buffers with event-based reuse.
16
+ All slots share a single buffer (CPU or GPU); each slot is a
17
+ contiguous slice carved out of it via :func:`allocate_layout_views`.
18
+ Args:
19
+ buffer_layout: ``{name: (shape, dtype)}`` for each buffer.
20
+ capacity: Number of buffers to pre-allocate.
21
+ device: Device for allocation.
22
+ reuse_barrier: Called with the pending event before a buffer is reused.
23
+ pin_memory: Pin buffers (for async H2D copies from CPU).
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ buffer_layout: TensorLayout,
29
+ capacity: int,
30
+ device: torch.device,
31
+ reuse_barrier: Callable[[torch.cuda.Event], None],
32
+ pin_memory: bool = False,
33
+ ) -> None:
34
+ self._buffer_layout = buffer_layout
35
+ self._capacity = capacity
36
+ self._free: deque[dict[str, torch.Tensor]] = deque()
37
+ self._events: dict[int, torch.cuda.Event] = {}
38
+ self._reuse_barrier = reuse_barrier
39
+ memory_layout = {
40
+ _make_key(slot, name): (shape, dtype)
41
+ for slot in range(capacity)
42
+ for name, (shape, dtype) in buffer_layout.items()
43
+ }
44
+ all_views = allocate_layout_views(memory_layout, device=device, pin_memory=pin_memory)
45
+ for slot in range(capacity):
46
+ self._free.append({name: all_views[_make_key(slot, name)] for name in buffer_layout})
47
+
48
+ @property
49
+ def capacity(self) -> int:
50
+ return self._capacity
51
+
52
+ @property
53
+ def buffer_layout(self) -> TensorLayout:
54
+ return self._buffer_layout
55
+
56
+ def acquire(self) -> dict[str, torch.Tensor]:
57
+ """Take a free buffer, waiting any pending event before returning."""
58
+ weights = self._free.popleft()
59
+ event = self._events.pop(id(weights), None)
60
+ if event is not None:
61
+ self._reuse_barrier(event)
62
+ return weights
63
+
64
+ def release(self, weights: dict[str, torch.Tensor], event: torch.cuda.Event | None = None) -> None:
65
+ """Return a buffer to the free list.
66
+ If *event* is given it is waited on the next :meth:`acquire`
67
+ of this buffer, ensuring the prior operation has completed.
68
+ """
69
+ if event is not None:
70
+ self._events[id(weights)] = event
71
+ self._free.append(weights)
72
+
73
+
74
+ def _make_key(slot: int, name: str) -> str:
75
+ return f"{slot}/{name}"
packages/ltx-core/src/ltx_core/block_streaming/provider.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU weights provider for block streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+
7
+ import torch
8
+
9
+ from ltx_core.block_streaming.disk import LoraSource
10
+ from ltx_core.block_streaming.pool import WeightPool
11
+ from ltx_core.block_streaming.source import WeightSource
12
+ from ltx_core.block_streaming.utils import FP8_DTYPES
13
+ from ltx_core.loader.fuse_loras import aggregate_lora_products, fuse_cast_fp8_weight
14
+
15
+
16
+ def _contiguous_byte_view(weights: dict[str, torch.Tensor]) -> torch.Tensor | None:
17
+ """Return a ``uint8`` view spanning every tensor in *weights*, or ``None`` if
18
+ they don't share one contiguous storage region."""
19
+ tensors = list(weights.values())
20
+ if not tensors:
21
+ return None
22
+ storage = tensors[0].untyped_storage()
23
+ storage_ptr = storage.data_ptr()
24
+ start = end = tensors[0].storage_offset() * tensors[0].element_size()
25
+ for t in tensors:
26
+ if t.untyped_storage().data_ptr() != storage_ptr or not t.is_contiguous():
27
+ return None
28
+ offset = t.storage_offset() * t.element_size()
29
+ nbytes = t.numel() * t.element_size()
30
+ start = min(start, offset)
31
+ end = max(end, offset + nbytes)
32
+ view = torch.empty(0, dtype=torch.uint8, device=tensors[0].device)
33
+ view.set_(storage, start, (end - start,), (1,))
34
+ return view
35
+
36
+
37
+ class WeightsProvider:
38
+ """Provides GPU-ready block weights via H2D copy from a pinned CPU weight source.
39
+ Args:
40
+ pool: Pre-allocated GPU weight buffer pool.
41
+ copy_stream: Dedicated CUDA stream for async H2D copies.
42
+ target_device: GPU device for compute.
43
+ source: Pinned CPU weight source.
44
+ lora_sources: LoRA adapters fused on H2D copy.
45
+ blocks_prefix: State-dict prefix for LoRA key matching.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ pool: WeightPool,
51
+ copy_stream: torch.cuda.Stream,
52
+ target_device: torch.device,
53
+ source: WeightSource,
54
+ lora_sources: list[LoraSource] | None = None,
55
+ blocks_prefix: str = "",
56
+ ) -> None:
57
+ self._copy_stream = copy_stream
58
+ self._pool = pool
59
+ self._cache: OrderedDict[int, dict[str, torch.Tensor]] = OrderedDict()
60
+ self._events: dict[int, torch.cuda.Event] = {}
61
+ self._target_device = target_device
62
+ self._source = source
63
+ self._lora_sources = lora_sources or []
64
+ self._blocks_prefix = blocks_prefix
65
+
66
+ def get(self, idx: int) -> dict[str, torch.Tensor]:
67
+ """Return GPU weights for block *idx*. Does H2D copy on miss."""
68
+ if idx in self._cache:
69
+ return self._cache[idx]
70
+
71
+ # Evict oldest GPU buffer if at capacity.
72
+ if len(self._cache) >= self._pool.capacity:
73
+ evicted_idx, evicted_weights = self._cache.popitem(last=False)
74
+ self._pool.release(evicted_weights, event=self._events.pop(evicted_idx, None))
75
+
76
+ gpu_weights = self._pool.acquire()
77
+ cpu_weights = self._source.get(idx)
78
+
79
+ h2d_event = self._copy_to_gpu(idx, gpu_weights, cpu_weights)
80
+ self._source.release(idx, event=h2d_event)
81
+
82
+ self._cache[idx] = gpu_weights
83
+ return gpu_weights
84
+
85
+ def _copy_to_gpu(
86
+ self,
87
+ idx: int,
88
+ gpu_weights: dict[str, torch.Tensor],
89
+ cpu_weights: dict[str, torch.Tensor],
90
+ ) -> torch.cuda.Event:
91
+ """Enqueue H2D copy + LoRA fusion on the copy stream and wait on compute.
92
+ The wait is intentionally inside this method so callers -- and
93
+ instrumentation regions wrapping it -- observe the full transfer time.
94
+ """
95
+ with torch.cuda.stream(self._copy_stream):
96
+ gpu_view = _contiguous_byte_view(gpu_weights)
97
+ cpu_view = _contiguous_byte_view(cpu_weights)
98
+ if gpu_view is not None and cpu_view is not None and gpu_view.numel() == cpu_view.numel():
99
+ gpu_view.copy_(cpu_view, non_blocking=True)
100
+ else:
101
+ for name, gpu_tensor in gpu_weights.items():
102
+ gpu_tensor.copy_(cpu_weights[name], non_blocking=True)
103
+ if self._lora_sources:
104
+ self._fuse_block_loras(idx, gpu_weights)
105
+ h2d_event = torch.cuda.Event()
106
+ h2d_event.record(self._copy_stream)
107
+
108
+ torch.cuda.current_stream(self._target_device).wait_event(h2d_event)
109
+ return h2d_event
110
+
111
+ def release(self, idx: int, event: torch.cuda.Event) -> None:
112
+ """Attach a compute-done event -- waited before this buffer is recycled."""
113
+ self._events[idx] = event
114
+
115
+ def cleanup(self) -> None:
116
+ """Synchronize streams and release all resources."""
117
+ self._copy_stream.synchronize()
118
+ torch.cuda.current_stream(self._target_device).synchronize()
119
+ self._cache.clear()
120
+ self._events.clear()
121
+ self._source.cleanup()
122
+ for lora in self._lora_sources:
123
+ lora.cleanup()
124
+
125
+ def __len__(self) -> int:
126
+ return len(self._cache)
127
+
128
+ def _fuse_block_loras(self, idx: int, weights: dict[str, torch.Tensor]) -> None:
129
+ """Fuse LoRA deltas directly into GPU block weights."""
130
+ for name, tensor in weights.items():
131
+ if not name.endswith(".weight"):
132
+ continue
133
+ prefix = f"{self._blocks_prefix}.{idx}.{name}".removesuffix(".weight")
134
+ is_fp8 = tensor.dtype in FP8_DTYPES
135
+ agg_dtype = torch.bfloat16 if is_fp8 else tensor.dtype
136
+ products = (
137
+ ab
138
+ for ab in (s.get_ab(prefix, device=self._target_device, dtype=agg_dtype) for s in self._lora_sources)
139
+ if ab is not None
140
+ )
141
+ aggregated = aggregate_lora_products(products, agg_dtype)
142
+ if aggregated is None:
143
+ continue
144
+ if is_fp8:
145
+ tensor.copy_(fuse_cast_fp8_weight(aggregated, tensor, tensor.dtype))
146
+ else:
147
+ tensor.add_(aggregated)
packages/ltx-core/src/ltx_core/block_streaming/source.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weight sources for block streaming: protocol and implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from typing import Protocol
7
+
8
+ import torch
9
+
10
+ from ltx_core.block_streaming.disk import DiskBlockReader
11
+ from ltx_core.block_streaming.pool import WeightPool
12
+ from ltx_core.loader.primitives import TensorLayout
13
+
14
+
15
+ class WeightSource(Protocol):
16
+ """Provides pinned CPU weights for a given block index.
17
+ Assumes all buffers share an identical layout across all block indices.
18
+ """
19
+
20
+ @property
21
+ def block_layout(self) -> TensorLayout:
22
+ """Shared per-block buffer layout (shape + dtype for each param)."""
23
+ ...
24
+
25
+ def get(self, idx: int) -> dict[str, torch.Tensor]:
26
+ """Return CPU weights for block *idx*."""
27
+ ...
28
+
29
+ def release(self, idx: int, event: torch.cuda.Event) -> None:
30
+ """Signal that an async operation using these weights is guarded by *event*."""
31
+ ...
32
+
33
+ def cleanup(self) -> None:
34
+ """Release all resources (buffers, readers, events)."""
35
+ ...
36
+
37
+
38
+ class DiskWeightSource(WeightSource):
39
+ """Reads block weights from disk into pinned CPU buffers on demand."""
40
+
41
+ def __init__(self, pool: WeightPool, reader: DiskBlockReader) -> None:
42
+ self._pool = pool
43
+ self._cache: OrderedDict[int, dict[str, torch.Tensor]] = OrderedDict()
44
+ self._events: dict[int, torch.cuda.Event] = {}
45
+ self._reader = reader
46
+
47
+ @property
48
+ def block_layout(self) -> TensorLayout:
49
+ return self._pool.buffer_layout
50
+
51
+ def get(self, idx: int) -> dict[str, torch.Tensor]:
52
+ """Return CPU weights for block *idx*. Reads from disk on miss."""
53
+ if idx in self._cache:
54
+ return self._cache[idx]
55
+
56
+ if len(self._cache) >= self._pool.capacity:
57
+ evicted_idx, evicted_weights = self._cache.popitem(last=False)
58
+ self._pool.release(evicted_weights, event=self._events.pop(evicted_idx, None))
59
+
60
+ weights = self._pool.acquire()
61
+ self._reader.read_into(weights, idx)
62
+ self._cache[idx] = weights
63
+ return weights
64
+
65
+ def release(self, idx: int, event: torch.cuda.Event) -> None:
66
+ """Attach an H2D event -- waited before this buffer is recycled."""
67
+ self._events[idx] = event
68
+
69
+ def cleanup(self) -> None:
70
+ """Clear cache and close the disk reader."""
71
+ self._cache.clear()
72
+ self._events.clear()
73
+ self._reader.cleanup()
74
+
75
+ def __len__(self) -> int:
76
+ return len(self._cache)
77
+
78
+
79
+ class PinnedWeightSource(WeightSource):
80
+ """Pre-loaded pinned CPU weights."""
81
+
82
+ def __init__(self, weights: dict[int, dict[str, torch.Tensor]]) -> None:
83
+ if not weights:
84
+ raise ValueError("PinnedWeightSource requires at least one block")
85
+ self._weights = weights
86
+
87
+ @property
88
+ def block_layout(self) -> TensorLayout:
89
+ first_block = self._weights[min(self._weights)]
90
+ return {name: (t.shape, t.dtype) for name, t in first_block.items()}
91
+
92
+ def get(self, idx: int) -> dict[str, torch.Tensor]:
93
+ return self._weights[idx]
94
+
95
+ def release(self, idx: int, event: torch.cuda.Event) -> None:
96
+ pass
97
+
98
+ def cleanup(self) -> None:
99
+ self._weights.clear()
100
+
101
+ def __len__(self) -> int:
102
+ return len(self._weights)
packages/ltx-core/src/ltx_core/block_streaming/utils.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared utilities for the block_streaming package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import weakref
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import torch
11
+ from torch import nn
12
+
13
+ from ltx_core.loader.primitives import TensorLayout
14
+
15
+ FP8_DTYPES = frozenset({torch.float8_e4m3fn, torch.float8_e5m2})
16
+
17
+ _BUFFER_ALIGN = 16
18
+
19
+
20
+ def make_block_key(blocks_prefix: str, block_idx: int, param_name: str) -> str:
21
+ """Return the state-dict key for *param_name* under block *block_idx*."""
22
+ return f"{blocks_prefix}.{block_idx}.{param_name}"
23
+
24
+
25
+ def resolve_attr(module: nn.Module, dotted_path: str) -> nn.ModuleList:
26
+ """Resolve a dotted attribute path like ``'model.language_model.layers'``."""
27
+ obj: Any = module
28
+ for part in dotted_path.split("."):
29
+ obj = getattr(obj, part)
30
+ if not isinstance(obj, nn.ModuleList):
31
+ raise TypeError(f"Expected nn.ModuleList at '{dotted_path}', got {type(obj).__name__}")
32
+ return obj
33
+
34
+
35
+ def assign_tensor_to_module(root: nn.Module, dotted_name: str, tensor: torch.Tensor) -> None:
36
+ """Assign *tensor* to the parameter/buffer at *dotted_name* inside *root*.
37
+ Unlike ``param.data = tensor``, this works even when the existing parameter
38
+ lives on the ``meta`` device (which has an incompatible storage type).
39
+ """
40
+ parts = dotted_name.split(".")
41
+ parent = root
42
+ for part in parts[:-1]:
43
+ parent = getattr(parent, part)
44
+ leaf = parts[-1]
45
+ if leaf in parent._parameters:
46
+ parent._parameters[leaf] = nn.Parameter(tensor, requires_grad=False)
47
+ elif leaf in parent._buffers:
48
+ parent._buffers[leaf] = tensor
49
+ else:
50
+ raise AttributeError(f"{leaf} is not a parameter or buffer of {type(parent).__name__}")
51
+
52
+
53
+ def derive_layout(tensors: dict[str, torch.Tensor], dtype: torch.dtype | None = None) -> TensorLayout:
54
+ """Derive a layout from a ``{name: tensor}`` dict.
55
+ If ``dtype`` is given, non-FP8 dtypes are coerced to it (FP8 preserved). If
56
+ ``None``, the source dtype is preserved as-is.
57
+ """
58
+ return {
59
+ name: (t.shape, t.dtype if dtype is None or t.dtype in FP8_DTYPES else dtype) for name, t in tensors.items()
60
+ }
61
+
62
+
63
+ def _align_up(offset: int, alignment: int) -> int:
64
+ return (offset + alignment - 1) & ~(alignment - 1)
65
+
66
+
67
+ def _alloc_pinned_exact(nbytes: int) -> torch.Tensor | None:
68
+ """Allocate exactly ``nbytes`` of pinned host memory via ``cudaHostRegister``.
69
+ Bypasses PyTorch's ``CachingHostAllocator``, which rounds every
70
+ ``pin_memory=True`` request up to ``PowerOf2Ceil(N)`` (see
71
+ ``aten/src/ATen/core/CachingHostAllocator.h``). Returns ``None`` if
72
+ registration fails. The unregister hook is bound to the storage (not the
73
+ tensor) so views of the buffer keep the registration alive until the
74
+ memory is actually freed. Caller is responsible for ensuring CUDA is
75
+ available.
76
+ """
77
+ cudart = torch.cuda.cudart()
78
+ buf = torch.empty(nbytes, dtype=torch.uint8)
79
+ ptr = buf.data_ptr()
80
+ err = int(cudart.cudaHostRegister(ptr, nbytes, 0))
81
+ if err != 0:
82
+ return None
83
+ weakref.finalize(buf.untyped_storage(), lambda p=ptr: cudart.cudaHostUnregister(p))
84
+ return buf
85
+
86
+
87
+ def _alloc_buffer(nbytes: int, device: torch.device | None, pin_memory: bool) -> torch.Tensor:
88
+ """Allocate one ``uint8`` buffer for :func:`allocate_layout_views`.
89
+ For pinned host buffers, prefer ``cudaHostRegister`` to dodge the caching
90
+ allocator's power-of-2 rounding. Falls back to the caching allocator if
91
+ registration fails. Raises if pinning is requested without a CUDA runtime,
92
+ since pinning is fundamentally a CUDA driver operation.
93
+ """
94
+ if pin_memory and (device is None or torch.device(device).type == "cpu"):
95
+ if not torch.cuda.is_available():
96
+ raise RuntimeError("pin_memory=True requires CUDA, which is not available")
97
+ buf = _alloc_pinned_exact(nbytes)
98
+ if buf is not None:
99
+ return buf
100
+ return torch.empty(nbytes, dtype=torch.uint8, device=device, pin_memory=pin_memory)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class _TensorSlice:
105
+ """Location of a single tensor view within the buffer."""
106
+
107
+ offset: int
108
+ shape: torch.Size
109
+ dtype: torch.dtype
110
+
111
+ def size(self) -> int:
112
+ return math.prod(self.shape) * self.dtype.itemsize
113
+
114
+
115
+ def allocate_layout_views(
116
+ layout: TensorLayout,
117
+ device: torch.device | None = None,
118
+ pin_memory: bool = False,
119
+ ) -> dict[str, torch.Tensor]:
120
+ """Allocate a single ``uint8`` buffer and return per-key tensor views into it.
121
+ All keys in *layout* live in one contiguous allocation; each returned
122
+ tensor is a non-overlapping slice of that buffer reinterpreted at the
123
+ requested shape and dtype. The views keep the underlying storage alive
124
+ via PyTorch refcounting — drop them all to release the memory.
125
+ """
126
+ slices: dict[str, _TensorSlice] = {}
127
+ cursor = 0
128
+ for key, (shape, dtype) in layout.items():
129
+ cursor = _align_up(cursor, _BUFFER_ALIGN)
130
+ slices[key] = _TensorSlice(offset=cursor, shape=shape, dtype=dtype)
131
+ cursor += slices[key].size()
132
+ # Allocate at least one byte so empty layouts still produce a valid buffer.
133
+ buffer = _alloc_buffer(max(_align_up(cursor, _BUFFER_ALIGN), 1), device, pin_memory)
134
+ return {key: buffer[s.offset : s.offset + s.size()].view(s.dtype).view(s.shape) for key, s in slices.items()}
packages/ltx-core/src/ltx_core/block_streaming/wrapper.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Block streaming wrapper: streams transformer blocks through a WeightsProvider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from typing import Any
7
+
8
+ import torch
9
+ from torch import nn
10
+
11
+ from ltx_core.block_streaming.provider import WeightsProvider
12
+ from ltx_core.block_streaming.utils import assign_tensor_to_module
13
+
14
+
15
+ class BlockStreamingWrapper(nn.Module):
16
+ """Streams sequential model blocks through GPU buffer caches.
17
+ The wrapper delegates all weight management to a :class:`WeightsProvider`
18
+ which handles CPU-to-GPU copies, caching, LoRA fusion, and stream
19
+ synchronization internally.
20
+ Use :class:`StreamingModelBuilder` to construct this wrapper -- it
21
+ handles checkpoint parsing, source selection, and provider creation.
22
+ Args:
23
+ model: The wrapped model (non-block params already on GPU).
24
+ blocks: Sequential blocks to stream (``nn.ModuleList``).
25
+ provider: Provides GPU-ready weights on demand.
26
+ target_device: GPU device for compute.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ model: nn.Module,
32
+ blocks: nn.ModuleList,
33
+ provider: WeightsProvider,
34
+ target_device: torch.device,
35
+ ) -> None:
36
+ super().__init__()
37
+ self._model = model
38
+ self._blocks = blocks
39
+ self._target_device = target_device
40
+ self._provider = provider
41
+
42
+ self._hooks: list[torch.utils.hooks.RemovableHandle] = []
43
+ self._register_hooks()
44
+
45
+ # ------------------------------------------------------------------
46
+ # Hook registration
47
+ # ------------------------------------------------------------------
48
+
49
+ def _pre_hook(self, block_idx: int) -> None:
50
+ """Load GPU weights for a block and inject them into its parameters."""
51
+ gpu_weights = self._provider.get(block_idx)
52
+
53
+ block = self._blocks[block_idx]
54
+ for name, _param in itertools.chain(block.named_parameters(), block.named_buffers()):
55
+ assign_tensor_to_module(block, name, gpu_weights[name])
56
+
57
+ def _post_hook(self, block_idx: int) -> None:
58
+ """Record a compute-done event and release the block weights."""
59
+ compute_done = torch.cuda.Event()
60
+ compute_done.record(torch.cuda.current_stream(self._target_device))
61
+ self._provider.release(block_idx, event=compute_done)
62
+
63
+ def _register_hooks(self) -> None:
64
+ for idx, block in enumerate(self._blocks):
65
+ pre = block.register_forward_pre_hook(
66
+ lambda _mod, _args, *, idx=idx: self._pre_hook(idx),
67
+ )
68
+ post = block.register_forward_hook(
69
+ lambda _mod, _args, _out, *, idx=idx: self._post_hook(idx),
70
+ )
71
+ self._hooks.extend([pre, post])
72
+
73
+ # ------------------------------------------------------------------
74
+ # Teardown
75
+ # ------------------------------------------------------------------
76
+
77
+ def teardown(self) -> None:
78
+ """Remove hooks and release all resources."""
79
+ for h in self._hooks:
80
+ h.remove()
81
+ self._hooks.clear()
82
+ self._provider.cleanup()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Forward and attribute delegation
86
+ # ------------------------------------------------------------------
87
+
88
+ def forward(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
89
+ return self._model(*args, **kwargs)
90
+
91
+ def __getattr__(self, name: str) -> Any: # noqa: ANN401
92
+ """Proxy attribute access to the wrapped model."""
93
+ try:
94
+ return super().__getattr__(name)
95
+ except AttributeError:
96
+ return getattr(self._model, name)
packages/ltx-core/src/ltx_core/components/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Diffusion pipeline components.
3
+ Submodules:
4
+ diffusion_steps - Diffusion stepping algorithms (EulerDiffusionStep)
5
+ guiders - Guidance strategies (CFGGuider, STGGuider, APG variants)
6
+ noisers - Noise samplers (GaussianNoiser)
7
+ patchifiers - Latent patchification (VideoLatentPatchifier, AudioPatchifier)
8
+ protocols - Protocol definitions (Patchifier, etc.)
9
+ schedulers - Sigma schedulers (LTX2Scheduler, LinearQuadraticScheduler)
10
+ """
packages/ltx-core/src/ltx_core/components/diffusion_steps.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from ltx_core.components.protocols import DiffusionStepProtocol
4
+ from ltx_core.utils import to_velocity
5
+
6
+
7
+ def _get_ancestral_step(
8
+ sigma_from: torch.Tensor,
9
+ sigma_to: torch.Tensor,
10
+ eta: float = 1.0,
11
+ ) -> tuple[torch.Tensor, torch.Tensor]:
12
+ """Compute ``(sigma_down, sigma_up)`` for one DDIM ancestral sampling step.
13
+ Both inputs are in the rescaled parameterization ``sigma / alpha``.
14
+ Returns ``sigma_down`` (deterministic component) and ``sigma_up``
15
+ (stochastic component) in the same rescaled space.
16
+ """
17
+ if not eta:
18
+ return sigma_to, torch.zeros_like(sigma_to)
19
+ variance = sigma_to**2 * (sigma_from**2 - sigma_to**2).clamp(min=0) / sigma_from**2
20
+ sigma_up = (eta * variance**0.5).clamp(max=sigma_to)
21
+ sigma_down = (sigma_to**2 - sigma_up**2).clamp(min=0) ** 0.5
22
+ return sigma_down, sigma_up
23
+
24
+
25
+ class EulerDiffusionStep(DiffusionStepProtocol):
26
+ """
27
+ First-order Euler method for diffusion sampling.
28
+ Takes a single step from the current noise level (sigma) to the next by
29
+ computing velocity from the denoised prediction and applying: sample + velocity * dt.
30
+ """
31
+
32
+ def step(
33
+ self, sample: torch.Tensor, denoised_sample: torch.Tensor, sigmas: torch.Tensor, step_index: int, **_kwargs
34
+ ) -> torch.Tensor:
35
+ sigma = sigmas[step_index]
36
+ sigma_next = sigmas[step_index + 1]
37
+ dt = sigma_next - sigma
38
+ velocity = to_velocity(sample, sigma, denoised_sample)
39
+
40
+ return (sample.to(torch.float32) + velocity.to(torch.float32) * dt).to(sample.dtype)
41
+
42
+
43
+ class Res2sDiffusionStep(DiffusionStepProtocol):
44
+ """
45
+ Second-order diffusion step for res_2s sampling with SDE noise injection.
46
+ Used by the res_2s denoising loop. Advances the sample from the current
47
+ sigma to the next by mixing a deterministic update (from the denoised
48
+ prediction) with injected noise via ``get_sde_coeff``, producing
49
+ variance-preserving transitions.
50
+ """
51
+
52
+ @staticmethod
53
+ def get_sde_coeff(
54
+ sigma_next: torch.Tensor,
55
+ sigma_up: torch.Tensor | None = None,
56
+ sigma_down: torch.Tensor | None = None,
57
+ sigma_max: torch.Tensor | None = None,
58
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
59
+ """
60
+ Compute SDE coefficients (alpha_ratio, sigma_down, sigma_up) for the step.
61
+ Given either ``sigma_down`` or ``sigma_up``, returns the mixing
62
+ coefficients used for variance-preserving noise injection. If
63
+ ``sigma_up`` is provided, ``sigma_down`` and ``alpha_ratio`` are
64
+ derived; if ``sigma_down`` is provided, ``sigma_up`` and
65
+ ``alpha_ratio`` are derived.
66
+ """
67
+ if sigma_down is not None:
68
+ alpha_ratio = (1 - sigma_next) / (1 - sigma_down)
69
+ sigma_up = (sigma_next**2 - sigma_down**2 * alpha_ratio**2).clamp(min=0) ** 0.5
70
+ elif sigma_up is not None:
71
+ # Fallback to avoid sqrt(neg_num)
72
+ sigma_up.clamp_(max=sigma_next * 0.9999)
73
+ sigmax = sigma_max if sigma_max is not None else torch.ones_like(sigma_next)
74
+ sigma_signal = sigmax - sigma_next
75
+ sigma_residual = (sigma_next**2 - sigma_up**2).clamp(min=0) ** 0.5
76
+ alpha_ratio = sigma_signal + sigma_residual
77
+ sigma_down = sigma_residual / alpha_ratio
78
+ else:
79
+ alpha_ratio = torch.ones_like(sigma_next)
80
+ sigma_down = sigma_next
81
+ sigma_up = torch.zeros_like(sigma_next)
82
+
83
+ sigma_up = torch.nan_to_num(sigma_up if sigma_up is not None else torch.zeros_like(sigma_next), 0.0)
84
+ # Replace NaNs in sigma_down with corresponding sigma_next elements (float32)
85
+ nan_mask = torch.isnan(sigma_down)
86
+ sigma_down[nan_mask] = sigma_next[nan_mask].to(sigma_down.dtype)
87
+ alpha_ratio = torch.nan_to_num(alpha_ratio, 1.0)
88
+
89
+ return alpha_ratio, sigma_down, sigma_up
90
+
91
+ def step(
92
+ self,
93
+ sample: torch.Tensor,
94
+ denoised_sample: torch.Tensor,
95
+ sigmas: torch.Tensor,
96
+ step_index: int,
97
+ noise: torch.Tensor,
98
+ eta: float = 0.5,
99
+ ) -> torch.Tensor:
100
+ """Advance one step with SDE noise injection via get_sde_coeff.
101
+ Args:
102
+ sample: Current noisy sample.
103
+ denoised_sample: Denoised prediction from the model.
104
+ sigmas: Noise schedule tensor.
105
+ step_index: Current step index in the schedule.
106
+ noise: Random noise tensor for stochastic injection.
107
+ eta: Controls stochastic noise injection strength (0=deterministic, 1=maximum). Default 0.5.
108
+ Returns:
109
+ Next sample with SDE noise injection applied.
110
+ """
111
+ sigma = sigmas[step_index]
112
+ sigma_next = sigmas[step_index + 1]
113
+ alpha_ratio, sigma_down, sigma_up = self.get_sde_coeff(sigma_next, sigma_up=sigma_next * eta)
114
+ output_dtype = denoised_sample.dtype
115
+ if torch.any(sigma_up == 0) or torch.any(sigma_next == 0):
116
+ return denoised_sample
117
+
118
+ # Extract epsilon prediction
119
+ eps_next = (sample - denoised_sample) / (sigma - sigma_next)
120
+ denoised_next = sample - sigma * eps_next
121
+
122
+ # Mix deterministic and stochastic components
123
+ x_noised = alpha_ratio * (denoised_next + sigma_down * eps_next) + sigma_up * noise
124
+ return x_noised.to(output_dtype)
125
+
126
+
127
+ class EulerCfgPpDiffusionStep(DiffusionStepProtocol):
128
+ """Euler step using the CFG++ correction for the ODE derivative.
129
+ Instead of the standard velocity formula, the ODE derivative is computed
130
+ from the unconditioned prediction, keeping the conditioned prediction as
131
+ the target denoised state. Ancestral (DDIM) noise injection is applied
132
+ in the rescaled sigma parameterization (sigma / alpha).
133
+ All diffusion quantities (alpha, ODE derivative, ancestral coefficients)
134
+ are computed internally from ``sigmas`` and ``uncond_denoised``.
135
+ Reference: CFG++ (https://arxiv.org/abs/2406.08070).
136
+ """
137
+
138
+ def __init__(self, eta: float = 1.0, s_noise: float = 1.0) -> None:
139
+ self.eta = eta
140
+ self.s_noise = s_noise
141
+
142
+ def step(
143
+ self,
144
+ sample: torch.Tensor,
145
+ denoised_sample: torch.Tensor,
146
+ sigmas: torch.Tensor,
147
+ step_index: int,
148
+ uncond_denoised: torch.Tensor,
149
+ noise: torch.Tensor | None = None,
150
+ **_kwargs,
151
+ ) -> torch.Tensor:
152
+ """Advance one CFG++ Euler step.
153
+ Args:
154
+ sample: Current noisy latent x_t.
155
+ denoised_sample: Conditioned denoised prediction x_0^cond.
156
+ sigmas: Full sigma schedule tensor.
157
+ step_index: Current step index.
158
+ uncond_denoised: Unconditioned denoised prediction x_0^uncond,
159
+ used to compute the ODE derivative direction.
160
+ noise: Noise tensor for stochastic injection; ignored when
161
+ ``eta=0`` or ``s_noise=0``.
162
+ Returns:
163
+ Updated latent x_{t-1}.
164
+ """
165
+ sigma_s = sigmas[step_index].to(torch.float32)
166
+ sigma_t = sigmas[step_index + 1].to(torch.float32)
167
+ _eps = torch.finfo(torch.float32).eps
168
+ # Clamp to avoid division by zero when sigma == 1.0 exactly.
169
+ alpha_s = (1.0 - sigma_s).clamp(min=_eps)
170
+ alpha_t = (1.0 - sigma_t).clamp(min=_eps)
171
+
172
+ x = sample.to(torch.float32)
173
+ denoised = denoised_sample.to(torch.float32)
174
+ uncond = uncond_denoised.to(torch.float32)
175
+
176
+ # ODE derivative: direction toward noise using uncond prediction (CFG++ correction)
177
+ d = (x - alpha_s * uncond) / sigma_s
178
+
179
+ # Ancestral step in rescaled sigma space (sigma / alpha)
180
+ sigma_down, sigma_up = _get_ancestral_step(sigma_s / alpha_s, sigma_t / alpha_t, eta=self.eta)
181
+ sigma_down = alpha_t * sigma_down
182
+
183
+ x_next = alpha_t * denoised + sigma_down * d
184
+ if noise is not None and self.eta > 0 and self.s_noise > 0:
185
+ x_next = x_next + alpha_t * noise.to(torch.float32) * self.s_noise * sigma_up
186
+ return x_next.to(sample.dtype)
packages/ltx-core/src/ltx_core/components/guiders.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from collections.abc import Mapping, Sequence
3
+ from dataclasses import dataclass, field
4
+
5
+ import torch
6
+
7
+ from ltx_core.components.protocols import GuiderProtocol
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CFGGuider(GuiderProtocol):
12
+ """
13
+ Classifier-free guidance (CFG) guider.
14
+ Computes the guidance delta as (scale - 1) * (cond - uncond), steering the
15
+ denoising process toward the conditioned prediction.
16
+ Attributes:
17
+ scale: Guidance strength. 1.0 means no guidance, higher values increase
18
+ adherence to the conditioning.
19
+ """
20
+
21
+ scale: float
22
+
23
+ def delta(self, cond: torch.Tensor, uncond: torch.Tensor) -> torch.Tensor:
24
+ return (self.scale - 1) * (cond - uncond)
25
+
26
+ def enabled(self) -> bool:
27
+ return self.scale != 1.0
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CFGStarRescalingGuider(GuiderProtocol):
32
+ """
33
+ Calculates the CFG delta between conditioned and unconditioned samples.
34
+ To minimize offset in the denoising direction and move mostly along the
35
+ conditioning axis within the distribution, the unconditioned sample is
36
+ rescaled in accordance with the norm of the conditioned sample.
37
+ Attributes:
38
+ scale (float):
39
+ Global guidance strength. A value of 1.0 corresponds to no extra
40
+ guidance beyond the base model prediction. Values > 1.0 increase
41
+ the influence of the conditioned sample relative to the
42
+ unconditioned one.
43
+ """
44
+
45
+ scale: float
46
+
47
+ def delta(self, cond: torch.Tensor, uncond: torch.Tensor) -> torch.Tensor:
48
+ rescaled_neg = projection_coef(cond, uncond) * uncond
49
+ return (self.scale - 1) * (cond - rescaled_neg)
50
+
51
+ def enabled(self) -> bool:
52
+ return self.scale != 1.0
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class STGGuider(GuiderProtocol):
57
+ """
58
+ Calculates the STG delta between conditioned and perturbed denoised samples.
59
+ Perturbed samples are the result of the denoising process with perturbations,
60
+ e.g. attentions acting as passthrough for certain layers and modalities.
61
+ Attributes:
62
+ scale (float):
63
+ Global strength of the STG guidance. A value of 0.0 disables the
64
+ guidance. Larger values increase the correction applied in the
65
+ direction of (pos_denoised - perturbed_denoised).
66
+ """
67
+
68
+ scale: float
69
+
70
+ def delta(self, pos_denoised: torch.Tensor, perturbed_denoised: torch.Tensor) -> torch.Tensor:
71
+ return self.scale * (pos_denoised - perturbed_denoised)
72
+
73
+ def enabled(self) -> bool:
74
+ return self.scale != 0.0
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class LtxAPGGuider(GuiderProtocol):
79
+ """
80
+ Calculates the APG (adaptive projected guidance) delta between conditioned
81
+ and unconditioned samples.
82
+ To minimize offset in the denoising direction and move mostly along the
83
+ conditioning axis within the distribution, the (cond - uncond) delta is
84
+ decomposed into components parallel and orthogonal to the conditioned
85
+ sample. The `eta` parameter weights the parallel component, while `scale`
86
+ is applied to the orthogonal component. Optionally, a norm threshold can
87
+ be used to suppress guidance when the magnitude of the correction is small.
88
+ Attributes:
89
+ scale (float):
90
+ Strength applied to the component of the guidance that is orthogonal
91
+ to the conditioned sample. Controls how aggressively we move in
92
+ directions that change semantics but stay consistent with the
93
+ conditioning manifold.
94
+ eta (float):
95
+ Weight of the component of the guidance that is parallel to the
96
+ conditioned sample. A value of 1.0 keeps the full parallel
97
+ component; values in [0, 1] attenuate it, and values > 1.0 amplify
98
+ motion along the conditioning direction.
99
+ norm_threshold (float):
100
+ Minimum L2 norm of the guidance delta below which the guidance
101
+ can be reduced or ignored (depending on implementation).
102
+ This is useful for avoiding noisy or unstable updates when the
103
+ guidance signal is very small.
104
+ """
105
+
106
+ scale: float
107
+ eta: float = 1.0
108
+ norm_threshold: float = 0.0
109
+
110
+ def delta(self, cond: torch.Tensor, uncond: torch.Tensor) -> torch.Tensor:
111
+ guidance = cond - uncond
112
+ if self.norm_threshold > 0:
113
+ ones = torch.ones_like(guidance)
114
+ guidance_norm = guidance.norm(p=2, dim=[-1, -2, -3], keepdim=True)
115
+ scale_factor = torch.minimum(ones, self.norm_threshold / guidance_norm)
116
+ guidance = guidance * scale_factor
117
+ proj_coeff = projection_coef(guidance, cond)
118
+ g_parallel = proj_coeff * cond
119
+ g_orth = guidance - g_parallel
120
+ g_apg = g_parallel * self.eta + g_orth
121
+
122
+ return g_apg * (self.scale - 1)
123
+
124
+ def enabled(self) -> bool:
125
+ return self.scale != 1.0
126
+
127
+
128
+ @dataclass(frozen=False)
129
+ class LegacyStatefulAPGGuider(GuiderProtocol):
130
+ """
131
+ Calculates the APG (adaptive projected guidance) delta between conditioned
132
+ and unconditioned samples.
133
+ To minimize offset in the denoising direction and move mostly along the
134
+ conditioning axis within the distribution, the (cond - uncond) delta is
135
+ decomposed into components parallel and orthogonal to the conditioned
136
+ sample. The `eta` parameter weights the parallel component, while `scale`
137
+ is applied to the orthogonal component. Optionally, a norm threshold can
138
+ be used to suppress guidance when the magnitude of the correction is small.
139
+ Attributes:
140
+ scale (float):
141
+ Strength applied to the component of the guidance that is orthogonal
142
+ to the conditioned sample. Controls how aggressively we move in
143
+ directions that change semantics but stay consistent with the
144
+ conditioning manifold.
145
+ eta (float):
146
+ Weight of the component of the guidance that is parallel to the
147
+ conditioned sample. A value of 1.0 keeps the full parallel
148
+ component; values in [0, 1] attenuate it, and values > 1.0 amplify
149
+ motion along the conditioning direction.
150
+ norm_threshold (float):
151
+ Minimum L2 norm of the guidance delta below which the guidance
152
+ can be reduced or ignored (depending on implementation).
153
+ This is useful for avoiding noisy or unstable updates when the
154
+ guidance signal is very small.
155
+ momentum (float):
156
+ Exponential moving-average coefficient for accumulating guidance
157
+ over time. running_avg = momentum * running_avg + guidance
158
+ """
159
+
160
+ scale: float
161
+ eta: float
162
+ norm_threshold: float = 5.0
163
+ momentum: float = 0.0
164
+ # it is user's responsibility not to use same APGGuider for several denoisings or different modalities
165
+ # in order not to share accumulated average across different denoisings or modalities
166
+ running_avg: torch.Tensor | None = None
167
+
168
+ def delta(self, cond: torch.Tensor, uncond: torch.Tensor) -> torch.Tensor:
169
+ guidance = cond - uncond
170
+ if self.momentum != 0:
171
+ if self.running_avg is None:
172
+ self.running_avg = guidance.clone()
173
+ else:
174
+ self.running_avg = self.momentum * self.running_avg + guidance
175
+ guidance = self.running_avg
176
+
177
+ if self.norm_threshold > 0:
178
+ ones = torch.ones_like(guidance)
179
+ guidance_norm = guidance.norm(p=2, dim=[-1, -2, -3], keepdim=True)
180
+ scale_factor = torch.minimum(ones, self.norm_threshold / guidance_norm)
181
+ guidance = guidance * scale_factor
182
+
183
+ proj_coeff = projection_coef(guidance, cond)
184
+ g_parallel = proj_coeff * cond
185
+ g_orth = guidance - g_parallel
186
+ g_apg = g_parallel * self.eta + g_orth
187
+
188
+ return g_apg * self.scale
189
+
190
+ def enabled(self) -> bool:
191
+ return self.scale != 0.0
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class MultiModalGuiderParams:
196
+ """
197
+ Parameters for the multi-modal guider.
198
+ """
199
+
200
+ cfg_scale: float = 1.0
201
+ "CFG (Classifier-free guidance) scale controlling how strongly the model adheres to the prompt."
202
+ stg_scale: float = 0.0
203
+ "STG (Spatio-Temporal Guidance) scale controls how strongly the model reacts to the perturbation of the modality."
204
+ stg_blocks: list[int] | None = field(default_factory=list)
205
+ "Which transformer blocks to perturb for STG."
206
+ rescale_scale: float = 0.0
207
+ "Rescale scale controlling how strongly the model rescales the modality after applying other guidance."
208
+ modality_scale: float = 1.0
209
+ "Modality scale controlling how strongly the model reacts to the perturbation of the modality."
210
+ skip_step: int = 0
211
+ "Skip step controlling how often the model skips the step."
212
+
213
+
214
+ def _params_for_sigma_from_sorted_dict(
215
+ sigma: float, params_by_sigma: Sequence[tuple[float, MultiModalGuiderParams]]
216
+ ) -> MultiModalGuiderParams:
217
+ """
218
+ Return params for the given sigma from a sorted (sigma_upper_bound -> params) structure.
219
+ Keys are sorted descending (bin upper bounds). Bin i is (key_{i+1}, key_i].
220
+ Get all keys >= sigma; use last in list (smallest such key = upper bound of bin containing sigma),
221
+ or last entry in the sequence if list is empty (sigma above max key).
222
+ """
223
+ if not params_by_sigma:
224
+ raise ValueError("params_by_sigma must be non-empty")
225
+ sigma = float(sigma)
226
+ keys_desc = [k for k, _ in params_by_sigma]
227
+ keys_ge_sigma = [k for k in keys_desc if k >= sigma]
228
+ # sigma above all keys: use first bin (max key)
229
+ key = keys_ge_sigma[-1] if keys_ge_sigma else keys_desc[0]
230
+ return next(p for k, p in params_by_sigma if k == key)
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class MultiModalGuider:
235
+ """
236
+ Multi-modal guider with constant params per instance.
237
+ For sigma-dependent params, use MultiModalGuiderFactory.build_from_sigma(sigma) to
238
+ obtain a guider for each step.
239
+ """
240
+
241
+ params: MultiModalGuiderParams
242
+ negative_context: torch.Tensor | None = None
243
+
244
+ def calculate(
245
+ self,
246
+ cond: torch.Tensor,
247
+ uncond_text: torch.Tensor | float,
248
+ uncond_perturbed: torch.Tensor | float,
249
+ uncond_modality: torch.Tensor | float,
250
+ ) -> torch.Tensor:
251
+ """
252
+ The guider calculates the guidance delta as (scale - 1) * (cond - uncond) for cfg and modality cfg,
253
+ and as scale * (cond - uncond) for stg, steering the denoising process away from the unconditioned
254
+ prediction.
255
+ """
256
+ pred = (
257
+ cond
258
+ + (self.params.cfg_scale - 1) * (cond - uncond_text)
259
+ + self.params.stg_scale * (cond - uncond_perturbed)
260
+ + (self.params.modality_scale - 1) * (cond - uncond_modality)
261
+ )
262
+
263
+ if self.params.rescale_scale != 0:
264
+ factor = cond.std() / pred.std()
265
+ factor = self.params.rescale_scale * factor + (1 - self.params.rescale_scale)
266
+ pred = pred * factor
267
+
268
+ return pred
269
+
270
+ def do_unconditional_generation(self) -> bool:
271
+ """Returns True if the guider is doing unconditional generation."""
272
+ return not math.isclose(self.params.cfg_scale, 1.0)
273
+
274
+ def do_perturbed_generation(self) -> bool:
275
+ """Returns True if the guider is doing perturbed generation."""
276
+ return not math.isclose(self.params.stg_scale, 0.0)
277
+
278
+ def do_isolated_modality_generation(self) -> bool:
279
+ """Returns True if the guider is doing isolated modality generation."""
280
+ return not math.isclose(self.params.modality_scale, 1.0)
281
+
282
+ def should_skip_step(self, step: int) -> bool:
283
+ """Returns True if the guider should skip the step."""
284
+ if self.params.skip_step == 0:
285
+ return False
286
+ return step % (self.params.skip_step + 1) != 0
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class MultiModalGuiderFactory:
291
+ """
292
+ Factory that creates a MultiModalGuider for a given sigma.
293
+ Single source of truth: _params_by_sigma (schedule). Use constant() for
294
+ one params for all sigma, from_dict() for sigma-binned params.
295
+ """
296
+
297
+ negative_context: torch.Tensor | None = None
298
+ _params_by_sigma: tuple[tuple[float, MultiModalGuiderParams], ...] = ()
299
+
300
+ @classmethod
301
+ def constant(
302
+ cls,
303
+ params: MultiModalGuiderParams,
304
+ negative_context: torch.Tensor | None = None,
305
+ ) -> "MultiModalGuiderFactory":
306
+ """Build a factory with constant params (same guider for all sigma)."""
307
+ return cls(
308
+ negative_context=negative_context,
309
+ _params_by_sigma=((float("inf"), params),),
310
+ )
311
+
312
+ @classmethod
313
+ def from_dict(
314
+ cls,
315
+ sigma_to_params: Mapping[float, MultiModalGuiderParams],
316
+ negative_context: torch.Tensor | None = None,
317
+ ) -> "MultiModalGuiderFactory":
318
+ """
319
+ Build a factory from a dict of sigma_value -> MultiModalGuiderParams.
320
+ Keys are sorted descending and used for bin lookup in params(sigma).
321
+ """
322
+ if not sigma_to_params:
323
+ raise ValueError("sigma_to_params must be non-empty")
324
+ sorted_items = tuple(sorted(sigma_to_params.items(), key=lambda x: x[0], reverse=True))
325
+ return cls(negative_context=negative_context, _params_by_sigma=sorted_items)
326
+
327
+ def params(self, sigma: float | torch.Tensor) -> MultiModalGuiderParams:
328
+ """Return params effective for the given sigma (getter; single source of truth)."""
329
+ sigma_val = float(sigma.item() if isinstance(sigma, torch.Tensor) else sigma)
330
+ return _params_for_sigma_from_sorted_dict(sigma_val, self._params_by_sigma)
331
+
332
+ def build_from_sigma(self, sigma: float | torch.Tensor) -> MultiModalGuider:
333
+ """Return a MultiModalGuider with params effective for the given sigma."""
334
+ return MultiModalGuider(
335
+ params=self.params(sigma),
336
+ negative_context=self.negative_context,
337
+ )
338
+
339
+
340
+ def create_multimodal_guider_factory(
341
+ params: MultiModalGuiderParams | MultiModalGuiderFactory,
342
+ negative_context: torch.Tensor | None = None,
343
+ ) -> MultiModalGuiderFactory:
344
+ """
345
+ Create or return a MultiModalGuiderFactory. Pass constant params for a
346
+ single-params factory (uses MultiModalGuiderFactory.constant), or an existing
347
+ MultiModalGuiderFactory. When given a factory, returns it as-is unless
348
+ negative_context is provided. For sigma-dependent params use
349
+ MultiModalGuiderFactory.from_dict(...) and pass that as params.
350
+ """
351
+ if isinstance(params, MultiModalGuiderFactory):
352
+ if negative_context is not None and params.negative_context is not negative_context:
353
+ return MultiModalGuiderFactory.from_dict(dict(params._params_by_sigma), negative_context=negative_context)
354
+ return params
355
+ return MultiModalGuiderFactory.constant(params, negative_context=negative_context)
356
+
357
+
358
+ def projection_coef(to_project: torch.Tensor, project_onto: torch.Tensor) -> torch.Tensor:
359
+ batch_size = to_project.shape[0]
360
+ positive_flat = to_project.reshape(batch_size, -1)
361
+ negative_flat = project_onto.reshape(batch_size, -1)
362
+ dot_product = torch.sum(positive_flat * negative_flat, dim=1, keepdim=True)
363
+ squared_norm = torch.sum(negative_flat**2, dim=1, keepdim=True) + 1e-8
364
+ return dot_product / squared_norm
packages/ltx-core/src/ltx_core/components/noisers.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import replace
2
+ from typing import Protocol
3
+
4
+ import torch
5
+
6
+ from ltx_core.types import LatentState
7
+
8
+
9
+ class Noiser(Protocol):
10
+ """Protocol for adding noise to a latent state during diffusion."""
11
+
12
+ def __call__(self, latent_state: LatentState, noise_scale: float) -> LatentState: ...
13
+
14
+
15
+ class GaussianNoiser(Noiser):
16
+ """Adds Gaussian noise to a latent state, scaled by the denoise mask."""
17
+
18
+ def __init__(self, generator: torch.Generator):
19
+ super().__init__()
20
+
21
+ self.generator = generator
22
+
23
+ def __call__(self, latent_state: LatentState, noise_scale: float = 1.0) -> LatentState:
24
+ noise = torch.randn(
25
+ *latent_state.latent.shape,
26
+ device=latent_state.latent.device,
27
+ dtype=latent_state.latent.dtype,
28
+ generator=self.generator,
29
+ )
30
+ scaled_mask = latent_state.denoise_mask * noise_scale
31
+ latent = noise * scaled_mask + latent_state.latent * (1 - scaled_mask)
32
+ return replace(
33
+ latent_state,
34
+ latent=latent.to(latent_state.latent.dtype),
35
+ )
packages/ltx-core/src/ltx_core/components/patchifiers.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import Optional, Tuple
3
+
4
+ import einops
5
+ import torch
6
+
7
+ from ltx_core.components.protocols import Patchifier
8
+ from ltx_core.types import AudioLatentShape, SpatioTemporalScaleFactors, VideoLatentShape
9
+
10
+
11
+ class VideoLatentPatchifier(Patchifier):
12
+ def __init__(self, patch_size: int):
13
+ # Patch sizes for video latents.
14
+ self._patch_size = (
15
+ 1, # temporal dimension
16
+ patch_size, # height dimension
17
+ patch_size, # width dimension
18
+ )
19
+
20
+ @property
21
+ def patch_size(self) -> Tuple[int, int, int]:
22
+ return self._patch_size
23
+
24
+ def get_token_count(self, tgt_shape: VideoLatentShape) -> int:
25
+ return math.prod(tgt_shape.to_torch_shape()[2:]) // math.prod(self._patch_size)
26
+
27
+ def patchify(
28
+ self,
29
+ latents: torch.Tensor,
30
+ ) -> torch.Tensor:
31
+ latents = einops.rearrange(
32
+ latents,
33
+ "b c (f p1) (h p2) (w p3) -> b (f h w) (c p1 p2 p3)",
34
+ p1=self._patch_size[0],
35
+ p2=self._patch_size[1],
36
+ p3=self._patch_size[2],
37
+ )
38
+
39
+ return latents
40
+
41
+ def unpatchify(
42
+ self,
43
+ latents: torch.Tensor,
44
+ output_shape: VideoLatentShape,
45
+ ) -> torch.Tensor:
46
+ assert self._patch_size[0] == 1, "Temporal patch size must be 1 for symmetric patchifier"
47
+
48
+ patch_grid_frames = output_shape.frames // self._patch_size[0]
49
+ patch_grid_height = output_shape.height // self._patch_size[1]
50
+ patch_grid_width = output_shape.width // self._patch_size[2]
51
+
52
+ latents = einops.rearrange(
53
+ latents,
54
+ "b (f h w) (c p q) -> b c f (h p) (w q)",
55
+ f=patch_grid_frames,
56
+ h=patch_grid_height,
57
+ w=patch_grid_width,
58
+ p=self._patch_size[1],
59
+ q=self._patch_size[2],
60
+ )
61
+
62
+ return latents
63
+
64
+ def get_patch_grid_bounds(
65
+ self,
66
+ output_shape: AudioLatentShape | VideoLatentShape,
67
+ device: Optional[torch.device] = None,
68
+ ) -> torch.Tensor:
69
+ """
70
+ Return the per-dimension bounds [inclusive start, exclusive end) for every
71
+ patch produced by `patchify`. The bounds are expressed in the original
72
+ video grid coordinates: frame/time, height, and width.
73
+ The resulting tensor is shaped `[batch_size, 3, num_patches, 2]`, where:
74
+ - axis 1 (size 3) enumerates (frame/time, height, width) dimensions
75
+ - axis 3 (size 2) stores `[start, end)` indices within each dimension
76
+ Args:
77
+ output_shape: Video grid description containing frames, height, and width.
78
+ device: Device of the latent tensor.
79
+ """
80
+ if not isinstance(output_shape, VideoLatentShape):
81
+ raise ValueError("VideoLatentPatchifier expects VideoLatentShape when computing coordinates")
82
+
83
+ frames = output_shape.frames
84
+ height = output_shape.height
85
+ width = output_shape.width
86
+ batch_size = output_shape.batch
87
+
88
+ # Validate inputs to ensure positive dimensions
89
+ assert frames > 0, f"frames must be positive, got {frames}"
90
+ assert height > 0, f"height must be positive, got {height}"
91
+ assert width > 0, f"width must be positive, got {width}"
92
+ assert batch_size > 0, f"batch_size must be positive, got {batch_size}"
93
+
94
+ # Generate grid coordinates for each dimension (frame, height, width)
95
+ # We use torch.arange to create the starting coordinates for each patch.
96
+ # indexing='ij' ensures the dimensions are in the order (frame, height, width).
97
+ grid_coords = torch.meshgrid(
98
+ torch.arange(start=0, end=frames, step=self._patch_size[0], device=device),
99
+ torch.arange(start=0, end=height, step=self._patch_size[1], device=device),
100
+ torch.arange(start=0, end=width, step=self._patch_size[2], device=device),
101
+ indexing="ij",
102
+ )
103
+
104
+ # Stack the grid coordinates to create the start coordinates tensor.
105
+ # Shape becomes (3, grid_f, grid_h, grid_w)
106
+ patch_starts = torch.stack(grid_coords, dim=0)
107
+
108
+ # Create a tensor containing the size of a single patch:
109
+ # (frame_patch_size, height_patch_size, width_patch_size).
110
+ # Reshape to (3, 1, 1, 1) to enable broadcasting when adding to the start coordinates.
111
+ patch_size_delta = torch.tensor(
112
+ self._patch_size,
113
+ device=patch_starts.device,
114
+ dtype=patch_starts.dtype,
115
+ ).view(3, 1, 1, 1)
116
+
117
+ # Calculate end coordinates: start + patch_size
118
+ # Shape becomes (3, grid_f, grid_h, grid_w)
119
+ patch_ends = patch_starts + patch_size_delta
120
+
121
+ # Stack start and end coordinates together along the last dimension
122
+ # Shape becomes (3, grid_f, grid_h, grid_w, 2), where the last dimension is [start, end]
123
+ latent_coords = torch.stack((patch_starts, patch_ends), dim=-1)
124
+
125
+ # Broadcast to batch size and flatten all spatial/temporal dimensions into one sequence.
126
+ # Final Shape: (batch_size, 3, num_patches, 2)
127
+ latent_coords = einops.repeat(
128
+ latent_coords,
129
+ "c f h w bounds -> b c (f h w) bounds",
130
+ b=batch_size,
131
+ bounds=2,
132
+ )
133
+
134
+ return latent_coords
135
+
136
+
137
+ def get_pixel_coords(
138
+ latent_coords: torch.Tensor,
139
+ scale_factors: SpatioTemporalScaleFactors,
140
+ causal_fix: bool = False,
141
+ ) -> torch.Tensor:
142
+ """
143
+ Map latent-space `[start, end)` coordinates to their pixel-space equivalents by scaling
144
+ each axis (frame/time, height, width) with the corresponding VAE downsampling factors.
145
+ Optionally compensate for causal encoding that keeps the first frame at unit temporal scale.
146
+ Args:
147
+ latent_coords: Tensor of latent bounds shaped `(batch, 3, num_patches, 2)`.
148
+ scale_factors: SpatioTemporalScaleFactors tuple `(temporal, height, width)` with integer scale factors applied
149
+ per axis.
150
+ causal_fix: When True, rewrites the temporal axis of the first frame so causal VAEs
151
+ that treat frame zero differently still yield non-negative timestamps.
152
+ """
153
+ # Broadcast the VAE scale factors so they align with the `(batch, axis, patch, bound)` layout.
154
+ # Axis 1 of `latent_coords` is ordered (frame/time, height, width) — match that explicitly by
155
+ # pulling fields from the NamedTuple rather than relying on tuple iteration order.
156
+ broadcast_shape = [1] * latent_coords.ndim
157
+ broadcast_shape[1] = -1 # axis dimension corresponds to (frame/time, height, width)
158
+ scale_tensor = torch.tensor(
159
+ [scale_factors.time, scale_factors.height, scale_factors.width],
160
+ device=latent_coords.device,
161
+ ).view(*broadcast_shape)
162
+
163
+ # Apply per-axis scaling to convert latent bounds into pixel-space coordinates.
164
+ pixel_coords = latent_coords * scale_tensor
165
+
166
+ if causal_fix:
167
+ # VAE temporal stride for the very first frame is 1 instead of `scale_factors.time`.
168
+ # Shift and clamp to keep the first-frame timestamps causal and non-negative.
169
+ pixel_coords[:, 0, ...] = (pixel_coords[:, 0, ...] + 1 - scale_factors.time).clamp(min=0)
170
+
171
+ return pixel_coords
172
+
173
+
174
+ class AudioPatchifier(Patchifier):
175
+ def __init__(
176
+ self,
177
+ patch_size: int,
178
+ sample_rate: int = 16000,
179
+ hop_length: int = 160,
180
+ audio_latent_downsample_factor: int = 4,
181
+ is_causal: bool = True,
182
+ shift: int = 0,
183
+ ):
184
+ """
185
+ Patchifier tailored for spectrogram/audio latents.
186
+ Args:
187
+ patch_size: Number of mel bins combined into a single patch. This
188
+ controls the resolution along the frequency axis.
189
+ sample_rate: Original waveform sampling rate. Used to map latent
190
+ indices back to seconds so downstream consumers can align audio
191
+ and video cues.
192
+ hop_length: Window hop length used for the spectrogram. Determines
193
+ how many real-time samples separate two consecutive latent frames.
194
+ audio_latent_downsample_factor: Ratio between spectrogram frames and
195
+ latent frames; compensates for additional downsampling inside the
196
+ VAE encoder.
197
+ is_causal: When True, timing is shifted to account for causal
198
+ receptive fields so timestamps do not peek into the future.
199
+ shift: Integer offset applied to the latent indices. Enables
200
+ constructing overlapping windows from the same latent sequence.
201
+ """
202
+ self.hop_length = hop_length
203
+ self.sample_rate = sample_rate
204
+ self.audio_latent_downsample_factor = audio_latent_downsample_factor
205
+ self.is_causal = is_causal
206
+ self.shift = shift
207
+ self._patch_size = (1, patch_size, patch_size)
208
+
209
+ @property
210
+ def patch_size(self) -> Tuple[int, int, int]:
211
+ return self._patch_size
212
+
213
+ def get_token_count(self, tgt_shape: AudioLatentShape) -> int:
214
+ return tgt_shape.frames
215
+
216
+ def _get_audio_latent_time_in_sec(
217
+ self,
218
+ start_latent: int,
219
+ end_latent: int,
220
+ dtype: torch.dtype,
221
+ device: Optional[torch.device] = None,
222
+ ) -> torch.Tensor:
223
+ """
224
+ Converts latent indices into real-time seconds while honoring causal
225
+ offsets and the configured hop length.
226
+ Args:
227
+ start_latent: Inclusive start index inside the latent sequence. This
228
+ sets the first timestamp returned.
229
+ end_latent: Exclusive end index. Determines how many timestamps get
230
+ generated.
231
+ dtype: Floating-point dtype used for the returned tensor, allowing
232
+ callers to control precision.
233
+ device: Target device for the timestamp tensor. When omitted the
234
+ computation occurs on CPU to avoid surprising GPU allocations.
235
+ """
236
+ if device is None:
237
+ device = torch.device("cpu")
238
+
239
+ audio_latent_frame = torch.arange(start_latent, end_latent, dtype=dtype, device=device)
240
+
241
+ audio_mel_frame = audio_latent_frame * self.audio_latent_downsample_factor
242
+
243
+ if self.is_causal:
244
+ # Frame offset for causal alignment.
245
+ # The "+1" ensures the timestamp corresponds to the first sample that is fully available.
246
+ causal_offset = 1
247
+ audio_mel_frame = (audio_mel_frame + causal_offset - self.audio_latent_downsample_factor).clip(min=0)
248
+
249
+ return audio_mel_frame * self.hop_length / self.sample_rate
250
+
251
+ def _compute_audio_timings(
252
+ self,
253
+ batch_size: int,
254
+ num_steps: int,
255
+ device: Optional[torch.device] = None,
256
+ ) -> torch.Tensor:
257
+ """
258
+ Builds a `(B, 1, T, 2)` tensor containing timestamps for each latent frame.
259
+ This helper method underpins `get_patch_grid_bounds` for the audio patchifier.
260
+ Args:
261
+ batch_size: Number of sequences to broadcast the timings over.
262
+ num_steps: Number of latent frames (time steps) to convert into timestamps.
263
+ device: Device on which the resulting tensor should reside.
264
+ """
265
+ resolved_device = device
266
+ if resolved_device is None:
267
+ resolved_device = torch.device("cpu")
268
+
269
+ start_timings = self._get_audio_latent_time_in_sec(
270
+ self.shift,
271
+ num_steps + self.shift,
272
+ torch.float32,
273
+ resolved_device,
274
+ )
275
+ start_timings = start_timings.unsqueeze(0).expand(batch_size, -1).unsqueeze(1)
276
+
277
+ end_timings = self._get_audio_latent_time_in_sec(
278
+ self.shift + 1,
279
+ num_steps + self.shift + 1,
280
+ torch.float32,
281
+ resolved_device,
282
+ )
283
+ end_timings = end_timings.unsqueeze(0).expand(batch_size, -1).unsqueeze(1)
284
+
285
+ return torch.stack([start_timings, end_timings], dim=-1)
286
+
287
+ def patchify(
288
+ self,
289
+ audio_latents: torch.Tensor,
290
+ ) -> torch.Tensor:
291
+ """
292
+ Flattens the audio latent tensor along time. Use `get_patch_grid_bounds`
293
+ to derive timestamps for each latent frame based on the configured hop
294
+ length and downsampling.
295
+ Args:
296
+ audio_latents: Latent tensor to patchify.
297
+ Returns:
298
+ Flattened patch tokens tensor. Use `get_patch_grid_bounds` to compute the
299
+ corresponding timing metadata when needed.
300
+ """
301
+ audio_latents = einops.rearrange(
302
+ audio_latents,
303
+ "b c t f -> b t (c f)",
304
+ )
305
+
306
+ return audio_latents
307
+
308
+ def unpatchify(
309
+ self,
310
+ audio_latents: torch.Tensor,
311
+ output_shape: AudioLatentShape,
312
+ ) -> torch.Tensor:
313
+ """
314
+ Restores the `(B, C, T, F)` spectrogram tensor from flattened patches.
315
+ Use `get_patch_grid_bounds` to recompute the timestamps that describe each
316
+ frame's position in real time.
317
+ Args:
318
+ audio_latents: Latent tensor to unpatchify.
319
+ output_shape: Shape of the unpatched output tensor.
320
+ Returns:
321
+ Unpatched latent tensor. Use `get_patch_grid_bounds` to compute the timing
322
+ metadata associated with the restored latents.
323
+ """
324
+ # audio_latents shape: (batch, time, freq * channels)
325
+ audio_latents = einops.rearrange(
326
+ audio_latents,
327
+ "b t (c f) -> b c t f",
328
+ c=output_shape.channels,
329
+ f=output_shape.mel_bins,
330
+ )
331
+
332
+ return audio_latents
333
+
334
+ def get_patch_grid_bounds(
335
+ self,
336
+ output_shape: AudioLatentShape | VideoLatentShape,
337
+ device: Optional[torch.device] = None,
338
+ ) -> torch.Tensor:
339
+ """
340
+ Return the temporal bounds `[inclusive start, exclusive end)` for every
341
+ patch emitted by `patchify`. For audio this corresponds to timestamps in
342
+ seconds aligned with the original spectrogram grid.
343
+ The returned tensor has shape `[batch_size, 1, time_steps, 2]`, where:
344
+ - axis 1 (size 1) represents the temporal dimension
345
+ - axis 3 (size 2) stores the `[start, end)` timestamps per patch
346
+ Args:
347
+ output_shape: Audio grid specification describing the number of time steps.
348
+ device: Target device for the returned tensor.
349
+ """
350
+ if not isinstance(output_shape, AudioLatentShape):
351
+ raise ValueError("AudioPatchifier expects AudioLatentShape when computing coordinates")
352
+
353
+ return self._compute_audio_timings(output_shape.batch, output_shape.frames, device)
packages/ltx-core/src/ltx_core/components/protocols.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Protocol, Tuple
2
+
3
+ import torch
4
+
5
+ from ltx_core.types import AudioLatentShape, VideoLatentShape
6
+
7
+
8
+ class Patchifier(Protocol):
9
+ """
10
+ Protocol for patchifiers that convert latent tensors into patches and assemble them back.
11
+ """
12
+
13
+ def patchify(
14
+ self,
15
+ latents: torch.Tensor,
16
+ ) -> torch.Tensor:
17
+ ...
18
+ """
19
+ Convert latent tensors into flattened patch tokens.
20
+ Args:
21
+ latents: Latent tensor to patchify.
22
+ Returns:
23
+ Flattened patch tokens tensor.
24
+ """
25
+
26
+ def unpatchify(
27
+ self,
28
+ latents: torch.Tensor,
29
+ output_shape: AudioLatentShape | VideoLatentShape,
30
+ ) -> torch.Tensor:
31
+ """
32
+ Converts latent tensors between spatio-temporal formats and flattened sequence representations.
33
+ Args:
34
+ latents: Patch tokens that must be rearranged back into the latent grid constructed by `patchify`.
35
+ output_shape: Shape of the output tensor. Note that output_shape is either AudioLatentShape or
36
+ VideoLatentShape.
37
+ Returns:
38
+ Dense latent tensor restored from the flattened representation.
39
+ """
40
+
41
+ @property
42
+ def patch_size(self) -> Tuple[int, int, int]:
43
+ ...
44
+ """
45
+ Returns the patch size as a tuple of (temporal, height, width) dimensions
46
+ """
47
+
48
+ def get_patch_grid_bounds(
49
+ self,
50
+ output_shape: AudioLatentShape | VideoLatentShape,
51
+ device: torch.device | None = None,
52
+ ) -> torch.Tensor:
53
+ ...
54
+ """
55
+ Compute metadata describing where each latent patch resides within the
56
+ grid specified by `output_shape`.
57
+ Args:
58
+ output_shape: Target grid layout for the patches.
59
+ device: Target device for the returned tensor.
60
+ Returns:
61
+ Tensor containing patch coordinate metadata such as spatial or temporal intervals.
62
+ """
63
+
64
+
65
+ class SchedulerProtocol(Protocol):
66
+ """
67
+ Protocol for schedulers that provide a sigmas schedule tensor for a
68
+ given number of steps. Device is cpu.
69
+ """
70
+
71
+ def execute(self, steps: int, **kwargs) -> torch.FloatTensor: ...
72
+
73
+
74
+ class GuiderProtocol(Protocol):
75
+ """
76
+ Protocol for guiders that compute a delta tensor given conditioning inputs.
77
+ The returned delta should be added to the conditional output (cond), enabling
78
+ multiple guiders to be chained together by accumulating their deltas.
79
+ """
80
+
81
+ scale: float
82
+
83
+ def delta(self, cond: torch.Tensor, uncond: torch.Tensor) -> torch.Tensor: ...
84
+
85
+ def enabled(self) -> bool:
86
+ """
87
+ Returns whether the corresponding perturbation is enabled. E.g. for CFG, this should return False if the scale
88
+ is 1.0.
89
+ """
90
+ ...
91
+
92
+
93
+ class DiffusionStepProtocol(Protocol):
94
+ """
95
+ Protocol for diffusion steps that provide a next sample tensor for a given current sample tensor,
96
+ current denoised sample tensor, and sigmas tensor.
97
+ """
98
+
99
+ def step(
100
+ self, sample: torch.Tensor, denoised_sample: torch.Tensor, sigmas: torch.Tensor, step_index: int, **kwargs
101
+ ) -> torch.Tensor: ...
packages/ltx-core/src/ltx_core/components/schedulers.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from functools import lru_cache
3
+
4
+ import numpy
5
+ import scipy
6
+ import torch
7
+
8
+ from ltx_core.components.protocols import SchedulerProtocol
9
+
10
+ BASE_SHIFT_ANCHOR = 1024
11
+ MAX_SHIFT_ANCHOR = 4096
12
+
13
+
14
+ class LTX2Scheduler(SchedulerProtocol):
15
+ """
16
+ Default scheduler for LTX-2 diffusion sampling.
17
+ Generates a sigma schedule with token-count-dependent shifting and optional
18
+ stretching to a terminal value.
19
+ """
20
+
21
+ def execute(
22
+ self,
23
+ steps: int,
24
+ latent: torch.Tensor | None = None,
25
+ max_shift: float = 2.05,
26
+ base_shift: float = 0.95,
27
+ stretch: bool = True,
28
+ terminal: float = 0.1,
29
+ default_number_of_tokens: int = MAX_SHIFT_ANCHOR,
30
+ **_kwargs,
31
+ ) -> torch.FloatTensor:
32
+ tokens = math.prod(latent.shape[2:]) if latent is not None else default_number_of_tokens
33
+ sigmas = torch.linspace(1.0, 0.0, steps + 1)
34
+
35
+ x1 = BASE_SHIFT_ANCHOR
36
+ x2 = MAX_SHIFT_ANCHOR
37
+ mm = (max_shift - base_shift) / (x2 - x1)
38
+ b = base_shift - mm * x1
39
+ sigma_shift = (tokens) * mm + b
40
+
41
+ power = 1
42
+ sigmas = torch.where(
43
+ sigmas != 0,
44
+ math.exp(sigma_shift) / (math.exp(sigma_shift) + (1 / sigmas - 1) ** power),
45
+ 0,
46
+ )
47
+
48
+ # Stretch sigmas so that its final value matches the given terminal value.
49
+ if stretch:
50
+ non_zero_mask = sigmas != 0
51
+ non_zero_sigmas = sigmas[non_zero_mask]
52
+ one_minus_z = 1.0 - non_zero_sigmas
53
+ scale_factor = one_minus_z[-1] / (1.0 - terminal)
54
+ stretched = 1.0 - (one_minus_z / scale_factor)
55
+ sigmas[non_zero_mask] = stretched
56
+
57
+ return sigmas.to(torch.float32)
58
+
59
+
60
+ class LinearQuadraticScheduler(SchedulerProtocol):
61
+ """
62
+ Scheduler with linear steps followed by quadratic steps.
63
+ Produces a sigma schedule that transitions linearly up to a threshold,
64
+ then follows a quadratic curve for the remaining steps.
65
+ """
66
+
67
+ def execute(
68
+ self, steps: int, threshold_noise: float = 0.025, linear_steps: int | None = None, **_kwargs
69
+ ) -> torch.FloatTensor:
70
+ if steps == 1:
71
+ return torch.FloatTensor([1.0, 0.0])
72
+
73
+ if linear_steps is None:
74
+ linear_steps = steps // 2
75
+ linear_sigma_schedule = [i * threshold_noise / linear_steps for i in range(linear_steps)]
76
+ threshold_noise_step_diff = linear_steps - threshold_noise * steps
77
+ quadratic_steps = steps - linear_steps
78
+ quadratic_sigma_schedule = []
79
+ if quadratic_steps > 0:
80
+ quadratic_coef = threshold_noise_step_diff / (linear_steps * quadratic_steps**2)
81
+ linear_coef = threshold_noise / linear_steps - 2 * threshold_noise_step_diff / (quadratic_steps**2)
82
+ const = quadratic_coef * (linear_steps**2)
83
+ quadratic_sigma_schedule = [
84
+ quadratic_coef * (i**2) + linear_coef * i + const for i in range(linear_steps, steps)
85
+ ]
86
+ sigma_schedule = linear_sigma_schedule + quadratic_sigma_schedule + [1.0]
87
+ sigma_schedule = [1.0 - x for x in sigma_schedule]
88
+ return torch.FloatTensor(sigma_schedule)
89
+
90
+
91
+ class BetaScheduler(SchedulerProtocol):
92
+ """
93
+ Scheduler using a beta distribution to sample timesteps.
94
+ Based on: https://arxiv.org/abs/2407.12173
95
+ """
96
+
97
+ shift = 2.37
98
+ timesteps_length = 10000
99
+
100
+ def execute(self, steps: int, alpha: float = 0.6, beta: float = 0.6) -> torch.FloatTensor:
101
+ """
102
+ Execute the beta scheduler.
103
+ Args:
104
+ steps: The number of steps to execute the scheduler for.
105
+ alpha: The alpha parameter for the beta distribution.
106
+ beta: The beta parameter for the beta distribution.
107
+ Warnings:
108
+ The number of steps within `sigmas` theoretically might be less than `steps+1`,
109
+ because of the deduplication of the identical timesteps
110
+ Returns:
111
+ A tensor of sigmas.
112
+ """
113
+ model_sampling_sigmas = _precalculate_model_sampling_sigmas(self.shift, self.timesteps_length)
114
+ total_timesteps = len(model_sampling_sigmas) - 1
115
+ ts = 1 - numpy.linspace(0, 1, steps, endpoint=False)
116
+ ts = numpy.rint(scipy.stats.beta.ppf(ts, alpha, beta) * total_timesteps).tolist()
117
+ ts = list(dict.fromkeys(ts))
118
+
119
+ sigmas = [float(model_sampling_sigmas[int(t)]) for t in ts] + [0.0]
120
+ return torch.FloatTensor(sigmas)
121
+
122
+
123
+ @lru_cache(maxsize=5)
124
+ def _precalculate_model_sampling_sigmas(shift: float, timesteps_length: int) -> torch.Tensor:
125
+ timesteps = torch.arange(1, timesteps_length + 1, 1) / timesteps_length
126
+ return torch.Tensor([flux_time_shift(shift, 1.0, t) for t in timesteps])
127
+
128
+
129
+ def flux_time_shift(mu: float, sigma: float, t: float) -> float:
130
+ return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma)
packages/ltx-core/src/ltx_core/conditioning/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conditioning utilities: latent state, tools, and conditioning types."""
2
+
3
+ from ltx_core.conditioning.exceptions import ConditioningError
4
+ from ltx_core.conditioning.item import ConditioningItem
5
+ from ltx_core.conditioning.types import (
6
+ AudioConditionByReferenceLatent,
7
+ ConditioningItemAttentionStrengthWrapper,
8
+ VideoConditionByKeyframeIndex,
9
+ VideoConditionByLatentIndex,
10
+ VideoConditionByReferenceLatent,
11
+ )
12
+
13
+ __all__ = [
14
+ "AudioConditionByReferenceLatent",
15
+ "ConditioningError",
16
+ "ConditioningItem",
17
+ "ConditioningItemAttentionStrengthWrapper",
18
+ "VideoConditionByKeyframeIndex",
19
+ "VideoConditionByLatentIndex",
20
+ "VideoConditionByReferenceLatent",
21
+ ]
packages/ltx-core/src/ltx_core/conditioning/exceptions.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ class ConditioningError(Exception):
2
+ """
3
+ Class for conditioning-related errors.
4
+ """
packages/ltx-core/src/ltx_core/conditioning/item.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Protocol
2
+
3
+ from ltx_core.tools import LatentTools
4
+ from ltx_core.types import LatentState
5
+
6
+
7
+ class ConditioningItem(Protocol):
8
+ """Protocol for conditioning items that modify latent state during diffusion."""
9
+
10
+ def apply_to(self, latent_state: LatentState, latent_tools: LatentTools) -> LatentState:
11
+ """
12
+ Apply the conditioning to the latent state.
13
+ Args:
14
+ latent_state: The latent state to apply the conditioning to. This is state always patchified.
15
+ Returns:
16
+ The latent state after the conditioning has been applied.
17
+ IMPORTANT: If the conditioning needs to add extra tokens to the latent, it should add them to the end of the
18
+ latent.
19
+ """
20
+ ...
packages/ltx-core/src/ltx_core/conditioning/mask_utils.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utilities for building 2D self-attention masks for conditioning items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import torch
8
+
9
+ if TYPE_CHECKING:
10
+ from ltx_core.types import LatentState
11
+
12
+
13
+ def resolve_cross_mask(
14
+ attention_mask: float | int | torch.Tensor,
15
+ num_new_tokens: int,
16
+ batch_size: int,
17
+ device: torch.device,
18
+ dtype: torch.dtype,
19
+ ) -> torch.Tensor:
20
+ """Convert an attention_mask (scalar or tensor) to a (B, M) cross_mask tensor.
21
+ Args:
22
+ attention_mask: Scalar value applied uniformly, 1D tensor of shape (M,)
23
+ broadcast across batch, or 2D tensor of shape (B, M).
24
+ num_new_tokens: Number of new conditioning tokens M.
25
+ batch_size: Batch size B.
26
+ device: Device for the output tensor.
27
+ dtype: Data type for the output tensor.
28
+ Returns:
29
+ Cross-mask tensor of shape (B, M).
30
+ """
31
+ if isinstance(attention_mask, (int, float)):
32
+ return torch.full(
33
+ (batch_size, num_new_tokens),
34
+ fill_value=float(attention_mask),
35
+ device=device,
36
+ dtype=dtype,
37
+ )
38
+ mask = attention_mask.to(device=device, dtype=dtype)
39
+
40
+ # Handle scalar (0-D) tensor like a Python scalar.
41
+ if mask.dim() == 0:
42
+ return torch.full(
43
+ (batch_size, num_new_tokens),
44
+ fill_value=float(mask.item()),
45
+ device=device,
46
+ dtype=dtype,
47
+ )
48
+
49
+ if mask.dim() == 1:
50
+ if mask.shape[0] != num_new_tokens:
51
+ raise ValueError(
52
+ f"1-D attention_mask length must equal num_new_tokens ({num_new_tokens}), got shape {tuple(mask.shape)}"
53
+ )
54
+ mask = mask.unsqueeze(0).expand(batch_size, -1)
55
+ elif mask.dim() == 2:
56
+ b, m = mask.shape
57
+ if m != num_new_tokens:
58
+ raise ValueError(
59
+ f"2-D attention_mask second dimension must equal num_new_tokens ({num_new_tokens}), "
60
+ f"got shape {tuple(mask.shape)}"
61
+ )
62
+ if b not in (batch_size, 1):
63
+ raise ValueError(
64
+ f"2-D attention_mask batch dimension must equal batch_size ({batch_size}) or 1, "
65
+ f"got shape {tuple(mask.shape)}"
66
+ )
67
+ if b == 1 and batch_size > 1:
68
+ mask = mask.expand(batch_size, -1)
69
+ else:
70
+ raise ValueError(
71
+ f"attention_mask tensor must be 0-D, 1-D, or 2-D, got {mask.dim()}-D with shape {tuple(mask.shape)}"
72
+ )
73
+ return mask
74
+
75
+
76
+ def update_attention_mask(
77
+ latent_state: LatentState,
78
+ attention_mask: float | torch.Tensor | None,
79
+ num_noisy_tokens: int,
80
+ num_new_tokens: int,
81
+ batch_size: int,
82
+ device: torch.device,
83
+ dtype: torch.dtype,
84
+ ) -> torch.Tensor | None:
85
+ """Build or update the self-attention mask for newly appended conditioning tokens.
86
+ If *attention_mask* is ``None`` and no existing mask is present, returns
87
+ ``None``. If *attention_mask* is ``None`` but an existing mask is present,
88
+ the mask is expanded with full attention (1s) for the new tokens so that
89
+ its dimensions stay consistent with the growing latent sequence. Otherwise,
90
+ resolves *attention_mask* to a per-token cross-mask and expands the 2-D
91
+ attention mask via :func:`build_attention_mask`.
92
+ Args:
93
+ latent_state: Current latent state (provides the existing mask and total
94
+ existing-token count).
95
+ attention_mask: Per-token attention weight. Scalar, 1-D ``(M,)``, 2-D
96
+ ``(B, M)`` tensor, or ``None`` (no-op).
97
+ num_noisy_tokens: Number of original noisy tokens (from
98
+ ``latent_tools.target_shape.token_count()``).
99
+ num_new_tokens: Number of new conditioning tokens being appended.
100
+ batch_size: Batch size.
101
+ device: Device for the output tensor.
102
+ dtype: Data type for the output tensor.
103
+ Returns:
104
+ Updated attention mask of shape ``(B, N+M, N+M)``, or ``None`` if no
105
+ masking is needed.
106
+ """
107
+ if attention_mask is None:
108
+ if latent_state.attention_mask is None:
109
+ return None
110
+ # Existing mask present but no new mask requested: pad with 1s (full
111
+ # attention) so the mask dimensions stay consistent with the growing
112
+ # latent sequence.
113
+ cross_mask = torch.ones(batch_size, num_new_tokens, device=device, dtype=dtype)
114
+ return build_attention_mask(
115
+ existing_mask=latent_state.attention_mask,
116
+ num_noisy_tokens=num_noisy_tokens,
117
+ num_new_tokens=num_new_tokens,
118
+ num_existing_tokens=latent_state.latent.shape[1],
119
+ cross_mask=cross_mask,
120
+ device=device,
121
+ dtype=dtype,
122
+ )
123
+
124
+ cross_mask = resolve_cross_mask(attention_mask, num_new_tokens, batch_size, device, dtype)
125
+ return build_attention_mask(
126
+ existing_mask=latent_state.attention_mask,
127
+ num_noisy_tokens=num_noisy_tokens,
128
+ num_new_tokens=num_new_tokens,
129
+ num_existing_tokens=latent_state.latent.shape[1],
130
+ cross_mask=cross_mask,
131
+ device=device,
132
+ dtype=dtype,
133
+ )
134
+
135
+
136
+ def build_attention_mask(
137
+ existing_mask: torch.Tensor | None,
138
+ num_noisy_tokens: int,
139
+ num_new_tokens: int,
140
+ num_existing_tokens: int,
141
+ cross_mask: torch.Tensor,
142
+ device: torch.device,
143
+ dtype: torch.dtype,
144
+ ) -> torch.Tensor:
145
+ """
146
+ Expand the attention mask to include newly appended conditioning tokens.
147
+ Each conditioning item appends M new reference tokens to the sequence. This function
148
+ builds a (B, N+M, N+M) attention mask with the following block structure:
149
+ noisy prev_ref new_ref
150
+ (N_noisy) (N-N_noisy) (M)
151
+ ┌───────────┬───────────┬───────────┐
152
+ noisy │ │ │ │
153
+ (N_noisy) │ existing │ existing │ cross │
154
+ │ │ │ │
155
+ ├───────────┼───────────┼───────────┤
156
+ prev_ref │ │ │ │
157
+ (N-N_noisy)│ existing │ existing │ 0 │
158
+ │ │ │ │
159
+ ├───────────┼───────────┼───────────┤
160
+ new_ref │ │ │ │
161
+ (M) │ cross │ 0 │ 1 │
162
+ │ │ │ │
163
+ └───────────┴───────────┴───────────┘
164
+ Where:
165
+ - **existing**: preserved from the previous mask (or 1.0 if first conditioning)
166
+ - **cross**: values from *cross_mask* (shape B, M), in [0, 1]
167
+ - **0**: no attention between different reference groups
168
+ Args:
169
+ existing_mask: Current attention mask of shape (B, N, N), or None if no mask exists yet.
170
+ When None, the top-left NxN block is filled with 1s (full attention between all
171
+ existing tokens including any prior reference tokens that had no mask).
172
+ num_noisy_tokens: Number of original noisy tokens (always at positions [0:num_noisy_tokens]).
173
+ num_new_tokens: Number of new conditioning tokens M being appended.
174
+ num_existing_tokens: Total number of current tokens N (noisy + any prior conditioning tokens).
175
+ cross_mask: Per-token attention weight of shape (B, M) controlling attention between
176
+ new reference tokens and noisy tokens. Values in [0, 1].
177
+ device: Device for the output tensor.
178
+ dtype: Data type for the output tensor.
179
+ Returns:
180
+ Attention mask of shape (B, N+M, N+M) with values in [0, 1].
181
+ """
182
+ batch_size = cross_mask.shape[0]
183
+ total = num_existing_tokens + num_new_tokens
184
+
185
+ # Start with zeros
186
+ mask = torch.zeros((batch_size, total, total), device=device, dtype=dtype)
187
+
188
+ # Top-left: preserve existing mask or fill with 1s for noisy tokens
189
+ if existing_mask is not None:
190
+ mask[:, :num_existing_tokens, :num_existing_tokens] = existing_mask
191
+ else:
192
+ mask[:, :num_existing_tokens, :num_existing_tokens] = 1.0
193
+
194
+ # Bottom-right: new reference tokens fully attend to themselves
195
+ mask[:, num_existing_tokens:, num_existing_tokens:] = 1.0
196
+
197
+ # Cross-attention between noisy tokens and new reference tokens
198
+ # cross_mask shape: (B, M) -> broadcast to (B, N_noisy, M) and (B, M, N_noisy)
199
+
200
+ # Noisy tokens attending to new reference tokens: [0:N_noisy, N:N+M]
201
+ # Each column j in this block gets cross_mask[:, j]
202
+ mask[:, :num_noisy_tokens, num_existing_tokens:] = cross_mask.unsqueeze(1)
203
+
204
+ # New reference tokens attending to noisy tokens: [N:N+M, 0:N_noisy]
205
+ # Each row i in this block gets cross_mask[:, i]
206
+ mask[:, num_existing_tokens:, :num_noisy_tokens] = cross_mask.unsqueeze(2)
207
+
208
+ # [N_noisy:N, N:N+M] and [N:N+M, N_noisy:N] remain 0 (no cross-ref attention)
209
+
210
+ return mask
packages/ltx-core/src/ltx_core/conditioning/types/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conditioning type implementations."""
2
+
3
+ from ltx_core.conditioning.types.attention_strength_wrapper import ConditioningItemAttentionStrengthWrapper
4
+ from ltx_core.conditioning.types.keyframe_cond import VideoConditionByKeyframeIndex
5
+ from ltx_core.conditioning.types.latent_cond import VideoConditionByLatentIndex
6
+ from ltx_core.conditioning.types.reference_audio_cond import AudioConditionByReferenceLatent
7
+ from ltx_core.conditioning.types.reference_video_cond import VideoConditionByReferenceLatent
8
+
9
+ __all__ = [
10
+ "AudioConditionByReferenceLatent",
11
+ "ConditioningItemAttentionStrengthWrapper",
12
+ "VideoConditionByKeyframeIndex",
13
+ "VideoConditionByLatentIndex",
14
+ "VideoConditionByReferenceLatent",
15
+ ]
packages/ltx-core/src/ltx_core/conditioning/types/attention_strength_wrapper.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrapper conditioning item that adds attention masking to any inner conditioning."""
2
+
3
+ from dataclasses import replace
4
+
5
+ import torch
6
+
7
+ from ltx_core.conditioning.item import ConditioningItem
8
+ from ltx_core.conditioning.mask_utils import update_attention_mask
9
+ from ltx_core.tools import LatentTools
10
+ from ltx_core.types import LatentState
11
+
12
+
13
+ class ConditioningItemAttentionStrengthWrapper(ConditioningItem):
14
+ """Wraps a conditioning item to add an attention mask for its tokens.
15
+ Separates the *attention-masking* concern from the underlying conditioning
16
+ logic (token layout, positional encoding, denoise strength). The inner
17
+ conditioning item appends tokens to the latent sequence as usual, and this
18
+ wrapper then builds or updates the self-attention mask so that the newly
19
+ added tokens interact with the noisy tokens according to *attention_mask*.
20
+ Args:
21
+ conditioning: Any conditioning item that appends tokens to the latent.
22
+ attention_mask: Per-token attention weight controlling how strongly the
23
+ new conditioning tokens attend to/from noisy tokens. Can be a
24
+ scalar (float) applied uniformly, or a tensor of shape ``(B, M)``
25
+ for spatial control, where ``M = F * H * W`` is the number of
26
+ patchified conditioning tokens. Values in ``[0, 1]``.
27
+ Example::
28
+ cond = ConditioningItemAttentionStrengthWrapper(
29
+ VideoConditionByReferenceLatent(latent=ref, strength=1.0),
30
+ attention_mask=0.5,
31
+ )
32
+ state = cond.apply_to(latent_state, latent_tools)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ conditioning: ConditioningItem,
38
+ attention_mask: float | torch.Tensor,
39
+ ):
40
+ self.conditioning = conditioning
41
+ self.attention_mask = attention_mask
42
+
43
+ def apply_to(
44
+ self,
45
+ latent_state: LatentState,
46
+ latent_tools: LatentTools,
47
+ ) -> LatentState:
48
+ """Apply inner conditioning, then build the attention mask for its tokens."""
49
+ # Snapshot the original state for mask building
50
+ original_state = latent_state
51
+
52
+ # Inner conditioning appends tokens (positions, denoise mask, etc.)
53
+ new_state = self.conditioning.apply_to(latent_state, latent_tools)
54
+
55
+ num_new_tokens = new_state.latent.shape[1] - original_state.latent.shape[1]
56
+ if num_new_tokens == 0:
57
+ return new_state
58
+
59
+ # Build the attention mask using the *original* state as the reference
60
+ # so that the block structure is computed correctly.
61
+ new_attention_mask = update_attention_mask(
62
+ latent_state=original_state,
63
+ attention_mask=self.attention_mask,
64
+ num_noisy_tokens=latent_tools.target_shape.token_count(),
65
+ num_new_tokens=num_new_tokens,
66
+ batch_size=new_state.latent.shape[0],
67
+ device=new_state.latent.device,
68
+ dtype=new_state.latent.dtype,
69
+ )
70
+
71
+ return replace(new_state, attention_mask=new_attention_mask)
packages/ltx-core/src/ltx_core/conditioning/types/keyframe_cond.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from ltx_core.components.patchifiers import get_pixel_coords
4
+ from ltx_core.conditioning.item import ConditioningItem
5
+ from ltx_core.conditioning.mask_utils import update_attention_mask
6
+ from ltx_core.tools import VideoLatentTools
7
+ from ltx_core.types import LatentState, VideoLatentShape
8
+
9
+
10
+ class VideoConditionByKeyframeIndex(ConditioningItem):
11
+ """
12
+ Conditions video generation on keyframe latents at a specific frame index.
13
+ Appends keyframe tokens to the latent state with positions offset by frame_idx,
14
+ and sets denoise strength according to the strength parameter.
15
+ To add attention masking, wrap with :class:`ConditioningItemAttentionStrengthWrapper`.
16
+ Args:
17
+ keyframes: Keyframe latents [B, C, F, H, W].
18
+ frame_idx: Frame index offset for positional encoding.
19
+ strength: Conditioning strength (1.0 = clean, 0.0 = fully denoised).
20
+ num_pixel_frames: Number of pixel frames the keyframe latent originally encodes.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ keyframes: torch.Tensor,
26
+ frame_idx: int,
27
+ strength: float,
28
+ num_pixel_frames: int = 1,
29
+ ):
30
+ self.keyframes = keyframes
31
+ self.frame_idx = frame_idx
32
+ self.strength = strength
33
+ self.num_pixel_frames = num_pixel_frames
34
+
35
+ def apply_to(
36
+ self,
37
+ latent_state: LatentState,
38
+ latent_tools: VideoLatentTools,
39
+ ) -> LatentState:
40
+ tokens = latent_tools.patchifier.patchify(self.keyframes)
41
+ latent_coords = latent_tools.patchifier.get_patch_grid_bounds(
42
+ output_shape=VideoLatentShape.from_torch_shape(self.keyframes.shape),
43
+ device=self.keyframes.device,
44
+ )
45
+ positions = get_pixel_coords(
46
+ latent_coords=latent_coords,
47
+ scale_factors=latent_tools.scale_factors,
48
+ causal_fix=latent_tools.causal_fix if self.frame_idx == 0 else False,
49
+ )
50
+
51
+ positions[:, 0, ...] += self.frame_idx
52
+ # If the keyframe latent encodes a single pixel frame,
53
+ # narrow the temporal end to [start, start + 1) instead of the
54
+ # VAE-scaled range.
55
+ if self.num_pixel_frames == 1:
56
+ positions[:, 0, ..., 1:] = positions[:, 0, ..., :1] + 1
57
+ positions = positions.to(dtype=torch.float32)
58
+ positions[:, 0, ...] /= latent_tools.fps
59
+
60
+ denoise_mask = torch.full(
61
+ size=(*tokens.shape[:2], 1),
62
+ fill_value=1.0 - self.strength,
63
+ device=self.keyframes.device,
64
+ dtype=self.keyframes.dtype,
65
+ )
66
+
67
+ new_attention_mask = update_attention_mask(
68
+ latent_state=latent_state,
69
+ attention_mask=None,
70
+ num_noisy_tokens=latent_tools.target_shape.token_count(),
71
+ num_new_tokens=tokens.shape[1],
72
+ batch_size=tokens.shape[0],
73
+ device=self.keyframes.device,
74
+ dtype=self.keyframes.dtype,
75
+ )
76
+
77
+ return LatentState(
78
+ latent=torch.cat([latent_state.latent, tokens], dim=1),
79
+ denoise_mask=torch.cat([latent_state.denoise_mask, denoise_mask], dim=1),
80
+ positions=torch.cat([latent_state.positions, positions], dim=2),
81
+ clean_latent=torch.cat([latent_state.clean_latent, tokens], dim=1),
82
+ attention_mask=new_attention_mask,
83
+ )
packages/ltx-core/src/ltx_core/conditioning/types/latent_cond.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from ltx_core.conditioning.exceptions import ConditioningError
4
+ from ltx_core.conditioning.item import ConditioningItem
5
+ from ltx_core.tools import LatentTools
6
+ from ltx_core.types import LatentState
7
+
8
+
9
+ class VideoConditionByLatentIndex(ConditioningItem):
10
+ """
11
+ Conditions video generation by injecting latents at a specific latent frame index.
12
+ Replaces tokens in the latent state at positions corresponding to latent_idx,
13
+ and sets denoise strength according to the strength parameter.
14
+ """
15
+
16
+ def __init__(self, latent: torch.Tensor, strength: float, latent_idx: int):
17
+ self.latent = latent
18
+ self.strength = strength
19
+ self.latent_idx = latent_idx
20
+
21
+ def apply_to(self, latent_state: LatentState, latent_tools: LatentTools) -> LatentState:
22
+ cond_batch, cond_channels, _, cond_height, cond_width = self.latent.shape
23
+ tgt_batch, tgt_channels, tgt_frames, tgt_height, tgt_width = latent_tools.target_shape.to_torch_shape()
24
+
25
+ if (cond_batch, cond_channels, cond_height, cond_width) != (tgt_batch, tgt_channels, tgt_height, tgt_width):
26
+ raise ConditioningError(
27
+ f"Can't apply image conditioning item to latent with shape {latent_tools.target_shape}, expected "
28
+ f"shape is ({tgt_batch}, {tgt_channels}, {tgt_frames}, {tgt_height}, {tgt_width}). Make sure "
29
+ "the image and latent have the same spatial shape."
30
+ )
31
+
32
+ tokens = latent_tools.patchifier.patchify(self.latent)
33
+ start_token = latent_tools.patchifier.get_token_count(
34
+ latent_tools.target_shape._replace(frames=self.latent_idx)
35
+ )
36
+ stop_token = start_token + tokens.shape[1]
37
+
38
+ latent_state = latent_state.clone()
39
+
40
+ latent_state.latent[:, start_token:stop_token] = tokens
41
+ latent_state.clean_latent[:, start_token:stop_token] = tokens
42
+ latent_state.denoise_mask[:, start_token:stop_token] = 1.0 - self.strength
43
+
44
+ return latent_state
packages/ltx-core/src/ltx_core/conditioning/types/noise_mask_cond.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ from ltx_core.components.patchifiers import get_pixel_coords
4
+ from ltx_core.conditioning.item import ConditioningItem
5
+ from ltx_core.tools import LatentTools, SpatioTemporalScaleFactors
6
+ from ltx_core.types import AudioLatentShape, LatentState, VideoLatentShape
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TemporalRegionMask(ConditioningItem):
11
+ """Conditioning item that sets ``denoise_mask = 0`` outside a time range
12
+ and ``1`` inside, so only the specified temporal region is regenerated.
13
+ Uses ``start_time`` and ``end_time`` in seconds. Works in *patchified*
14
+ (token) space using the patchifier's ``get_patch_grid_bounds``: for video
15
+ coords are latent frame indices (converted from seconds via ``fps``), for
16
+ audio coords are already in seconds.
17
+ """
18
+
19
+ start_time: float # seconds, inclusive
20
+ end_time: float # seconds, exclusive
21
+ fps: float
22
+
23
+ def apply_to(self, latent_state: LatentState, latent_tools: LatentTools) -> LatentState:
24
+ coords = latent_tools.patchifier.get_patch_grid_bounds(
25
+ latent_tools.target_shape, device=latent_state.denoise_mask.device
26
+ )
27
+ if isinstance(latent_tools.target_shape, AudioLatentShape):
28
+ # Audio: patchifier get_patch_grid_bounds returns seconds
29
+ t_boundaries = coords[:, 0]
30
+ elif isinstance(latent_tools.target_shape, VideoLatentShape):
31
+ # Video: patchifier get_patch_grid_bounds returns latent bounds, converting to frame numbers & pixel bounds
32
+ scale_factors = getattr(latent_tools, "scale_factors", SpatioTemporalScaleFactors.default())
33
+ pixel_bounds = get_pixel_coords(coords, scale_factors, causal_fix=getattr(latent_tools, "causal_fix", True))
34
+ # converting frame numbers to seconds
35
+ t_boundaries = pixel_bounds[:, 0] / self.fps
36
+ else:
37
+ raise ValueError("Unsupported LatentShape type, expected AudioLatentShape or VideoLatentShape")
38
+ t_start, t_end = t_boundaries.unbind(dim=-1) # [B, N]
39
+ in_region = (t_end > self.start_time) & (t_start < self.end_time)
40
+ state = latent_state.clone()
41
+ mask_val = in_region.to(state.denoise_mask.dtype)
42
+ if state.denoise_mask.dim() == 3:
43
+ mask_val = mask_val.unsqueeze(-1)
44
+ state.denoise_mask.copy_(mask_val)
45
+ return state
packages/ltx-core/src/ltx_core/conditioning/types/reference_audio_cond.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio reference conditioning items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from ltx_core.conditioning.mask_utils import update_attention_mask
8
+ from ltx_core.tools import LatentTools
9
+ from ltx_core.types import LatentState
10
+
11
+
12
+ class AudioConditionByReferenceLatent:
13
+ """Append patchified reference audio tokens after the target audio sequence.
14
+ Mirrors :class:`ltx_core.conditioning.types.reference_video_cond.VideoConditionByReferenceLatent`
15
+ but for audio. The reference tokens are appended so the target audio tokens stay
16
+ in the first ``num_noisy_tokens`` positions and can be kept by
17
+ :meth:`ltx_core.tools.LatentTools.clear_conditioning`.
18
+ Args:
19
+ patchified: Patchified reference latent ``[B, T_ref, C]``.
20
+ positions: RoPE positions for reference tokens, ``[B, 1, T_ref, 2]``.
21
+ strength: 1.0 keeps reference clean; 0.0 would fully denoise it.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ patchified: torch.Tensor,
27
+ positions: torch.Tensor,
28
+ strength: float = 1.0,
29
+ ) -> None:
30
+ self.patchified = patchified
31
+ self.positions = positions.to(dtype=torch.float32)
32
+ self.strength = strength
33
+
34
+ def apply_to(self, latent_state: LatentState, latent_tools: LatentTools) -> LatentState:
35
+ tokens = self.patchified
36
+ denoise_mask = torch.full(
37
+ size=(*tokens.shape[:2], 1),
38
+ fill_value=1.0 - self.strength,
39
+ device=tokens.device,
40
+ dtype=tokens.dtype,
41
+ )
42
+
43
+ new_attention_mask = update_attention_mask(
44
+ latent_state=latent_state,
45
+ attention_mask=None,
46
+ num_noisy_tokens=latent_tools.patchifier.get_token_count(latent_tools.target_shape),
47
+ num_new_tokens=tokens.shape[1],
48
+ batch_size=tokens.shape[0],
49
+ device=tokens.device,
50
+ dtype=tokens.dtype,
51
+ )
52
+
53
+ return LatentState(
54
+ latent=torch.cat([latent_state.latent, tokens], dim=1),
55
+ denoise_mask=torch.cat([latent_state.denoise_mask, denoise_mask], dim=1),
56
+ positions=torch.cat([latent_state.positions, self.positions], dim=2),
57
+ clean_latent=torch.cat([latent_state.clean_latent, tokens], dim=1),
58
+ attention_mask=new_attention_mask,
59
+ )
packages/ltx-core/src/ltx_core/conditioning/types/reference_video_cond.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference video conditioning for IC-LoRA inference."""
2
+
3
+ import torch
4
+
5
+ from ltx_core.components.patchifiers import get_pixel_coords
6
+ from ltx_core.conditioning.item import ConditioningItem
7
+ from ltx_core.conditioning.mask_utils import update_attention_mask
8
+ from ltx_core.tools import VideoLatentTools
9
+ from ltx_core.types import LatentState, VideoLatentShape
10
+
11
+
12
+ class VideoConditionByReferenceLatent(ConditioningItem):
13
+ """
14
+ Conditions video generation on a reference video latent for IC-LoRA inference.
15
+ IC-LoRAs are trained by concatenating reference (control signal) and target tokens,
16
+ learning to attend across both. This class replicates that setup at inference by
17
+ appending reference tokens to the latent sequence.
18
+ IC-LoRAs can be trained with lower-resolution references than the target (e.g., 384px
19
+ reference for 768px output) for efficiency and better generalization. The
20
+ `downscale_factor` scales reference positions to match target coordinates, preserving
21
+ the learned positional relationships. This must match the factor used during training
22
+ (stored in LoRA metadata).
23
+ To add attention masking, wrap with :class:`ConditioningItemAttentionStrengthWrapper`.
24
+ Args:
25
+ latent: Reference video latents [B, C, F, H, W]
26
+ downscale_factor: Target/reference resolution ratio (e.g., 2 = half-resolution
27
+ reference). Spatial positions are scaled by this factor.
28
+ strength: Conditioning strength. 1.0 = full (reference kept clean),
29
+ 0.0 = none (reference denoised). Default 1.0.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ latent: torch.Tensor,
35
+ downscale_factor: int = 1,
36
+ strength: float = 1.0,
37
+ ):
38
+ self.latent = latent
39
+ self.downscale_factor = downscale_factor
40
+ self.strength = strength
41
+
42
+ def apply_to(
43
+ self,
44
+ latent_state: LatentState,
45
+ latent_tools: VideoLatentTools,
46
+ ) -> LatentState:
47
+ """Append reference video tokens with scaled positions."""
48
+ tokens = latent_tools.patchifier.patchify(self.latent)
49
+
50
+ # Compute positions for the reference video's actual dimensions
51
+ latent_coords = latent_tools.patchifier.get_patch_grid_bounds(
52
+ output_shape=VideoLatentShape.from_torch_shape(self.latent.shape),
53
+ device=self.latent.device,
54
+ )
55
+ positions = get_pixel_coords(
56
+ latent_coords=latent_coords,
57
+ scale_factors=latent_tools.scale_factors,
58
+ causal_fix=latent_tools.causal_fix,
59
+ )
60
+ positions = positions.to(dtype=torch.float32)
61
+ positions[:, 0, ...] /= latent_tools.fps
62
+
63
+ # Scale spatial positions to match target coordinate space
64
+ if self.downscale_factor != 1:
65
+ positions[:, 1, ...] *= self.downscale_factor # height axis
66
+ positions[:, 2, ...] *= self.downscale_factor # width axis
67
+
68
+ denoise_mask = torch.full(
69
+ size=(*tokens.shape[:2], 1),
70
+ fill_value=1.0 - self.strength,
71
+ device=self.latent.device,
72
+ dtype=self.latent.dtype,
73
+ )
74
+
75
+ new_attention_mask = update_attention_mask(
76
+ latent_state=latent_state,
77
+ attention_mask=None,
78
+ num_noisy_tokens=latent_tools.target_shape.token_count(),
79
+ num_new_tokens=tokens.shape[1],
80
+ batch_size=tokens.shape[0],
81
+ device=self.latent.device,
82
+ dtype=self.latent.dtype,
83
+ )
84
+
85
+ return LatentState(
86
+ latent=torch.cat([latent_state.latent, tokens], dim=1),
87
+ denoise_mask=torch.cat([latent_state.denoise_mask, denoise_mask], dim=1),
88
+ positions=torch.cat([latent_state.positions, positions], dim=2),
89
+ clean_latent=torch.cat([latent_state.clean_latent, tokens], dim=1),
90
+ attention_mask=new_attention_mask,
91
+ )
packages/ltx-core/src/ltx_core/guidance/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guidance and perturbation utilities for attention manipulation."""
2
+
3
+ from ltx_core.guidance.perturbations import (
4
+ BatchedPerturbationConfig,
5
+ Perturbation,
6
+ PerturbationConfig,
7
+ PerturbationType,
8
+ )
9
+
10
+ __all__ = [
11
+ "BatchedPerturbationConfig",
12
+ "Perturbation",
13
+ "PerturbationConfig",
14
+ "PerturbationType",
15
+ ]
packages/ltx-core/src/ltx_core/guidance/perturbations.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+ import torch
5
+ from torch._prims_common import DeviceLikeType
6
+
7
+
8
+ class PerturbationType(Enum):
9
+ """Types of attention perturbations for STG (Spatio-Temporal Guidance)."""
10
+
11
+ SKIP_A2V_CROSS_ATTN = "skip_a2v_cross_attn"
12
+ SKIP_V2A_CROSS_ATTN = "skip_v2a_cross_attn"
13
+ SKIP_VIDEO_SELF_ATTN = "skip_video_self_attn"
14
+ SKIP_AUDIO_SELF_ATTN = "skip_audio_self_attn"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Perturbation:
19
+ """A single perturbation specifying which attention type to skip and in which blocks."""
20
+
21
+ type: PerturbationType
22
+ blocks: list[int] | None # None means all blocks
23
+
24
+ def is_perturbed(self, perturbation_type: PerturbationType, block: int) -> bool:
25
+ if self.type != perturbation_type:
26
+ return False
27
+
28
+ if self.blocks is None:
29
+ return True
30
+
31
+ return block in self.blocks
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PerturbationConfig:
36
+ """Configuration holding a list of perturbations for a single sample."""
37
+
38
+ perturbations: list[Perturbation] | None
39
+
40
+ def is_perturbed(self, perturbation_type: PerturbationType, block: int) -> bool:
41
+ if self.perturbations is None:
42
+ return False
43
+
44
+ return any(perturbation.is_perturbed(perturbation_type, block) for perturbation in self.perturbations)
45
+
46
+ @staticmethod
47
+ def empty() -> "PerturbationConfig":
48
+ return PerturbationConfig([])
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BatchedPerturbationConfig:
53
+ """Perturbation configurations for a batch, with utilities for generating attention masks."""
54
+
55
+ perturbations: list[PerturbationConfig]
56
+
57
+ def mask(
58
+ self, perturbation_type: PerturbationType, block: int, device: DeviceLikeType, dtype: torch.dtype
59
+ ) -> torch.Tensor:
60
+ mask = torch.ones((len(self.perturbations),), device=device, dtype=dtype)
61
+ for batch_idx, perturbation in enumerate(self.perturbations):
62
+ if perturbation.is_perturbed(perturbation_type, block):
63
+ mask[batch_idx] = 0
64
+
65
+ return mask
66
+
67
+ def mask_like(self, perturbation_type: PerturbationType, block: int, values: torch.Tensor) -> torch.Tensor:
68
+ mask = self.mask(perturbation_type, block, values.device, values.dtype)
69
+ return mask.view(mask.numel(), *([1] * len(values.shape[1:])))
70
+
71
+ def any_in_batch(self, perturbation_type: PerturbationType, block: int) -> bool:
72
+ return any(perturbation.is_perturbed(perturbation_type, block) for perturbation in self.perturbations)
73
+
74
+ def all_in_batch(self, perturbation_type: PerturbationType, block: int) -> bool:
75
+ return all(perturbation.is_perturbed(perturbation_type, block) for perturbation in self.perturbations)
76
+
77
+ @staticmethod
78
+ def empty(batch_size: int) -> "BatchedPerturbationConfig":
79
+ return BatchedPerturbationConfig([PerturbationConfig.empty() for _ in range(batch_size)])
packages/ltx-core/src/ltx_core/hdr.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HDR utilities: LogC3 compression for HDR IC-LoRA training and inference.
2
+ Provides compress/decompress and postprocess helpers for HDR video generation.
3
+ Used by ltx-pipelines for HDR IC-LoRA and by ltx-trainer for HDR validation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Literal
9
+
10
+ import torch
11
+ from torch import Tensor
12
+
13
+
14
+ class LogC3:
15
+ """ARRI LogC3 (EI 800) HDR compression.
16
+ Maps linear [0, ∞) <-> LogC3 [0, 1] via the camera log curve. The log
17
+ curve allocates more precision to shadows/midtones and compresses
18
+ highlights smoothly. Callers are responsible for mapping the [0, 1]
19
+ output to the VAE's [-1, 1] input range.
20
+ """
21
+
22
+ name = "LogC3"
23
+ A = 5.555556
24
+ B = 0.052272
25
+ C = 0.247190
26
+ D = 0.385537
27
+ E = 5.367655
28
+ F = 0.092809
29
+ CUT = 0.010591
30
+
31
+ def compress(self, hdr: Tensor) -> Tensor:
32
+ """Compress linear HDR [0, ∞) → LogC3 [0, 1]."""
33
+ x = torch.clamp(hdr, min=0.0)
34
+ log_part = self.C * torch.log10(self.A * x + self.B) + self.D
35
+ lin_part = self.E * x + self.F
36
+ logc = torch.where(x >= self.CUT, log_part, lin_part)
37
+ return torch.clamp(logc, 0.0, 1.0)
38
+
39
+ def compress_ldr(self, ldr: Tensor) -> Tensor:
40
+ """Compress LDR [0, 1] → [0, 1] (no log curve, just clamp)."""
41
+ return torch.clamp(ldr, 0.0, 1.0)
42
+
43
+ def decompress(self, logc: Tensor) -> Tensor:
44
+ """Decompress LogC3 [0, 1] → linear HDR [0, ∞)."""
45
+ logc = torch.clamp(logc, 0.0, 1.0)
46
+ cut_log = self.E * self.CUT + self.F
47
+ lin_from_log = (torch.pow(10.0, (logc - self.D) / self.C) - self.B) / self.A
48
+ lin_from_lin = (logc - self.F) / self.E
49
+ return torch.where(logc >= cut_log, lin_from_log, lin_from_lin)
50
+
51
+ def decompress_ldr(self, logc: Tensor) -> Tensor:
52
+ """Decompress [0, 1] → LDR [0, 1] (identity clamp)."""
53
+ return torch.clamp(logc, 0.0, 1.0)
54
+
55
+
56
+ def apply_hdr_decode_postprocess(
57
+ decoded_video: Tensor,
58
+ transform: Literal["logc3"] = "logc3",
59
+ ) -> Tensor:
60
+ """Apply HDR decompress to VAE decode output for HDR recovery.
61
+ Args:
62
+ decoded_video: Tensor from VAE decode in [0, 1], shape [B, C, F, H, W].
63
+ Must be float32 for sufficient color resolution.
64
+ transform: "logc3".
65
+ Returns:
66
+ HDR video tensor float32.
67
+ """
68
+ decoded_video = decoded_video.float()
69
+ if transform == "logc3":
70
+ return LogC3().decompress(decoded_video)
71
+ raise ValueError(f"Unsupported HDR transform: {transform}")
packages/ltx-core/src/ltx_core/loader/__init__.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loader utilities for model weights, LoRAs, and safetensor operations."""
2
+
3
+ from ltx_core.loader.fuse_loras import apply_loras
4
+ from ltx_core.loader.helpers import (
5
+ create_meta_model,
6
+ load_state_dict,
7
+ read_model_config,
8
+ )
9
+ from ltx_core.loader.module_ops import ModuleOps
10
+ from ltx_core.loader.primitives import (
11
+ LoRAAdaptableProtocol,
12
+ LoraPathStrengthAndSDOps,
13
+ LoraStateDictWithStrength,
14
+ ModelBuilderProtocol,
15
+ StateDict,
16
+ StateDictLoader,
17
+ )
18
+ from ltx_core.loader.registry import DummyRegistry, Registry, StateDictRegistry
19
+ from ltx_core.loader.sd_ops import (
20
+ LTXV_LORA_COMFY_RENAMING_MAP,
21
+ ContentMatching,
22
+ ContentReplacement,
23
+ KeyValueOperation,
24
+ KeyValueOperationResult,
25
+ SDKeyValueOperation,
26
+ SDOps,
27
+ )
28
+ from ltx_core.loader.sft_loader import SafetensorsModelStateDictLoader, SafetensorsStateDictLoader
29
+ from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
30
+
31
+ __all__ = [
32
+ "LTXV_LORA_COMFY_RENAMING_MAP",
33
+ "ContentMatching",
34
+ "ContentReplacement",
35
+ "DummyRegistry",
36
+ "KeyValueOperation",
37
+ "KeyValueOperationResult",
38
+ "LoRAAdaptableProtocol",
39
+ "LoraPathStrengthAndSDOps",
40
+ "LoraStateDictWithStrength",
41
+ "ModelBuilderProtocol",
42
+ "ModuleOps",
43
+ "Registry",
44
+ "SDKeyValueOperation",
45
+ "SDOps",
46
+ "SafetensorsModelStateDictLoader",
47
+ "SafetensorsStateDictLoader",
48
+ "SingleGPUModelBuilder",
49
+ "StateDict",
50
+ "StateDictLoader",
51
+ "StateDictRegistry",
52
+ "apply_loras",
53
+ "create_meta_model",
54
+ "load_state_dict",
55
+ "read_model_config",
56
+ ]
packages/ltx-core/src/ltx_core/loader/fuse_loras.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Iterable, Iterator
2
+ from typing import NamedTuple
3
+
4
+ import torch
5
+
6
+ from ltx_core.loader.kernels import TRITON_AVAILABLE
7
+ from ltx_core.loader.primitives import LoraStateDictWithStrength, StateDict
8
+ from ltx_core.quantization.fp8_cast import fused_add_round_launch
9
+ from ltx_core.quantization.fp8_scaled_mm import quantize_weight_to_fp8_per_tensor
10
+
11
+
12
+ class LoraProduct(NamedTuple):
13
+ """A LoRA's ``A``, ``B`` factors and its strength scalar."""
14
+
15
+ a: torch.Tensor
16
+ b: torch.Tensor
17
+ strength: float
18
+
19
+
20
+ def _get_device() -> torch.device:
21
+ if torch.cuda.is_available():
22
+ return torch.device("cuda", torch.cuda.current_device())
23
+ return torch.device("cpu")
24
+
25
+
26
+ def aggregate_lora_products(
27
+ products: Iterable[LoraProduct],
28
+ dtype: torch.dtype | None = None,
29
+ *,
30
+ out: torch.Tensor | None = None,
31
+ ) -> torch.Tensor | None:
32
+ """Accumulate ``sum((B * strength) @ A)`` across :class:`LoraProduct` items.
33
+ If ``out`` is provided, ``addmm_`` accumulates directly into it — caller
34
+ ensures A/B dtypes and devices match ``out``. Otherwise the first product
35
+ materializes the ``(out, in)``-shape aggregator at ``dtype``; subsequent
36
+ products use ``addmm_`` to avoid allocating the full intermediate delta.
37
+ Returns ``out`` (or the new aggregator), or ``None`` if ``products`` was empty
38
+ and ``out`` was not given.
39
+ """
40
+ aggregated = out
41
+ for product in products:
42
+ if aggregated is None:
43
+ aggregated = torch.matmul(product.b * product.strength, product.a).to(dtype=dtype)
44
+ else:
45
+ aggregated.addmm_(product.b, product.a, alpha=product.strength)
46
+ return aggregated
47
+
48
+
49
+ def fuse_cast_fp8_weight(
50
+ delta_bf16: torch.Tensor,
51
+ weight_fp8: torch.Tensor,
52
+ target_dtype: torch.dtype,
53
+ ) -> torch.Tensor:
54
+ """Return ``(delta_bf16 + dequantize(weight_fp8)).to(target_dtype)``.
55
+ CUDA with Triton uses stochastic rounding; otherwise uses a deterministic bf16 add.
56
+ ``delta_bf16`` is the bf16 accumulator and is mutated in place.
57
+ """
58
+ if delta_bf16.dtype != torch.bfloat16:
59
+ raise ValueError(f"delta_bf16 must be bfloat16, got {delta_bf16.dtype}")
60
+ if str(weight_fp8.device).startswith("cuda") and TRITON_AVAILABLE:
61
+ fused_add_round_launch(delta_bf16, weight_fp8, seed=0)
62
+ else:
63
+ delta_bf16.add_(weight_fp8.to(dtype=torch.bfloat16))
64
+ return delta_bf16.to(dtype=target_dtype)
65
+
66
+
67
+ def fuse_lora_weights(
68
+ model_sd: StateDict,
69
+ lora_sd_and_strengths: list[LoraStateDictWithStrength],
70
+ dtype: torch.dtype | None = None,
71
+ preserve_input_device: bool = True,
72
+ ) -> Iterator[tuple[str, torch.Tensor]]:
73
+ """Yield ``(key, fused_tensor)`` for each weight modified by at least one LoRA.
74
+ For scaled-FP8 weights, this includes both the updated ``.weight`` tensor
75
+ and its corresponding ``.weight_scale`` tensor.
76
+ When ``preserve_input_device`` is False, fused tensors are yielded on the device
77
+ used for fusion; caller is responsible for moving them to their final
78
+ destination.
79
+ """
80
+ for key, original_weight in model_sd.sd.items():
81
+ if original_weight is None or key.endswith(".weight_scale"):
82
+ continue
83
+ original_device = original_weight.device
84
+ weight = original_weight.to(device=_get_device())
85
+ target_dtype = dtype if dtype is not None else weight.dtype
86
+ deltas_dtype = target_dtype if target_dtype not in [torch.float8_e4m3fn, torch.float8_e5m2] else torch.bfloat16
87
+
88
+ deltas = _aggregate_deltas(lora_sd_and_strengths, key, deltas_dtype, weight.device)
89
+ if deltas is None:
90
+ continue
91
+
92
+ scale_key = key.replace(".weight", ".weight_scale") if key.endswith(".weight") else None
93
+ is_scaled_fp8 = scale_key is not None and scale_key in model_sd.sd
94
+
95
+ if weight.dtype == torch.float8_e4m3fn:
96
+ if is_scaled_fp8:
97
+ fused = _fuse_delta_with_scaled_fp8(deltas, weight, key, scale_key, model_sd)
98
+ else:
99
+ fused = {key: fuse_cast_fp8_weight(deltas, weight, target_dtype)}
100
+ elif weight.dtype == torch.bfloat16:
101
+ deltas.add_(weight)
102
+ fused = {key: deltas.to(dtype=target_dtype)}
103
+ else:
104
+ raise ValueError(f"Unsupported dtype: {weight.dtype}")
105
+
106
+ for k, v in fused.items():
107
+ yield k, v.to(device=original_device) if preserve_input_device else v
108
+
109
+
110
+ def apply_loras(
111
+ model_sd: StateDict,
112
+ lora_sd_and_strengths: list[LoraStateDictWithStrength],
113
+ dtype: torch.dtype | None = None,
114
+ destination_sd: StateDict | None = None,
115
+ ) -> StateDict:
116
+ """Fuse LoRAs into ``model_sd`` and place the results in ``destination_sd``.
117
+ When ``destination_sd`` is provided, the fused tensors are placed directly into it.
118
+ """
119
+ if destination_sd is not None:
120
+ for key, fused in fuse_lora_weights(model_sd, lora_sd_and_strengths, dtype):
121
+ destination_sd.sd[key] = fused
122
+ return destination_sd
123
+
124
+ fused = dict(fuse_lora_weights(model_sd, lora_sd_and_strengths, dtype))
125
+ sd = {k: (fused[k] if k in fused else v.clone()) for k, v in model_sd.sd.items()}
126
+ return StateDict(sd, model_sd.device, model_sd.size, model_sd.dtype)
127
+
128
+
129
+ def _aggregate_deltas(
130
+ lora_sd_and_strengths: list[LoraStateDictWithStrength], key: str, dtype: torch.dtype, device: torch.device
131
+ ) -> torch.Tensor | None:
132
+ prefix = key[: -len(".weight")]
133
+ key_a = f"{prefix}.lora_A.weight"
134
+ key_b = f"{prefix}.lora_B.weight"
135
+
136
+ def _ab_products() -> Iterator[LoraProduct]:
137
+ for lsd, coef in lora_sd_and_strengths:
138
+ if key_a not in lsd.sd or key_b not in lsd.sd:
139
+ continue
140
+ a = lsd.sd[key_a].to(device=device, dtype=dtype, non_blocking=True)
141
+ b = lsd.sd[key_b].to(device=device, dtype=dtype, non_blocking=True)
142
+ yield LoraProduct(a, b, coef)
143
+
144
+ return aggregate_lora_products(_ab_products(), dtype)
145
+
146
+
147
+ def _fuse_delta_with_scaled_fp8(
148
+ deltas: torch.Tensor,
149
+ weight: torch.Tensor,
150
+ key: str,
151
+ scale_key: str,
152
+ model_sd: StateDict,
153
+ ) -> dict[str, torch.Tensor]:
154
+ """Dequantize scaled FP8 weight, add LoRA delta, and re-quantize."""
155
+ weight_scale = model_sd.sd[scale_key]
156
+
157
+ original_weight = weight.to(torch.float32) * weight_scale
158
+
159
+ new_weight = original_weight + deltas.to(torch.float32)
160
+
161
+ new_fp8_weight, new_weight_scale = quantize_weight_to_fp8_per_tensor(new_weight)
162
+ return {key: new_fp8_weight, scale_key: new_weight_scale}
packages/ltx-core/src/ltx_core/loader/helpers.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared model-construction helpers used by both SingleGPUModelBuilder and StreamingModelBuilder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypeVar
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from ltx_core.loader.module_ops import ModuleOps
11
+ from ltx_core.loader.primitives import StateDict, StateDictLoader
12
+ from ltx_core.loader.registry import Registry
13
+ from ltx_core.loader.sd_ops import SDOps
14
+ from ltx_core.model.model_protocol import ModelConfigurator
15
+
16
+ _M = TypeVar("_M", bound=nn.Module)
17
+
18
+
19
+ def load_state_dict(
20
+ paths: str | tuple[str, ...] | list[str],
21
+ loader: StateDictLoader,
22
+ registry: Registry,
23
+ device: torch.device | None,
24
+ sd_ops: SDOps | None = None,
25
+ ) -> StateDict:
26
+ """Load a state dict from disk, using registry caching."""
27
+ if isinstance(paths, str):
28
+ path_list = [paths]
29
+ elif isinstance(paths, tuple):
30
+ path_list = list(paths)
31
+ else:
32
+ path_list = paths
33
+ cached = registry.get(path_list, sd_ops)
34
+ if cached is not None:
35
+ return cached
36
+ result = loader.load(path_list, sd_ops=sd_ops, device=device)
37
+ registry.add(path_list, sd_ops=sd_ops, state_dict=result)
38
+ return result
39
+
40
+
41
+ def read_model_config(
42
+ model_path: str | tuple[str, ...],
43
+ loader: StateDictLoader,
44
+ ) -> dict:
45
+ """Read metadata from the first shard of a checkpoint."""
46
+ first = model_path[0] if isinstance(model_path, tuple) else model_path
47
+ return loader.metadata(first)
48
+
49
+
50
+ def create_meta_model(
51
+ configurator: type[ModelConfigurator[_M]],
52
+ config: dict,
53
+ module_ops: tuple[ModuleOps, ...] = (),
54
+ ) -> _M:
55
+ """Create a model on the meta device and apply module operations."""
56
+ with torch.device("meta"):
57
+ model = configurator.from_config(config)
58
+ for op in module_ops:
59
+ if op.matcher(model):
60
+ model = op.mutator(model)
61
+ return model
packages/ltx-core/src/ltx_core/loader/kernels.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa: ANN001, ANN201, ERA001, N803, N806
2
+ try:
3
+ import triton
4
+ import triton.language as tl
5
+
6
+ TRITON_AVAILABLE = True
7
+ except (ImportError, OSError):
8
+ TRITON_AVAILABLE = False
9
+
10
+
11
+ if TRITON_AVAILABLE:
12
+
13
+ @triton.jit
14
+ def fused_add_round_kernel(
15
+ x_ptr,
16
+ output_ptr, # contents will be added to the output
17
+ seed,
18
+ n_elements,
19
+ EXPONENT_BIAS,
20
+ MANTISSA_BITS,
21
+ BLOCK_SIZE: tl.constexpr,
22
+ ):
23
+ """
24
+ A kernel to upcast 8bit quantized weights to bfloat16 with stochastic rounding
25
+ and add them to bfloat16 output weights. Might be used to upcast original model weights
26
+ and to further add them to precalculated deltas coming from LoRAs.
27
+ """
28
+ # Get program ID and compute offsets
29
+ pid = tl.program_id(axis=0)
30
+ block_start = pid * BLOCK_SIZE
31
+ offsets = block_start + tl.arange(0, BLOCK_SIZE)
32
+ mask = offsets < n_elements
33
+
34
+ # Load data
35
+ x = tl.load(x_ptr + offsets, mask=mask)
36
+ rand_vals = tl.rand(seed, offsets) - 0.5
37
+
38
+ x = tl.cast(x, tl.float16)
39
+ delta = tl.load(output_ptr + offsets, mask=mask)
40
+ delta = tl.cast(delta, tl.float16)
41
+ x = x + delta
42
+
43
+ x_bits = tl.cast(x, tl.int16, bitcast=True)
44
+
45
+ # Calculate the exponent. Unbiased fp16 exponent is ((x_bits & 0x7C00) >> 10) - 15 for
46
+ # normal numbers and -14 for subnormals.
47
+ fp16_exponent_bits = (x_bits & 0x7C00) >> 10
48
+ fp16_normals = fp16_exponent_bits > 0
49
+ fp16_exponent = tl.where(fp16_normals, fp16_exponent_bits - 15, -14)
50
+
51
+ # Add the target dtype's exponent bias and clamp to the target dtype's exponent range.
52
+ exponent = fp16_exponent + EXPONENT_BIAS
53
+ MAX_EXPONENT = 2 * EXPONENT_BIAS + 1
54
+ exponent = tl.where(exponent > MAX_EXPONENT, MAX_EXPONENT, exponent)
55
+ exponent = tl.where(exponent < 0, 0, exponent)
56
+
57
+ # Normal ULP exponent, expressed as an fp16 exponent field:
58
+ # (exponent - EXPONENT_BIAS - MANTISSA_BITS) + 15
59
+ # Simplifies to: fp16_exponent - MANTISSA_BITS + 15
60
+ # See https://en.wikipedia.org/wiki/Unit_in_the_last_place
61
+ eps_exp = tl.maximum(0, tl.minimum(31, exponent - EXPONENT_BIAS - MANTISSA_BITS + 15))
62
+
63
+ # Calculate epsilon in the target dtype
64
+ eps_normal = tl.cast(tl.cast(eps_exp << 10, tl.int16), tl.float16, bitcast=True)
65
+
66
+ # Subnormal ULP: 2^(1 - EXPONENT_BIAS - MANTISSA_BITS) ->
67
+ # fp16 exponent bits: (1 - EXPONENT_BIAS - MANTISSA_BITS) + 15 =
68
+ # 16 - EXPONENT_BIAS - MANTISSA_BITS
69
+ eps_subnormal = tl.cast(tl.cast((16 - EXPONENT_BIAS - MANTISSA_BITS) << 10, tl.int16), tl.float16, bitcast=True)
70
+ eps = tl.where(exponent > 0, eps_normal, eps_subnormal)
71
+
72
+ # Apply zero mask to epsilon
73
+ eps = tl.where(x == 0, 0.0, eps)
74
+
75
+ # Apply stochastic rounding
76
+ output = tl.cast(x + rand_vals * eps, tl.bfloat16)
77
+
78
+ # Store the result
79
+ tl.store(output_ptr + offsets, output, mask=mask)
packages/ltx-core/src/ltx_core/loader/module_ops.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Callable, NamedTuple
2
+
3
+ import torch
4
+
5
+
6
+ class ModuleOps(NamedTuple):
7
+ """
8
+ Defines a named operation for matching and mutating PyTorch modules.
9
+ Used to selectively transform modules in a model (e.g., replacing layers with quantized versions).
10
+ """
11
+
12
+ name: str
13
+ matcher: Callable[[torch.nn.Module], bool]
14
+ mutator: Callable[[torch.nn.Module], torch.nn.Module]
packages/ltx-core/src/ltx_core/loader/primitives.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, NamedTuple, Protocol
5
+
6
+ import torch
7
+
8
+ from ltx_core.loader.module_ops import ModuleOps
9
+ from ltx_core.loader.sd_ops import SDOps
10
+ from ltx_core.model.model_protocol import ModelType
11
+
12
+ if TYPE_CHECKING:
13
+ from ltx_core.loader.registry import Registry
14
+
15
+
16
+ # Per-key shape and dtype description for a flat collection of tensors.
17
+ TensorLayout = dict[str, tuple[torch.Size, torch.dtype]]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class StateDict:
22
+ """
23
+ Immutable container for a PyTorch state dictionary.
24
+ Contains:
25
+ - sd: Dictionary of tensors (weights, buffers, etc.)
26
+ - device: Device where tensors are stored
27
+ - size: Total memory footprint in bytes
28
+ - dtype: Set of tensor dtypes present
29
+ """
30
+
31
+ sd: dict
32
+ device: torch.device
33
+ size: int
34
+ dtype: set[torch.dtype]
35
+
36
+ def footprint(self) -> tuple[int, torch.device]:
37
+ return self.size, self.device
38
+
39
+
40
+ class StateDictLoader(Protocol):
41
+ """
42
+ Protocol for loading state dictionaries from various sources.
43
+ Implementations must provide:
44
+ - metadata: Extract model metadata from a single path
45
+ - load: Load state dict from path(s) and apply SDOps transformations
46
+ """
47
+
48
+ def metadata(self, path: str) -> dict:
49
+ """
50
+ Load metadata from path
51
+ """
52
+
53
+ def load(self, path: str | list[str], sd_ops: SDOps | None = None, device: torch.device | None = None) -> StateDict:
54
+ """
55
+ Load state dict from path or paths (for sharded model storage) and apply sd_ops
56
+ """
57
+
58
+
59
+ class BuilderProtocol(Protocol[ModelType]):
60
+ """Protocol for model builders that produce a model via ``build()``."""
61
+
62
+ def build(
63
+ self, device: torch.device | None = None, dtype: torch.dtype | None = None, **kwargs: object
64
+ ) -> ModelType: ...
65
+
66
+
67
+ class ModelBuilderProtocol(BuilderProtocol[ModelType], Protocol[ModelType]):
68
+ """
69
+ Protocol for building PyTorch models from configuration dictionaries.
70
+ Implementations must provide:
71
+ - meta_model: Create a model from configuration dictionary and apply module operations
72
+ - build: Create and initialize a model from state dictionary and apply dtype transformations
73
+ """
74
+
75
+ model_sd_ops: SDOps | None
76
+ module_ops: tuple[ModuleOps, ...]
77
+ loras: tuple["LoraPathStrengthAndSDOps", ...]
78
+ registry: "Registry"
79
+
80
+ def meta_model(self, config: dict, module_ops: list[ModuleOps] | None = None) -> ModelType:
81
+ """
82
+ Create a model on the meta device from a configuration dictionary.
83
+ This decouples model creation from weight loading, allowing the model
84
+ architecture to be instantiated without allocating memory for parameters.
85
+ Args:
86
+ config: Model configuration dictionary.
87
+ module_ops: Optional list of module operations to apply (e.g., quantization).
88
+ Returns:
89
+ Model instance on meta device (no actual memory allocated for parameters).
90
+ """
91
+ ...
92
+
93
+ def with_sd_ops(self, sd_ops: SDOps | None) -> "ModelBuilderProtocol[ModelType]":
94
+ """Return a copy of this builder with the given state-dict key remapping ops."""
95
+ ...
96
+
97
+ def with_module_ops(self, module_ops: tuple[ModuleOps, ...]) -> "ModelBuilderProtocol[ModelType]":
98
+ """Return a copy of this builder with the given module operations (e.g. quantization)."""
99
+ ...
100
+
101
+ def with_loras(self, loras: tuple["LoraPathStrengthAndSDOps", ...]) -> "ModelBuilderProtocol[ModelType]":
102
+ """Return a copy of this builder with the given LoRAs to fuse at build time."""
103
+ ...
104
+
105
+ def with_registry(self, registry: "Registry") -> "ModelBuilderProtocol[ModelType]":
106
+ """Return a copy of this builder using the given weight registry for allocation."""
107
+ ...
108
+
109
+ def with_lora_load_device(self, device: torch.device) -> "ModelBuilderProtocol[ModelType]":
110
+ """Return a copy of this builder that loads LoRA weights onto the given device."""
111
+ ...
112
+
113
+ def build(
114
+ self, device: torch.device | None = None, dtype: torch.dtype | None = None, **kwargs: object
115
+ ) -> ModelType:
116
+ """
117
+ Build the model
118
+ Args:
119
+ device: Target device for the model
120
+ dtype: Target dtype for the model, if None, uses the dtype of the model_path model
121
+ Returns:
122
+ Model instance
123
+ """
124
+ ...
125
+
126
+ def model_config(self) -> dict:
127
+ """Return the model configuration dictionary extracted from the checkpoint metadata."""
128
+ ...
129
+
130
+
131
+ class LoRAAdaptableProtocol(Protocol):
132
+ """
133
+ Protocol for models that can be adapted with LoRAs.
134
+ Implementations must provide:
135
+ - lora: Add a LoRA to the model
136
+ """
137
+
138
+ def lora(self, lora_path: str, strength: float) -> "LoRAAdaptableProtocol":
139
+ pass
140
+
141
+
142
+ class LoraPathStrengthAndSDOps(NamedTuple):
143
+ """
144
+ Tuple containing a LoRA path, strength, and SDOps for applying to the LoRA state dict.
145
+ """
146
+
147
+ path: str
148
+ strength: float
149
+ sd_ops: SDOps
150
+
151
+
152
+ class LoraStateDictWithStrength(NamedTuple):
153
+ """
154
+ Tuple containing a LoRA state dict and strength for applying to the model.
155
+ """
156
+
157
+ state_dict: StateDict
158
+ strength: float
packages/ltx-core/src/ltx_core/loader/registry.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import threading
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from ltx_core.loader.primitives import StateDict
8
+ from ltx_core.loader.sd_ops import SDOps
9
+
10
+
11
+ class Registry(Protocol):
12
+ """
13
+ Protocol for managing state dictionaries in a registry.
14
+ It is used to store state dictionaries and reuse them later without loading them again.
15
+ Implementations must provide:
16
+ - add: Add a state dictionary to the registry
17
+ - pop: Remove a state dictionary from the registry
18
+ - get: Retrieve a state dictionary from the registry
19
+ - clear: Clear all state dictionaries from the registry
20
+ """
21
+
22
+ def add(self, paths: list[str], sd_ops: SDOps | None, state_dict: StateDict) -> None: ...
23
+
24
+ def pop(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None: ...
25
+
26
+ def get(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None: ...
27
+
28
+ def clear(self) -> None: ...
29
+
30
+
31
+ class DummyRegistry(Registry):
32
+ """
33
+ Dummy registry that does not store state dictionaries.
34
+ """
35
+
36
+ def add(self, paths: list[str], sd_ops: SDOps | None, state_dict: StateDict) -> None:
37
+ pass
38
+
39
+ def pop(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None:
40
+ pass
41
+
42
+ def get(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None:
43
+ pass
44
+
45
+ def clear(self) -> None:
46
+ pass
47
+
48
+
49
+ @dataclass
50
+ class StateDictRegistry(Registry):
51
+ """
52
+ Registry that stores state dictionaries in a dictionary.
53
+ """
54
+
55
+ _state_dicts: dict[str, StateDict] = field(default_factory=dict)
56
+ _lock: threading.Lock = field(default_factory=threading.Lock)
57
+
58
+ def _generate_id(self, paths: list[str], sd_ops: SDOps) -> str:
59
+ m = hashlib.sha256()
60
+ parts = [str(Path(p).resolve()) for p in paths]
61
+ if sd_ops is not None:
62
+ parts.append(sd_ops.name)
63
+ m.update("\0".join(parts).encode("utf-8"))
64
+ return m.hexdigest()
65
+
66
+ def add(self, paths: list[str], sd_ops: SDOps | None, state_dict: StateDict) -> str:
67
+ sd_id = self._generate_id(paths, sd_ops)
68
+ with self._lock:
69
+ if sd_id in self._state_dicts:
70
+ raise ValueError(f"State dict retrieved from {paths} with {sd_ops} already added, check with get first")
71
+ self._state_dicts[sd_id] = state_dict
72
+ return sd_id
73
+
74
+ def pop(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None:
75
+ with self._lock:
76
+ return self._state_dicts.pop(self._generate_id(paths, sd_ops), None)
77
+
78
+ def get(self, paths: list[str], sd_ops: SDOps | None) -> StateDict | None:
79
+ with self._lock:
80
+ return self._state_dicts.get(self._generate_id(paths, sd_ops), None)
81
+
82
+ def clear(self) -> None:
83
+ with self._lock:
84
+ self._state_dicts.clear()
packages/ltx-core/src/ltx_core/loader/sd_ops.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, replace
2
+ from typing import NamedTuple, Protocol
3
+
4
+ import torch
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ContentReplacement:
9
+ """
10
+ Represents a content replacement operation.
11
+ Used to replace a specific content with a replacement in a state dict key.
12
+ """
13
+
14
+ content: str
15
+ replacement: str
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ContentMatching:
20
+ """
21
+ Represents a content matching operation.
22
+ Used to match a specific prefix and suffix in a state dict key.
23
+ """
24
+
25
+ prefix: str = ""
26
+ suffix: str = ""
27
+
28
+
29
+ class KeyValueOperationResult(NamedTuple):
30
+ """
31
+ Represents the result of a key-value operation.
32
+ Contains the new key and value after the operation has been applied.
33
+ """
34
+
35
+ new_key: str
36
+ new_value: torch.Tensor
37
+
38
+
39
+ class KeyValueOperation(Protocol):
40
+ """
41
+ Protocol for key-value operations.
42
+ Used to apply operations to a specific key and value in a state dict.
43
+ """
44
+
45
+ def __call__(self, tensor_key: str, tensor_value: torch.Tensor) -> list[KeyValueOperationResult]: ...
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class SDKeyValueOperation:
50
+ """
51
+ Represents a key-value operation.
52
+ Used to apply operations to a specific key and value in a state dict.
53
+ """
54
+
55
+ key_matcher: ContentMatching
56
+ kv_operation: KeyValueOperation
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SDOps:
61
+ """Immutable class representing state dict key operations."""
62
+
63
+ name: str
64
+ mapping: tuple[
65
+ ContentReplacement | ContentMatching | SDKeyValueOperation, ...
66
+ ] = () # Immutable tuple of (key, value) pairs
67
+ allowed_keys: frozenset[str] | None = None
68
+
69
+ def with_replacement(self, content: str, replacement: str) -> "SDOps":
70
+ """Create a new SDOps instance with the specified replacement added to the mapping."""
71
+
72
+ new_mapping = (*self.mapping, ContentReplacement(content, replacement))
73
+ return replace(self, mapping=new_mapping)
74
+
75
+ def with_matching(self, prefix: str = "", suffix: str = "") -> "SDOps":
76
+ """Create a new SDOps instance with the specified prefix and suffix matching added to the mapping."""
77
+
78
+ new_mapping = (*self.mapping, ContentMatching(prefix, suffix))
79
+ return replace(self, mapping=new_mapping)
80
+
81
+ def with_additional_allowed_keys(self, keys: frozenset[str]) -> "SDOps":
82
+ """Create a new SDOps instance that only passes keys present in *keys* (post-replacement).
83
+ If allowed_keys already exists, the sets are merged via union.
84
+ """
85
+ merged = frozenset(keys) | self.allowed_keys if self.allowed_keys is not None else frozenset(keys)
86
+ return replace(self, allowed_keys=merged)
87
+
88
+ def with_kv_operation(
89
+ self,
90
+ operation: KeyValueOperation,
91
+ key_prefix: str = "",
92
+ key_suffix: str = "",
93
+ ) -> "SDOps":
94
+ """Create a new SDOps instance with the specified value operation added to the mapping."""
95
+ key_matcher = ContentMatching(key_prefix, key_suffix)
96
+ sd_kv_operation = SDKeyValueOperation(key_matcher, operation)
97
+ new_mapping = (*self.mapping, sd_kv_operation)
98
+ return replace(self, mapping=new_mapping)
99
+
100
+ def apply_to_key(self, key: str) -> str | None:
101
+ """Apply the mapping to the given name."""
102
+ matchers = [content for content in self.mapping if isinstance(content, ContentMatching)]
103
+ valid = any(key.startswith(f.prefix) and key.endswith(f.suffix) for f in matchers)
104
+ if not valid:
105
+ return None
106
+
107
+ for replacement in self.mapping:
108
+ if not isinstance(replacement, ContentReplacement):
109
+ continue
110
+ if replacement.content in key:
111
+ key = key.replace(replacement.content, replacement.replacement)
112
+
113
+ if self.allowed_keys is not None and key not in self.allowed_keys:
114
+ return None
115
+
116
+ return key
117
+
118
+ def apply_to_key_value(self, key: str, value: torch.Tensor) -> list[KeyValueOperationResult]:
119
+ """Apply the value operation to the given name and associated value."""
120
+ for operation in self.mapping:
121
+ if not isinstance(operation, SDKeyValueOperation):
122
+ continue
123
+ if key.startswith(operation.key_matcher.prefix) and key.endswith(operation.key_matcher.suffix):
124
+ return operation.kv_operation(key, value)
125
+ return [KeyValueOperationResult(key, value)]
126
+
127
+
128
+ # Predefined SDOps instances
129
+ LTXV_LORA_COMFY_RENAMING_MAP = (
130
+ SDOps("LTXV_LORA_COMFY_PREFIX_MAP").with_matching().with_replacement("diffusion_model.", "")
131
+ )
132
+
133
+ LTXV_LORA_COMFY_TARGET_MAP = (
134
+ SDOps("LTXV_LORA_COMFY_TARGET_MAP")
135
+ .with_matching()
136
+ .with_replacement("diffusion_model.", "")
137
+ .with_replacement(".lora_A.weight", ".weight")
138
+ .with_replacement(".lora_B.weight", ".weight")
139
+ )
packages/ltx-core/src/ltx_core/loader/sft_loader.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import safetensors
4
+ import torch
5
+
6
+ from ltx_core.loader.primitives import StateDict, StateDictLoader
7
+ from ltx_core.loader.sd_ops import SDOps
8
+
9
+
10
+ class SafetensorsStateDictLoader(StateDictLoader):
11
+ """
12
+ Loads weights from safetensors files without metadata support.
13
+ Use this for loading raw weight files. For model files that include
14
+ configuration metadata, use SafetensorsModelStateDictLoader instead.
15
+ """
16
+
17
+ def metadata(self, path: str) -> dict:
18
+ raise NotImplementedError("Not implemented")
19
+
20
+ def load(self, path: str | list[str], sd_ops: SDOps, device: torch.device | None = None) -> StateDict:
21
+ """
22
+ Load state dict from path or paths (for sharded model storage) and apply sd_ops
23
+ """
24
+ sd = {}
25
+ size = 0
26
+ dtype = set()
27
+ device = device or torch.device("cpu")
28
+ model_paths = path if isinstance(path, list) else [path]
29
+ for shard_path in model_paths:
30
+ with safetensors.safe_open(shard_path, framework="pt", device=str(device)) as f:
31
+ safetensor_keys = f.keys()
32
+ for name in safetensor_keys:
33
+ expected_name = name if sd_ops is None else sd_ops.apply_to_key(name)
34
+ if expected_name is None:
35
+ continue
36
+ value = f.get_tensor(name).to(device=device, non_blocking=True, copy=False)
37
+ key_value_pairs = ((expected_name, value),)
38
+ if sd_ops is not None:
39
+ key_value_pairs = sd_ops.apply_to_key_value(expected_name, value)
40
+ for key, value in key_value_pairs:
41
+ size += value.nbytes
42
+ dtype.add(value.dtype)
43
+ sd[key] = value
44
+
45
+ return StateDict(sd=sd, device=device, size=size, dtype=dtype)
46
+
47
+
48
+ class SafetensorsModelStateDictLoader(StateDictLoader):
49
+ """
50
+ Loads weights and configuration metadata from safetensors model files.
51
+ Unlike SafetensorsStateDictLoader, this loader can read model configuration
52
+ from the safetensors file metadata via the metadata() method.
53
+ """
54
+
55
+ def __init__(self, weight_loader: SafetensorsStateDictLoader | None = None):
56
+ self.weight_loader = weight_loader if weight_loader is not None else SafetensorsStateDictLoader()
57
+
58
+ def metadata(self, path: str) -> dict:
59
+ with safetensors.safe_open(path, framework="pt") as f:
60
+ meta = f.metadata()
61
+ if meta is None or "config" not in meta:
62
+ return {}
63
+ return json.loads(meta["config"])
64
+
65
+ def load(self, path: str | list[str], sd_ops: SDOps | None = None, device: torch.device | None = None) -> StateDict:
66
+ return self.weight_loader.load(path, sd_ops, device)
packages/ltx-core/src/ltx_core/loader/single_gpu_model_builder.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from dataclasses import dataclass, field, replace
3
+ from typing import Generic
4
+
5
+ import torch
6
+ from torch import nn
7
+
8
+ from ltx_core.loader.fuse_loras import apply_loras
9
+ from ltx_core.loader.helpers import create_meta_model, load_state_dict, read_model_config
10
+ from ltx_core.loader.module_ops import ModuleOps
11
+ from ltx_core.loader.primitives import (
12
+ LoRAAdaptableProtocol,
13
+ LoraPathStrengthAndSDOps,
14
+ LoraStateDictWithStrength,
15
+ ModelBuilderProtocol,
16
+ StateDict,
17
+ StateDictLoader,
18
+ )
19
+ from ltx_core.loader.registry import DummyRegistry, Registry
20
+ from ltx_core.loader.sd_ops import SDOps
21
+ from ltx_core.loader.sft_loader import SafetensorsModelStateDictLoader
22
+ from ltx_core.model.model_protocol import ModelConfigurator, ModelType
23
+
24
+ logger: logging.Logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _check_uninitialized(model: nn.Module) -> list[str]:
28
+ """Return names of any parameters/buffers still on meta device."""
29
+ names = []
30
+ for name, param in model.named_parameters():
31
+ if str(param.device) == "meta":
32
+ names.append(name)
33
+ for name, buf in model.named_buffers():
34
+ if str(buf.device) == "meta":
35
+ names.append(name)
36
+ return names
37
+
38
+
39
+ def _load_model_weights(
40
+ meta_model: nn.Module,
41
+ model_path: str | tuple[str, ...],
42
+ loras: tuple[LoraPathStrengthAndSDOps, ...],
43
+ loader: StateDictLoader,
44
+ registry: Registry,
45
+ device: torch.device,
46
+ dtype: torch.dtype | None,
47
+ model_sd_ops: SDOps | None = None,
48
+ lora_load_device: torch.device | None = None,
49
+ ) -> None:
50
+ """Load base weights and fuse LoRAs into *meta_model* in-place."""
51
+ if lora_load_device is None:
52
+ lora_load_device = device
53
+
54
+ model_sd = load_state_dict(model_path, loader, registry, device, model_sd_ops)
55
+
56
+ lora_strengths = [lora.strength for lora in loras]
57
+ if not lora_strengths or (min(lora_strengths) == 0 and max(lora_strengths) == 0):
58
+ sd = model_sd.sd
59
+ if dtype is not None:
60
+ sd = {key: value.to(dtype=dtype) for key, value in model_sd.sd.items()}
61
+ meta_model.load_state_dict(sd, strict=False, assign=True)
62
+ return
63
+
64
+ lora_state_dicts = [load_state_dict([lora.path], loader, registry, lora_load_device, lora.sd_ops) for lora in loras]
65
+ lora_sd_and_strengths = [
66
+ LoraStateDictWithStrength(sd, strength) for sd, strength in zip(lora_state_dicts, lora_strengths, strict=True)
67
+ ]
68
+ final_sd = apply_loras(
69
+ model_sd=model_sd,
70
+ lora_sd_and_strengths=lora_sd_and_strengths,
71
+ dtype=dtype,
72
+ destination_sd=model_sd if isinstance(registry, DummyRegistry) else None,
73
+ )
74
+ meta_model.load_state_dict(final_sd.sd, strict=False, assign=True)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class SingleGPUModelBuilder(Generic[ModelType], ModelBuilderProtocol[ModelType], LoRAAdaptableProtocol):
79
+ """
80
+ Builder for PyTorch models residing on a single GPU.
81
+ Attributes:
82
+ model_class_configurator: Class responsible for constructing the model from a config dict.
83
+ model_path: Path (or tuple of shard paths) to the model's `.safetensors` checkpoint(s).
84
+ model_sd_ops: Optional state-dict operations applied when loading the model weights.
85
+ module_ops: Sequence of module-level mutations applied to the meta model before weight loading.
86
+ loras: Sequence of LoRA adapters (path, strength, optional sd_ops) to fuse into the model.
87
+ model_loader: Strategy for loading state dicts from disk. Defaults to
88
+ :class:`SafetensorsModelStateDictLoader`.
89
+ registry: Cache for already-loaded state dicts. Defaults to :class:`DummyRegistry` (no caching).
90
+ lora_load_device: Device used when loading LoRA weight tensors from disk. Defaults to
91
+ ``torch.device("cpu")``, which keeps LoRA weights in CPU memory and transfers them to
92
+ the target GPU sequentially during fusion, reducing peak GPU memory usage compared to
93
+ loading all LoRA weights directly onto the GPU at once.
94
+ """
95
+
96
+ model_class_configurator: type[ModelConfigurator[ModelType]]
97
+ model_path: str | tuple[str, ...]
98
+ model_sd_ops: SDOps | None = None
99
+ module_ops: tuple[ModuleOps, ...] = field(default_factory=tuple)
100
+ loras: tuple[LoraPathStrengthAndSDOps, ...] = field(default_factory=tuple)
101
+ model_loader: StateDictLoader = field(default_factory=SafetensorsModelStateDictLoader)
102
+ registry: Registry = field(default_factory=DummyRegistry)
103
+ lora_load_device: torch.device = field(default_factory=lambda: torch.device("cpu"))
104
+
105
+ def lora(self, lora_path: str, strength: float, sd_ops: SDOps) -> "SingleGPUModelBuilder":
106
+ return replace(self, loras=(*self.loras, LoraPathStrengthAndSDOps(lora_path, strength, sd_ops)))
107
+
108
+ def with_sd_ops(self, sd_ops: SDOps | None) -> "SingleGPUModelBuilder":
109
+ return replace(self, model_sd_ops=sd_ops)
110
+
111
+ def with_module_ops(self, module_ops: tuple[ModuleOps, ...]) -> "SingleGPUModelBuilder":
112
+ return replace(self, module_ops=module_ops)
113
+
114
+ def with_loras(self, loras: tuple[LoraPathStrengthAndSDOps, ...]) -> "SingleGPUModelBuilder":
115
+ return replace(self, loras=loras)
116
+
117
+ def with_registry(self, registry: Registry) -> "SingleGPUModelBuilder":
118
+ return replace(self, registry=registry)
119
+
120
+ def with_lora_load_device(self, device: torch.device) -> "SingleGPUModelBuilder":
121
+ return replace(self, lora_load_device=device)
122
+
123
+ def model_config(self) -> dict:
124
+ return read_model_config(self.model_path, self.model_loader)
125
+
126
+ def meta_model(self, config: dict, module_ops: tuple[ModuleOps, ...]) -> ModelType:
127
+ return create_meta_model(self.model_class_configurator, config, module_ops)
128
+
129
+ def load_sd(
130
+ self, paths: list[str], registry: Registry, device: torch.device | None, sd_ops: SDOps | None = None
131
+ ) -> StateDict:
132
+ return load_state_dict(paths, self.model_loader, registry, device, sd_ops)
133
+
134
+ def _return_model(self, meta_model: ModelType, device: torch.device) -> ModelType:
135
+ uninitialized = _check_uninitialized(meta_model)
136
+ if uninitialized:
137
+ logger.warning(f"Uninitialized parameters or buffers: {uninitialized}")
138
+ return meta_model
139
+ return meta_model.to(device)
140
+
141
+ def build(
142
+ self,
143
+ device: torch.device | None = None,
144
+ dtype: torch.dtype | None = None,
145
+ **kwargs: object, # noqa: ARG002
146
+ ) -> ModelType:
147
+ device = torch.device("cuda") if device is None else device
148
+ config = self.model_config()
149
+ meta_model = self.meta_model(config, self.module_ops)
150
+
151
+ _load_model_weights(
152
+ meta_model=meta_model,
153
+ model_path=self.model_path,
154
+ loras=self.loras,
155
+ loader=self.model_loader,
156
+ registry=self.registry,
157
+ device=device,
158
+ dtype=dtype,
159
+ model_sd_ops=self.model_sd_ops,
160
+ lora_load_device=self.lora_load_device,
161
+ )
162
+ return self._return_model(meta_model, device)
packages/ltx-core/src/ltx_core/modality_tiling.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video modality tiling helpers.
2
+ Provides :class:`VideoModalityTilingHelper` — a stateless helper that
3
+ tiles and blends video :class:`Modality` token sequences by
4
+ spatial/temporal region. Tile geometry is represented by the existing
5
+ :class:`Tile` NamedTuple from :mod:`ltx_core.tiling`; no distributed
6
+ primitives are required.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, replace
12
+
13
+ import torch
14
+
15
+ from ltx_core.model.transformer.modality import Modality
16
+ from ltx_core.tiling import Tile, TileCountConfig, create_tiles, identity_mapping_operation, split_by_count
17
+ from ltx_core.tools import VideoLatentTools
18
+ from ltx_core.types import VideoLatentShape
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TilingContext:
23
+ """Opaque context produced by :meth:`VideoModalityTilingHelper.tile_modality`.
24
+ Carries the token-level keep mask and per-conditioning-token blend
25
+ weights needed by :meth:`~VideoModalityTilingHelper.blend`.
26
+ """
27
+
28
+ keep_mask: torch.Tensor
29
+ cond_blend_weights: torch.Tensor | None
30
+ """``(num_kept_cond,)`` — weight for each kept conditioning token,
31
+ equal to ``1 / num_tiles_that_keep_this_token``. ``None`` when
32
+ there are no conditioning tokens."""
33
+
34
+
35
+ class VideoModalityTilingHelper:
36
+ """Stateless helper that tiles and blends video :class:`Modality` sequences.
37
+ Constructed once with a :class:`TileCountConfig` and
38
+ :class:`VideoLatentTools`. Tiles are computed at construction and
39
+ available via the :attr:`tiles` property. Use :meth:`tile_modality`
40
+ and :meth:`blend` with any tile from that list.
41
+ Usage::
42
+ helper = VideoModalityTilingHelper(tiling, video_tools)
43
+ for tile in helper.tiles:
44
+ tiled_mod, ctx = helper.tile_modality(modality, tile)
45
+ result = run_model(tiled_mod)
46
+ helper.blend(result, tile, ctx, output=output)
47
+ """
48
+
49
+ def __init__(self, tiling: TileCountConfig, video_tools: VideoLatentTools) -> None:
50
+ self._patchifier = video_tools.patchifier
51
+ self._latent_shape = video_tools.target_shape
52
+ self._num_generated_tokens = self._patchifier.get_token_count(self._latent_shape)
53
+ self._tiles = create_tiles(
54
+ torch.Size([self._latent_shape.frames, self._latent_shape.height, self._latent_shape.width]),
55
+ splitters=[
56
+ split_by_count(tiling.frames.num_tiles, tiling.frames.overlap),
57
+ split_by_count(tiling.height.num_tiles, tiling.height.overlap),
58
+ split_by_count(tiling.width.num_tiles, tiling.width.overlap),
59
+ ],
60
+ mappers=[identity_mapping_operation] * 3,
61
+ )
62
+
63
+ @property
64
+ def tiles(self) -> list[Tile]:
65
+ """All tiles for the configured tiling layout."""
66
+ return self._tiles
67
+
68
+ # -- tile modality -----------------------------------------------------
69
+
70
+ def tile_modality(
71
+ self, modality: Modality, tile: Tile, *, normalize_positions: bool = True
72
+ ) -> tuple[Modality, TilingContext]:
73
+ """Slice *modality* to the tokens covered by *tile*.
74
+ Selects generated tokens belonging to the tile's spatial region
75
+ and conditioning tokens that overlap with the tile (or have
76
+ negative time coordinates).
77
+ Args:
78
+ normalize_positions: When True, shift all positions so the
79
+ tile's generated tokens start at zero in every dimension.
80
+ Returns:
81
+ A ``(tiled_modality, context)`` tuple. Pass *context* to
82
+ :meth:`blend` together with the model output.
83
+ """
84
+ keep_mask = self._keep_mask(modality, tile)
85
+
86
+ tile_attention_mask = None
87
+ if modality.attention_mask is not None:
88
+ keep_indices = keep_mask.nonzero(as_tuple=False).squeeze(1)
89
+ tile_attention_mask = modality.attention_mask[:, keep_indices, :][:, :, keep_indices]
90
+
91
+ positions = modality.positions[:, :, keep_mask, :]
92
+ if normalize_positions:
93
+ num_tile_gen = self._tile_generated_token_count(tile)
94
+ gen_pos = positions[:, :, :num_tile_gen, :] # (B, 3, num_tile_gen, 2)
95
+ offset = gen_pos[..., 0].amin(dim=2, keepdim=True).unsqueeze(-1) # (B, 3, 1, 1)
96
+ positions = positions - offset
97
+
98
+ tiled = replace(
99
+ modality,
100
+ latent=modality.latent[:, keep_mask, :],
101
+ timesteps=modality.timesteps[:, keep_mask],
102
+ positions=positions,
103
+ attention_mask=tile_attention_mask,
104
+ )
105
+
106
+ cond_blend_weights = None
107
+ num_total = modality.latent.shape[1]
108
+ if num_total > self._num_generated_tokens:
109
+ cond_keep = keep_mask[self._num_generated_tokens :]
110
+ # Count how many tiles keep each conditioning token.
111
+ cond_counts = torch.zeros(cond_keep.sum(), dtype=torch.float32)
112
+ for t in self._tiles:
113
+ other_mask = self._keep_mask(modality, t)
114
+ other_cond = other_mask[self._num_generated_tokens :]
115
+ # Map other tile's kept cond tokens into this tile's kept subset.
116
+ cond_counts += other_cond[cond_keep].float()
117
+ cond_blend_weights = 1.0 / cond_counts
118
+
119
+ return tiled, TilingContext(keep_mask=keep_mask, cond_blend_weights=cond_blend_weights)
120
+
121
+ # -- blend -------------------------------------------------------------
122
+
123
+ def blend(
124
+ self,
125
+ tile_to_blend: torch.Tensor,
126
+ tile: Tile,
127
+ context: TilingContext,
128
+ output: torch.Tensor | None = None,
129
+ ) -> torch.Tensor:
130
+ """Blend-weight tile results and accumulate into the full token space.
131
+ Premultiplied (blend-weighted) data is **added** to *output*,
132
+ allowing multiple tiles to be accumulated into the same buffer.
133
+ Args:
134
+ tile_to_blend: Denoised tile tensor ``(B, num_tile_tokens, D)``,
135
+ where the first ``_tile_generated_token_count(tile)``
136
+ entries are generated tokens and the remainder are
137
+ conditioning tokens.
138
+ tile: The :class:`Tile` that was used in :meth:`tile_modality`.
139
+ context: The :class:`TilingContext` returned by :meth:`tile_modality`.
140
+ output: Optional pre-allocated output tensor. When provided
141
+ its shape must be ``(B, num_total_tokens, D)`` and the
142
+ blended tile is **added** into it. When ``None`` a new
143
+ zero-filled tensor is created.
144
+ Returns:
145
+ The output tensor with the blended tile added at the correct
146
+ positions.
147
+ """
148
+ batch, _, dim = tile_to_blend.shape
149
+ num_tile_gen = self._tile_generated_token_count(tile)
150
+ gen_indices = self._generated_token_indices(tile)
151
+
152
+ num_total_tokens = context.keep_mask.shape[0]
153
+ expected_shape = (batch, num_total_tokens, dim)
154
+
155
+ if output is not None:
156
+ if output.shape != expected_shape:
157
+ raise ValueError(f"Expected output shape {expected_shape}, got {output.shape}")
158
+ result = output
159
+ else:
160
+ result = torch.zeros(*expected_shape, device=tile_to_blend.device, dtype=tile_to_blend.dtype)
161
+
162
+ # Blend mask is (tile_F, tile_H, tile_W) — one weight per token in row-major order.
163
+ blend_weights = tile.blend_mask.reshape(-1).to(device=tile_to_blend.device, dtype=tile_to_blend.dtype)
164
+ tile_gen = tile_to_blend[:, :num_tile_gen, :] * blend_weights[None, :, None]
165
+
166
+ result[:, gen_indices, :] += tile_gen
167
+
168
+ # Scatter kept conditioning tokens, weighted by 1/N where N is
169
+ # the number of tiles that keep each token (so they sum to 1).
170
+ if num_total_tokens > self._num_generated_tokens and context.cond_blend_weights is not None:
171
+ cond_keep = context.keep_mask[self._num_generated_tokens :]
172
+ cond_indices = self._num_generated_tokens + cond_keep.nonzero(as_tuple=False).squeeze(1)
173
+ weights = context.cond_blend_weights.to(device=tile_to_blend.device, dtype=tile_to_blend.dtype)
174
+ result[:, cond_indices, :] += tile_to_blend[:, num_tile_gen:, :] * weights[None, :, None]
175
+
176
+ return result
177
+
178
+ # -- private -----------------------------------------------------------
179
+
180
+ def _tile_generated_token_count(self, tile: Tile) -> int:
181
+ """Number of generated tokens in *tile*."""
182
+ frame_slice, height_slice, width_slice = tile.in_coords
183
+ tile_shape = VideoLatentShape(
184
+ batch=self._latent_shape.batch,
185
+ channels=self._latent_shape.channels,
186
+ frames=frame_slice.stop - frame_slice.start,
187
+ height=height_slice.stop - height_slice.start,
188
+ width=width_slice.stop - width_slice.start,
189
+ )
190
+ return self._patchifier.get_token_count(tile_shape)
191
+
192
+ def _generated_token_indices(self, tile: Tile) -> torch.Tensor:
193
+ """Flat token indices of *tile*'s generated tokens in the full sequence."""
194
+ frame_slice, height_slice, width_slice = tile.in_coords
195
+ f = torch.arange(frame_slice.start, frame_slice.stop)
196
+ h = torch.arange(height_slice.start, height_slice.stop)
197
+ w = torch.arange(width_slice.start, width_slice.stop)
198
+ return (
199
+ f[:, None, None] * self._latent_shape.height * self._latent_shape.width
200
+ + h[None, :, None] * self._latent_shape.width
201
+ + w[None, None, :]
202
+ ).reshape(-1)
203
+
204
+ def _keep_mask(self, modality: Modality, tile: Tile) -> torch.Tensor:
205
+ """Boolean mask ``(num_total_tokens,)`` — True for tokens the tile processes.
206
+ Generated tokens are selected by grid position. Conditioning
207
+ tokens are kept when their ``[start, end)`` intervals overlap
208
+ the tile in all three dimensions, or when they have a negative
209
+ time coordinate (reference tokens).
210
+ """
211
+ num_total = modality.latent.shape[1]
212
+ mask = torch.zeros(num_total, dtype=torch.bool)
213
+
214
+ gen_indices = self._generated_token_indices(tile)
215
+ mask[gen_indices] = True
216
+
217
+ if num_total > self._num_generated_tokens:
218
+ gen_positions = modality.positions[:, :, gen_indices, :] # (B, 3, num_tile_gen, 2)
219
+ tile_start = gen_positions[..., 0].amin(dim=2) # (B, 3)
220
+ tile_end = gen_positions[..., 1].amax(dim=2) # (B, 3)
221
+
222
+ cond_positions = modality.positions[:, :, self._num_generated_tokens :, :] # (B, 3, num_cond, 2)
223
+
224
+ overlaps = (cond_positions[..., 0] < tile_end.unsqueeze(2)) & (
225
+ cond_positions[..., 1] > tile_start.unsqueeze(2)
226
+ ) # (B, 3, num_cond)
227
+ overlaps_all_dims = overlaps.all(dim=1) # (B, num_cond)
228
+
229
+ has_negative_time = cond_positions[:, 0, :, 0] < 0 # (B, num_cond)
230
+
231
+ keep_cond = (overlaps_all_dims | has_negative_time).any(dim=0) # (num_cond,)
232
+ mask[self._num_generated_tokens :] = keep_cond
233
+
234
+ return mask
packages/ltx-core/src/ltx_core/model/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Model definitions for LTX-2."""
2
+
3
+ from ltx_core.model.model_protocol import ModelConfigurator, ModelType
4
+
5
+ __all__ = [
6
+ "ModelConfigurator",
7
+ "ModelType",
8
+ ]
packages/ltx-core/src/ltx_core/model/audio_vae/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio VAE model components."""
2
+
3
+ from ltx_core.model.audio_vae.audio_vae import AudioDecoder, AudioEncoder, decode_audio, encode_audio
4
+ from ltx_core.model.audio_vae.model_configurator import (
5
+ AUDIO_VAE_DECODER_COMFY_KEYS_FILTER,
6
+ AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER,
7
+ VOCODER_COMFY_KEYS_FILTER,
8
+ AudioDecoderConfigurator,
9
+ AudioEncoderConfigurator,
10
+ VocoderConfigurator,
11
+ )
12
+ from ltx_core.model.audio_vae.ops import AudioProcessor
13
+ from ltx_core.model.audio_vae.vocoder import Vocoder, VocoderWithBWE
14
+
15
+ __all__ = [
16
+ "AUDIO_VAE_DECODER_COMFY_KEYS_FILTER",
17
+ "AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER",
18
+ "VOCODER_COMFY_KEYS_FILTER",
19
+ "AudioDecoder",
20
+ "AudioDecoderConfigurator",
21
+ "AudioEncoder",
22
+ "AudioEncoderConfigurator",
23
+ "AudioProcessor",
24
+ "Vocoder",
25
+ "VocoderConfigurator",
26
+ "VocoderWithBWE",
27
+ "decode_audio",
28
+ "encode_audio",
29
+ ]
packages/ltx-core/src/ltx_core/model/audio_vae/attention.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+ import torch
4
+
5
+ from ltx_core.model.common.normalization import NormType, build_normalization_layer
6
+
7
+
8
+ class AttentionType(Enum):
9
+ """Enum for specifying the attention mechanism type."""
10
+
11
+ VANILLA = "vanilla"
12
+ LINEAR = "linear"
13
+ NONE = "none"
14
+
15
+
16
+ class AttnBlock(torch.nn.Module):
17
+ def __init__(
18
+ self,
19
+ in_channels: int,
20
+ norm_type: NormType = NormType.GROUP,
21
+ ) -> None:
22
+ super().__init__()
23
+ self.in_channels = in_channels
24
+
25
+ self.norm = build_normalization_layer(in_channels, normtype=norm_type)
26
+ self.q = torch.nn.Conv2d(in_channels, in_channels, kernel_size=1, stride=1, padding=0)
27
+ self.k = torch.nn.Conv2d(in_channels, in_channels, kernel_size=1, stride=1, padding=0)
28
+ self.v = torch.nn.Conv2d(in_channels, in_channels, kernel_size=1, stride=1, padding=0)
29
+ self.proj_out = torch.nn.Conv2d(in_channels, in_channels, kernel_size=1, stride=1, padding=0)
30
+
31
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
32
+ h_ = x
33
+ h_ = self.norm(h_)
34
+ q = self.q(h_)
35
+ k = self.k(h_)
36
+ v = self.v(h_)
37
+
38
+ # compute attention
39
+ b, c, h, w = q.shape
40
+ q = q.reshape(b, c, h * w).contiguous()
41
+ q = q.permute(0, 2, 1).contiguous() # b,hw,c
42
+ k = k.reshape(b, c, h * w).contiguous() # b,c,hw
43
+ w_ = torch.bmm(q, k).contiguous() # b,hw,hw w[b,i,j]=sum_c q[b,i,c]k[b,c,j]
44
+ w_ = w_ * (int(c) ** (-0.5))
45
+ w_ = torch.nn.functional.softmax(w_, dim=2)
46
+
47
+ # attend to values
48
+ v = v.reshape(b, c, h * w).contiguous()
49
+ w_ = w_.permute(0, 2, 1).contiguous() # b,hw,hw (first hw of k, second of q)
50
+ h_ = torch.bmm(v, w_).contiguous() # b, c,hw (hw of q) h_[b,c,j] = sum_i v[b,c,i] w_[b,i,j]
51
+ h_ = h_.reshape(b, c, h, w).contiguous()
52
+
53
+ h_ = self.proj_out(h_)
54
+
55
+ return x + h_
56
+
57
+
58
+ def make_attn(
59
+ in_channels: int,
60
+ attn_type: AttentionType = AttentionType.VANILLA,
61
+ norm_type: NormType = NormType.GROUP,
62
+ ) -> torch.nn.Module:
63
+ match attn_type:
64
+ case AttentionType.VANILLA:
65
+ return AttnBlock(in_channels, norm_type=norm_type)
66
+ case AttentionType.NONE:
67
+ return torch.nn.Identity()
68
+ case AttentionType.LINEAR:
69
+ raise NotImplementedError(f"Attention type {attn_type.value} is not supported yet.")
70
+ case _:
71
+ raise ValueError(f"Unknown attention type: {attn_type}")
packages/ltx-core/src/ltx_core/model/audio_vae/audio_vae.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Set, Tuple
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+
6
+ from ltx_core.components.patchifiers import AudioPatchifier
7
+ from ltx_core.model.audio_vae.attention import AttentionType, make_attn
8
+ from ltx_core.model.audio_vae.causal_conv_2d import make_conv2d
9
+ from ltx_core.model.audio_vae.causality_axis import CausalityAxis
10
+ from ltx_core.model.audio_vae.downsample import build_downsampling_path
11
+ from ltx_core.model.audio_vae.ops import AudioProcessor, PerChannelStatistics
12
+ from ltx_core.model.audio_vae.resnet import ResnetBlock
13
+ from ltx_core.model.audio_vae.upsample import build_upsampling_path
14
+ from ltx_core.model.audio_vae.vocoder import Vocoder
15
+ from ltx_core.model.common.normalization import NormType, build_normalization_layer
16
+ from ltx_core.types import Audio, AudioLatentShape
17
+
18
+ LATENT_DOWNSAMPLE_FACTOR = 4
19
+
20
+
21
+ def build_mid_block(
22
+ channels: int,
23
+ temb_channels: int,
24
+ dropout: float,
25
+ norm_type: NormType,
26
+ causality_axis: CausalityAxis,
27
+ attn_type: AttentionType,
28
+ add_attention: bool,
29
+ ) -> torch.nn.Module:
30
+ """Build the middle block with two ResNet blocks and optional attention."""
31
+ mid = torch.nn.Module()
32
+ mid.block_1 = ResnetBlock(
33
+ in_channels=channels,
34
+ out_channels=channels,
35
+ temb_channels=temb_channels,
36
+ dropout=dropout,
37
+ norm_type=norm_type,
38
+ causality_axis=causality_axis,
39
+ )
40
+ mid.attn_1 = make_attn(channels, attn_type=attn_type, norm_type=norm_type) if add_attention else torch.nn.Identity()
41
+ mid.block_2 = ResnetBlock(
42
+ in_channels=channels,
43
+ out_channels=channels,
44
+ temb_channels=temb_channels,
45
+ dropout=dropout,
46
+ norm_type=norm_type,
47
+ causality_axis=causality_axis,
48
+ )
49
+ return mid
50
+
51
+
52
+ def run_mid_block(mid: torch.nn.Module, features: torch.Tensor) -> torch.Tensor:
53
+ """Run features through the middle block."""
54
+ features = mid.block_1(features, temb=None)
55
+ features = mid.attn_1(features)
56
+ return mid.block_2(features, temb=None)
57
+
58
+
59
+ class AudioEncoder(torch.nn.Module):
60
+ """
61
+ Encoder that compresses audio spectrograms into latent representations.
62
+ The encoder uses a series of downsampling blocks with residual connections,
63
+ attention mechanisms, and configurable causal convolutions.
64
+ """
65
+
66
+ def __init__( # noqa: PLR0913
67
+ self,
68
+ *,
69
+ ch: int,
70
+ ch_mult: Tuple[int, ...] = (1, 2, 4, 8),
71
+ num_res_blocks: int,
72
+ attn_resolutions: Set[int],
73
+ dropout: float = 0.0,
74
+ resamp_with_conv: bool = True,
75
+ in_channels: int,
76
+ resolution: int,
77
+ z_channels: int,
78
+ double_z: bool = True,
79
+ attn_type: AttentionType = AttentionType.VANILLA,
80
+ mid_block_add_attention: bool = True,
81
+ norm_type: NormType = NormType.GROUP,
82
+ causality_axis: CausalityAxis = CausalityAxis.WIDTH,
83
+ sample_rate: int = 16000,
84
+ mel_hop_length: int = 160,
85
+ n_fft: int = 1024,
86
+ is_causal: bool = True,
87
+ mel_bins: int = 64,
88
+ **_ignore_kwargs,
89
+ ) -> None:
90
+ """
91
+ Initialize the Encoder.
92
+ Args:
93
+ Arguments are configuration parameters, loaded from the audio VAE checkpoint config
94
+ (audio_vae.model.params.ddconfig):
95
+ ch: Base number of feature channels used in the first convolution layer.
96
+ ch_mult: Multiplicative factors for the number of channels at each resolution level.
97
+ num_res_blocks: Number of residual blocks to use at each resolution level.
98
+ attn_resolutions: Spatial resolutions (e.g., in time/frequency) at which to apply attention.
99
+ resolution: Input spatial resolution of the spectrogram (height, width).
100
+ z_channels: Number of channels in the latent representation.
101
+ norm_type: Normalization layer type to use within the network (e.g., group, batch).
102
+ causality_axis: Axis along which convolutions should be causal (e.g., time axis).
103
+ sample_rate: Audio sample rate in Hz for the input signals.
104
+ mel_hop_length: Hop length used when computing the mel spectrogram.
105
+ n_fft: FFT size used to compute the spectrogram.
106
+ mel_bins: Number of mel-frequency bins in the input spectrogram.
107
+ in_channels: Number of channels in the input spectrogram tensor.
108
+ double_z: If True, predict both mean and log-variance (doubling latent channels).
109
+ is_causal: If True, use causal convolutions suitable for streaming setups.
110
+ dropout: Dropout probability used in residual and mid blocks.
111
+ attn_type: Type of attention mechanism to use in attention blocks.
112
+ resamp_with_conv: If True, perform resolution changes using strided convolutions.
113
+ mid_block_add_attention: If True, add an attention block in the mid-level of the encoder.
114
+ """
115
+ super().__init__()
116
+
117
+ self.per_channel_statistics = PerChannelStatistics(latent_channels=ch)
118
+ self.sample_rate = sample_rate
119
+ self.mel_hop_length = mel_hop_length
120
+ self.n_fft = n_fft
121
+ self.is_causal = is_causal
122
+ self.mel_bins = mel_bins
123
+
124
+ self.patchifier = AudioPatchifier(
125
+ patch_size=1,
126
+ audio_latent_downsample_factor=LATENT_DOWNSAMPLE_FACTOR,
127
+ sample_rate=sample_rate,
128
+ hop_length=mel_hop_length,
129
+ is_causal=is_causal,
130
+ )
131
+
132
+ self.ch = ch
133
+ self.temb_ch = 0
134
+ self.num_resolutions = len(ch_mult)
135
+ self.num_res_blocks = num_res_blocks
136
+ self.resolution = resolution
137
+ self.in_channels = in_channels
138
+ self.z_channels = z_channels
139
+ self.double_z = double_z
140
+ self.norm_type = norm_type
141
+ self.causality_axis = causality_axis
142
+ self.attn_type = attn_type
143
+
144
+ # downsampling
145
+ self.conv_in = make_conv2d(
146
+ in_channels,
147
+ self.ch,
148
+ kernel_size=3,
149
+ stride=1,
150
+ causality_axis=self.causality_axis,
151
+ )
152
+
153
+ self.non_linearity = torch.nn.SiLU()
154
+
155
+ self.down, block_in = build_downsampling_path(
156
+ ch=ch,
157
+ ch_mult=ch_mult,
158
+ num_resolutions=self.num_resolutions,
159
+ num_res_blocks=num_res_blocks,
160
+ resolution=resolution,
161
+ temb_channels=self.temb_ch,
162
+ dropout=dropout,
163
+ norm_type=self.norm_type,
164
+ causality_axis=self.causality_axis,
165
+ attn_type=self.attn_type,
166
+ attn_resolutions=attn_resolutions,
167
+ resamp_with_conv=resamp_with_conv,
168
+ )
169
+
170
+ self.mid = build_mid_block(
171
+ channels=block_in,
172
+ temb_channels=self.temb_ch,
173
+ dropout=dropout,
174
+ norm_type=self.norm_type,
175
+ causality_axis=self.causality_axis,
176
+ attn_type=self.attn_type,
177
+ add_attention=mid_block_add_attention,
178
+ )
179
+
180
+ self.norm_out = build_normalization_layer(block_in, normtype=self.norm_type)
181
+ self.conv_out = make_conv2d(
182
+ block_in,
183
+ 2 * z_channels if double_z else z_channels,
184
+ kernel_size=3,
185
+ stride=1,
186
+ causality_axis=self.causality_axis,
187
+ )
188
+
189
+ def forward(self, spectrogram: torch.Tensor) -> torch.Tensor:
190
+ """
191
+ Encode audio spectrogram into latent representations.
192
+ Args:
193
+ spectrogram: Input spectrogram of shape (batch, channels, time, frequency)
194
+ Returns:
195
+ Encoded latent representation of shape (batch, channels, frames, mel_bins)
196
+ """
197
+ h = self.conv_in(spectrogram)
198
+ h = self._run_downsampling_path(h)
199
+ h = run_mid_block(self.mid, h)
200
+ h = self._finalize_output(h)
201
+
202
+ return self._normalize_latents(h)
203
+
204
+ def _run_downsampling_path(self, h: torch.Tensor) -> torch.Tensor:
205
+ for level in range(self.num_resolutions):
206
+ stage = self.down[level]
207
+ for block_idx in range(self.num_res_blocks):
208
+ h = stage.block[block_idx](h, temb=None)
209
+ if stage.attn:
210
+ h = stage.attn[block_idx](h)
211
+
212
+ if level != self.num_resolutions - 1:
213
+ h = stage.downsample(h)
214
+
215
+ return h
216
+
217
+ def _finalize_output(self, h: torch.Tensor) -> torch.Tensor:
218
+ h = self.norm_out(h)
219
+ h = self.non_linearity(h)
220
+ return self.conv_out(h)
221
+
222
+ def _normalize_latents(self, latent_output: torch.Tensor) -> torch.Tensor:
223
+ """
224
+ Normalize encoder latents using per-channel statistics.
225
+ When the encoder is configured with ``double_z=True``, the final
226
+ convolution produces twice the number of latent channels, typically
227
+ interpreted as two concatenated tensors along the channel dimension
228
+ (e.g., mean and variance or other auxiliary parameters).
229
+ This method intentionally uses only the first half of the channels
230
+ (the "mean" component) as input to the patchifier and normalization
231
+ logic. The remaining channels are left unchanged by this method and
232
+ are expected to be consumed elsewhere in the VAE pipeline.
233
+ If ``double_z=False``, the encoder output already contains only the
234
+ mean latents and the chunking operation simply returns that tensor.
235
+ """
236
+ means = torch.chunk(latent_output, 2, dim=1)[0]
237
+ latent_shape = AudioLatentShape(
238
+ batch=means.shape[0],
239
+ channels=means.shape[1],
240
+ frames=means.shape[2],
241
+ mel_bins=means.shape[3],
242
+ )
243
+ latent_patched = self.patchifier.patchify(means)
244
+ latent_normalized = self.per_channel_statistics.normalize(latent_patched)
245
+ return self.patchifier.unpatchify(latent_normalized, latent_shape)
246
+
247
+
248
+ def encode_audio(
249
+ audio: Audio,
250
+ audio_encoder: AudioEncoder,
251
+ audio_processor: AudioProcessor | None = None,
252
+ ) -> torch.Tensor:
253
+ """Encode audio waveform into latent representation.
254
+ Args:
255
+ audio: Audio container with waveform tensor of shape (batch, channels, samples) and sampling rate.
256
+ audio_encoder: Audio encoder model
257
+ audio_processor: Audio processor model (optional, if not provided, it will be created from the audio encoder)
258
+ """
259
+ dtype = next(audio_encoder.parameters()).dtype
260
+ device = next(audio_encoder.parameters()).device
261
+
262
+ if audio_processor is None:
263
+ audio_processor = AudioProcessor(
264
+ target_sample_rate=audio_encoder.sample_rate,
265
+ mel_bins=audio_encoder.mel_bins,
266
+ mel_hop_length=audio_encoder.mel_hop_length,
267
+ n_fft=audio_encoder.n_fft,
268
+ ).to(device=device)
269
+
270
+ mel_spectrogram = audio_processor.waveform_to_mel(audio.to(device=device))
271
+
272
+ latent = audio_encoder(mel_spectrogram.to(dtype=dtype))
273
+ return latent
274
+
275
+
276
+ class AudioDecoder(torch.nn.Module):
277
+ """
278
+ Symmetric decoder that reconstructs audio spectrograms from latent features.
279
+ The decoder mirrors the encoder structure with configurable channel multipliers,
280
+ attention resolutions, and causal convolutions.
281
+ """
282
+
283
+ def __init__( # noqa: PLR0913
284
+ self,
285
+ *,
286
+ ch: int,
287
+ out_ch: int,
288
+ ch_mult: Tuple[int, ...] = (1, 2, 4, 8),
289
+ num_res_blocks: int,
290
+ attn_resolutions: Set[int],
291
+ resolution: int,
292
+ z_channels: int,
293
+ norm_type: NormType = NormType.GROUP,
294
+ causality_axis: CausalityAxis = CausalityAxis.WIDTH,
295
+ dropout: float = 0.0,
296
+ mid_block_add_attention: bool = True,
297
+ sample_rate: int = 16000,
298
+ mel_hop_length: int = 160,
299
+ is_causal: bool = True,
300
+ mel_bins: int | None = None,
301
+ ) -> None:
302
+ """
303
+ Initialize the Decoder.
304
+ Args:
305
+ Arguments are configuration parameters, loaded from the audio VAE checkpoint config
306
+ (audio_vae.model.params.ddconfig):
307
+ - ch, out_ch, ch_mult, num_res_blocks, attn_resolutions
308
+ - resolution, z_channels
309
+ - norm_type, causality_axis
310
+ """
311
+ super().__init__()
312
+
313
+ # Internal behavioural defaults that are not driven by the checkpoint.
314
+ resamp_with_conv = True
315
+ attn_type = AttentionType.VANILLA
316
+
317
+ # Per-channel statistics for denormalizing latents
318
+ self.per_channel_statistics = PerChannelStatistics(latent_channels=ch)
319
+ self.sample_rate = sample_rate
320
+ self.mel_hop_length = mel_hop_length
321
+ self.is_causal = is_causal
322
+ self.mel_bins = mel_bins
323
+ self.patchifier = AudioPatchifier(
324
+ patch_size=1,
325
+ audio_latent_downsample_factor=LATENT_DOWNSAMPLE_FACTOR,
326
+ sample_rate=sample_rate,
327
+ hop_length=mel_hop_length,
328
+ is_causal=is_causal,
329
+ )
330
+
331
+ self.ch = ch
332
+ self.temb_ch = 0
333
+ self.num_resolutions = len(ch_mult)
334
+ self.num_res_blocks = num_res_blocks
335
+ self.resolution = resolution
336
+ self.out_ch = out_ch
337
+ self.give_pre_end = False
338
+ self.tanh_out = False
339
+ self.norm_type = norm_type
340
+ self.z_channels = z_channels
341
+ self.channel_multipliers = ch_mult
342
+ self.attn_resolutions = attn_resolutions
343
+ self.causality_axis = causality_axis
344
+ self.attn_type = attn_type
345
+
346
+ base_block_channels = ch * self.channel_multipliers[-1]
347
+ base_resolution = resolution // (2 ** (self.num_resolutions - 1))
348
+ self.z_shape = (1, z_channels, base_resolution, base_resolution)
349
+
350
+ self.conv_in = make_conv2d(
351
+ z_channels, base_block_channels, kernel_size=3, stride=1, causality_axis=self.causality_axis
352
+ )
353
+ self.non_linearity = torch.nn.SiLU()
354
+ self.mid = build_mid_block(
355
+ channels=base_block_channels,
356
+ temb_channels=self.temb_ch,
357
+ dropout=dropout,
358
+ norm_type=self.norm_type,
359
+ causality_axis=self.causality_axis,
360
+ attn_type=self.attn_type,
361
+ add_attention=mid_block_add_attention,
362
+ )
363
+ self.up, final_block_channels = build_upsampling_path(
364
+ ch=ch,
365
+ ch_mult=ch_mult,
366
+ num_resolutions=self.num_resolutions,
367
+ num_res_blocks=num_res_blocks,
368
+ resolution=resolution,
369
+ temb_channels=self.temb_ch,
370
+ dropout=dropout,
371
+ norm_type=self.norm_type,
372
+ causality_axis=self.causality_axis,
373
+ attn_type=self.attn_type,
374
+ attn_resolutions=attn_resolutions,
375
+ resamp_with_conv=resamp_with_conv,
376
+ initial_block_channels=base_block_channels,
377
+ )
378
+
379
+ self.norm_out = build_normalization_layer(final_block_channels, normtype=self.norm_type)
380
+ self.conv_out = make_conv2d(
381
+ final_block_channels, out_ch, kernel_size=3, stride=1, causality_axis=self.causality_axis
382
+ )
383
+
384
+ def forward(self, sample: torch.Tensor) -> torch.Tensor:
385
+ """
386
+ Decode latent features back to audio spectrograms.
387
+ Args:
388
+ sample: Encoded latent representation of shape (batch, channels, frames, mel_bins)
389
+ Returns:
390
+ Reconstructed audio spectrogram of shape (batch, channels, time, frequency)
391
+ """
392
+ sample, target_shape = self._denormalize_latents(sample)
393
+
394
+ h = self.conv_in(sample)
395
+ h = run_mid_block(self.mid, h)
396
+ h = self._run_upsampling_path(h)
397
+ h = self._finalize_output(h)
398
+
399
+ return self._adjust_output_shape(h, target_shape)
400
+
401
+ def _denormalize_latents(self, sample: torch.Tensor) -> tuple[torch.Tensor, AudioLatentShape]:
402
+ latent_shape = AudioLatentShape(
403
+ batch=sample.shape[0],
404
+ channels=sample.shape[1],
405
+ frames=sample.shape[2],
406
+ mel_bins=sample.shape[3],
407
+ )
408
+
409
+ sample_patched = self.patchifier.patchify(sample)
410
+ sample_denormalized = self.per_channel_statistics.un_normalize(sample_patched)
411
+ sample = self.patchifier.unpatchify(sample_denormalized, latent_shape)
412
+
413
+ target_frames = latent_shape.frames * LATENT_DOWNSAMPLE_FACTOR
414
+ if self.causality_axis != CausalityAxis.NONE:
415
+ target_frames = max(target_frames - (LATENT_DOWNSAMPLE_FACTOR - 1), 1)
416
+
417
+ target_shape = AudioLatentShape(
418
+ batch=latent_shape.batch,
419
+ channels=self.out_ch,
420
+ frames=target_frames,
421
+ mel_bins=self.mel_bins if self.mel_bins is not None else latent_shape.mel_bins,
422
+ )
423
+
424
+ return sample, target_shape
425
+
426
+ def _adjust_output_shape(
427
+ self,
428
+ decoded_output: torch.Tensor,
429
+ target_shape: AudioLatentShape,
430
+ ) -> torch.Tensor:
431
+ """
432
+ Adjust output shape to match target dimensions for variable-length audio.
433
+ This function handles the common case where decoded audio spectrograms need to be
434
+ resized to match a specific target shape.
435
+ Args:
436
+ decoded_output: Tensor of shape (batch, channels, time, frequency)
437
+ target_shape: AudioLatentShape describing (batch, channels, time, mel bins)
438
+ Returns:
439
+ Tensor adjusted to match target_shape exactly
440
+ """
441
+ # Current output shape: (batch, channels, time, frequency)
442
+ _, _, current_time, current_freq = decoded_output.shape
443
+ target_channels = target_shape.channels
444
+ target_time = target_shape.frames
445
+ target_freq = target_shape.mel_bins
446
+
447
+ # Step 1: Crop first to avoid exceeding target dimensions
448
+ decoded_output = decoded_output[
449
+ :, :target_channels, : min(current_time, target_time), : min(current_freq, target_freq)
450
+ ]
451
+
452
+ # Step 2: Calculate padding needed for time and frequency dimensions
453
+ time_padding_needed = target_time - decoded_output.shape[2]
454
+ freq_padding_needed = target_freq - decoded_output.shape[3]
455
+
456
+ # Step 3: Apply padding if needed
457
+ if time_padding_needed > 0 or freq_padding_needed > 0:
458
+ # PyTorch padding format: (pad_left, pad_right, pad_top, pad_bottom)
459
+ # For audio: pad_left/right = frequency, pad_top/bottom = time
460
+ padding = (
461
+ 0,
462
+ max(freq_padding_needed, 0), # frequency padding (left, right)
463
+ 0,
464
+ max(time_padding_needed, 0), # time padding (top, bottom)
465
+ )
466
+ decoded_output = F.pad(decoded_output, padding)
467
+
468
+ # Step 4: Final safety crop to ensure exact target shape
469
+ decoded_output = decoded_output[:, :target_channels, :target_time, :target_freq]
470
+
471
+ return decoded_output
472
+
473
+ def _run_upsampling_path(self, h: torch.Tensor) -> torch.Tensor:
474
+ for level in reversed(range(self.num_resolutions)):
475
+ stage = self.up[level]
476
+ for block_idx, block in enumerate(stage.block):
477
+ h = block(h, temb=None)
478
+ if stage.attn:
479
+ h = stage.attn[block_idx](h)
480
+
481
+ if level != 0 and hasattr(stage, "upsample"):
482
+ h = stage.upsample(h)
483
+
484
+ return h
485
+
486
+ def _finalize_output(self, h: torch.Tensor) -> torch.Tensor:
487
+ if self.give_pre_end:
488
+ return h
489
+
490
+ h = self.norm_out(h)
491
+ h = self.non_linearity(h)
492
+ h = self.conv_out(h)
493
+ return torch.tanh(h) if self.tanh_out else h
494
+
495
+
496
+ def decode_audio(latent: torch.Tensor, audio_decoder: "AudioDecoder", vocoder: "Vocoder") -> Audio:
497
+ """
498
+ Decode an audio latent representation using the provided audio decoder and vocoder.
499
+ Args:
500
+ latent: Input audio latent tensor.
501
+ audio_decoder: Model to decode the latent to waveform features.
502
+ vocoder: Model to convert decoded features to audio waveform.
503
+ Returns:
504
+ Decoded audio with waveform and sampling rate.
505
+ """
506
+ decoded_audio = audio_decoder(latent)
507
+ waveform = vocoder(decoded_audio).squeeze(0).float()
508
+ return Audio(waveform=waveform, sampling_rate=vocoder.output_sampling_rate)