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}