Skip to main content

mold_server/
chain_limits.rs

1//! Chain-limits computation for the `/api/capabilities/chain-limits` route.
2//!
3//! The model's hardcoded per-clip cap is the primary constraint; the
4//! hardware-derived recommended value is `min(cap, free_vram_adjusted)` and
5//! is inert for distilled LTX-2 today because 97 is model-capped.
6
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
11pub struct ChainLimits {
12    pub model: String,
13    pub frames_per_clip_cap: u32,
14    pub frames_per_clip_recommended: u32,
15    pub max_stages: u32,
16    pub max_total_frames: u32,
17    pub fade_frames_max: u32,
18    pub transition_modes: Vec<String>,
19    pub quantization_family: String,
20    /// Whether this model's family has an audio decode path. The SPA reads
21    /// this to decide whether to show the chain-level "Generate audio"
22    /// toggle; the chain endpoint refuses `enable_audio: true` upstream
23    /// when this is false. Single source of truth: `family_supports_audio`.
24    pub supports_audio: bool,
25}
26
27/// Per-model-family hardcoded caps. Keyed by the family string returned by
28/// `mold_core::manifest::resolve_family`.
29///
30/// LTX-2 19B (`ltx-2-19b-{dev,distilled}:fp8`) and LTX-2.3 22B
31/// (`ltx-2.3-22b-{dev,distilled}:fp8`) both resolve to the `"ltx2"` family
32/// and therefore share the same per-clip cap and chain-renderer wiring.
33pub fn family_cap(family: &str) -> Option<u32> {
34    match family {
35        // LTX-2 distilled has true latent-handoff chain support via the
36        // engine's `as_chain_renderer()`. Covers both v2 and v2.3.
37        "ltx2" => Some(97),
38        // LTX-Video uses an img2vid fallback: each stage renders independently
39        // and the stitch layer concatenates clips. There's no temporal context
40        // handoff, so subjects can drift between clips, but it lets users
41        // generate videos longer than the per-clip cap.
42        "ltx-video" => Some(97),
43        _ => None,
44    }
45}
46
47/// Whether a chain-capable family also has an audio path. Currently only
48/// LTX-2 / LTX-2.3 (`"ltx2"`) — LTX-Video is video-only and FLUX/SDXL/etc.
49/// don't render video at all. The chain handler rejects requests with
50/// `enable_audio: true` when this returns false, so users get a clear
51/// upfront error instead of silently-dropped audio.
52pub fn family_supports_audio(family: &str) -> bool {
53    matches!(family, "ltx2")
54}
55
56/// Compute the chain-limits response for a resolved model name.
57///
58/// `family` is the canonical family string (e.g. "ltx2").
59/// `quant` is the quantization slug ("fp8", "fp16", "q8", ...).
60/// `free_vram_bytes` is the current free VRAM on the primary GPU.
61pub fn compute_limits(model: &str, family: &str, quant: &str, free_vram_bytes: u64) -> ChainLimits {
62    let cap = family_cap(family).unwrap_or(97);
63    // Hardware-derived recommended: for distilled LTX-2, 97 is already
64    // the binding constraint. Reserve the derivation scaffolding for
65    // future non-distilled models.
66    let _ = free_vram_bytes; // suppress unused for now; D wires this up
67    let recommended = cap;
68
69    const MAX_STAGES: u32 = 16;
70    ChainLimits {
71        model: model.to_string(),
72        frames_per_clip_cap: cap,
73        frames_per_clip_recommended: recommended,
74        max_stages: MAX_STAGES,
75        max_total_frames: cap * MAX_STAGES,
76        fade_frames_max: 32,
77        transition_modes: vec!["smooth".into(), "cut".into(), "fade".into()],
78        quantization_family: quant.to_string(),
79        supports_audio: family_supports_audio(family),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn ltx2_cap_is_97() {
89        // ltx2 family covers both v2 19B and v2.3 22B (dev and distilled);
90        // both resolve to family="ltx2" via `resolve_family`.
91        assert_eq!(family_cap("ltx2"), Some(97));
92    }
93
94    #[test]
95    fn ltx_video_cap_is_97() {
96        // LTX-Video uses the img2vid-less fallback; same per-clip cap as
97        // ltx2 because the chain endpoint stitches independent clips at the
98        // pixel level once the cap is hit.
99        assert_eq!(family_cap("ltx-video"), Some(97));
100    }
101
102    #[test]
103    fn audio_capability_is_ltx2_only() {
104        // Only the LTX-2 / LTX-2.3 AV transformer has an audio decode path.
105        // LTX-Video is video-only; FLUX/SDXL aren't in chain support at all.
106        assert!(family_supports_audio("ltx2"));
107        assert!(!family_supports_audio("ltx-video"));
108        assert!(!family_supports_audio("flux"));
109        assert!(!family_supports_audio(""));
110    }
111
112    #[test]
113    fn unknown_family_has_no_cap() {
114        assert_eq!(family_cap("flux"), None);
115        assert_eq!(family_cap("sdxl"), None);
116    }
117
118    #[test]
119    fn compute_limits_for_distilled() {
120        let lim = compute_limits("ltx-2-19b-distilled:fp8", "ltx2", "fp8", 8_000_000_000);
121        assert_eq!(lim.frames_per_clip_cap, 97);
122        assert_eq!(lim.frames_per_clip_recommended, 97);
123        assert_eq!(lim.max_stages, 16);
124        assert_eq!(lim.max_total_frames, 97 * 16);
125        assert_eq!(
126            lim.transition_modes,
127            vec!["smooth".to_string(), "cut".into(), "fade".into()]
128        );
129        assert!(
130            lim.supports_audio,
131            "ltx2 family has the AV transformer + audio VAE / vocoder path",
132        );
133    }
134
135    #[test]
136    fn compute_limits_for_ltx_video_has_no_audio() {
137        // LTX-Video is video-only; the SPA must hide the audio toggle and the
138        // chain endpoint will reject `enable_audio: true` upstream regardless.
139        let lim = compute_limits("ltx-video-0.9.7-distilled:fp8", "ltx-video", "fp8", 0);
140        assert!(
141            !lim.supports_audio,
142            "ltx-video has no audio path — toggle must stay off",
143        );
144    }
145}