1use serde::{Deserialize, Serialize};
2
3use crate::config::ModelConfig;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum Ltx2CameraProfile {
7 Ltx2_19b,
8}
9
10impl Ltx2CameraProfile {
11 pub fn label(self) -> &'static str {
12 match self {
13 Self::Ltx2_19b => "LTX-2 19B",
14 }
15 }
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Ltx2CameraControlPreset {
20 pub id: &'static str,
21 pub label: &'static str,
22 pub hf_repo: &'static str,
23 pub hf_filename: &'static str,
24 pub size_bytes: u64,
25 pub sha256: &'static str,
26 pub download_model: &'static str,
27}
28
29pub const LTX2_CAMERA_CONTROLS: &[Ltx2CameraControlPreset] = &[
30 Ltx2CameraControlPreset {
31 id: "dolly-in",
32 label: "Dolly in",
33 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In",
34 hf_filename: "ltx-2-19b-lora-camera-control-dolly-in.safetensors",
35 size_bytes: 327_309_208,
36 sha256: "0da937d528ea619c95859ad0c5cab012c7213fe058b7cb289e2a513ec351f237",
37 download_model: "ltx2-camera-control-dolly-in-19b",
38 },
39 Ltx2CameraControlPreset {
40 id: "dolly-left",
41 label: "Dolly left",
42 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left",
43 hf_filename: "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
44 size_bytes: 327_309_208,
45 sha256: "fdd28ee1a53e5413d74b90064c7ec0fe98b78457b5da06c0c9c4ea5810d1f5f6",
46 download_model: "ltx2-camera-control-dolly-left-19b",
47 },
48 Ltx2CameraControlPreset {
49 id: "dolly-out",
50 label: "Dolly out",
51 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out",
52 hf_filename: "ltx-2-19b-lora-camera-control-dolly-out.safetensors",
53 size_bytes: 327_309_208,
54 sha256: "554c30e64fa57b6c98e059f1b3ccabc50ad85195aab0911aed317b714f8d644d",
55 download_model: "ltx2-camera-control-dolly-out-19b",
56 },
57 Ltx2CameraControlPreset {
58 id: "dolly-right",
59 label: "Dolly right",
60 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right",
61 hf_filename: "ltx-2-19b-lora-camera-control-dolly-right.safetensors",
62 size_bytes: 327_309_208,
63 sha256: "ee1a3a34dc21c6b61a4c5775756dd0fab8700e06dd35658c852c0127202aa5dd",
64 download_model: "ltx2-camera-control-dolly-right-19b",
65 },
66 Ltx2CameraControlPreset {
67 id: "jib-down",
68 label: "Jib down",
69 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down",
70 hf_filename: "ltx-2-19b-lora-camera-control-jib-down.safetensors",
71 size_bytes: 2_214_978_664,
72 sha256: "5a2f72b231841f998a46b949271fa0bcec21012d4238a53e7eb4762bc828bee3",
73 download_model: "ltx2-camera-control-jib-down-19b",
74 },
75 Ltx2CameraControlPreset {
76 id: "jib-up",
77 label: "Jib up",
78 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up",
79 hf_filename: "ltx-2-19b-lora-camera-control-jib-up.safetensors",
80 size_bytes: 2_214_978_664,
81 sha256: "c4e8bd628238caf20079515ba2bce5770fa1108e6ed8c1d403a3179a68dce77f",
82 download_model: "ltx2-camera-control-jib-up-19b",
83 },
84 Ltx2CameraControlPreset {
85 id: "static",
86 label: "Static",
87 hf_repo: "Lightricks/LTX-2-19b-LoRA-Camera-Control-Static",
88 hf_filename: "ltx-2-19b-lora-camera-control-static.safetensors",
89 size_bytes: 2_214_978_664,
90 sha256: "6b79aad7ecdd60aef07f39177d1ac225a6608806086af94848436fec432e2d0d",
91 download_model: "ltx2-camera-control-static-19b",
92 },
93];
94
95pub fn normalize_camera_control_id(value: &str) -> String {
96 value.trim().to_ascii_lowercase().replace('_', "-")
97}
98
99pub fn camera_controls_for_profile(
100 profile: Ltx2CameraProfile,
101) -> impl Iterator<Item = &'static Ltx2CameraControlPreset> {
102 match profile {
103 Ltx2CameraProfile::Ltx2_19b => LTX2_CAMERA_CONTROLS.iter(),
104 }
105}
106
107pub fn resolve_camera_control_preset(id: &str) -> Result<&'static Ltx2CameraControlPreset, String> {
108 let id = normalize_camera_control_id(id);
109 LTX2_CAMERA_CONTROLS
110 .iter()
111 .find(|preset| preset.id == id)
112 .ok_or_else(|| {
113 let valid = LTX2_CAMERA_CONTROLS
114 .iter()
115 .map(|preset| preset.id)
116 .collect::<Vec<_>>()
117 .join(", ");
118 format!("unknown LTX-2 camera-control preset '{id}' (expected one of: {valid})")
119 })
120}
121
122pub fn camera_profile_for_config(config: &ModelConfig) -> Result<Ltx2CameraProfile, String> {
126 if config.family.as_deref() != Some("ltx2") {
127 return Err("camera controls require an LTX-2 model".to_string());
128 }
129 camera_profile_for_artifact_paths([
130 config.transformer.as_deref(),
131 config.vae.as_deref(),
132 config.spatial_upscaler.as_deref(),
133 ])
134}
135
136pub fn camera_profile_for_artifact_paths<'a>(
145 architecture_paths: impl IntoIterator<Item = Option<&'a str>>,
146) -> Result<Ltx2CameraProfile, String> {
147 let architecture_paths: Vec<&str> = architecture_paths.into_iter().flatten().collect();
148 if architecture_paths
149 .iter()
150 .any(|path| path.contains("ltx-2.3"))
151 {
152 return Err(
153 "camera-control presets are currently published for LTX-2 19B only".to_string(),
154 );
155 }
156 if architecture_paths.iter().any(|path| path.contains("ltx-2")) {
157 return Ok(Ltx2CameraProfile::Ltx2_19b);
158 }
159 Err("the installed checkpoint architecture is unknown; select an LTX-2 19B profile".to_string())
160}
161
162pub fn camera_profile_for_model(
165 model: &str,
166 config: &ModelConfig,
167) -> Result<Ltx2CameraProfile, String> {
168 let canonical = crate::manifest::resolve_model_name(model);
169 if matches!(
170 canonical.as_str(),
171 "ltx-2-19b-dev:fp8" | "ltx-2-19b-distilled:fp8"
172 ) && config.family.as_deref() == Some("ltx2")
173 {
174 return Ok(Ltx2CameraProfile::Ltx2_19b);
175 }
176 if canonical.starts_with("ltx-2.3-") {
177 return Err(
178 "camera-control presets are currently published for LTX-2 19B only".to_string(),
179 );
180 }
181 camera_profile_for_config(config)
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
185pub struct Ltx2CameraControlInfo {
186 pub id: String,
187 pub label: String,
188 pub size_bytes: u64,
189 pub installed: bool,
190 pub download_model: String,
191 pub download_repo: String,
192 pub download_filename: String,
193 pub download_sha256: String,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
206pub struct Ltx2CameraControlAvailability {
207 pub controls: Vec<Ltx2CameraControlInfo>,
208 pub supported: bool,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub unsupported_reason: Option<String>,
211}
212
213#[cfg(test)]
214mod tests {
215 use std::collections::HashSet;
216
217 use super::*;
218
219 #[test]
224 fn camera_motion_ts_mirror_matches_the_rust_registry() {
225 let workspace = env!("CARGO_MANIFEST_DIR")
226 .strip_suffix("/crates/mold-core")
227 .or_else(|| env!("CARGO_MANIFEST_DIR").strip_suffix("crates/mold-core"))
228 .unwrap_or(env!("CARGO_MANIFEST_DIR"));
229 let path = format!("{workspace}/studio/lib/cameraMotion.ts");
230 let source =
231 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
232 let list = source
233 .split_once("export const CAMERA_MOTION_PRESETS = [")
234 .expect("cameraMotion.ts must declare CAMERA_MOTION_PRESETS")
235 .1
236 .split_once("] as const;")
237 .expect("CAMERA_MOTION_PRESETS must end in `] as const;`")
238 .0;
239
240 for preset in LTX2_CAMERA_CONTROLS {
241 let entry = format!("{{ id: \"{}\", label: \"{}\" }}", preset.id, preset.label);
242 assert!(
243 list.contains(&entry),
244 "studio/lib/cameraMotion.ts is missing `{entry}`"
245 );
246 }
247 assert_eq!(
248 list.matches("{ id:").count(),
249 LTX2_CAMERA_CONTROLS.len(),
250 "studio/lib/cameraMotion.ts declares a different number of presets than the registry"
251 );
252 }
253
254 #[test]
258 fn camera_profile_reads_artifact_paths_not_the_model_name() {
259 assert!(
260 camera_profile_for_artifact_paths([Some(
261 "/models/cv-2752735/ltx-2.3-22b-distilled.safetensors"
262 )])
263 .unwrap_err()
264 .contains("LTX-2 19B only"),
265 "an opaque catalog ID pointing at LTX-2.3 artifacts must be rejected"
266 );
267 assert_eq!(
268 camera_profile_for_artifact_paths([Some(
269 "/models/cv-3063794/ltx-2-19b-distilled-fp8.safetensors"
270 )])
271 .unwrap(),
272 Ltx2CameraProfile::Ltx2_19b,
273 "an opaque catalog ID pointing at 19B artifacts must be accepted"
274 );
275 assert!(
276 camera_profile_for_artifact_paths([Some("/models/mystery/weights.safetensors")])
277 .unwrap_err()
278 .contains("unknown")
279 );
280 }
281
282 #[test]
283 fn registry_has_unique_normalized_ids_and_download_models() {
284 let mut ids = HashSet::new();
285 let mut download_models = HashSet::new();
286 for preset in LTX2_CAMERA_CONTROLS {
287 assert!(ids.insert(preset.id));
288 assert!(download_models.insert(preset.download_model));
289 assert_eq!(normalize_camera_control_id(preset.id), preset.id);
290 assert_eq!(preset.sha256.len(), 64);
291 assert!(preset.sha256.chars().all(|ch| ch.is_ascii_hexdigit()));
292 assert!(preset.hf_filename.ends_with(".safetensors"));
293 }
294 assert_eq!(ids.len(), 7);
295 }
296
297 #[test]
298 fn official_artifact_identities_are_exact() {
299 let actual = LTX2_CAMERA_CONTROLS
300 .iter()
301 .map(|preset| {
302 (
303 preset.id,
304 preset.hf_repo,
305 preset.hf_filename,
306 preset.size_bytes,
307 preset.sha256,
308 )
309 })
310 .collect::<Vec<_>>();
311 assert_eq!(
312 actual,
313 vec![
314 (
315 "dolly-in",
316 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In",
317 "ltx-2-19b-lora-camera-control-dolly-in.safetensors",
318 327_309_208,
319 "0da937d528ea619c95859ad0c5cab012c7213fe058b7cb289e2a513ec351f237",
320 ),
321 (
322 "dolly-left",
323 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left",
324 "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
325 327_309_208,
326 "fdd28ee1a53e5413d74b90064c7ec0fe98b78457b5da06c0c9c4ea5810d1f5f6",
327 ),
328 (
329 "dolly-out",
330 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out",
331 "ltx-2-19b-lora-camera-control-dolly-out.safetensors",
332 327_309_208,
333 "554c30e64fa57b6c98e059f1b3ccabc50ad85195aab0911aed317b714f8d644d",
334 ),
335 (
336 "dolly-right",
337 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right",
338 "ltx-2-19b-lora-camera-control-dolly-right.safetensors",
339 327_309_208,
340 "ee1a3a34dc21c6b61a4c5775756dd0fab8700e06dd35658c852c0127202aa5dd",
341 ),
342 (
343 "jib-down",
344 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down",
345 "ltx-2-19b-lora-camera-control-jib-down.safetensors",
346 2_214_978_664,
347 "5a2f72b231841f998a46b949271fa0bcec21012d4238a53e7eb4762bc828bee3",
348 ),
349 (
350 "jib-up",
351 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up",
352 "ltx-2-19b-lora-camera-control-jib-up.safetensors",
353 2_214_978_664,
354 "c4e8bd628238caf20079515ba2bce5770fa1108e6ed8c1d403a3179a68dce77f",
355 ),
356 (
357 "static",
358 "Lightricks/LTX-2-19b-LoRA-Camera-Control-Static",
359 "ltx-2-19b-lora-camera-control-static.safetensors",
360 2_214_978_664,
361 "6b79aad7ecdd60aef07f39177d1ac225a6608806086af94848436fec432e2d0d",
362 ),
363 ]
364 );
365 }
366
367 #[test]
368 fn preset_ids_are_normalized_without_guessing_aliases() {
369 assert_eq!(normalize_camera_control_id(" Dolly_In "), "dolly-in");
370 assert!(resolve_camera_control_preset("DOLLY_IN").is_ok());
371 assert!(resolve_camera_control_preset("dolly").is_err());
372 }
373
374 #[test]
375 fn profile_resolution_accepts_only_ltx2_19b() {
376 let built_in = ModelConfig {
377 family: Some("ltx2".into()),
378 is_schnell: Some(true),
379 ..Default::default()
380 };
381 assert_eq!(
382 camera_profile_for_model("ltx-2-19b-distilled:fp8", &built_in).unwrap(),
383 Ltx2CameraProfile::Ltx2_19b
384 );
385 assert_eq!(
386 camera_profile_for_model("ltx-2-19b-dev:fp8", &built_in).unwrap(),
387 Ltx2CameraProfile::Ltx2_19b
388 );
389 assert!(
390 camera_profile_for_model("ltx-2.3-22b-distilled:fp8", &built_in)
391 .unwrap_err()
392 .contains("19B only")
393 );
394
395 let opaque_19b = ModelConfig {
396 transformer: Some("/models/catalog/ltx-2-19b-distilled.safetensors".into()),
397 ..built_in.clone()
398 };
399 assert_eq!(
400 camera_profile_for_model("hf:opaque", &opaque_19b).unwrap(),
401 Ltx2CameraProfile::Ltx2_19b
402 );
403
404 let dev = ModelConfig {
405 is_schnell: Some(false),
406 ..opaque_19b
407 };
408 assert_eq!(
409 camera_profile_for_config(&dev).unwrap(),
410 Ltx2CameraProfile::Ltx2_19b
411 );
412 }
413}