1use serde::{Deserialize, Serialize};
2
3use crate::config::ModelConfig;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum Ltx2ControlProfile {
7 Ltx2_19bDistilled,
8 Ltx2_23_22bDistilled,
9}
10
11impl Ltx2ControlProfile {
12 pub fn label(self) -> &'static str {
13 match self {
14 Self::Ltx2_19bDistilled => "LTX-2 19B distilled",
15 Self::Ltx2_23_22bDistilled => "LTX-2.3 22B distilled",
16 }
17 }
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Ltx2ControlAdapter {
22 pub id: &'static str,
23 pub label: &'static str,
24 pub guide: &'static str,
25 pub profile: Ltx2ControlProfile,
26 pub hf_repo: &'static str,
27 pub hf_filename: &'static str,
28 pub size_bytes: u64,
29 pub sha256: &'static str,
30 pub download_model: &'static str,
31 pub gated: bool,
35 pub extra_files: &'static [Ltx2ControlAdapterFile],
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Ltx2ControlAdapterFile {
45 pub hf_filename: &'static str,
46 pub size_bytes: u64,
47 pub sha256: &'static str,
48}
49
50impl Ltx2ControlAdapter {
51 pub fn files(&self) -> impl Iterator<Item = Ltx2ControlAdapterFile> + '_ {
53 std::iter::once(Ltx2ControlAdapterFile {
54 hf_filename: self.hf_filename,
55 size_bytes: self.size_bytes,
56 sha256: self.sha256,
57 })
58 .chain(self.extra_files.iter().copied())
59 }
60
61 pub fn total_size_bytes(&self) -> u64 {
63 self.files().map(|file| file.size_bytes).sum()
64 }
65
66 pub fn scene_embeddings_filename(&self) -> Option<&'static str> {
72 self.extra_files
73 .iter()
74 .map(|file| file.hf_filename)
75 .find(|name| name.contains("scene-emb"))
76 }
77}
78
79pub const LTX2_CONTROL_ADAPTERS: &[Ltx2ControlAdapter] = &[
80 Ltx2ControlAdapter {
81 id: "union",
82 label: "Union control",
83 guide: "A frame-aligned Canny, depth, or pose guide video.",
84 profile: Ltx2ControlProfile::Ltx2_19bDistilled,
85 hf_repo: "Lightricks/LTX-2-19b-IC-LoRA-Union-Control",
86 hf_filename: "ltx-2-19b-ic-lora-union-control-ref0.5.safetensors",
87 size_bytes: 654_465_296,
88 sha256: "cd342e0dcf7754d8b36135b5e768b0f0e820703acd372adc49e53de0b00a931b",
89 download_model: "ltx2-control-union-19b",
90 gated: false,
91 extra_files: &[],
92 },
93 Ltx2ControlAdapter {
94 id: "pose",
95 label: "Pose control",
96 guide: "A frame-aligned pose or OpenPose guide video.",
97 profile: Ltx2ControlProfile::Ltx2_19bDistilled,
98 hf_repo: "Lightricks/LTX-2-19b-IC-LoRA-Pose-Control",
99 hf_filename: "ltx-2-19b-ic-lora-pose-control.safetensors",
100 size_bytes: 654_465_256,
101 sha256: "61816bf0985d4470c456160deec65df69188ae45d553e5aa8f1252fc543bc8aa",
102 download_model: "ltx2-control-pose-19b",
103 gated: false,
104 extra_files: &[],
105 },
106 Ltx2ControlAdapter {
107 id: "detailer",
108 label: "Detailer",
109 guide: "The source video whose details and textures should be refined.",
110 profile: Ltx2ControlProfile::Ltx2_19bDistilled,
111 hf_repo: "Lightricks/LTX-2-19b-IC-LoRA-Detailer",
112 hf_filename: "ltx-2-19b-ic-lora-detailer.safetensors",
113 size_bytes: 2_617_401_920,
114 sha256: "05efdae9e472e06d168e122f5ebb890e7ef348cc047cf9876da6504c36d7d0e2",
115 download_model: "ltx2-control-detailer-19b",
116 gated: false,
117 extra_files: &[],
118 },
119 Ltx2ControlAdapter {
120 id: "union",
121 label: "Union control",
122 guide: "A frame-aligned Canny, depth, or pose guide video.",
123 profile: Ltx2ControlProfile::Ltx2_23_22bDistilled,
124 hf_repo: "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control",
125 hf_filename: "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
126 size_bytes: 654_465_352,
127 sha256: "a1b888a87f661d27f08b394ae559e8e1050be33900bcc36a5cdf659e48f88d18",
128 download_model: "ltx2-control-union-23",
129 gated: false,
130 extra_files: &[],
131 },
132 Ltx2ControlAdapter {
133 id: "motion-track",
134 label: "Motion track",
135 guide: "A video with colored spline overlays marking the desired trajectories.",
136 profile: Ltx2ControlProfile::Ltx2_23_22bDistilled,
137 hf_repo: "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control",
138 hf_filename: "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
139 size_bytes: 327_309_314,
140 sha256: "e279807ee3aa3db1ce60188d665ff83342860367dcd6bac19f8bd5a99a9e1dca",
141 download_model: "ltx2-control-motion-track-23",
142 gated: false,
143 extra_files: &[],
144 },
145 Ltx2ControlAdapter {
146 id: "lipdub",
147 label: "Lip dub",
148 guide: "A reference video with speech; the mouth is re-timed to new audio.",
149 profile: Ltx2ControlProfile::Ltx2_23_22bDistilled,
150 hf_repo: "Lightricks/LTX-2.3-22b-IC-LoRA-DubIt",
154 hf_filename: "ltx-2.3-22b-ic-lora-dubit-0.9.safetensors",
155 size_bytes: 2_466_665_072,
156 sha256: "fc415b12cb639e78511bc264f85080c2f7b188e334c1d9fade76b310e2bc419c",
157 download_model: "ltx2-control-lipdub-23",
158 gated: true,
159 extra_files: &[],
160 },
161 Ltx2ControlAdapter {
162 id: "hdr",
163 label: "HDR",
164 guide: "An SDR reference video; the render is re-graded to linear HDR.",
165 profile: Ltx2ControlProfile::Ltx2_23_22bDistilled,
166 hf_repo: "Lightricks/LTX-2.3-22b-IC-LoRA-HDR",
167 hf_filename: "ltx-2.3-22b-ic-lora-hdr-0.9.safetensors",
168 size_bytes: 327_309_312,
169 sha256: "c56bfa0f2e4461a8b2f318f494c61c5bf97f462f2220e31ece93ea7851ca871e",
170 download_model: "ltx2-control-hdr-23",
171 gated: true,
172 extra_files: &[Ltx2ControlAdapterFile {
175 hf_filename: "ltx-2.3-22b-ic-lora-hdr-scene-emb.safetensors",
176 size_bytes: 12_583_096,
177 sha256: "78bffa6049bae2649a4365ec8769db88052c21348d643e8fc1ce6d483d994c5b",
178 }],
179 },
180];
181
182pub const LIP_DUB_CONTROL_ID: &str = "lipdub";
189
190pub fn pipeline_for_control_id(id: &str) -> crate::Ltx2PipelineMode {
192 if normalize_control_id(id) == LIP_DUB_CONTROL_ID {
193 crate::Ltx2PipelineMode::LipDub
194 } else {
195 crate::Ltx2PipelineMode::IcLora
196 }
197}
198
199pub fn normalize_control_id(value: &str) -> String {
200 value.trim().to_ascii_lowercase().replace('_', "-")
201}
202
203pub fn adapters_for_profile(
204 profile: Ltx2ControlProfile,
205) -> impl Iterator<Item = &'static Ltx2ControlAdapter> {
206 LTX2_CONTROL_ADAPTERS
207 .iter()
208 .filter(move |adapter| adapter.profile == profile)
209}
210
211pub fn resolve_control_adapter(
212 profile: Ltx2ControlProfile,
213 id: &str,
214) -> Result<&'static Ltx2ControlAdapter, String> {
215 let id = normalize_control_id(id);
216 adapters_for_profile(profile)
217 .find(|adapter| adapter.id == id)
218 .ok_or_else(|| {
219 let valid = adapters_for_profile(profile)
220 .map(|adapter| adapter.id)
221 .collect::<Vec<_>>()
222 .join(", ");
223 format!(
224 "IC-LoRA control '{id}' is not compatible with {}; choose one of: {valid}",
225 profile.label()
226 )
227 })
228}
229
230pub fn control_profile_for_config(config: &ModelConfig) -> Result<Ltx2ControlProfile, String> {
235 if config.family.as_deref() != Some("ltx2") {
236 return Err("IC-LoRA controls require an LTX-2 model".to_string());
237 }
238 if config.is_schnell != Some(true) {
239 return Err(
240 "built-in IC-LoRA controls require an effective distilled LTX-2 profile".to_string(),
241 );
242 }
243
244 let architecture_paths = [
245 config.transformer.as_deref(),
246 config.vae.as_deref(),
247 config.spatial_upscaler.as_deref(),
248 ];
249 if architecture_paths
250 .iter()
251 .flatten()
252 .any(|path| path.contains("ltx-2.3"))
253 {
254 return Ok(Ltx2ControlProfile::Ltx2_23_22bDistilled);
255 }
256 if architecture_paths
257 .iter()
258 .flatten()
259 .any(|path| path.contains("ltx-2"))
260 {
261 return Ok(Ltx2ControlProfile::Ltx2_19bDistilled);
262 }
263 Err(
264 "the installed checkpoint architecture is unknown; select an LTX-2 19B distilled or LTX-2.3 22B distilled profile"
265 .to_string(),
266 )
267}
268
269pub fn control_profile_for_model(
273 model: &str,
274 config: &ModelConfig,
275) -> Result<Ltx2ControlProfile, String> {
276 let canonical = crate::manifest::resolve_model_name(model);
277 let manifest_profile = match canonical.as_str() {
278 "ltx-2-19b-distilled:fp8" => Some(Ltx2ControlProfile::Ltx2_19bDistilled),
279 "ltx-2.3-22b-distilled:fp8" | "ltx-2.3-22b-distilled:bf16" => {
280 Some(Ltx2ControlProfile::Ltx2_23_22bDistilled)
281 }
282 _ => None,
283 };
284 if let Some(profile) = manifest_profile {
285 if config.family.as_deref() == Some("ltx2") {
286 return Ok(profile);
287 }
288 }
289 control_profile_for_config(config)
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
293pub struct Ltx2ControlAdapterInfo {
294 pub id: String,
295 pub label: String,
296 pub guide: String,
297 pub size_bytes: u64,
299 pub installed: bool,
300 pub download_model: String,
301 pub download_repo: String,
302 pub download_filename: String,
303 pub download_sha256: String,
304 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
308 pub gated: bool,
309}
310
311#[cfg(test)]
312mod tests {
313 use std::collections::HashSet;
314
315 use super::*;
316
317 #[test]
323 fn only_the_lip_dub_adapter_selects_a_pipeline_other_than_ic_lora() {
324 for adapter in LTX2_CONTROL_ADAPTERS {
325 let expected = if adapter.id == LIP_DUB_CONTROL_ID {
326 crate::Ltx2PipelineMode::LipDub
327 } else {
328 crate::Ltx2PipelineMode::IcLora
329 };
330 assert_eq!(
331 pipeline_for_control_id(adapter.id),
332 expected,
333 "adapter '{}' routes to the wrong pipeline",
334 adapter.id
335 );
336 }
337 assert!(LTX2_CONTROL_ADAPTERS
338 .iter()
339 .any(|adapter| adapter.id == LIP_DUB_CONTROL_ID));
340 }
341
342 #[test]
346 fn ltx2_control_pipeline_ts_mirror_matches_the_rust_registry() {
347 let workspace = env!("CARGO_MANIFEST_DIR")
348 .strip_suffix("/crates/mold-core")
349 .or_else(|| env!("CARGO_MANIFEST_DIR").strip_suffix("crates/mold-core"))
350 .unwrap_or(env!("CARGO_MANIFEST_DIR"));
351 let path = format!("{workspace}/studio/lib/ltx2Control.ts");
352 let source =
353 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
354
355 assert!(
356 source.contains(&format!(
357 "export const LIP_DUB_CONTROL_ID = \"{LIP_DUB_CONTROL_ID}\";"
358 )),
359 "studio/lib/ltx2Control.ts must pin LIP_DUB_CONTROL_ID to `{LIP_DUB_CONTROL_ID}`"
360 );
361 assert!(
362 source.contains(&format!(
363 "? \"{}\"\n : \"{}\";",
364 crate::Ltx2PipelineMode::LipDub,
365 crate::Ltx2PipelineMode::IcLora
366 )) || source.contains(&format!(
367 "? \"{}\" : \"{}\";",
368 crate::Ltx2PipelineMode::LipDub,
369 crate::Ltx2PipelineMode::IcLora
370 )),
371 "studio/lib/ltx2Control.ts must return the wire pipeline strings \
372 `{}` / `{}`",
373 crate::Ltx2PipelineMode::LipDub,
374 crate::Ltx2PipelineMode::IcLora
375 );
376 }
377
378 #[test]
379 fn registry_has_unique_profile_and_id_pairs() {
380 let mut seen = HashSet::new();
381 for adapter in LTX2_CONTROL_ADAPTERS {
382 assert!(seen.insert((adapter.profile, adapter.id)));
383 assert_eq!(adapter.sha256.len(), 64);
384 assert!(adapter.hf_filename.ends_with(".safetensors"));
385 }
386 assert_eq!(seen.len(), 7);
387 }
388
389 #[test]
390 fn official_artifact_identities_are_exact() {
391 let actual = LTX2_CONTROL_ADAPTERS
392 .iter()
393 .map(|adapter| {
394 (
395 adapter.hf_repo,
396 adapter.hf_filename,
397 adapter.size_bytes,
398 adapter.sha256,
399 )
400 })
401 .collect::<Vec<_>>();
402 assert_eq!(
403 actual,
404 vec![
405 (
406 "Lightricks/LTX-2-19b-IC-LoRA-Union-Control",
407 "ltx-2-19b-ic-lora-union-control-ref0.5.safetensors",
408 654_465_296,
409 "cd342e0dcf7754d8b36135b5e768b0f0e820703acd372adc49e53de0b00a931b",
410 ),
411 (
412 "Lightricks/LTX-2-19b-IC-LoRA-Pose-Control",
413 "ltx-2-19b-ic-lora-pose-control.safetensors",
414 654_465_256,
415 "61816bf0985d4470c456160deec65df69188ae45d553e5aa8f1252fc543bc8aa",
416 ),
417 (
418 "Lightricks/LTX-2-19b-IC-LoRA-Detailer",
419 "ltx-2-19b-ic-lora-detailer.safetensors",
420 2_617_401_920,
421 "05efdae9e472e06d168e122f5ebb890e7ef348cc047cf9876da6504c36d7d0e2",
422 ),
423 (
424 "Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control",
425 "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
426 654_465_352,
427 "a1b888a87f661d27f08b394ae559e8e1050be33900bcc36a5cdf659e48f88d18",
428 ),
429 (
430 "Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control",
431 "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
432 327_309_314,
433 "e279807ee3aa3db1ce60188d665ff83342860367dcd6bac19f8bd5a99a9e1dca",
434 ),
435 (
436 "Lightricks/LTX-2.3-22b-IC-LoRA-DubIt",
437 "ltx-2.3-22b-ic-lora-dubit-0.9.safetensors",
438 2_466_665_072,
439 "fc415b12cb639e78511bc264f85080c2f7b188e334c1d9fade76b310e2bc419c",
440 ),
441 (
442 "Lightricks/LTX-2.3-22b-IC-LoRA-HDR",
443 "ltx-2.3-22b-ic-lora-hdr-0.9.safetensors",
444 327_309_312,
445 "c56bfa0f2e4461a8b2f318f494c61c5bf97f462f2220e31ece93ea7851ca871e",
446 ),
447 ]
448 );
449 }
450
451 #[test]
455 fn adapters_enumerate_every_file_they_need() {
456 let hdr = LTX2_CONTROL_ADAPTERS
457 .iter()
458 .find(|a| a.id == "hdr")
459 .expect("hdr adapter is registered");
460 let files: Vec<_> = hdr.files().collect();
461 assert_eq!(files.len(), 2, "HDR ships weights plus scene embeddings");
462 assert_eq!(files[0].hf_filename, hdr.hf_filename, "weights come first");
463 assert!(files[1].hf_filename.contains("scene-emb"));
464 assert_eq!(
465 hdr.total_size_bytes(),
466 files.iter().map(|f| f.size_bytes).sum::<u64>()
467 );
468 assert!(hdr.total_size_bytes() > hdr.size_bytes);
469
470 for adapter in LTX2_CONTROL_ADAPTERS.iter().filter(|a| a.id != "hdr") {
472 assert_eq!(adapter.files().count(), 1, "{}", adapter.id);
473 assert_eq!(adapter.total_size_bytes(), adapter.size_bytes);
474 }
475 }
476
477 #[test]
481 fn gated_adapters_propagate_to_their_manifests() {
482 for adapter in LTX2_CONTROL_ADAPTERS {
483 let manifest = crate::manifest::find_manifest(adapter.download_model)
484 .unwrap_or_else(|| panic!("{} has no manifest", adapter.id));
485 assert_eq!(
486 manifest.files.len(),
487 adapter.files().count(),
488 "{} manifest file count",
489 adapter.id
490 );
491 for file in &manifest.files {
492 assert_eq!(file.gated, adapter.gated, "{} gating", adapter.id);
493 }
494 }
495 let gated: Vec<_> = LTX2_CONTROL_ADAPTERS
496 .iter()
497 .filter(|a| a.gated)
498 .map(|a| a.id)
499 .collect();
500 assert_eq!(gated, ["lipdub", "hdr"]);
501 }
502
503 #[test]
506 fn new_adapters_resolve_only_for_the_22b_profile() {
507 for id in ["lipdub", "hdr"] {
508 assert!(resolve_control_adapter(Ltx2ControlProfile::Ltx2_23_22bDistilled, id).is_ok());
509 assert!(resolve_control_adapter(Ltx2ControlProfile::Ltx2_19bDistilled, id).is_err());
510 }
511 }
512
513 #[test]
514 fn compatibility_matrix_is_exact() {
515 let controls_19b = adapters_for_profile(Ltx2ControlProfile::Ltx2_19bDistilled)
516 .map(|adapter| adapter.id)
517 .collect::<Vec<_>>();
518 assert_eq!(controls_19b, ["union", "pose", "detailer"]);
519
520 let controls_23 = adapters_for_profile(Ltx2ControlProfile::Ltx2_23_22bDistilled)
521 .map(|adapter| adapter.id)
522 .collect::<Vec<_>>();
523 assert_eq!(controls_23, ["union", "motion-track", "lipdub", "hdr"]);
524
525 for id in ["union", "motion-track", "pose", "detailer"] {
526 assert_eq!(
527 resolve_control_adapter(Ltx2ControlProfile::Ltx2_19bDistilled, id).is_ok(),
528 matches!(id, "union" | "pose" | "detailer"),
529 "unexpected LTX-2 19B compatibility for {id}"
530 );
531 assert_eq!(
532 resolve_control_adapter(Ltx2ControlProfile::Ltx2_23_22bDistilled, id).is_ok(),
533 matches!(id, "union" | "motion-track"),
534 "unexpected LTX-2.3 compatibility for {id}"
535 );
536 }
537 }
538
539 #[test]
540 fn ids_are_normalized_without_guessing_aliases() {
541 assert_eq!(normalize_control_id(" Motion_Track "), "motion-track");
542 assert!(
543 resolve_control_adapter(Ltx2ControlProfile::Ltx2_23_22bDistilled, "MOTION_TRACK")
544 .is_ok()
545 );
546 assert!(
547 resolve_control_adapter(Ltx2ControlProfile::Ltx2_23_22bDistilled, "motion").is_err()
548 );
549 }
550
551 #[test]
552 fn effective_config_is_the_profile_authority() {
553 let config = ModelConfig {
554 family: Some("ltx2".into()),
555 is_schnell: Some(true),
556 transformer: Some("/models/catalog/opaque/ltx-2.3-22b-distilled.safetensors".into()),
557 ..Default::default()
558 };
559 assert_eq!(
560 control_profile_for_config(&config).unwrap(),
561 Ltx2ControlProfile::Ltx2_23_22bDistilled
562 );
563
564 let dev = ModelConfig {
565 is_schnell: Some(false),
566 ..config
567 };
568 assert!(control_profile_for_config(&dev)
569 .unwrap_err()
570 .contains("distilled"));
571 }
572
573 #[test]
574 fn built_in_manifest_profiles_do_not_require_landed_paths() {
575 let config = ModelConfig {
576 family: Some("ltx2".into()),
577 is_schnell: Some(true),
578 ..Default::default()
579 };
580 assert_eq!(
581 control_profile_for_model("ltx-2-19b-distilled:fp8", &config).unwrap(),
582 Ltx2ControlProfile::Ltx2_19bDistilled
583 );
584 assert_eq!(
585 control_profile_for_model("ltx-2.3-22b-distilled:fp8", &config).unwrap(),
586 Ltx2ControlProfile::Ltx2_23_22bDistilled
587 );
588 assert!(control_profile_for_model("ltx-2.3-22b-dev:bf16", &config).is_err());
589 }
590}