Skip to main content

mold_core/
ltx2_camera.rs

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
122/// Resolve camera-control compatibility from the effective runtime model
123/// configuration. Official camera-control LoRAs are published only for the
124/// LTX-2 19B architecture.
125pub 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
136/// The architecture sniff behind `camera_profile_for_config`, over resolved
137/// artifact paths rather than a `ModelConfig`.
138///
139/// The engine holds `ModelPaths`, not a `ModelConfig`, and used to make this
140/// call with `model_name.contains("ltx-2.3")` — which is wrong in both
141/// directions for opaque `cv:` / `hf:` catalog IDs, whose names contain no
142/// architecture at all. Routing both callers through one function keeps the
143/// answer identical wherever it is asked.
144pub 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
162/// Resolve built-in manifests through an explicit profile table, then fall
163/// back to effective runtime configuration for opaque catalog IDs.
164pub 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/// The detailed form of `/api/capabilities/ltx2-camera-controls`, returned
197/// only for `?detail=1`.
198///
199/// The bare-array form cannot distinguish "this checkpoint has no published
200/// presets" from "the request failed", so every client hard-coded its own
201/// guess at the server's policy — and guessed wrong for the
202/// unknown-architecture case, which used to 422 into a silent empty picker.
203/// This carries the server's own reason instead. It is opt-in so the array
204/// response older clients parse stays byte-identical.
205#[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    /// `studio/lib/cameraMotion.ts` mirrors this registry so a clip's camera
220    /// choice can be recovered from a chain script or a saved print with no
221    /// server in the loop. That matching falls back to the human label, so a
222    /// drifted label silently stops restoring the user's choice.
223    #[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    /// Compatibility comes from the resolved artifacts, never the model name.
255    /// An opaque catalog ID carries no architecture, so a name-substring test
256    /// both admitted LTX-2.3 and rejected a 19B install reached that way.
257    #[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}