Skip to main content

mold_core/
validation.rs

1use crate::{
2    GenerateRequest, KeyframeCondition, LoraWeight, Ltx2GuidanceOverrides, Ltx2PipelineMode,
3    Ltx2SpatialUpscale, OutputFormat, UpscaleRequest,
4};
5
6/// Maximum total pixels allowed (~1.8 megapixels). Qwen-Image trains at ~1.6MP
7/// (1328x1328), other models at ≤1MP. Headroom for non-square aspect ratios.
8pub const MAX_PIXELS: u64 = 1_800_000;
9/// LTX-2's own ceiling: upstream's shipped `LTX_2_3_HQ_PARAMS` renders
10/// 1920x1088 (stage 1 at 960x544, refined x2), which is 2,088,960 px. The
11/// flat 1.8 MP limit made mold unable to express the reference
12/// implementation's own top-end preset.
13pub const LTX2_MAX_PIXELS: u64 = 1_920 * 1_088;
14/// Per-axis span, independent of the pixel budget.
15///
16/// The checkpoints ship `positional_embedding_max_pos = [20, 2048, 2048]` and
17/// RoPE normalizes pixel positions by it, so an axis past 2048 lands outside
18/// the trained [-1, 1] range with no error raised. 3200x512 is only 1.64 MP
19/// and still out of distribution. Going beyond this needs tiled stage-2
20/// refinement with renormalized positions, not a larger single denoise —
21/// see [`LTX2_COMPOSED_MAX_AXIS_PIXELS`].
22pub const LTX2_MAX_AXIS_PIXELS: u32 = 2_048;
23
24/// Per-axis ceiling for a render that *composes* its output: stage 1 at half
25/// the target, one x2 spatial rung, then a tiled stage-2 refinement.
26///
27/// This is `2 * LTX2_MAX_AXIS_PIXELS` and the factor is not a safety margin —
28/// it is exactly where the composition stops working. Stage 1 renders the
29/// target halved (`derive_stage1_render_shape`), so a 4096 px target puts
30/// stage 1 at 2048 px, the last shape still inside the trained span. Stage 2
31/// is tiled, and a tile is always brought back inside that span, so stage 2
32/// itself imposes no ceiling. mold applies at most one spatial rung, so there
33/// is no second halving to rescue a wider target: past 4096 px, stage 1 is out
34/// of distribution and no amount of tiling downstream repairs it.
35pub const LTX2_COMPOSED_MAX_AXIS_PIXELS: u32 = 2 * LTX2_MAX_AXIS_PIXELS;
36
37/// Total-pixel ceiling for a composed LTX-2 render (`4096 x 2176`, 8.9 MP).
38///
39/// Like the single-pass budget this is a resource guard rather than a model
40/// limit: it is the widest axis the composition can hold paired with a
41/// 4K-class height. It is deliberately *above* the top of
42/// [`LTX2_OUTPUT_RUNGS`], which stops at what the bundled H.264 encoder can
43/// write — generation and delivery have different ceilings, and conflating
44/// them would refuse shapes that render correctly to a non-MP4 target.
45pub const LTX2_COMPOSED_MAX_PIXELS: u64 = 4_096 * 2_176;
46pub const MAX_INLINE_AUDIO_BYTES: usize = 64 * 1024 * 1024;
47pub const MAX_INLINE_SOURCE_VIDEO_BYTES: usize = 64 * 1024 * 1024;
48pub const FLUX2_DEV_MAX_REFERENCE_IMAGES: usize = 4;
49/// BFL's pixel cap for a single FLUX.2 Dev reference. The upstream value is
50/// intentionally 2024 squared, not 2048 squared.
51pub const FLUX2_DEV_SINGLE_REFERENCE_MAX_PIXELS: u64 = 2_024 * 2_024;
52/// BFL's per-image pixel cap when a FLUX.2 Dev request has multiple references.
53pub const FLUX2_DEV_MULTI_REFERENCE_MAX_PIXELS: u64 = 1_024 * 1_024;
54pub const LORA_CAPABLE_FAMILIES: &[&str] = &[
55    "flux",
56    "flux2",
57    "ltx2",
58    "sd15",
59    "sd3",
60    "sdxl",
61    "qwen-image",
62    "qwen-image-edit",
63    "wan",
64    "z-image",
65];
66
67pub fn family_supports_lora(family: &str) -> bool {
68    LORA_CAPABLE_FAMILIES.contains(&family)
69}
70
71/// Temporal RoPE budget for LTX-2 / LTX-2.3, **in seconds of video runtime**.
72///
73/// The checkpoints ship `pos_embed_max_pos = 20`, and both upstream `ltx_core`
74/// and mold's own RoPE path convert the temporal axis to *seconds* before
75/// normalizing by it: `ltx2/model/rope.rs`'s `scale_video_time_to_seconds`
76/// divides the pixel-frame coordinate by the request's fps. So `20` bounds
77/// twenty seconds of runtime, not twenty latent frames — which is exactly the
78/// ~20 s single-generation duration Lightricks advertises for LTX-2.3.
79pub const LTX2_MAX_RUNTIME_SECONDS: u32 = 20;
80
81/// fps assumed for LTX-2 when a caller must name a frame ceiling without a
82/// request in hand (the `/api/models` scalar fallback, and requests that leave
83/// `fps` unset for the server to fill in). Matches the manifest default.
84pub const LTX2_DEFAULT_FPS: u32 = 24;
85
86/// Absolute pixel-frame ceiling for LTX-2 regardless of fps.
87///
88/// This is a resource guard, not a model limit: the seconds budget alone would
89/// admit 2404 frames at the maximum allowed 120 fps, which no current GPU can
90/// denoise in one pass. 604 is `LTX2_MAX_RUNTIME_SECONDS` at 30 fps — the point
91/// where a practical frame budget meets the model's real duration budget.
92pub const LTX2_MAX_FRAMES_ABSOLUTE: u32 = LTX2_MAX_RUNTIME_SECONDS * 30 + 4;
93
94/// Global frame ceiling for video families that do not publish their own
95/// duration budget (currently `ltx-video`).
96pub const MAX_FRAMES_GLOBAL: u32 = 257;
97
98/// Default pixel-frame overlap for `extend_video` on LTX-2 — and the fallback
99/// for a family whose carryover cannot be resolved — matching the chain
100/// motion-tail default so an extend seam and a sequence seam behave the same.
101/// 17 pixel frames is three LTX-2 latent frames under the VAE's 8x causal
102/// temporal compression. Resolve it through
103/// [`default_extend_overlap_frames_for_family`] rather than reading it
104/// directly: it is not the answer for every extend-capable family.
105pub const DEFAULT_EXTEND_OVERLAP_FRAMES: u32 = 17;
106
107/// Pixel-frame overlap an `extend_video` request gets when it names none.
108///
109/// The default is a property of the family's *carryover*, never a global
110/// scalar. Wan's continuation is seeded with one frame and its engine refuses
111/// any other overlap, so advertising LTX-2's 17 handed wan clients a value
112/// that clears wan's `4k+1` grid check at admission and then fails inside the
113/// engine, after the model load had already been paid for (#783).
114pub fn default_extend_overlap_frames_for_family(family: Option<&str>) -> u32 {
115    match family {
116        Some("wan") => WAN_HANDOFF_DUPLICATED_FRAMES,
117        _ => DEFAULT_EXTEND_OVERLAP_FRAMES,
118    }
119}
120
121/// Write the family's own carryover into a continuation that named no overlap.
122///
123/// This is a mutation rather than a read at the point of use because of
124/// *provenance*. [`crate::OutputMetadata::from_generate_request`] records what
125/// rendered, and it holds no family — it resolves one through the manifest,
126/// which an installed `cv:` / `hf:` wan checkpoint does not have. A wan
127/// continuation that ran with one carryover frame was therefore saved as
128/// having used LTX-2's 17 (#783). Server admission and the forced-local CLI
129/// path both know the resolved family, so both fill the field in before
130/// anything reads it — the same seam `materialize_default_negative_prompt`
131/// uses for the wan uncond.
132///
133/// An explicit value is authoritative and passes through untouched, and a
134/// non-extend request is never given one: a bare `extend_overlap_frames` is a
135/// validation error, not a default.
136pub fn materialize_extend_overlap_frames(req: &mut GenerateRequest, family: Option<&str>) {
137    if req.is_extend() && req.extend_overlap_frames.is_none() {
138        req.extend_overlap_frames = Some(default_extend_overlap_frames_for_family(family));
139    }
140}
141
142/// The motion-tail overlap a chain seam actually renders with.
143///
144/// The tail is a property of the family's carryover and, for wan, of the
145/// selected *checkpoint's* conditioning contract — never of what the caller
146/// asked for. Wan has no latent motion tail: its seam re-renders exactly the
147/// one frame the continuation was seeded with, and only an image-conditioned
148/// checkpoint can be seeded at all. LTX-Video has no img2vid path, so its
149/// Smooth boundaries collapse to clean concatenation.
150///
151/// This lives here, beside [`WAN_HANDOFF_DUPLICATED_FRAMES`], because the
152/// server had been the only caller that normalized it (#936). The forced-local
153/// `--script` path, `mold chain validate`, and `--dry-run` all ran the
154/// family-generic `ChainRequest::normalise` and passed the requested tail
155/// through untouched — and `17 % 4 == 1`, so LTX-2's default clears wan's own
156/// `4k+1` grid check and then discards sixteen good frames at every Smooth
157/// seam, with correct-looking validation output (#783).
158///
159/// `source_image` is the resolved contract — probed from the checkpoint's own
160/// headers where possible, the manifest as the cold fallback. `None` is
161/// "unknown", which takes the conservative path rather than assuming a
162/// handoff. Families with a real latent window keep what the caller asked for.
163pub fn chain_motion_tail_frames_for_family(
164    family: &str,
165    source_image: Option<crate::SourceImageCapability>,
166    requested: u32,
167) -> u32 {
168    match family {
169        "wan" => {
170            let carries_context = source_image.is_some_and(|capability| {
171                matches!(
172                    capability,
173                    crate::SourceImageCapability::Required | crate::SourceImageCapability::Optional
174                )
175            });
176            if carries_context {
177                WAN_HANDOFF_DUPLICATED_FRAMES
178            } else {
179                0
180            }
181        }
182        "ltx-video" => 0,
183        _ => requested,
184    }
185}
186
187/// Inline `extend_video` payloads share the source-video body budget.
188pub const MAX_INLINE_EXTEND_VIDEO_BYTES: usize = MAX_INLINE_SOURCE_VIDEO_BYTES;
189
190/// Upper bound for a requested STG block index. The deepest LTX-2 transformer
191/// mold runs has 48 layers; the ceiling is loose on purpose because the exact
192/// depth is a property of the resolved checkpoint, which validation does not
193/// have. The engine rejects an index the loaded transformer does not have.
194pub const MAX_STG_BLOCK_INDEX: u32 = 64;
195
196/// Maximum number of simultaneously perturbed STG blocks. Every extra block
197/// deepens the perturbed pass; upstream configurations use one or two.
198pub const MAX_STG_BLOCKS: usize = 8;
199
200/// Largest pixel-frame count whose final RoPE token still lands inside the
201/// LTX-2 temporal budget at `fps`.
202///
203/// After the causal first-frame fix, latent frame `k` spans pixel bounds
204/// `[8k - 7, 8k + 1]`, so the midpoint the RoPE grid actually sees is
205/// `(8k - 3) / fps` seconds. `F` pixel frames on the `8n + 1` grid put the last
206/// latent at `k = (F - 1) / 8`, giving a midpoint of `(F - 4) / fps`. Requiring
207/// that to stay within the budget yields `F <= seconds * fps + 4`.
208pub fn ltx2_max_frames_at_fps(fps: u32) -> u32 {
209    LTX2_MAX_RUNTIME_SECONDS
210        .saturating_mul(fps.max(1))
211        .saturating_add(4)
212        .min(LTX2_MAX_FRAMES_ABSOLUTE)
213}
214
215/// [`ltx2_max_frames_at_fps`] snapped down onto the `8n+1` grid the validator
216/// actually enforces.
217///
218/// The raw cap is not requestable: `20 * 24 + 4 = 484` and `483 % 8 == 3`, so a
219/// client that clamps a slider to the advertised maximum and submits gets a
220/// 422. At 48 fps the absolute guard bites first — 964 clamps to 604, which is
221/// equally off-grid — so this matters at every rate, not just the default.
222pub fn ltx2_max_frames_on_grid_at_fps(fps: u32) -> u32 {
223    snap_frames_to_8k1(ltx2_max_frames_at_fps(fps))
224}
225
226/// Spatial alignment a two-stage LTX-2 render needs. Stage 1 renders at half
227/// the requested size, so both axes must survive the halving and still land on
228/// the VAE's 32-pixel latent grid. Mirrors upstream `assert_resolution`'s
229/// `divisor = 64 if is_two_stage else 32`
230/// (`packages/ltx-pipelines/src/ltx_pipelines/utils/helpers.py:326`).
231pub const LTX2_TWO_STAGE_ALIGNMENT: u32 = 64;
232
233/// The VAE's causal temporal compression factor: latent frame 0 covers one
234/// pixel frame and every later latent frame covers eight, so a renderable
235/// pixel-frame count is always `8k + 1`.
236pub const LTX2_TEMPORAL_SCALE: u32 = 8;
237
238/// Round `frames` **down** onto the `8k + 1` grid LTX-2 can actually render.
239///
240/// Mirrors upstream `_snap_frames_to_8k1`
241/// (`packages/ltx-pipelines/src/ltx_pipelines/lipdub.py:46-49`). Rounding down
242/// matters for lip-dub: the reference clip's frame count is whatever the
243/// camera produced, and rounding *up* would ask for frames the reference does
244/// not have.
245pub fn snap_frames_to_8k1(frames: u32) -> u32 {
246    if frames <= 1 {
247        return 1;
248    }
249    frames - ((frames - 1) % LTX2_TEMPORAL_SCALE)
250}
251
252/// Wan's causal video VAE compresses time by 4: latent frame 0 covers one
253/// pixel frame and every later latent frame covers four, so a renderable
254/// pixel-frame count is always `4k + 1` (upstream enforces the same grid in
255/// `Wan2.1/generate.py`).
256pub const WAN_TEMPORAL_SCALE: u32 = 4;
257
258/// The pixel frames a wan continuation duplicates from the clip before it.
259///
260/// Wan has no latent motion tail. Its handoff is last-frame *image*
261/// conditioning: the continuation is seeded with the previous clip's final
262/// frame, so it re-renders exactly that one frame and the stitch trims exactly
263/// one. This is deliberately not LTX-2's 17 — that number is the pixel window
264/// its VAE turns into three latent slots of carryover, which wan has no
265/// equivalent of, and copying it would discard sixteen good frames per seam.
266///
267/// It lives in `mold-core` because it is the value the whole stack derives
268/// from — admission, `/api/models`, the CLI's chain planner, and the engine
269/// gate that enforces it (`mold_inference::wan::pipeline` re-exports this).
270pub const WAN_HANDOFF_DUPLICATED_FRAMES: u32 = 1;
271
272/// Smallest clip `wan22-ti2v-5b` first/last-frame conditioning accepts. TI2V
273/// pins both endpoints in latent space, where the 2.2 VAE's 4x temporal
274/// stride turns a 5-frame pixel clip into two latent frames — both anchored,
275/// nothing left to denoise. Nine pixel frames (three latent frames) is the
276/// smallest `4k + 1` clip with an interior. Admission, the CLI, Discord, and
277/// the studio surfaces (`studio/lib/sourceImageCapability.ts`) all enforce
278/// this same floor before dispatch; the shared fixture
279/// `tests/fixtures/wan/surface-parity-v1.json` pins them together (#806).
280pub const WAN_TI2V_FLF_MIN_FRAMES: u32 = 9;
281
282/// The frame count and rate a lip-dub render must use, plus anything the
283/// caller asked for that the reference video overrode.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct LipDubTiming {
286    /// Reference frame count snapped down onto the `8k + 1` grid.
287    pub frames: u32,
288    /// The reference clip's own frame rate.
289    pub fps: u32,
290    /// Human-readable notes about requested values that were replaced.
291    /// Empty when the caller asked for exactly what the reference provides.
292    pub warnings: Vec<String>,
293}
294
295/// What a probe of the lip-dub reference clip reports.
296///
297/// A struct rather than positional arguments so every property the pipeline
298/// depends on is supplied by name, and so adding one is a compile error at
299/// every call site — which is what stops the server and forced-local paths
300/// validating different things.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub struct LipDubReference {
303    pub frames: u32,
304    pub fps: u32,
305    /// Whether the clip carries a decodable audio stream. Lip dub imitates the
306    /// reference speaker's voice, so a silent reference cannot drive one.
307    pub has_audio: bool,
308}
309
310/// Resolve a lip-dub render's parameters from its reference video, rejecting a
311/// reference that cannot drive one.
312///
313/// Lip-dub re-voices an existing clip, so the output must land on the
314/// reference's own timeline: upstream reads both the frame count and the frame
315/// rate straight off the reference stream
316/// (`packages/ltx-pipelines/src/ltx_pipelines/lipdub.py:190-192`) rather than
317/// taking them from the caller. mold keeps the same rule, but says so out loud
318/// when a client asked for something else — a silently retimed dub looks fine
319/// and is out of sync.
320/// Every precondition lives here rather than at the call sites, so a request
321/// that cannot succeed is refused before it is validated, scheduled, and
322/// granted VRAM — not several minutes later when the audio VAE has nothing to
323/// encode.
324pub fn resolve_lip_dub_timing(
325    reference: LipDubReference,
326    requested_frames: Option<u32>,
327    requested_fps: Option<u32>,
328) -> Result<LipDubTiming, String> {
329    let LipDubReference {
330        frames: reference_frames,
331        fps: reference_fps,
332        has_audio,
333    } = reference;
334    if reference_fps == 0 {
335        return Err("lip-dub reference video reports a frame rate of 0".to_string());
336    }
337    // Upstream raises on a reference with no audio stream
338    // (`lipdub.py:166-170`). Catching it at the request boundary rather than at
339    // decode time is the difference between a 422 and a queued job that dies
340    // after loading a 22B checkpoint.
341    if !has_audio {
342        return Err(
343            "lip-dub reference video has no audio track; the pipeline re-voices existing \
344             speech, so the reference must contain some"
345                .to_string(),
346        );
347    }
348    let frames = snap_frames_to_8k1(reference_frames);
349    if frames < 9 {
350        return Err(format!(
351            "lip-dub reference video is too short: {reference_frames} frames snap down to \
352             {frames}, and the pipeline needs at least 9"
353        ));
354    }
355    let mut warnings = Vec::new();
356    if requested_frames.is_some_and(|requested| requested != frames) {
357        warnings.push(format!(
358            "lip-dub takes its length from the reference video: rendering {frames} frames \
359             instead of the requested {}",
360            requested_frames.unwrap_or_default()
361        ));
362    } else if requested_frames.is_none() && frames != reference_frames {
363        warnings.push(format!(
364            "lip-dub snapped the reference video's {reference_frames} frames down to {frames} \
365             (LTX-2 renders 8k+1 frames)"
366        ));
367    }
368    if requested_fps.is_some_and(|requested| requested != reference_fps) {
369        warnings.push(format!(
370            "lip-dub takes its frame rate from the reference video: rendering at \
371             {reference_fps} fps instead of the requested {}",
372            requested_fps.unwrap_or_default()
373        ));
374    }
375    Ok(LipDubTiming {
376        frames,
377        fps: reference_fps,
378        warnings,
379    })
380}
381
382/// Per-family single-request frame ceiling at `fps` — the value `/api/models`
383/// advertises as `max_frames`. Must stay in agreement with
384/// `validate_generate_request`'s rejections, which consume this helper.
385///
386/// LTX-2's ceiling is a duration, so it moves with fps; every other video
387/// family reports the flat global ceiling.
388pub fn max_frames_for_family_at_fps(family: &str, fps: u32) -> Option<u32> {
389    match family {
390        // Advertise the value a client can actually submit. The raw duration
391        // ceiling sits off the `8n+1` grid at every fps, so a slider clamped
392        // to it produced a 422.
393        "ltx2" => Some(ltx2_max_frames_on_grid_at_fps(fps)),
394        "ltx-video" => Some(MAX_FRAMES_GLOBAL),
395        // Wan's temporal RoPE is indexed by latent frame against a 1024-entry
396        // table, so duration is nowhere near the binding limit — memory is.
397        // The flat global ceiling is the resource guard, and 257 sits on the
398        // `4k+1` grid, so the advertised maximum is itself submittable.
399        "wan" => Some(MAX_FRAMES_GLOBAL),
400        family if crate::minimax_h3::is_family(family) => Some(crate::minimax_h3::MAX_FRAMES),
401        _ => None,
402    }
403}
404
405/// `max_frames_for_family_at_fps` at each family's default fps, for callers
406/// that have no per-model fps to hand.
407pub fn max_frames_for_family(family: &str) -> Option<u32> {
408    max_frames_for_family_at_fps(family, LTX2_DEFAULT_FPS)
409}
410
411/// Minimum requestable frame count for families that impose one above the
412/// generic single-frame floor. `None` retains the historical minimum of one.
413pub fn min_frames_for_family(family: &str) -> Option<u32> {
414    crate::minimax_h3::is_family(family).then_some(crate::minimax_h3::MIN_FRAMES)
415}
416
417/// A family's mandatory frame rate, when the checkpoint does not support
418/// arbitrary FPS. `None` means callers may choose any otherwise-valid rate.
419pub fn fixed_fps_for_family(family: &str) -> Option<u32> {
420    crate::minimax_h3::is_family(family).then_some(crate::minimax_h3::FIXED_FPS)
421}
422
423/// Single-request runtime ceiling in seconds for families whose real limit is
424/// a duration. `None` means the family's ceiling is a plain frame count.
425pub fn max_runtime_seconds_for_family(family: &str) -> Option<u32> {
426    match family {
427        "ltx2" => Some(LTX2_MAX_RUNTIME_SECONDS),
428        family if crate::minimax_h3::is_family(family) => {
429            Some(crate::minimax_h3::MAX_DURATION_SECONDS)
430        }
431        _ => None,
432    }
433}
434
435/// fps-independent frame guard, paired with `max_runtime_seconds_for_family`.
436pub fn max_frames_absolute_for_family(family: &str) -> Option<u32> {
437    match family {
438        "ltx2" => Some(LTX2_MAX_FRAMES_ABSOLUTE),
439        family if crate::minimax_h3::is_family(family) => Some(crate::minimax_h3::MAX_FRAMES),
440        _ => None,
441    }
442}
443
444/// Step of the frame-count grid for a family. Pair with
445/// [`frame_offset_for_family`]; valid counts are `k * step + offset`.
446pub fn frame_step_for_family(family: &str) -> Option<u32> {
447    match family {
448        "ltx2" | "ltx-video" => Some(LTX2_TEMPORAL_SCALE),
449        "wan" => Some(WAN_TEMPORAL_SCALE),
450        family if crate::minimax_h3::is_family(family) => Some(crate::minimax_h3::FRAME_STEP),
451        _ => None,
452    }
453}
454
455/// Offset of the frame-count grid. Existing video families use 1; MiniMax H3
456/// uses 5 (`17n+5`). `None` means the family has no temporal grid.
457pub fn frame_offset_for_family(family: &str) -> Option<u32> {
458    frame_step_for_family(family).map(|_| {
459        if crate::minimax_h3::is_family(family) {
460            crate::minimax_h3::FRAME_OFFSET
461        } else {
462            1
463        }
464    })
465}
466
467/// Validate family-specific temporal constraints that sit above the generic
468/// non-zero FPS/frame checks. Public admission calls this only after the model
469/// activation gate; keeping it factored lets the authority be tested without
470/// introducing a test-only authorization bypass.
471fn validate_family_video_timing_constraints(
472    frames: Option<u32>,
473    fps: Option<u32>,
474    family: Option<&str>,
475) -> Result<(), String> {
476    if let (Some(family), Some(fps)) = (family, fps) {
477        if let Some(fixed_fps) = fixed_fps_for_family(family) {
478            if fps != fixed_fps {
479                return Err(format!("{family} requires {fixed_fps} fps; received {fps}"));
480            }
481        }
482    }
483    if let (Some(family), Some(frames)) = (family, frames) {
484        if let Some(min_frames) = min_frames_for_family(family) {
485            if frames < min_frames {
486                return Err(format!(
487                    "frames ({frames}) must be >= {min_frames} for {family}"
488                ));
489            }
490        }
491    }
492    Ok(())
493}
494
495fn megapixel_limit_label_for(limit: u64) -> String {
496    format!("{:.1}MP", limit as f64 / 1_000_000.0)
497}
498
499/// How much spatial work a resolved LTX-2 render splits into.
500///
501/// This is the only thing that decides whether an axis past the trained RoPE
502/// span is renderable, so it is resolved once — from the model and the
503/// requested pipeline — rather than inferred separately by each surface.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
505pub enum Ltx2SpatialComposition {
506    /// One un-tiled denoise at the requested shape. The trained span is a hard
507    /// ceiling: there is nothing downstream to renormalize positions.
508    #[default]
509    SinglePass,
510    /// Stage 1 at half the target, one x2 spatial rung, then a stage-2
511    /// refinement over latent tiles each brought back inside the trained span.
512    TiledTwoStage,
513}
514
515/// Whether a checkpoint ships the spatial upsampler the composition needs.
516///
517/// Mirrors `Ltx2Pipeline::select_pipeline`: without the upsampler asset every
518/// LTX-2 request falls back to a plain one-stage denoise, whatever pipeline was
519/// asked for. Single-file catalog checkpoints (`cv:` / `hf:`) have no manifest
520/// and therefore no upsampler, which is the conservative answer.
521///
522/// Component paths supplied through `config.toml` are deliberately not
523/// consulted: the validator has no config, and guessing "yes" here would admit
524/// a shape the engine then renders out of distribution.
525fn model_has_spatial_upsampler(model: &str) -> bool {
526    let canonical = crate::manifest::resolve_model_name(model);
527    crate::manifest::find_manifest(&canonical).is_some_and(|manifest| {
528        manifest
529            .files
530            .iter()
531            .any(|file| file.component == crate::manifest::ModelComponent::SpatialUpscaler)
532    })
533}
534
535/// Resolve the spatial composition a request will actually run.
536///
537/// `pipeline` is the request's explicit `ltx2.pipeline`, or `None` to let the
538/// engine choose. Either way the answer requires a spatial upsampler on disk,
539/// because that is what `select_pipeline` requires before it will pick a
540/// refining pipeline at all.
541///
542/// Prefer [`ltx2_spatial_composition_for_request`] when the request is in
543/// hand: with `pipeline: None` this assumes the engine's *default* choice, and
544/// several request fields override that default before it is reached.
545pub fn ltx2_spatial_composition(
546    model: &str,
547    pipeline: Option<Ltx2PipelineMode>,
548) -> Ltx2SpatialComposition {
549    if !model_has_spatial_upsampler(model) {
550        return Ltx2SpatialComposition::SinglePass;
551    }
552    let refines = match pipeline {
553        Some(mode) => mode.refines_spatially(),
554        // `select_pipeline`'s default for a checkpoint that has the upsampler
555        // is `Distilled` or `TwoStage`; both refine.
556        None => true,
557    };
558    if refines {
559        Ltx2SpatialComposition::TiledTwoStage
560    } else {
561        Ltx2SpatialComposition::SinglePass
562    }
563}
564
565/// The pipeline `select_pipeline` will resolve for a request that names none.
566///
567/// Mirrors `Ltx2Pipeline::select_pipeline`'s implicit branch order
568/// (`ltx2/pipeline.rs:377-388`). Only the *conditioning* selectors are
569/// mirrored: the checkpoint-name fallback below them chooses between
570/// `Distilled` and `TwoStage`, which both refine, so it cannot change this
571/// answer. `retake_range` can and does — retake denoises once.
572fn ltx2_implicit_pipeline(req: &GenerateRequest) -> Option<Ltx2PipelineMode> {
573    if req.retake_range.is_some() {
574        return Some(Ltx2PipelineMode::Retake);
575    }
576    if req.audio_file.is_some() || req.audio_file_path.is_some() {
577        return Some(Ltx2PipelineMode::A2Vid);
578    }
579    if req.keyframes.as_ref().is_some_and(|items| items.len() > 1) {
580        return Some(Ltx2PipelineMode::Keyframe);
581    }
582    if req.source_video.is_some() || req.source_video_path.is_some() {
583        return Some(Ltx2PipelineMode::IcLora);
584    }
585    None
586}
587
588/// [`ltx2_spatial_composition`] resolved from the whole request.
589///
590/// An explicit `pipeline` wins; otherwise the request's own conditioning
591/// decides, exactly as the engine's `select_pipeline` does. Without this a
592/// retake — which denoises once — would be admitted at the composed ceiling
593/// and only refused by the engine's backstop, minutes later.
594pub fn ltx2_spatial_composition_for_request(req: &GenerateRequest) -> Ltx2SpatialComposition {
595    ltx2_spatial_composition(
596        &req.model,
597        req.pipeline.or_else(|| ltx2_implicit_pipeline(req)),
598    )
599}
600
601/// Total-pixel ceiling for a generation family, assuming no composition.
602///
603/// Callers that know the resolved model should use
604/// [`max_pixels_for_family_composed`]; this is the conservative answer for the
605/// ones that only have a family string.
606pub fn max_pixels_for_family(family: Option<&str>) -> u64 {
607    max_pixels_for_family_composed(family, Ltx2SpatialComposition::SinglePass)
608}
609
610/// Composition-aware counterpart to [`max_pixels_for_family`].
611pub fn max_pixels_for_family_composed(
612    family: Option<&str>,
613    composition: Ltx2SpatialComposition,
614) -> u64 {
615    match (family, composition) {
616        (Some("ltx2"), Ltx2SpatialComposition::TiledTwoStage) => LTX2_COMPOSED_MAX_PIXELS,
617        (Some("ltx2"), Ltx2SpatialComposition::SinglePass) => LTX2_MAX_PIXELS,
618        (Some(family), _) if crate::minimax_h3::is_family(family) => crate::minimax_h3::MAX_PIXELS,
619        _ => MAX_PIXELS,
620    }
621}
622
623/// Per-axis ceiling for a generation family, where one exists.
624pub fn max_axis_pixels_for_family(family: Option<&str>) -> Option<u32> {
625    max_axis_pixels_for_family_composed(family, Ltx2SpatialComposition::SinglePass)
626}
627
628/// Composition-aware counterpart to [`max_axis_pixels_for_family`].
629pub fn max_axis_pixels_for_family_composed(
630    family: Option<&str>,
631    composition: Ltx2SpatialComposition,
632) -> Option<u32> {
633    match (family, composition) {
634        (Some("ltx2"), Ltx2SpatialComposition::TiledTwoStage) => {
635            Some(LTX2_COMPOSED_MAX_AXIS_PIXELS)
636        }
637        (Some("ltx2"), Ltx2SpatialComposition::SinglePass) => Some(LTX2_MAX_AXIS_PIXELS),
638        _ => None,
639    }
640}
641
642/// Required pixel grid for a generation family.
643///
644/// LTX video VAEs compress spatial dimensions by 32. Every other current
645/// family uses the shared 16px generation grid.
646pub fn dimension_alignment_for_family(family: Option<&str>) -> u32 {
647    if matches!(family, Some("ltx-video" | "ltx2"))
648        || family.is_some_and(crate::minimax_h3::is_family)
649    {
650        32
651    } else {
652        16
653    }
654}
655
656/// Model-aware counterpart to [`dimension_alignment_for_family`].
657///
658/// Most families have one grid, but Wan's is per checkpoint:
659/// `wan22-ti2v-5b`'s 2.2 VAE compresses 16x spatially and its DiT patches the
660/// latent 2x2, putting it on a 32 px grid while the 2.1-VAE checkpoints keep
661/// the family's 16 (see [`wan_dimension_alignment`]). `family_hint` mirrors
662/// [`validate_generate_request_with_family`]: pass the catalog-resolved family
663/// for `cv:` / `hf:` ids; manifest models resolve without it.
664pub fn dimension_alignment_for_model(model: &str, family_hint: Option<&str>) -> u32 {
665    let family = resolved_family(model, family_hint);
666    if family == Some("wan") {
667        return wan_dimension_alignment(model);
668    }
669    dimension_alignment_for_family(family)
670}
671
672/// Validate explicit generation dimensions without rewriting them.
673///
674/// This is the shared admission boundary for one-shot and chain requests.
675/// Clients may project a source image onto this contract, but the server must
676/// reject invalid dimensions rather than silently changing the requested
677/// canvas.
678pub fn validate_generation_dimensions(
679    width: u32,
680    height: u32,
681    family: Option<&str>,
682) -> Result<(), String> {
683    validate_generation_dimensions_composed(
684        width,
685        height,
686        family,
687        Ltx2SpatialComposition::SinglePass,
688    )
689}
690
691/// Composition-aware counterpart to [`validate_generation_dimensions`].
692///
693/// Callers that have resolved the model — the HTTP generate and chain paths,
694/// and the CLI — pass the real composition so a two-stage LTX-2 render can be
695/// admitted past the trained span. Callers that only have a family string keep
696/// the conservative single-pass ceiling.
697pub fn validate_generation_dimensions_composed(
698    width: u32,
699    height: u32,
700    family: Option<&str>,
701    composition: Ltx2SpatialComposition,
702) -> Result<(), String> {
703    validate_generation_dimensions_with_alignment(
704        width,
705        height,
706        family,
707        composition,
708        dimension_alignment_for_family(family),
709    )
710}
711
712/// Model-aware sibling of [`validate_generation_dimensions_composed`].
713///
714/// Same contract, but the pixel grid comes from
715/// [`dimension_alignment_for_model`], so a per-checkpoint grid — currently
716/// `wan22-ti2v-5b`'s 32 — is enforced at admission instead of after the model
717/// has loaded. Callers that cannot name a model keep the family-only
718/// validator, whose answer is deliberately unchanged.
719pub fn validate_generation_dimensions_for_model(
720    model: &str,
721    width: u32,
722    height: u32,
723    family: Option<&str>,
724    composition: Ltx2SpatialComposition,
725) -> Result<(), String> {
726    validate_generation_dimensions_with_alignment(
727        width,
728        height,
729        family,
730        composition,
731        dimension_alignment_for_model(model, family),
732    )
733}
734
735fn validate_generation_dimensions_with_alignment(
736    width: u32,
737    height: u32,
738    family: Option<&str>,
739    composition: Ltx2SpatialComposition,
740    alignment: u32,
741) -> Result<(), String> {
742    if width == 0 || height == 0 {
743        return Err("width and height must be > 0".to_string());
744    }
745
746    if !width.is_multiple_of(alignment) || !height.is_multiple_of(alignment) {
747        let family_label = family
748            .filter(|value| !value.is_empty())
749            .map(|value| format!(" for {value} models"))
750            .unwrap_or_default();
751        return Err(format!(
752            "width ({width}) and height ({height}) must be multiples of {alignment}{family_label}"
753        ));
754    }
755
756    if let Some(axis_limit) = max_axis_pixels_for_family_composed(family, composition) {
757        let longest = width.max(height);
758        if longest > axis_limit {
759            // Two different failures wear the same shape here, and telling
760            // them apart is the whole difference between an actionable error
761            // and a dead end. Past the composed ceiling nothing helps but a
762            // smaller output; past the trained span with a single-pass model,
763            // a checkpoint that ships the spatial upsampler does.
764            let mut remedy = String::new();
765            if composition == Ltx2SpatialComposition::SinglePass
766                && longest <= LTX2_COMPOSED_MAX_AXIS_PIXELS
767            {
768                remedy.push_str(
769                    " This checkpoint renders in one pass; reaching that size needs a checkpoint \
770                     that ships the spatial upsampler, which renders stage 1 at half size and \
771                     refines it over tiles.",
772                );
773            }
774            if let Some(rung) = largest_ltx2_rung_within(axis_limit) {
775                remedy.push_str(&format!(
776                    " The largest output this render reaches is {} ({}x{}).",
777                    rung.label, rung.width, rung.height
778                ));
779            }
780            return Err(format!(
781                "{width}x{height} has a {longest}px axis, beyond the {axis_limit}px span this \
782                 render can hold — positions past it are out of distribution. Render at or below \
783                 {axis_limit}px on the long edge.{remedy}"
784            ));
785        }
786    }
787
788    let limit = max_pixels_for_family_composed(family, composition);
789    let pixels = width as u64 * height as u64;
790    if pixels > limit {
791        return Err(format!(
792            "{width}x{height} = {:.2} megapixels exceeds the {} limit (VAE VRAM constraint)",
793            pixels as f64 / 1_000_000.0,
794            megapixel_limit_label_for(limit)
795        ));
796    }
797
798    Ok(())
799}
800
801/// One rung of the LTX-2 output ladder.
802///
803/// A rung is an output shape plus the composition that reaches it. Every entry
804/// is 64-aligned so stage 1 — the target halved — still lands on the VAE's
805/// 32 px latent grid, which is upstream's own `divisor = 64 if is_two_stage`
806/// rule (`packages/ltx-pipelines/src/ltx_pipelines/utils/helpers.py:326`).
807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub struct Ltx2OutputRung {
809    /// Stable identifier, safe to persist and to match on.
810    pub id: &'static str,
811    /// Human-readable name for pickers and errors.
812    pub label: &'static str,
813    pub width: u32,
814    pub height: u32,
815}
816
817impl Ltx2OutputRung {
818    /// Shape stage 1 renders at under one x2 spatial rung.
819    ///
820    /// Mirrors `derive_stage1_render_shape` / `latent_grid_downsample`: the
821    /// target's latent grid is halved with ceiling division, then expanded
822    /// back to pixels. `advertised_rungs_match_the_engines_own_arithmetic` in
823    /// `mold-inference` pins this to the engine's own arithmetic — this crate
824    /// cannot see it, and a rung that names a stage-1 shape the engine does not
825    /// render is worse than naming none.
826    pub const fn stage1_shape(&self) -> (u32, u32) {
827        (
828            ltx2_stage1_axis_for(self.width, Some(Ltx2SpatialUpscale::X2)),
829            ltx2_stage1_axis_for(self.height, Some(Ltx2SpatialUpscale::X2)),
830        )
831    }
832
833    /// Whether this rung needs the tiled stage-2 refinement, i.e. whether it
834    /// has an axis past the span a single denoise can hold.
835    pub const fn requires_tiled_stage2(&self) -> bool {
836        self.width > LTX2_MAX_AXIS_PIXELS || self.height > LTX2_MAX_AXIS_PIXELS
837    }
838
839    /// Spatial tiles stage 2 splits into, as `(columns, rows)`.
840    ///
841    /// Mirrors `plan_stage2_tiling`: an axis inside the trained span stays
842    /// whole, and an oversized one is split into the fewest tiles whose every
843    /// tile fits. Pinned to the engine by
844    /// `advertised_rungs_match_the_engines_own_arithmetic`.
845    pub const fn stage2_tiles(&self) -> (u32, u32) {
846        (
847            ltx2_axis_tile_count(self.width),
848            ltx2_axis_tile_count(self.height),
849        )
850    }
851}
852
853/// Stage-1 extent for one axis under a spatial rung.
854///
855/// Mirrors `latent_grid_downsample` in `ltx2/model/upsampler.rs`, including
856/// its x1.5 case: the rational upsampler emits `floor((3 * latent + 1) / 2)`
857/// cells, so stage 1 needs `ceil((2 * target_latent - 1) / 3)` to cover the
858/// requested lattice. An absent rung means stage 1 renders the target itself.
859pub const fn ltx2_stage1_axis_for(target: u32, upscale: Option<Ltx2SpatialUpscale>) -> u32 {
860    let grid = LTX2_SPATIAL_LATENT_STRIDE;
861    let Some(upscale) = upscale else {
862        return if target < grid { grid } else { target };
863    };
864    let target_latent = if target < grid {
865        1
866    } else {
867        target.div_ceil(grid)
868    };
869    let stage1_latent = match upscale {
870        Ltx2SpatialUpscale::X2 => target_latent.div_ceil(2),
871        Ltx2SpatialUpscale::X1_5 => target_latent
872            .saturating_mul(2)
873            .saturating_sub(1)
874            .div_ceil(3),
875    };
876    if stage1_latent == 0 {
877        grid
878    } else {
879        stage1_latent * grid
880    }
881}
882
883/// Largest output axis whose stage 1 still lands inside the trained span
884/// under `upscale`.
885///
886/// x2 halves, so it reaches `2 * span`. x1.5 only divides by 1.5, so it stops
887/// at 3072px — asking for 4K with `--spatial-upscale x1.5` puts stage 1 at
888/// 2560px, exactly the out-of-distribution render the ceiling exists to
889/// prevent.
890pub fn ltx2_composed_axis_ceiling(upscale: Option<Ltx2SpatialUpscale>) -> u32 {
891    match upscale {
892        None | Some(Ltx2SpatialUpscale::X2) => LTX2_COMPOSED_MAX_AXIS_PIXELS,
893        Some(Ltx2SpatialUpscale::X1_5) => {
894            // Walk the 32px grid rather than inverting the rational
895            // downsample in closed form; the loop is bounded by the composed
896            // ceiling and runs once per admission.
897            let mut ceiling = LTX2_MAX_AXIS_PIXELS;
898            while ceiling < LTX2_COMPOSED_MAX_AXIS_PIXELS
899                && ltx2_stage1_axis_for(ceiling + LTX2_SPATIAL_LATENT_STRIDE, upscale)
900                    <= LTX2_MAX_AXIS_PIXELS
901            {
902                ceiling += LTX2_SPATIAL_LATENT_STRIDE;
903            }
904            ceiling
905        }
906    }
907}
908
909/// Refuse a composed render whose *stage 1* leaves the trained span.
910///
911/// [`LTX2_COMPOSED_MAX_AXIS_PIXELS`] is shorthand for this check under the
912/// default x2 rung. A request that names x1.5 instead needs the real one:
913/// a 3840px output renders stage 1 at 2560px, and nothing downstream repairs
914/// that — stage 2 tiles the *refinement*, never stage 1.
915pub fn validate_ltx2_stage1_span(
916    width: u32,
917    height: u32,
918    upscale: Option<Ltx2SpatialUpscale>,
919) -> Result<(), String> {
920    // An absent rung on a refining pipeline is not "no rung": the runtime
921    // applies an implicit x2 so stage 1 renders halved anyway
922    // (`ltx2/runtime.rs`'s `implicit_x2_shape`). Reading `None` literally here
923    // would refuse every composed render at once.
924    let effective = upscale.unwrap_or(Ltx2SpatialUpscale::X2);
925    let stage1 = (
926        ltx2_stage1_axis_for(width, Some(effective)),
927        ltx2_stage1_axis_for(height, Some(effective)),
928    );
929    let longest = stage1.0.max(stage1.1);
930    if longest <= LTX2_MAX_AXIS_PIXELS {
931        return Ok(());
932    }
933    let rung = match effective {
934        Ltx2SpatialUpscale::X1_5 => "x1.5",
935        Ltx2SpatialUpscale::X2 => "x2",
936    };
937    let ceiling = ltx2_composed_axis_ceiling(upscale);
938    Err(format!(
939        "{width}x{height} with {rung} spatial upscale renders stage 1 at {}x{}, whose {longest}px \
940         axis is past the {}px span these checkpoints were trained on. The rung sets the ceiling: \
941         it reaches {ceiling}px on the long edge. Use a x2 upscale, or render at or below \
942         {ceiling}px.",
943        stage1.0, stage1.1, LTX2_MAX_AXIS_PIXELS,
944    ))
945}
946
947/// Number of stage-2 tiles one axis is split into.
948const fn ltx2_axis_tile_count(target: u32) -> u32 {
949    if target <= LTX2_MAX_AXIS_PIXELS {
950        return 1;
951    }
952    let count = target.div_ceil(LTX2_MAX_AXIS_PIXELS);
953    if count < 2 {
954        2
955    } else {
956        count
957    }
958}
959
960/// The LTX video VAE's spatial compression factor.
961pub const LTX2_SPATIAL_LATENT_STRIDE: u32 = 32;
962
963/// The LTX-2 output ladder, smallest rung first.
964///
965/// Every rung above 1080p is reached by composition, not by a bigger denoise:
966/// stage 1 renders the halved shape, one x2 spatial rung upsamples it, and
967/// stage 2 refines the result over tiles.
968///
969/// **The ladder stops at 4K UHD because of the encoder, not the model.**
970/// `LTX2_COMPOSED_MAX_AXIS_PIXELS` (4096) is where a single halving stops
971/// landing stage 1 inside the trained span, and generation is admitted that
972/// far — but the bundled OpenH264 encoder refuses anything past 3840x2160
973/// ("Encoder max resolution 3840x2160 horizontal or 2160x3840 vertical"), and
974/// MP4 is this family's default container. A rung wider than 3840, or the
975/// 3840x2176 that rounding 2160 *up* onto the /64 grid would give, generates
976/// fine and then fails at save time — after the whole render. So the rung is
977/// 3840x2112, rounding 2160 *down*, which is upstream's own CENTER_CROP
978/// alignment and the largest UHD-class shape mold can actually deliver.
979///
980/// VRAM: see `website/models/ltx2.md`. The numbers live in prose because the
981/// only published figures are upstream's, they are for a different pipeline
982/// (HDR IC-LoRA, 161 frames, 22B), and pinning them here would read as mold's
983/// own measured requirement.
984pub const LTX2_OUTPUT_RUNGS: &[Ltx2OutputRung] = &[
985    Ltx2OutputRung {
986        id: "720p",
987        label: "720p HD",
988        width: 1_280,
989        height: 704,
990    },
991    Ltx2OutputRung {
992        id: "1080p",
993        label: "1080p Full HD",
994        width: 1_920,
995        height: 1_088,
996    },
997    Ltx2OutputRung {
998        id: "1440p",
999        label: "1440p QHD",
1000        width: 2_560,
1001        height: 1_408,
1002    },
1003    Ltx2OutputRung {
1004        id: "4k-uhd",
1005        label: "4K UHD",
1006        width: 3_840,
1007        height: 2_112,
1008    },
1009];
1010
1011/// The rung an output shape lands on, in either orientation.
1012///
1013/// Portrait is the same rung as its landscape transpose: the composition and
1014/// the cost are identical, and a picker that called 2176x3840 an unnamed shape
1015/// would be lying about both.
1016pub fn ltx2_output_rung(width: u32, height: u32) -> Option<&'static Ltx2OutputRung> {
1017    LTX2_OUTPUT_RUNGS.iter().find(|rung| {
1018        (rung.width == width && rung.height == height)
1019            || (rung.width == height && rung.height == width)
1020    })
1021}
1022
1023/// The largest rung whose long edge fits `axis_limit`.
1024///
1025/// This is what makes an over-size rejection actionable: naming the ceiling in
1026/// pixels tells the user what they cannot have, naming the rung tells them
1027/// what they can.
1028pub fn largest_ltx2_rung_within(axis_limit: u32) -> Option<&'static Ltx2OutputRung> {
1029    LTX2_OUTPUT_RUNGS
1030        .iter()
1031        .rfind(|rung| rung.width.max(rung.height) <= axis_limit)
1032}
1033
1034fn mib_label(bytes: usize) -> String {
1035    format!("{:.0} MiB", bytes as f64 / (1024.0 * 1024.0))
1036}
1037
1038/// Clamp dimensions to fit within the megapixel limit, preserving aspect ratio.
1039/// Both dimensions are rounded down to multiples of 16.
1040/// Returns the original dimensions unchanged if already within limits.
1041pub fn clamp_to_megapixel_limit(w: u32, h: u32) -> (u32, u32) {
1042    clamp_to_family_pixel_limit(w, h, None)
1043}
1044
1045/// Family-aware counterpart to [`clamp_to_megapixel_limit`].
1046///
1047/// Both the ceiling and the rounding grid come from the family. Clamping an
1048/// LTX-2 source projection with the shared 1.8 MP limit and a /16 grid would
1049/// shrink a canvas the validator would have accepted, and could land off the
1050/// /32 grid it requires — a silent downgrade followed by a rejection.
1051pub fn clamp_to_family_pixel_limit(w: u32, h: u32, family: Option<&str>) -> (u32, u32) {
1052    clamp_dims_to(
1053        w,
1054        h,
1055        max_pixels_for_family(family),
1056        dimension_alignment_for_family(family),
1057        max_axis_pixels_for_family(family),
1058    )
1059}
1060
1061fn clamp_dims_to(w: u32, h: u32, limit: u64, align: u32, axis_limit: Option<u32>) -> (u32, u32) {
1062    let pixels = w as u64 * h as u64;
1063    let within_axis = axis_limit.is_none_or(|axis| w.max(h) <= axis);
1064    if pixels <= limit && within_axis {
1065        return (w, h);
1066    }
1067
1068    let mut scale = if pixels > limit {
1069        (limit as f64 / pixels as f64).sqrt()
1070    } else {
1071        1.0
1072    };
1073    if let Some(axis) = axis_limit {
1074        let longest = w.max(h) as f64;
1075        if longest * scale > axis as f64 {
1076            scale = axis as f64 / longest;
1077        }
1078    }
1079
1080    let new_w = ((w as f64 * scale) as u32 / align) * align;
1081    let new_h = ((h as f64 * scale) as u32 / align) * align;
1082    // Ensure we don't produce zero dimensions
1083    (new_w.max(align), new_h.max(align))
1084}
1085
1086/// Fit source image dimensions into a model's native resolution bounding box,
1087/// preserving aspect ratio.
1088///
1089/// The model's default width/height define the bounding box. The source image's
1090/// aspect ratio is preserved:
1091/// - If the source is wider than the model bounds, width is set to `model_w` and
1092///   height is scaled proportionally.
1093/// - If the source is taller, height is set to `model_h` and width is scaled.
1094/// - If the source fits entirely within model bounds (same aspect ratio as the
1095///   model), the model's native dimensions are used as the output. For sources
1096///   with a different aspect ratio, the output fills the limiting axis at model
1097///   scale while keeping the other axis within bounds.
1098///
1099/// Output is rounded to 16px alignment and clamped to the megapixel limit.
1100///
1101/// This is the family-only compatibility path: 16 is the shared generation
1102/// grid, but not every checkpoint's. Callers that know the model (or its
1103/// advertised `dimension_alignment`) should use
1104/// [`fit_to_model_dimensions_aligned`] with
1105/// [`dimension_alignment_for_model`]'s answer so a 32-grid checkpoint like
1106/// `wan22-ti2v-5b` receives a canvas its VAE can encode.
1107pub fn fit_to_model_dimensions(src_w: u32, src_h: u32, model_w: u32, model_h: u32) -> (u32, u32) {
1108    fit_to_model_dimensions_aligned(src_w, src_h, model_w, model_h, 16)
1109}
1110
1111/// Alignment-aware counterpart to [`fit_to_model_dimensions`]: identical
1112/// aspect-preserving fit, but both axes are floored to the caller-supplied
1113/// grid — the resolved model's alignment, not the family-wide 16.
1114pub fn fit_to_model_dimensions_aligned(
1115    src_w: u32,
1116    src_h: u32,
1117    model_w: u32,
1118    model_h: u32,
1119    align: u32,
1120) -> (u32, u32) {
1121    let align = align.max(1);
1122    let src_ratio = src_w as f64 / src_h as f64;
1123    let model_ratio = model_w as f64 / model_h as f64;
1124
1125    let (w, h) = if src_ratio > model_ratio {
1126        // Source is wider: width-limited
1127        (model_w as f64, model_w as f64 / src_ratio)
1128    } else {
1129        // Source is taller or same: height-limited
1130        (model_h as f64 * src_ratio, model_h as f64)
1131    };
1132
1133    let w = ((w as u32) / align * align).max(align);
1134    let h = ((h as u32) / align * align).max(align);
1135    clamp_dims_to(w, h, MAX_PIXELS, align, None)
1136}
1137
1138/// Resize dimensions toward a target pixel area while preserving aspect ratio.
1139///
1140/// The result is rounded to the requested alignment and clamped to the shared
1141/// megapixel safety limit.
1142pub fn fit_to_target_area(src_w: u32, src_h: u32, target_area: u32, align: u32) -> (u32, u32) {
1143    let src_w = src_w.max(1);
1144    let src_h = src_h.max(1);
1145    let align = align.max(1);
1146    let scale = (f64::from(target_area) / (f64::from(src_w) * f64::from(src_h))).sqrt();
1147    let width = ((f64::from(src_w) * scale) / f64::from(align)).round() as u32 * align;
1148    let height = ((f64::from(src_h) * scale) / f64::from(align)).round() as u32 * align;
1149    clamp_to_megapixel_limit(width.max(align), height.max(align))
1150}
1151
1152/// Check whether `data` starts with a recognized image format magic bytes (PNG or JPEG).
1153fn is_valid_image_format(data: &[u8]) -> bool {
1154    let is_png = data.len() >= 4 && data[..4] == [0x89, 0x50, 0x4E, 0x47];
1155    let is_jpeg = data.len() >= 2 && data[..2] == [0xFF, 0xD8];
1156    is_png || is_jpeg
1157}
1158
1159fn model_family(model_name: &str) -> Option<&str> {
1160    crate::manifest::find_manifest(model_name)
1161        .map(|m| m.family.as_str())
1162        .or_else(|| {
1163            if model_name.starts_with("qwen-image-edit") {
1164                Some("qwen-image-edit")
1165            } else if model_name.starts_with("qwen-image") {
1166                Some("qwen-image")
1167            } else {
1168                None
1169            }
1170        })
1171}
1172
1173/// Resolve a model's family for validation, preferring an explicit hint when
1174/// provided. The hint lets callers (e.g. the HTTP server) pass through a family
1175/// that the manifest layer can't see — most notably catalog IDs like
1176/// `cv:2781713` whose family is recorded in the catalog DB rather than the
1177/// hardcoded manifest. When `family_hint` is `None` (or an empty string), the
1178/// manifest fallback runs as before.
1179fn resolved_family<'a>(model_name: &'a str, family_hint: Option<&'a str>) -> Option<&'a str> {
1180    family_hint
1181        .filter(|h| !h.is_empty())
1182        .or_else(|| model_family(model_name))
1183}
1184
1185/// Whether `req` must carry a non-empty prompt.
1186///
1187/// Video families whose text encoder pads to a fixed-width context (LTX-2's
1188/// Gemma connector replaces every padded position with learned register
1189/// embeddings, so `""` is a trained context rather than a degenerate one)
1190/// accept an empty prompt as long as the request carries visual conditioning
1191/// to continue: a source image, keyframes, a source video, or an extend. Pure
1192/// text-to-video and every image family keep the prompt required.
1193///
1194/// Note this buys no VRAM — the Gemma context is a fixed-size tensor whose
1195/// footprint is independent of the token count — and an unprompted clip tends
1196/// toward near-static micro-motion. Callers should surface that as guidance
1197/// rather than synthesising a placeholder prompt.
1198///
1199/// `family_hint` mirrors [`validate_generate_request_with_family`]: pass the
1200/// catalog-resolved family for `cv:` / `hf:` model IDs, whose family the
1201/// manifest cannot see.
1202pub fn prompt_required_for(req: &GenerateRequest, family_hint: Option<&str>) -> bool {
1203    prompt_required_with_conditioning(
1204        resolved_family(&req.model, family_hint),
1205        has_visual_conditioning(req),
1206    )
1207}
1208
1209/// Whether a request carries visual conditioning — a source image, keyframes,
1210/// a source video (inline or server-local path), or an extend.
1211///
1212/// This is the single definition of "conditioned" for the whole request path.
1213/// Beyond the optional-prompt rule it also separates OOM cooldown buckets, and
1214/// those must agree: two requests with different conditioning have different
1215/// VRAM profiles and must never share a cooldown or a reduced memory grant.
1216pub fn has_visual_conditioning(req: &GenerateRequest) -> bool {
1217    req.source_image.is_some()
1218        || req.keyframes.as_ref().is_some_and(|k| !k.is_empty())
1219        || req.source_video.is_some()
1220        || req.source_video_path.is_some()
1221        || req.is_extend()
1222}
1223
1224/// Lower-level form of [`prompt_required_for`] for callers that have not yet
1225/// assembled a [`GenerateRequest`] — the CLI, TUI and Discord front-ends build
1226/// the request only after the prompt is resolved. `has_visual_conditioning` is
1227/// true when the request will carry a source image, keyframes, a source video,
1228/// or an extend.
1229pub fn prompt_required_with_conditioning(
1230    family: Option<&str>,
1231    has_visual_conditioning: bool,
1232) -> bool {
1233    !(matches!(family, Some("ltx2" | "ltx-video")) && has_visual_conditioning)
1234}
1235
1236fn validate_lora_weight(lora: &LoraWeight, field_name: &str) -> Result<(), String> {
1237    if lora.scale < 0.0 || lora.scale > 2.0 {
1238        return Err(format!(
1239            "{field_name} scale ({}) must be in range [0.0, 2.0]",
1240            lora.scale
1241        ));
1242    }
1243    if !lora.path.ends_with(".safetensors") && !lora.path.starts_with("camera-control:") {
1244        return Err(format!(
1245            "{field_name} file must be a .safetensors file or camera-control preset"
1246        ));
1247    }
1248    Ok(())
1249}
1250
1251/// Refuse an explicit `expert` on a model that has no experts to route to.
1252///
1253/// Only the Wan 2.2 A14B pair has a high/low split. Silently ignoring the
1254/// field on a single-expert checkpoint would let a user believe an adapter was
1255/// bound to half the schedule when it was applied to all of it.
1256fn require_expert_routable_model(
1257    lora: &LoraWeight,
1258    model: &str,
1259    family: Option<&str>,
1260) -> Result<(), String> {
1261    let Some(expert) = lora.expert else {
1262        return Ok(());
1263    };
1264    let expert = match expert {
1265        crate::LoraExpert::High => "high",
1266        crate::LoraExpert::Low => "low",
1267    };
1268    if family != Some("wan") {
1269        return Err(format!(
1270            "lora expert ('{expert}') applies to the Wan 2.2 A14B expert pair; \
1271             {} is not a Wan model",
1272            model
1273        ));
1274    }
1275    // An opaque catalog id cannot be classified by name, and the engine reads
1276    // the real pair from the checkpoint, so those are left to it rather than
1277    // guessed at here.
1278    let opaque = model.starts_with("cv:") || model.starts_with("hf:");
1279    if !opaque && !model.to_ascii_lowercase().contains("a14b") {
1280        return Err(format!(
1281            "lora expert ('{expert}') needs the Wan 2.2 A14B two-expert pair; \
1282             {model} is a single-expert checkpoint — drop the expert field to \
1283             apply the adapter to it"
1284        ));
1285    }
1286    Ok(())
1287}
1288
1289fn validate_keyframes(
1290    keyframes: &[KeyframeCondition],
1291    frames: Option<u32>,
1292    family: Option<&str>,
1293) -> Result<(), String> {
1294    match family {
1295        Some("ltx2") => {}
1296        // Wan accepts exactly the first/last endpoint pair (#779) — the
1297        // family has no mid-clip keyframe path, so every other layout is
1298        // named at admission rather than after the model loads.
1299        Some("wan") => {
1300            if keyframes.len() != 2 {
1301                return Err(format!(
1302                    "Wan supports exactly two keyframes — the first and last pixel frames — \
1303                     got {}",
1304                    keyframes.len()
1305                ));
1306            }
1307            // Without an explicit clip length the closing anchor cannot be
1308            // checked here, and the engine would resolve its own default and
1309            // reject a mismatched endpoint only after the model loads —
1310            // defeating admission-time validation.
1311            let Some(frames) = frames else {
1312                return Err(
1313                    "Wan first/last-frame keyframes require an explicit frames count — the \
1314                     closing keyframe must anchor the clip's final frame"
1315                        .to_string(),
1316                );
1317            };
1318            // A single-frame clip has coincident endpoints; the generic
1319            // duplicate-frame check below would also refuse it, but with a
1320            // message that doesn't say why. Name the real problem instead.
1321            if frames < 2 {
1322                return Err(
1323                    "Wan first/last-frame keyframes need a multi-frame clip — frames=1 \
1324                     renders a single still, which has no distinct last frame"
1325                        .to_string(),
1326                );
1327            }
1328            let last = frames.saturating_sub(1);
1329            if keyframes[0].frame != 0 || keyframes[1].frame != last {
1330                return Err(format!(
1331                    "Wan first/last-frame keyframes must anchor frames 0 and {last} (the \
1332                     clip's endpoints), got frames {} and {}",
1333                    keyframes[0].frame, keyframes[1].frame
1334                ));
1335            }
1336        }
1337        None => {
1338            return Err(
1339                "unknown model family; keyframes are only supported for LTX-2 / LTX-2.3 and \
1340                 Wan models"
1341                    .to_string(),
1342            );
1343        }
1344        _ => {
1345            return Err(
1346                "keyframes are only supported for LTX-2 / LTX-2.3 and Wan models".to_string(),
1347            );
1348        }
1349    }
1350    if keyframes.is_empty() {
1351        return Err("keyframes must not be empty".to_string());
1352    }
1353
1354    let mut seen = std::collections::BTreeSet::new();
1355    for keyframe in keyframes {
1356        if !is_valid_image_format(&keyframe.image) {
1357            return Err("keyframes must contain only PNG or JPEG images".to_string());
1358        }
1359        if let Some(total_frames) = frames {
1360            if keyframe.frame >= total_frames {
1361                return Err(format!(
1362                    "keyframe frame ({}) must be less than frames ({total_frames})",
1363                    keyframe.frame
1364                ));
1365            }
1366        }
1367        if !seen.insert(keyframe.frame) {
1368            return Err(format!("duplicate keyframe frame: {}", keyframe.frame));
1369        }
1370    }
1371
1372    Ok(())
1373}
1374
1375/// Bounds-check the LTX-2 multimodal guider overrides.
1376///
1377/// These are advanced quality/motion knobs, so the ranges are deliberately
1378/// generous — the job here is to reject values that cannot mean anything
1379/// (NaN, negatives, block indices no checkpoint has) before a request reaches
1380/// the queue, not to police taste. The engine re-checks `stg_blocks` against
1381/// the resolved checkpoint's transformer depth, which validation cannot know.
1382fn validate_guidance_overrides(overrides: &Ltx2GuidanceOverrides) -> Result<(), String> {
1383    if overrides.is_empty() {
1384        return Err(
1385            "guidance_overrides must set at least one field; omit it to keep pipeline defaults"
1386                .to_string(),
1387        );
1388    }
1389    let bounded = |value: Option<f64>, name: &str, max: f64| -> Result<(), String> {
1390        match value {
1391            Some(value) if !value.is_finite() => Err(format!("{name} must be a finite number")),
1392            Some(value) if !(0.0..=max).contains(&value) => {
1393                Err(format!("{name} ({value}) must be between 0.0 and {max}"))
1394            }
1395            _ => Ok(()),
1396        }
1397    };
1398    bounded(
1399        overrides.stg_scale,
1400        "guidance_overrides.stg_scale",
1401        Ltx2GuidanceOverrides::MAX_SCALE,
1402    )?;
1403    bounded(
1404        overrides.modality_scale,
1405        "guidance_overrides.modality_scale",
1406        Ltx2GuidanceOverrides::MAX_SCALE,
1407    )?;
1408    // Rescale is an interpolation factor between the guided prediction and
1409    // its std-matched form, so anything outside 0..=1 is meaningless.
1410    bounded(
1411        overrides.rescale_scale,
1412        "guidance_overrides.rescale_scale",
1413        1.0,
1414    )?;
1415    if let Some(skip_step) = overrides.skip_step {
1416        if skip_step > Ltx2GuidanceOverrides::MAX_SKIP_STEP {
1417            return Err(format!(
1418                "guidance_overrides.skip_step ({skip_step}) must be <= {}",
1419                Ltx2GuidanceOverrides::MAX_SKIP_STEP
1420            ));
1421        }
1422    }
1423    if let Some(blocks) = &overrides.stg_blocks {
1424        if blocks.is_empty() {
1425            return Err(
1426                "guidance_overrides.stg_blocks must not be empty; omit it to keep the pipeline default block"
1427                    .to_string(),
1428            );
1429        }
1430        if blocks.len() > MAX_STG_BLOCKS {
1431            return Err(format!(
1432                "guidance_overrides.stg_blocks lists {} blocks; at most {MAX_STG_BLOCKS} are supported",
1433                blocks.len()
1434            ));
1435        }
1436        for (index, block) in blocks.iter().enumerate() {
1437            if *block >= MAX_STG_BLOCK_INDEX {
1438                return Err(format!(
1439                    "guidance_overrides.stg_blocks[{index}] ({block}) exceeds the deepest supported transformer block ({})",
1440                    MAX_STG_BLOCK_INDEX - 1
1441                ));
1442            }
1443            if blocks[..index].contains(block) {
1444                return Err(format!(
1445                    "guidance_overrides.stg_blocks[{index}] ({block}) is listed more than once"
1446                ));
1447            }
1448        }
1449    }
1450    Ok(())
1451}
1452
1453/// Admission rules for `extend_video` / `extend_video_path`.
1454///
1455/// Extend reuses the chain motion-tail machinery, so it inherits the same two
1456/// hard constraints: the overlap has to land on the family's own temporal grid
1457/// to re-encode cleanly — `8k+1` for the LTX-2 VAE's causal grid, `4k+1` for
1458/// wan, resolved through [`frame_step_for_family`] rather than assumed — and it
1459/// has to be strictly shorter than the rendered clip or the continuation
1460/// contributes no new frames at all.
1461fn validate_extend(req: &GenerateRequest, family: Option<&str>) -> Result<(), String> {
1462    if let Some(video) = &req.extend_video {
1463        require_extend_capable_family(family, "extend_video")?;
1464        if req.extend_video_path.is_some() {
1465            return Err("extend_video_path cannot be combined with extend_video".to_string());
1466        }
1467        if video.is_empty() {
1468            return Err("extend_video must not be empty".to_string());
1469        }
1470        validate_inline_media_size(video, "extend_video", MAX_INLINE_EXTEND_VIDEO_BYTES)?;
1471    }
1472    if let Some(path) = &req.extend_video_path {
1473        require_extend_capable_family(family, "extend_video_path")?;
1474        if path.trim().is_empty() {
1475            return Err("extend_video_path must not be empty".to_string());
1476        }
1477    }
1478
1479    if !req.is_extend() {
1480        if req.extend_overlap_frames.is_some() {
1481            return Err(
1482                "extend_overlap_frames requires extend_video or extend_video_path".to_string(),
1483            );
1484        }
1485        return Ok(());
1486    }
1487
1488    // Extend continues one clip's motion; a reference video conditions a fresh
1489    // render. Accepting both would leave two competing sources of truth for
1490    // what the first frames should look like.
1491    if req.source_video.is_some() || req.source_video_path.is_some() {
1492        return Err(
1493            "extend_video cannot be combined with source_video; extend continues an existing \
1494             clip, while source_video is reference conditioning for a fresh render"
1495                .to_string(),
1496        );
1497    }
1498    if req.source_image.is_some() {
1499        return Err(
1500            "extend_video cannot be combined with source_image; the continuation's first frames \
1501             are pinned by the source video's tail"
1502                .to_string(),
1503        );
1504    }
1505    if req.keyframes.is_some() {
1506        return Err("extend_video cannot be combined with keyframes".to_string());
1507    }
1508
1509    let overlap = req.effective_extend_overlap_frames_for_family(family);
1510    if overlap == 0 {
1511        return Err(
1512            "extend_overlap_frames must be >= 1 so the continuation has motion context".to_string(),
1513        );
1514    }
1515    // The carryover frames re-encode through the family's own video VAE, so the
1516    // overlap has to sit on that VAE's temporal grid — 8x causal for LTX-2,
1517    // 4x for wan. A hardcoded 8 rejected every valid wan overlap.
1518    let step = family.and_then(frame_step_for_family).unwrap_or(8);
1519    if overlap % step != 1 {
1520        let examples: Vec<String> = (0..4).map(|k| (k * step + 1).to_string()).collect();
1521        return Err(format!(
1522            "extend_overlap_frames ({overlap}) must be {step}k+1 ({}, …) so the carryover \
1523             frames re-encode cleanly through this family's video VAE temporal grid",
1524            examples.join(", "),
1525        ));
1526    }
1527    if let Some(frames) = req.frames {
1528        if overlap >= frames {
1529            return Err(format!(
1530                "extend_overlap_frames ({overlap}) must be strictly less than frames ({frames}) \
1531                 so the continuation adds at least one new frame"
1532            ));
1533        }
1534    }
1535    Ok(())
1536}
1537
1538/// Families whose engines can continue an existing clip.
1539///
1540/// LTX-2 re-encodes the tail as a latent motion carryover. Wan has no latent
1541/// motion tail, so it continues the way its chain seam does — the source
1542/// clip's final frame becomes the continuation's image conditioning — which
1543/// only an image-conditioned checkpoint can accept. The per-model
1544/// `supports_extend` field is what narrows the family to those checkpoints;
1545/// this gate only rejects families with no continuation path at all.
1546///
1547/// Public because the CLI preflight has to ask it *before* the source-image
1548/// contract gate does: an extend now counts as carrying source frames, so a
1549/// continuation aimed at a text-to-video-only family would otherwise be
1550/// refused for "does not accept a source image or keyframes" — wording for a
1551/// request that supplied neither (#783).
1552pub fn require_extend_capable_family(
1553    family: Option<&str>,
1554    feature_name: &str,
1555) -> Result<(), String> {
1556    match family {
1557        Some("ltx2") | Some("wan") => Ok(()),
1558        None => Err(format!(
1559            "unknown model family; {feature_name} is only supported for LTX-2 / LTX-2.3 and Wan models"
1560        )),
1561        _ => Err(format!(
1562            "{feature_name} is only supported for LTX-2 / LTX-2.3 and Wan models"
1563        )),
1564    }
1565}
1566
1567fn require_ltx2_family(family: Option<&str>, feature_name: &str) -> Result<(), String> {
1568    match family {
1569        Some("ltx2") => Ok(()),
1570        None => Err(format!(
1571            "unknown model family; {feature_name} is only supported for LTX-2 / LTX-2.3 models"
1572        )),
1573        _ => Err(format!(
1574            "{feature_name} is only supported for LTX-2 / LTX-2.3 models"
1575        )),
1576    }
1577}
1578
1579/// LoRA support is available for FLUX, Flux.2, LTX-2, SD1.5, SD3, SDXL,
1580/// Qwen-Image (and qwen-image-edit), Wan, and Z-Image — `mold-inference`'s
1581/// per-family `lora.rs` modules are the engine paths that know how to merge
1582/// low-rank adapters into the base weights. Surfacing the gate at validation
1583/// produces a clear 400 instead of an opaque inference-layer panic when a
1584/// user picks an unsupported model family + a LoRA.
1585fn require_lora_capable_family(family: Option<&str>) -> Result<(), String> {
1586    match family {
1587        Some(family) if family_supports_lora(family) => Ok(()),
1588        Some(other) => Err(format!(
1589            "LoRA is currently supported for FLUX, Flux.2, LTX-2, SD1.5, SD3, SDXL, Qwen-Image, Wan, and Z-Image models; got family {other:?}"
1590        )),
1591        None => Err(
1592            "LoRA requires a known model family — pick a FLUX, Flux.2, LTX-2, SD1.5, SD3, SDXL, Qwen-Image, Wan, or Z-Image model first"
1593                .to_string(),
1594        ),
1595    }
1596}
1597
1598fn require_controlnet_capable_family(family: Option<&str>) -> Result<(), String> {
1599    match family {
1600        Some("sd15" | "sd1.5" | "stable-diffusion-1.5") => Ok(()),
1601        Some(other) => Err(format!(
1602            "ControlNet generation is currently supported for SD1.5 models; got family {other:?}"
1603        )),
1604        None => Err(
1605            "ControlNet generation requires a known model family — pick an SD1.5 model first"
1606                .to_string(),
1607        ),
1608    }
1609}
1610
1611fn validate_inline_media_size(
1612    bytes: &[u8],
1613    field_name: &str,
1614    max_bytes: usize,
1615) -> Result<(), String> {
1616    if bytes.len() > max_bytes {
1617        return Err(format!(
1618            "{field_name} exceeds the {} inline request limit (got {:.1} MiB)",
1619            mib_label(max_bytes),
1620            bytes.len() as f64 / (1024.0 * 1024.0)
1621        ));
1622    }
1623    Ok(())
1624}
1625
1626/// Validate a generate request. Returns `Ok(())` if valid, or an error message.
1627/// Shared between the HTTP server and local CLI inference paths.
1628///
1629/// For models whose family can't be derived from the manifest (catalog IDs
1630/// like `cv:2781713`), use [`validate_generate_request_with_family`] and pass
1631/// the resolved family from the catalog DB; otherwise the family-gated
1632/// features (audio, keyframes, retake, …) will fail with
1633/// `unknown model family` even on legitimate LTX-2 catalog checkpoints.
1634pub fn validate_generate_request(req: &GenerateRequest) -> Result<(), String> {
1635    validate_generate_request_with_family(req, None)
1636}
1637
1638/// Enforce model access for every model/artifact identity carried directly by
1639/// a generation request.
1640///
1641/// This is deliberately separate from shape/feature validation: callers that
1642/// own an artifact root must run it before downloads, queue registration, or
1643/// other admission mutations. The base model's configured/default artifacts
1644/// remain the caller's responsibility because they do not travel in the
1645/// request itself.
1646pub fn require_generate_request_model_activation(
1647    req: &GenerateRequest,
1648    artifact_root: Option<&std::path::Path>,
1649    family_hint: Option<&str>,
1650) -> Result<(), crate::ModelActivationError> {
1651    crate::require_model_activation(&req.model, family_hint)?;
1652    for identity in [req.control_model.as_deref(), req.upscale_model.as_deref()]
1653        .into_iter()
1654        .flatten()
1655    {
1656        crate::require_model_activation(identity, None)?;
1657    }
1658    for lora in req.lora.iter().chain(req.loras.iter().flatten()) {
1659        crate::require_model_artifact_activation(
1660            std::path::Path::new(&lora.path),
1661            artifact_root,
1662            None,
1663        )?;
1664    }
1665    Ok(())
1666}
1667
1668/// Whether a request carries the source frames the per-checkpoint
1669/// source-image contract (#772) is asked about — the `has_source` argument of
1670/// [`source_image_contract_violation`].
1671///
1672/// Three inputs carry them. A `source_image` is the obvious one; first/last
1673/// frame `keyframes` carry them too (#779); and so does an extend, whose first
1674/// frames come from the tail of the clip it continues (#783). Extend is the
1675/// non-obvious member: [`validate_generate_request`] forbids pairing
1676/// `extend_video` with `source_image` or keyframes, so an extend request
1677/// provably has neither of the other two — and a gate that counted only those
1678/// saw every continuation as source-less. That refused every Wan I2V extend
1679/// with "this Wan I2V checkpoint needs a source image", the exact contract
1680/// that makes the checkpoint extend-capable, while letting a text-to-video
1681/// extend through to die in the engine after the load was paid for.
1682pub fn request_carries_source_frames(req: &GenerateRequest) -> bool {
1683    req.source_image.is_some()
1684        || req.keyframes.as_ref().is_some_and(|k| !k.is_empty())
1685        || req.is_extend()
1686}
1687
1688/// The one wording for a source-image contract violation (#772), shared by
1689/// server admission, the CLI preflight, and the Discord preflight so the
1690/// rejection reads identically wherever it lands.
1691///
1692/// `family` selects the family-aware phrasing — Wan keeps its checkpoint-swap
1693/// suggestions, every other family (plain LTX-Video today) gets wording that
1694/// names the actual model instead of mislabeling it as Wan. `has_source`
1695/// counts first/last-frame keyframes as well as a source image (#779): both
1696/// carry source frames, so either satisfies a required contract and either is
1697/// refused by a text-to-video-only checkpoint. A `None` capability enforces
1698/// nothing — the engine remains the authority.
1699pub fn source_image_contract_violation(
1700    family: Option<&str>,
1701    model: &str,
1702    capability: Option<crate::types::SourceImageCapability>,
1703    has_source: bool,
1704) -> Option<String> {
1705    use crate::types::SourceImageCapability;
1706    let wan = family == Some("wan");
1707    match capability {
1708        Some(SourceImageCapability::Unsupported) if has_source => Some(if wan {
1709            "this Wan checkpoint is text-to-video only and does not accept a source image \
1710             or keyframes — remove them, or pick an I2V-capable checkpoint such as \
1711             wan22-ti2v-5b or wan22-i2v-a14b"
1712                .to_string()
1713        } else {
1714            format!(
1715                "{model} is text-to-video only and does not accept a source image — its \
1716                 engine has no image-to-video path; remove the image, or pick an \
1717                 image-capable checkpoint such as an LTX-2 model"
1718            )
1719        }),
1720        Some(SourceImageCapability::Required) if !has_source => Some(if wan {
1721            "this Wan I2V checkpoint needs a source image; supply one, or pick a \
1722             text-to-video checkpoint such as wan22-t2v-a14b"
1723                .to_string()
1724        } else {
1725            format!("{model} needs a source image; supply one")
1726        }),
1727        _ => None,
1728    }
1729}
1730
1731/// Variant of [`validate_generate_request`] that accepts an explicit family
1732/// hint. The hint takes precedence over the manifest lookup, letting the HTTP
1733/// server feed in the catalog-resolved family for `cv:` / `hf:` model IDs.
1734pub fn validate_generate_request_with_family(
1735    req: &GenerateRequest,
1736    family_hint: Option<&str>,
1737) -> Result<(), String> {
1738    crate::require_model_activation(&req.model, family_hint).map_err(|error| error.to_string())?;
1739    validate_generate_request_after_activation(req, family_hint)
1740}
1741
1742/// Validate the exact MiniMax H3 private-UAT request partition after the
1743/// server has issued its authenticated ingress grant.
1744///
1745/// This feature-gated helper does not grant authorization or activate a model;
1746/// it only prevents the already-authorized private route from re-entering the
1747/// public compliance gate before applying the same field validation.
1748#[cfg(any(feature = "h3", feature = "h3-private-uat"))]
1749pub fn validate_h3_private_uat_request(req: &GenerateRequest) -> Result<(), String> {
1750    if !matches!(
1751        req.model.as_str(),
1752        crate::minimax_h3::FL2VA_COMFY | crate::minimax_h3::REF2VA_COMFY
1753    ) {
1754        return Err(
1755            "private MiniMax H3 validation requires an exact reviewed task model".to_string(),
1756        );
1757    }
1758    validate_generate_request_after_activation(req, Some(crate::minimax_h3::FAMILY))
1759}
1760
1761/// Shape/feature validation after the caller has passed the model-activation
1762/// authority. Kept private so tests can prove the future authorized H3 path
1763/// without exposing a compliance-gate bypass to production callers.
1764fn validate_generate_request_after_activation(
1765    req: &GenerateRequest,
1766    family_hint: Option<&str>,
1767) -> Result<(), String> {
1768    let family = resolved_family(&req.model, family_hint);
1769
1770    if req.references.is_some() && !family.is_some_and(crate::minimax_h3::is_family) {
1771        return Err(
1772            "references is only supported by MiniMax H3 Ref2VA; other families retain their existing source/edit fields"
1773                .to_string(),
1774        );
1775    }
1776
1777    if req.prompt.trim().is_empty() && prompt_required_for(req, family_hint) {
1778        return Err("prompt must not be empty".to_string());
1779    }
1780    // Resolve the composition from the request itself. A model that ships the
1781    // spatial upsampler renders stage 1 at half size and refines it over
1782    // tiles, which is the only way an axis past the trained RoPE span is in
1783    // distribution; anything else keeps the single-pass ceiling.
1784    let composition = if family == Some("ltx2") {
1785        ltx2_spatial_composition_for_request(req)
1786    } else {
1787        Ltx2SpatialComposition::SinglePass
1788    };
1789    let audio_only =
1790        family == Some("ltx2") && req.pipeline.is_some_and(Ltx2PipelineMode::is_audio_only);
1791    if !audio_only {
1792        validate_generation_dimensions_for_model(
1793            &req.model,
1794            req.width,
1795            req.height,
1796            family,
1797            composition,
1798        )?;
1799    }
1800    validate_family_video_timing_constraints(req.frames, req.fps, family)?;
1801    if composition == Ltx2SpatialComposition::TiledTwoStage {
1802        // The composed ceiling above is the x2 rung's. A request that names a
1803        // different rung reaches a different stage-1 shape, and only stage 1's
1804        // own span decides whether it is in distribution.
1805        validate_ltx2_stage1_span(req.width, req.height, req.spatial_upscale)?;
1806    }
1807    if req.steps == 0 {
1808        return Err("steps must be >= 1".to_string());
1809    }
1810    if req.steps > 100 {
1811        return Err(format!("steps ({}) must be <= 100", req.steps));
1812    }
1813    if req.batch_size == 0 {
1814        return Err("batch_size must be >= 1".to_string());
1815    }
1816    // The shared inference/planning contract intentionally has no generic
1817    // upper limit. Live atomic HTTP delivery has a separate server-advertised
1818    // materialization bound because its durable manifest and response are
1819    // still O(batch_size).
1820    if req.guidance < 0.0 {
1821        return Err(format!("guidance ({}) must be >= 0.0", req.guidance));
1822    }
1823    if req.guidance > 100.0 {
1824        return Err(format!("guidance ({}) must be <= 100.0", req.guidance));
1825    }
1826    if req.prompt.len() > 77_000 {
1827        return Err(format!(
1828            "prompt length ({} bytes) exceeds the 77,000-byte limit",
1829            req.prompt.len()
1830        ));
1831    }
1832    if let Some(ref neg) = req.negative_prompt {
1833        if neg.len() > 77_000 {
1834            return Err(format!(
1835                "negative_prompt length ({} bytes) exceeds the 77,000-byte limit",
1836                neg.len()
1837            ));
1838        }
1839    }
1840    if family.is_some_and(crate::minimax_h3::is_family) {
1841        let task = crate::minimax_h3::task_for_model(&req.model).ok_or_else(|| {
1842            "MiniMax H3 requests must resolve an explicit FL2VA or Ref2VA task partition"
1843                .to_string()
1844        })?;
1845        if req.mask_image.is_some() {
1846            return Err("MiniMax H3 does not support mask_image".to_string());
1847        }
1848        if req.control_image.is_some() || req.control_model.is_some() {
1849            return Err("MiniMax H3 does not support ControlNet inputs".to_string());
1850        }
1851        if req.cfg_plus.is_some() {
1852            return Err("MiniMax H3 does not support cfg_plus".to_string());
1853        }
1854        if req.scheduler.is_some() {
1855            return Err(
1856                "MiniMax H3 uses its dedicated synchronized dual-shift schedule; scheduler overrides are unsupported"
1857                    .to_string(),
1858            );
1859        }
1860        if req.lora.is_some() || req.loras.is_some() {
1861            return Err("MiniMax H3 does not support LoRA".to_string());
1862        }
1863        if req.upscale_model.is_some() {
1864            return Err("MiniMax H3 does not support post-generation image upscaling".to_string());
1865        }
1866        if req.pipeline.is_some()
1867            || req.ic_lora_control.is_some()
1868            || req.retake_range.is_some()
1869            || req.spatial_upscale.is_some()
1870            || req.temporal_upscale.is_some()
1871            || req.guidance_overrides.is_some()
1872            || req.hdr_exr_dir.is_some()
1873            || req.hdr_exr_full_float
1874        {
1875            return Err("MiniMax H3 does not accept LTX-2 pipeline controls".to_string());
1876        }
1877        if req
1878            .source_image
1879            .as_deref()
1880            .is_some_and(|image| !is_valid_image_format(image))
1881        {
1882            return Err("source_image must be a PNG or JPEG image".to_string());
1883        }
1884        if req.source_image.is_some()
1885            && (!req.strength.is_finite() || !(0.0..=1.0).contains(&req.strength))
1886        {
1887            return Err(format!(
1888                "strength ({}) must be a finite value in range [0.0, 1.0] when source_image is provided",
1889                req.strength
1890            ));
1891        }
1892        if req
1893            .edit_images
1894            .as_ref()
1895            .is_some_and(|images| images.iter().any(|image| !is_valid_image_format(image)))
1896        {
1897            return Err("edit_images must contain only PNG or JPEG images".to_string());
1898        }
1899        if req.edit_images.as_ref().is_some_and(Vec::is_empty) {
1900            return Err("edit_images must not be empty when provided".to_string());
1901        }
1902        if req.keyframes.as_ref().is_some_and(|keyframes| {
1903            keyframes
1904                .iter()
1905                .any(|keyframe| !is_valid_image_format(&keyframe.image))
1906        }) {
1907            return Err("keyframes must contain only PNG or JPEG images".to_string());
1908        }
1909        if req.keyframes.as_ref().is_some_and(Vec::is_empty) {
1910            return Err("keyframes must not be empty when provided".to_string());
1911        }
1912        if req.extend_overlap_frames.is_some() {
1913            return Err(
1914                "extend_overlap_frames requires extend_video or extend_video_path, which MiniMax H3 does not support"
1915                    .to_string(),
1916            );
1917        }
1918        crate::minimax_h3::validate_request_contract(req, task)
1919            .map(|_| ())
1920            .map_err(|error| error.to_string())?;
1921        return Ok(());
1922    }
1923    let flux2_dev = is_flux2_dev_model(&req.model);
1924    if family == Some("qwen-image-edit") {
1925        if req.edit_images.as_ref().is_none_or(Vec::is_empty) {
1926            return Err(
1927                "Qwen Image Edit needs at least one image. Add a Target image and try again."
1928                    .to_string(),
1929            );
1930        }
1931        if req.batch_size != 1 {
1932            return Err("qwen-image-edit only supports batch_size = 1".to_string());
1933        }
1934        if req.source_image.is_some() {
1935            return Err("qwen-image-edit uses edit_images instead of source_image".to_string());
1936        }
1937        if req.mask_image.is_some() {
1938            return Err("qwen-image-edit does not support mask_image".to_string());
1939        }
1940        if req.control_image.is_some() || req.control_model.is_some() {
1941            return Err("qwen-image-edit does not support ControlNet inputs".to_string());
1942        }
1943        if let Some(ref images) = req.edit_images {
1944            for image in images {
1945                if !is_valid_image_format(image) {
1946                    return Err("edit_images must contain only PNG or JPEG images".to_string());
1947                }
1948            }
1949        }
1950    } else if flux2_dev {
1951        if req.batch_size != 1
1952            && req
1953                .edit_images
1954                .as_ref()
1955                .is_some_and(|images| !images.is_empty())
1956        {
1957            return Err("flux2-dev reference editing only supports batch_size = 1".to_string());
1958        }
1959        if req.source_image.is_some() {
1960            return Err("flux2-dev uses edit_images instead of source_image".to_string());
1961        }
1962        if req.mask_image.is_some() {
1963            return Err("flux2-dev does not support mask_image".to_string());
1964        }
1965        if req.control_image.is_some() || req.control_model.is_some() {
1966            return Err("flux2-dev does not support ControlNet inputs".to_string());
1967        }
1968        if req.lora.is_some() || req.loras.as_ref().is_some_and(|loras| !loras.is_empty()) {
1969            return Err("flux2-dev does not support LoRA".to_string());
1970        }
1971        if let Some(images) = &req.edit_images {
1972            if images.len() > FLUX2_DEV_MAX_REFERENCE_IMAGES {
1973                return Err(format!(
1974                    "flux2-dev supports at most {FLUX2_DEV_MAX_REFERENCE_IMAGES} ordered reference images"
1975                ));
1976            }
1977            if images.iter().any(|image| !is_valid_image_format(image)) {
1978                return Err("edit_images must contain only PNG or JPEG images".to_string());
1979            }
1980        }
1981    } else if req.edit_images.is_some() {
1982        return Err(
1983            "edit_images are only supported for qwen-image-edit and flux2-dev models".to_string(),
1984        );
1985    }
1986    // img2img validation
1987    if let Some(ref img) = req.source_image {
1988        if req.strength < 0.0 || req.strength > 1.0 {
1989            return Err(format!(
1990                "strength ({}) must be in range [0.0, 1.0] when source_image is provided",
1991                req.strength
1992            ));
1993        }
1994        if !is_valid_image_format(img) {
1995            return Err("source_image must be a PNG or JPEG image".to_string());
1996        }
1997    }
1998    // ControlNet validation
1999    if let Some(ref ctrl) = req.control_image {
2000        require_controlnet_capable_family(family)?;
2001        if req.control_model.is_none() {
2002            return Err("control_image requires control_model to also be provided".to_string());
2003        }
2004        if !is_valid_image_format(ctrl) {
2005            return Err("control_image must be a PNG or JPEG image".to_string());
2006        }
2007        if req.control_scale < 0.0 {
2008            return Err(format!(
2009                "control_scale ({}) must be >= 0.0",
2010                req.control_scale
2011            ));
2012        }
2013    }
2014    if req.control_model.is_some() && req.control_image.is_none() {
2015        require_controlnet_capable_family(family)?;
2016        return Err("control_model requires control_image to also be provided".to_string());
2017    }
2018    // Inpainting validation
2019    if let Some(ref mask) = req.mask_image {
2020        if req.source_image.is_none() {
2021            return Err("mask_image requires source_image to also be provided".to_string());
2022        }
2023        if !is_valid_image_format(mask) {
2024            return Err("mask_image must be a PNG or JPEG image".to_string());
2025        }
2026    }
2027    // LoRA validation (format checks only — path existence is checked at the
2028    // inference layer, since in remote mode the path refers to the server filesystem).
2029    if let Some(ref lora) = req.lora {
2030        require_lora_capable_family(family)?;
2031        validate_lora_weight(lora, "lora")?;
2032        require_expert_routable_model(lora, &req.model, family)?;
2033    }
2034    if let Some(ref loras) = req.loras {
2035        if loras.is_empty() {
2036            return Err("loras must not be empty when provided".to_string());
2037        }
2038        require_lora_capable_family(family)?;
2039        for lora in loras {
2040            validate_lora_weight(lora, "loras")?;
2041            require_expert_routable_model(lora, &req.model, family)?;
2042        }
2043    }
2044    if let Some(fps) = req.fps {
2045        if fps == 0 {
2046            return Err("fps must be >= 1".to_string());
2047        }
2048        if fps > 120 {
2049            return Err(format!("fps ({fps}) must be <= 120"));
2050        }
2051    }
2052    // Video frame validation
2053    if let Some(frames) = req.frames {
2054        if frames == 0 {
2055            return Err("frames must be >= 1".to_string());
2056        }
2057        if let Some(step) = family.and_then(frame_step_for_family) {
2058            let offset = family.and_then(frame_offset_for_family).unwrap_or(1);
2059            if frames < offset || !(frames - offset).is_multiple_of(step) {
2060                return Err(format!(
2061                    "frames ({frames}) must be {step}n+{offset} for this model family (e.g. {}, {}, {}, …)",
2062                    step + offset,
2063                    2 * step + offset,
2064                    3 * step + offset,
2065                ));
2066            }
2067        }
2068        // LTX-2's ceiling is a duration (see `LTX2_MAX_RUNTIME_SECONDS`), so it
2069        // is derived per request from fps instead of the flat global ceiling.
2070        if matches!(family, Some("ltx2")) {
2071            let fps = req.fps.unwrap_or(LTX2_DEFAULT_FPS).max(1);
2072            // `derive_stage1_render_shape` halves BOTH the frame count and the
2073            // fps for `--temporal-upscale x2`, so stage 1 renders the same
2074            // runtime at half the frame rate. Mirror that: temporal upscaling
2075            // buys temporal resolution, never extra duration.
2076            let (stage1_frames, stage1_fps) = match req.temporal_upscale {
2077                Some(crate::Ltx2TemporalUpscale::X2) => {
2078                    (frames.saturating_sub(1) / 2 + 1, (fps / 2).max(1))
2079                }
2080                None => (frames, fps),
2081            };
2082            let stage1_cap = ltx2_max_frames_at_fps(stage1_fps);
2083            if stage1_frames > stage1_cap {
2084                // Quote a frame count the user can actually submit. The raw
2085                // duration ceiling is off the 8n+1 grid, so naming it sends
2086                // them straight into a second rejection.
2087                let delivered_cap = match req.temporal_upscale {
2088                    Some(crate::Ltx2TemporalUpscale::X2) => (stage1_cap - 1) * 2 + 1,
2089                    None => stage1_cap,
2090                };
2091                let delivered_cap = if delivered_cap > 1 {
2092                    delivered_cap - ((delivered_cap - 1) % 8)
2093                } else {
2094                    delivered_cap
2095                };
2096                return Err(format!(
2097                    "frames ({frames}) exceeds the LTX-2 / LTX-2.3 temporal RoPE budget of \
2098                     {LTX2_MAX_RUNTIME_SECONDS}s: at {fps} fps the ceiling is {delivered_cap} frames. \
2099                     Raise --fps, lower --frames, or render the shot as a multi-clip sequence"
2100                ));
2101            }
2102        } else {
2103            let max_frames = family
2104                .and_then(|family| {
2105                    max_frames_for_family_at_fps(family, req.fps.unwrap_or(LTX2_DEFAULT_FPS).max(1))
2106                })
2107                .unwrap_or(MAX_FRAMES_GLOBAL);
2108            if frames > max_frames {
2109                return Err(format!("frames ({frames}) must be <= {max_frames}"));
2110            }
2111        }
2112    }
2113    if let Some(keyframes) = &req.keyframes {
2114        validate_keyframes(keyframes, req.frames, family)?;
2115        // TI2V pins endpoints in latent space, where the 2.2 VAE's 4x
2116        // temporal stride turns a 5-frame pixel clip into two latent frames —
2117        // both anchored, nothing left to denoise. The engine refuses that
2118        // degenerate grid after the 10 GB load; admission must agree first.
2119        // 9 pixel frames (three latent frames) is the smallest 4k+1 clip with
2120        // an interior. Opaque cv:/hf: installs keep the engine's own check.
2121        if family == Some("wan")
2122            && keyframes.len() == 2
2123            && crate::manifest::resolve_model_name(&req.model).starts_with("wan22-ti2v-5b")
2124            && req
2125                .frames
2126                .is_some_and(|frames| frames < WAN_TI2V_FLF_MIN_FRAMES)
2127        {
2128            return Err(
2129                "wan22-ti2v-5b first/last-frame conditioning needs at least 9 frames — \
2130                 shorter clips leave no latent frames to denoise between the pinned endpoints"
2131                    .to_string(),
2132            );
2133        }
2134    }
2135    if let Some(audio) = &req.audio_file {
2136        require_ltx2_family(family, "audio_file")?;
2137        if req.audio_file_path.is_some() {
2138            return Err("audio_file_path cannot be combined with audio_file".to_string());
2139        }
2140        if audio.is_empty() {
2141            return Err("audio_file must not be empty".to_string());
2142        }
2143        validate_inline_media_size(audio, "audio_file", MAX_INLINE_AUDIO_BYTES)?;
2144    }
2145    if let Some(path) = &req.audio_file_path {
2146        require_ltx2_family(family, "audio_file_path")?;
2147        if path.trim().is_empty() {
2148            return Err("audio_file_path must not be empty".to_string());
2149        }
2150    }
2151    if let Some(video) = &req.source_video {
2152        require_ltx2_family(family, "source_video")?;
2153        if req.source_video_path.is_some() {
2154            return Err("source_video_path cannot be combined with source_video".to_string());
2155        }
2156        if video.is_empty() {
2157            return Err("source_video must not be empty".to_string());
2158        }
2159        validate_inline_media_size(video, "source_video", MAX_INLINE_SOURCE_VIDEO_BYTES)?;
2160    }
2161    if let Some(path) = &req.source_video_path {
2162        require_ltx2_family(family, "source_video_path")?;
2163        if path.trim().is_empty() {
2164            return Err("source_video_path must not be empty".to_string());
2165        }
2166    }
2167    validate_extend(req, family)?;
2168    // Only enforce the LTX-2 family gate when audio is actually requested
2169    // (`Some(true)`). The web form serializes its tri-state checkbox as
2170    // `Some(false)` when the user has explicitly turned audio off — which
2171    // must NOT trip a family error for video-only families, since the user
2172    // didn't ask for audio at all.
2173    if req.enable_audio == Some(true) {
2174        require_ltx2_family(family, "enable_audio")?;
2175    }
2176    if req.retake_range.is_some() {
2177        require_ltx2_family(family, "retake_range")?;
2178    }
2179    if req.spatial_upscale.is_some() {
2180        require_ltx2_family(family, "spatial_upscale")?;
2181    }
2182    if req.temporal_upscale.is_some() {
2183        require_ltx2_family(family, "temporal_upscale")?;
2184    }
2185    if req.pipeline.is_some() {
2186        require_ltx2_family(family, "pipeline")?;
2187    }
2188    if let Some(overrides) = &req.guidance_overrides {
2189        require_ltx2_family(family, "guidance_overrides")?;
2190        validate_guidance_overrides(overrides)?;
2191        // Cross-modal guidance needs both modalities resident. An audio-only
2192        // run has no video branch for `modality_scale` to act on, so a
2193        // non-1.0 value cannot be honoured — reject it instead of accepting
2194        // a number that would silently do nothing.
2195        if req.pipeline.is_some_and(Ltx2PipelineMode::is_audio_only) {
2196            if let Some(modality_scale) = overrides.modality_scale {
2197                if (modality_scale - 1.0).abs() > f64::EPSILON {
2198                    return Err(
2199                        "guidance_overrides.modality_scale must be 1.0 for pipeline=t2a: \
2200                         audio-only generation has no video modality to guide against"
2201                            .to_string(),
2202                    );
2203                }
2204            }
2205        }
2206    }
2207    if let Some(dir) = req.hdr_exr_dir.as_deref() {
2208        require_ltx2_family(family, "hdr_exr_dir")?;
2209        if dir.trim().is_empty() {
2210            return Err("hdr_exr_dir must not be empty".to_string());
2211        }
2212        // Today this is also unreachable transitively (hdr needs the ic-lora
2213        // control, which needs source_video, which extend forbids), but the
2214        // engine's extend path re-renders through the chain-stage machinery
2215        // where a per-clip EXR sequence would misalign with the stitched
2216        // timeline — say it directly instead of leaning on that implication
2217        // chain staying intact.
2218        if req.extend_video.is_some() || req.extend_video_path.is_some() {
2219            return Err("hdr_exr_dir cannot be combined with extend_video".to_string());
2220        }
2221        // The adapter is what makes the render HDR. Without it the decode
2222        // would apply a LogC3 inverse to an ordinary SDR signal and write a
2223        // wrongly-graded EXR that looks deliberate.
2224        // Through the shared normalizer, not a raw compare: every other
2225        // consumer accepts `HDR` and `hdr_`, so a bare `trim()` here would
2226        // reject spellings the rest of the stack resolves fine.
2227        if req
2228            .ic_lora_control
2229            .as_deref()
2230            .map(crate::ltx2_control::normalize_control_id)
2231            .as_deref()
2232            != Some("hdr")
2233        {
2234            return Err(
2235                "hdr_exr_dir requires ic_lora_control=hdr — EXR output is only meaningful for \
2236                 the HDR adapter's LogC3 signal"
2237                    .to_string(),
2238            );
2239        }
2240    } else if req.hdr_exr_full_float {
2241        return Err("hdr_exr_full_float requires hdr_exr_dir".to_string());
2242    }
2243
2244    if let Some(control) = req.ic_lora_control.as_deref() {
2245        require_ltx2_family(family, "ic_lora_control")?;
2246        if control.trim().is_empty() {
2247            return Err("ic_lora_control must not be empty".to_string());
2248        }
2249        // Most control adapters drive the generic in-context pipeline. The
2250        // lip-dub adapter has its own pipeline (frozen stage-2 audio, an
2251        // appended audio reference, the LoRA on both stages), so it is the one
2252        // control whose required pipeline is not `ic-lora`.
2253        let required_pipeline = crate::ltx2_control::pipeline_for_control_id(control);
2254        if req.pipeline != Some(required_pipeline) {
2255            return Err(format!(
2256                "ic_lora_control '{}' requires pipeline={required_pipeline}",
2257                crate::ltx2_control::normalize_control_id(control)
2258            ));
2259        }
2260        if req.source_video.is_none() && req.source_video_path.is_none() {
2261            return Err("ic_lora_control requires source_video or source_video_path".to_string());
2262        }
2263        let user_loras = usize::from(req.lora.is_some()) + req.loras.as_ref().map_or(0, Vec::len);
2264        if user_loras + 1 > 4 {
2265            return Err(
2266                "ic_lora_control plus custom LoRAs exceeds the four-LoRA stack limit".to_string(),
2267            );
2268        }
2269    }
2270
2271    // Wan renders video, with one deliberate exception: a single-frame render
2272    // is a still (#798) — upstream's own `t2i-14B` task is the same weights at
2273    // `frame_num=1` — so png/jpeg are admitted exactly when `frames == 1`.
2274    // Every other image format request would otherwise reach the engine and
2275    // fail after the model loads instead of at admission (Wan has no audio
2276    // path, so `wav` is refused here too).
2277    if family == Some("wan") {
2278        match (req.resolved_output_format(), req.frames) {
2279            (
2280                OutputFormat::Gif | OutputFormat::Apng | OutputFormat::Webp | OutputFormat::Mp4,
2281                _,
2282            ) => {}
2283            (OutputFormat::Png | OutputFormat::Jpeg, Some(1)) => {}
2284            _ => return Err("Wan outputs must use mp4, gif, apng, or webp".to_string()),
2285        }
2286
2287        // First/last-frame conditioning (#779): the first frame comes from
2288        // either `source_image` or `keyframes[0]`, never both — an ambiguous
2289        // mix is refused at admission with the engine's own wording.
2290        if req.source_image.is_some() && req.keyframes.as_ref().is_some_and(|k| !k.is_empty()) {
2291            return Err(
2292                "Wan takes the first frame from either source_image or keyframes[0], not both \
2293                 — for a first/last-frame render, put both endpoints in keyframes"
2294                    .to_string(),
2295            );
2296        }
2297    }
2298
2299    // The scheduler slot is shared by two disjoint solver families (#795):
2300    // wan's flow solvers are rejected off-family and the UNet schedulers are
2301    // rejected for wan — at admission, not after the model loads.
2302    match req.scheduler {
2303        Some(crate::Scheduler::Euler | crate::Scheduler::DpmPp) if family != Some("wan") => {
2304            return Err(format!(
2305                "scheduler '{}' is a Wan sample solver and is only supported for wan models",
2306                req.scheduler.expect("matched Some")
2307            ));
2308        }
2309        Some(crate::Scheduler::Ddim | crate::Scheduler::EulerAncestral)
2310            if family == Some("wan") =>
2311        {
2312            return Err(format!(
2313                "Wan supports the uni-pc, euler, and dpm-pp sample solvers; '{}' is a UNet \
2314                 scheduler",
2315                req.scheduler.expect("matched Some")
2316            ));
2317        }
2318        _ => {}
2319    }
2320
2321    // Wan flow shift (#782): rejected, not ignored, off-family — a silently
2322    // inert quality knob looks like the knob failing.
2323    if let Some(shift) = req.sample_shift {
2324        if family != Some("wan") {
2325            return Err(
2326                "sample_shift is a Wan flow-matching control and is not supported for this model"
2327                    .to_string(),
2328            );
2329        }
2330        if !shift.is_finite() || shift <= 0.0 {
2331            return Err(format!(
2332                "sample_shift must be finite and positive, got {shift}"
2333            ));
2334        }
2335    }
2336
2337    // The wan fp8-scaled A14B tier refuses every adapter stack (#777): an fp8
2338    // merge would re-round each targeted weight to three mantissa bits, and
2339    // the loader fails closed — but only after the UMT5 encode. Name it at
2340    // admission instead. Opaque cv:/hf: installs keep the engine's check.
2341    if family == Some("wan")
2342        && (req.lora.is_some() || req.loras.as_ref().is_some_and(|list| !list.is_empty()))
2343    {
2344        let canonical = crate::manifest::resolve_model_name(&req.model);
2345        if canonical.ends_with(":fp8") && canonical.contains("a14b") {
2346            return Err(format!(
2347                "{canonical} is fp8-scaled and refuses LoRA stacks — merging would re-round \
2348                 every targeted weight to three mantissa bits. Use the :q5/:q8 GGUF or bf16 \
2349                 tier for adapters"
2350            ));
2351        }
2352    }
2353
2354    // Wan Lightning distill strengths (#795): wan only, within the accepted
2355    // band. Whether the model actually ships a distill in the addressed slot
2356    // is the engine's check — it knows the resolved component paths.
2357    for (label, value) in [
2358        ("high", req.distill_strength_high),
2359        ("low", req.distill_strength_low),
2360    ] {
2361        if let Some(strength) = value {
2362            if family != Some("wan") {
2363                return Err(format!(
2364                    "distill_strength_{label} is a Wan Lightning control and is not supported \
2365                     for this model"
2366                ));
2367            }
2368            if !strength.is_finite() || strength <= 0.0 || strength > 4.0 {
2369                return Err(format!(
2370                    "distill_strength_{label} must be in (0, 4], got {strength}"
2371                ));
2372            }
2373        }
2374    }
2375
2376    if family == Some("ltx2") {
2377        let audio_only = req.pipeline.is_some_and(Ltx2PipelineMode::is_audio_only);
2378        match (req.resolved_output_format(), audio_only) {
2379            (OutputFormat::Wav, true) => {}
2380            (OutputFormat::Wav, false) => {
2381                return Err("wav output requires pipeline=t2a".to_string());
2382            }
2383            (_, true) => {
2384                return Err("pipeline=t2a renders audio only; set output_format=wav".to_string());
2385            }
2386            (
2387                OutputFormat::Gif | OutputFormat::Apng | OutputFormat::Webp | OutputFormat::Mp4,
2388                false,
2389            ) => {}
2390            (_, false) => return Err("LTX-2 outputs must use mp4, gif, apng, or webp".to_string()),
2391        }
2392
2393        if req.enable_audio == Some(true)
2394            && !audio_only
2395            && req.resolved_output_format() != OutputFormat::Mp4
2396        {
2397            return Err("audio-enabled LTX-2 outputs must use mp4 format".to_string());
2398        }
2399        if req.enable_audio == Some(false) && audio_only {
2400            return Err("pipeline=t2a cannot be combined with enable_audio=false".to_string());
2401        }
2402
2403        if req.retake_range.is_some()
2404            && req.source_video.is_none()
2405            && req.source_video_path.is_none()
2406        {
2407            return Err(
2408                "retake_range requires source_video or source_video_path to also be provided"
2409                    .to_string(),
2410            );
2411        }
2412
2413        if let Some(range) = &req.retake_range {
2414            if !(range.start_seconds.is_finite() && range.end_seconds.is_finite()) {
2415                return Err("retake_range values must be finite numbers".to_string());
2416            }
2417            if range.start_seconds < 0.0 {
2418                return Err("retake_range start_seconds must be >= 0.0".to_string());
2419            }
2420            if range.end_seconds <= range.start_seconds {
2421                return Err(
2422                    "retake_range end_seconds must be greater than start_seconds".to_string(),
2423                );
2424            }
2425        }
2426
2427        if let Some(pipeline) = req.pipeline {
2428            match pipeline {
2429                Ltx2PipelineMode::A2Vid => {
2430                    if req.audio_file.is_none() && req.audio_file_path.is_none() {
2431                        return Err(
2432                            "pipeline=a2-vid requires audio_file or audio_file_path".to_string()
2433                        );
2434                    }
2435                }
2436                Ltx2PipelineMode::Retake => {
2437                    if req.source_video.is_none() && req.source_video_path.is_none() {
2438                        return Err("pipeline=retake requires source_video or source_video_path"
2439                            .to_string());
2440                    }
2441                    if req.retake_range.is_none() {
2442                        return Err("pipeline=retake requires retake_range".to_string());
2443                    }
2444                }
2445                Ltx2PipelineMode::Keyframe => {
2446                    let keyframe_count = req.keyframes.as_ref().map_or(0, Vec::len);
2447                    if keyframe_count < 2 {
2448                        return Err("pipeline=keyframe requires at least 2 keyframes".to_string());
2449                    }
2450                }
2451                Ltx2PipelineMode::IcLora => {
2452                    if req.source_video.is_none() && req.source_video_path.is_none() {
2453                        return Err(
2454                            "pipeline=ic-lora requires source_video or source_video_path"
2455                                .to_string(),
2456                        );
2457                    }
2458                    if req.ic_lora_control.is_none()
2459                        && req.lora.is_none()
2460                        && req.loras.as_ref().is_none_or(Vec::is_empty)
2461                    {
2462                        return Err("pipeline=ic-lora requires at least one LoRA".to_string());
2463                    }
2464                }
2465                Ltx2PipelineMode::LipDub => {
2466                    if req.source_video.is_none() && req.source_video_path.is_none() {
2467                        return Err(
2468                            "pipeline=lip-dub requires source_video or source_video_path (the \
2469                             clip being re-voiced)"
2470                                .to_string(),
2471                        );
2472                    }
2473                    if req.ic_lora_control.is_none()
2474                        && req.lora.is_none()
2475                        && req.loras.as_ref().is_none_or(Vec::is_empty)
2476                    {
2477                        return Err("pipeline=lip-dub requires the lip-dub IC-LoRA; pass \
2478                             ic_lora_control=lipdub"
2479                            .to_string());
2480                    }
2481                    // Upstream asserts a two-stage resolution before doing any
2482                    // work (`assert_resolution(..., is_two_stage=True)` in
2483                    // `utils/helpers.py:321-332`). Lip dub is always two-stage
2484                    // — stage 1 renders at half size — so an odd multiple of 32
2485                    // would leave stage 1 off the latent grid.
2486                    if !req.width.is_multiple_of(LTX2_TWO_STAGE_ALIGNMENT)
2487                        || !req.height.is_multiple_of(LTX2_TWO_STAGE_ALIGNMENT)
2488                    {
2489                        return Err(format!(
2490                            "pipeline=lip-dub renders in two stages, so width and height must be \
2491                             multiples of {LTX2_TWO_STAGE_ALIGNMENT}; got {}x{}",
2492                            req.width, req.height
2493                        ));
2494                    }
2495                    if req.retake_range.is_some() {
2496                        return Err(
2497                            "pipeline=lip-dub cannot be combined with retake_range".to_string()
2498                        );
2499                    }
2500                    if req
2501                        .keyframes
2502                        .as_ref()
2503                        .is_some_and(|items| !items.is_empty())
2504                    {
2505                        return Err(
2506                            "pipeline=lip-dub cannot be combined with keyframes".to_string()
2507                        );
2508                    }
2509                    // Both would change the output shape out from under the
2510                    // reference clip, whose resolution and length the dub has
2511                    // to match. Upstream's pipeline composes with neither.
2512                    if req.spatial_upscale.is_some() || req.temporal_upscale.is_some() {
2513                        return Err(
2514                            "pipeline=lip-dub cannot be combined with spatial_upscale or \
2515                             temporal_upscale; the render must match the reference video"
2516                                .to_string(),
2517                        );
2518                    }
2519                }
2520                Ltx2PipelineMode::T2a => {
2521                    // Text-to-audio has no video modality at all: there is no
2522                    // frame to condition on and no cross-modal path for a
2523                    // reference to reach. Reject conditioning outright rather
2524                    // than silently ignoring inputs the caller paid to upload.
2525                    for (present, field) in [
2526                        (req.source_image.is_some(), "source_image"),
2527                        (req.source_video.is_some(), "source_video"),
2528                        (req.source_video_path.is_some(), "source_video_path"),
2529                        (req.audio_file.is_some(), "audio_file"),
2530                        (req.audio_file_path.is_some(), "audio_file_path"),
2531                        (req.is_extend(), "extend_video"),
2532                        (
2533                            req.keyframes.as_ref().is_some_and(|k| !k.is_empty()),
2534                            "keyframes",
2535                        ),
2536                        (req.retake_range.is_some(), "retake_range"),
2537                        (req.spatial_upscale.is_some(), "spatial_upscale"),
2538                        (req.temporal_upscale.is_some(), "temporal_upscale"),
2539                        (req.upscale_model.is_some(), "upscale_model"),
2540                    ] {
2541                        if present {
2542                            return Err(format!(
2543                                "pipeline=t2a generates audio only and cannot be combined with {field}"
2544                            ));
2545                        }
2546                    }
2547                }
2548                Ltx2PipelineMode::OneStage
2549                | Ltx2PipelineMode::TwoStage
2550                | Ltx2PipelineMode::TwoStageHq
2551                | Ltx2PipelineMode::Distilled => {}
2552            }
2553        }
2554    }
2555
2556    Ok(())
2557}
2558
2559/// Whether a stable name or catalog ID denotes the first-party FLUX.2 Dev
2560/// architecture rather than a Klein checkpoint.
2561pub fn is_flux2_dev_model(model: &str) -> bool {
2562    let model = model.to_ascii_lowercase();
2563    model.contains("flux2-dev") || model.contains("flux.2-dev")
2564}
2565
2566/// Validate an upscale request. Returns `Ok(())` if valid, or an error message.
2567pub fn validate_upscale_request(req: &UpscaleRequest) -> Result<(), String> {
2568    if req.model.trim().is_empty() {
2569        return Err("upscale model must not be empty".to_string());
2570    }
2571    if req.image.is_empty() {
2572        return Err("upscale image must not be empty".to_string());
2573    }
2574    if !is_valid_image_format(&req.image) {
2575        return Err("upscale image must be a PNG or JPEG image".to_string());
2576    }
2577    if let Some(tile_size) = req.tile_size {
2578        if tile_size != 0 && tile_size < 64 {
2579            return Err(format!(
2580                "tile_size ({tile_size}) must be 0 (disabled) or >= 64"
2581            ));
2582        }
2583    }
2584    Ok(())
2585}
2586
2587// ── Dimension recommendations ───────────────────────────────────────────────
2588
2589/// Per-checkpoint recommended buckets for the Wan family.
2590///
2591/// The family-wide list unions buckets no single checkpoint supports —
2592/// `wan21-t2v-1.3b` is 480p-only, and `wan22-ti2v-5b`'s native pair is
2593/// 1280x704 on its 2.2 VAE's 32px grid — so `/api/models` resolves the
2594/// advertisement per model. The family list remains the fallback for
2595/// checkpoints this build has no manifest for (catalog `cv:`/`hf:` ids).
2596pub fn wan_recommended_dimensions(model: &str) -> &'static [(u32, u32)] {
2597    crate::generation_profile::presets_for_identity(model, "wan", None)
2598}
2599
2600/// Per-checkpoint dimension grid for the Wan family.
2601///
2602/// `wan22-ti2v-5b`'s 2.2 VAE compresses 16x spatially and its DiT patches the
2603/// latent 2x2, so its pixel grid is 32 — the engine enforces exactly this
2604/// product after loading (`wan/pipeline.rs`), and admission must agree so an
2605/// off-grid canvas never queues a 10 GB load it cannot survive. The 2.1-VAE
2606/// checkpoints (1.3B, A14B: 8x stride x 2x2 patch) keep the family's 16.
2607/// Mirrors [`wan_recommended_dimensions`]: variant tags and legacy dash names
2608/// resolve through the manifest first, and unknown `cv:`/`hf:` installs keep
2609/// the family fallback — deriving the grid from a sidecar-described VAE
2610/// component is deliberately follow-up work.
2611pub fn wan_dimension_alignment(model: &str) -> u32 {
2612    let canonical = crate::manifest::resolve_model_name(model);
2613    if canonical.starts_with("wan22-ti2v-5b") {
2614        return 32;
2615    }
2616    dimension_alignment_for_family(Some("wan"))
2617}
2618
2619/// Return the list of recommended (width, height) pairs for a model family.
2620///
2621/// Returns an empty slice for unknown families, utility models (e.g. `qwen3-expand`),
2622/// and conditioning models (e.g. ControlNet).
2623pub fn recommended_dimensions(family: &str) -> &'static [(u32, u32)] {
2624    crate::generation_profile::family_presets(family)
2625}
2626
2627/// Composition-aware counterpart to [`recommended_dimensions`].
2628///
2629/// `/api/models` advertises this per model so a checkpoint that cannot compose
2630/// never offers a rung it cannot render. Returns an owned list because the
2631/// composed ladder is the base list plus the composed rungs.
2632pub fn recommended_dimensions_composed(
2633    family: &str,
2634    composition: Ltx2SpatialComposition,
2635) -> Vec<(u32, u32)> {
2636    let base = recommended_dimensions(family);
2637    if family != "ltx2" || composition != Ltx2SpatialComposition::TiledTwoStage {
2638        return base.to_vec();
2639    }
2640    // Derived from the ladder rather than restated beside it. A rung that
2641    // needs tiling is exactly a rung a single-pass checkpoint cannot render,
2642    // which is exactly the set to withhold from one.
2643    let mut out = base.to_vec();
2644    for rung in LTX2_OUTPUT_RUNGS
2645        .iter()
2646        .filter(|rung| rung.requires_tiled_stage2())
2647    {
2648        out.push((rung.width, rung.height));
2649        out.push((rung.height, rung.width));
2650    }
2651    out
2652}
2653
2654/// Check if the requested dimensions match any recommended resolution for the model family.
2655///
2656/// Returns `None` if the dimensions are recommended or the family has no recommendation list.
2657/// Returns `Some(warning_message)` with suggested alternatives otherwise.
2658pub fn dimension_warning(width: u32, height: u32, family: &str) -> Option<String> {
2659    dimension_warning_composed(width, height, family, Ltx2SpatialComposition::SinglePass)
2660}
2661
2662/// Composition-aware counterpart to [`dimension_warning`].
2663///
2664/// A composing LTX-2 checkpoint has more buckets than its family fallback, so
2665/// the single-pass list would call 3840x2176 unrecommended on the very
2666/// checkpoint the rung was added for.
2667pub fn dimension_warning_composed(
2668    width: u32,
2669    height: u32,
2670    family: &str,
2671    composition: Ltx2SpatialComposition,
2672) -> Option<String> {
2673    let dims = recommended_dimensions_composed(family, composition);
2674    if dims.is_empty() {
2675        return None;
2676    }
2677    if dims.contains(&(width, height)) {
2678        return None;
2679    }
2680    // Build a compact list of suggested alternatives (show up to 4)
2681    let suggestions: Vec<String> = dims
2682        .iter()
2683        .take(4)
2684        .map(|(w, h)| format!("{w}x{h}"))
2685        .collect();
2686    let more = if dims.len() > 4 {
2687        format!(", ... ({} total)", dims.len())
2688    } else {
2689        String::new()
2690    };
2691    Some(format!(
2692        "{width}x{height} is not a recommended resolution for {family} models. \
2693         Suggested: {}{}",
2694        suggestions.join(", "),
2695        more,
2696    ))
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701    use super::*;
2702    use crate::OutputFormat;
2703
2704    /// Only the A14B pair has experts; silently ignoring the field elsewhere
2705    /// would let a user believe an adapter was bound to half the schedule when
2706    /// it was applied to all of it.
2707    #[test]
2708    fn an_expert_bound_lora_needs_a_two_expert_checkpoint() {
2709        let lora = |expert| LoraWeight {
2710            path: "/loras/high_noise_model.safetensors".to_string(),
2711            scale: 1.0,
2712            expert,
2713        };
2714
2715        // The A14B pair accepts it.
2716        assert!(require_expert_routable_model(
2717            &lora(Some(crate::LoraExpert::High)),
2718            "wan22-t2v-a14b:q5",
2719            Some("wan"),
2720        )
2721        .is_ok());
2722
2723        // Single-expert wan checkpoints name the problem and the remedy.
2724        for model in ["wan21-t2v-1.3b:bf16", "wan22-ti2v-5b:fp16"] {
2725            let error = require_expert_routable_model(
2726                &lora(Some(crate::LoraExpert::Low)),
2727                model,
2728                Some("wan"),
2729            )
2730            .unwrap_err();
2731            assert!(error.contains("single-expert"), "{error}");
2732            assert!(error.contains("drop the expert field"), "{error}");
2733        }
2734
2735        // A non-wan family has no experts at all.
2736        let error = require_expert_routable_model(
2737            &lora(Some(crate::LoraExpert::High)),
2738            "flux-dev:q8",
2739            Some("flux"),
2740        )
2741        .unwrap_err();
2742        assert!(error.contains("not a Wan model"), "{error}");
2743
2744        // An opaque catalog id cannot be classified by name; the engine reads
2745        // the real pair from the checkpoint, so admission does not guess.
2746        assert!(require_expert_routable_model(
2747            &lora(Some(crate::LoraExpert::High)),
2748            "cv:123456",
2749            Some("wan"),
2750        )
2751        .is_ok());
2752
2753        // Absent stays absent — the historical apply-to-both path.
2754        assert!(require_expert_routable_model(&lora(None), "flux-dev:q8", Some("flux")).is_ok());
2755    }
2756
2757    /// Upstream's own shipped LTX-2.3 HQ default is 1920x1088
2758    /// (`LTX_2_3_HQ_PARAMS`: stage 1 at 960x544, refined x2). That is
2759    /// 2,088,960 px, so the flat 1.8 MP ceiling made mold unable to express
2760    /// the reference implementation's own top-end preset.
2761    #[test]
2762    fn ltx2_admits_upstreams_shipped_1080p_shape() {
2763        assert!(validate_generation_dimensions(1920, 1088, Some("ltx2")).is_ok());
2764        assert!(validate_generation_dimensions(1088, 1920, Some("ltx2")).is_ok());
2765    }
2766
2767    #[test]
2768    fn non_ltx2_families_keep_the_default_ceiling() {
2769        for family in [Some("flux"), Some("ltx-video"), Some("sdxl"), None] {
2770            let err = validate_generation_dimensions(1920, 1088, family)
2771                .expect_err("only LTX-2 gets the raised ceiling");
2772            assert!(
2773                err.contains("1.8MP"),
2774                "{family:?} must still report the default limit, got: {err}"
2775            );
2776        }
2777    }
2778
2779    /// Independent of the pixel budget. The checkpoints ship
2780    /// `positional_embedding_max_pos = [20, 2048, 2048]` and normalize pixel
2781    /// positions by it, so an axis past 2048 is out of distribution even when
2782    /// the frame is small: 3200x512 is only 1.64 MP but its width position is
2783    /// 1.5625, far outside the trained [-1, 1].
2784    #[test]
2785    fn ltx2_rejects_an_axis_beyond_the_rope_span() {
2786        let err = validate_generation_dimensions(3200, 512, Some("ltx2"))
2787            .expect_err("an over-wide axis must be rejected on its own merits");
2788        assert!(
2789            err.contains("2048"),
2790            "the error must name the axis limit, got: {err}"
2791        );
2792        // The transpose is equally out of distribution.
2793        assert!(validate_generation_dimensions(512, 3200, Some("ltx2")).is_err());
2794        // Exactly at the span is in distribution, when the pixel budget also
2795        // allows it: 2048x992 is 2.03 MP, 2048x1024 would be 2.10 MP and is
2796        // rejected on pixels instead. The two limits are independent.
2797        assert!(validate_generation_dimensions(2048, 992, Some("ltx2")).is_ok());
2798        assert!(validate_generation_dimensions(2048, 1024, Some("ltx2"))
2799            .expect_err("over the pixel budget")
2800            .contains("megapixels"));
2801    }
2802
2803    #[test]
2804    fn ltx2_recommended_dimensions_are_grid_aligned_and_inside_the_family_ceiling() {
2805        for &(width, height) in recommended_dimensions("ltx2") {
2806            assert!(
2807                validate_generation_dimensions(width, height, Some("ltx2")).is_ok(),
2808                "advertised preset {width}x{height} must be admissible"
2809            );
2810        }
2811    }
2812
2813    /// The whole point of gating the raised ceiling on the composition is that
2814    /// nothing renderable today changes. Every shape the single-pass validator
2815    /// used to accept or reject must still get the same answer, at exactly the
2816    /// same boundary — a ceiling that leaked into the default path would admit
2817    /// out-of-distribution renders on one-stage checkpoints.
2818    #[test]
2819    fn single_pass_admission_is_byte_for_byte_unchanged() {
2820        // Accepted before, accepted now.
2821        for &(width, height) in &[
2822            (768u32, 512u32),
2823            (1216, 704),
2824            (1920, 1088),
2825            (1088, 1920),
2826            (2048, 992),
2827        ] {
2828            assert!(
2829                validate_generation_dimensions(width, height, Some("ltx2")).is_ok(),
2830                "{width}x{height} was admissible before the composed ceiling"
2831            );
2832        }
2833        // Rejected before, rejected now — on the same limit each time.
2834        assert!(validate_generation_dimensions(2048, 1024, Some("ltx2"))
2835            .expect_err("2.10 MP is over the single-pass pixel budget")
2836            .contains("megapixels"));
2837        assert!(validate_generation_dimensions(3200, 512, Some("ltx2"))
2838            .expect_err("a 3200px axis is past the trained span")
2839            .contains("2048"));
2840        assert!(validate_generation_dimensions(2080, 512, Some("ltx2")).is_err());
2841    }
2842
2843    /// The threshold is the trained span itself, not a rounded-down neighbour.
2844    /// One latent cell either side of 2048 is the difference between the shape
2845    /// upstream ships and a shape no checkpoint has seen.
2846    #[test]
2847    fn the_axis_threshold_fires_exactly_at_the_trained_span() {
2848        // 2048 is the last in-distribution axis for a single pass; 2080 is the
2849        // next 32-aligned value and is the first rejected one.
2850        assert!(validate_generation_dimensions(2048, 512, Some("ltx2")).is_ok());
2851        assert!(validate_generation_dimensions(2080, 512, Some("ltx2")).is_err());
2852
2853        // A composed render admits both, and its own ceiling behaves the same
2854        // way one cell either side of 4096.
2855        let composed = Ltx2SpatialComposition::TiledTwoStage;
2856        assert!(validate_generation_dimensions_composed(2080, 512, Some("ltx2"), composed).is_ok());
2857        assert!(
2858            validate_generation_dimensions_composed(4096, 2176, Some("ltx2"), composed).is_ok()
2859        );
2860        assert!(
2861            validate_generation_dimensions_composed(4128, 2176, Some("ltx2"), composed).is_err(),
2862            "past 4096 the halved stage-1 shape is itself out of distribution"
2863        );
2864    }
2865
2866    /// The composed ceiling is `2 * trained span` for a reason that has to
2867    /// stay true: stage 1 renders the target halved, so 4096 is the widest
2868    /// target whose stage 1 still lands inside the span.
2869    #[test]
2870    fn the_composed_ceiling_is_where_stage_one_leaves_the_trained_span() {
2871        assert_eq!(LTX2_COMPOSED_MAX_AXIS_PIXELS, 2 * LTX2_MAX_AXIS_PIXELS);
2872        let widest = Ltx2OutputRung {
2873            id: "test",
2874            label: "test",
2875            width: LTX2_COMPOSED_MAX_AXIS_PIXELS,
2876            height: 2_176,
2877        };
2878        assert_eq!(widest.stage1_shape().0, LTX2_MAX_AXIS_PIXELS);
2879
2880        // One rung wider and stage 1 is already out of distribution, which no
2881        // amount of stage-2 tiling repairs.
2882        let too_wide = Ltx2OutputRung {
2883            id: "test",
2884            label: "test",
2885            width: LTX2_COMPOSED_MAX_AXIS_PIXELS + 64,
2886            height: 2_176,
2887        };
2888        assert!(too_wide.stage1_shape().0 > LTX2_MAX_AXIS_PIXELS);
2889    }
2890
2891    /// A checkpoint reaches the composed ceiling only if it can actually
2892    /// compose: it ships the spatial upsampler *and* runs a refining pipeline.
2893    #[test]
2894    fn the_composed_ceiling_requires_a_checkpoint_that_can_compose() {
2895        // Manifest LTX-2 checkpoints all ship the upsampler.
2896        assert_eq!(
2897            ltx2_spatial_composition("ltx-2-19b-distilled:fp8", None),
2898            Ltx2SpatialComposition::TiledTwoStage
2899        );
2900        // A single-file catalog checkpoint has no manifest and no upsampler.
2901        assert_eq!(
2902            ltx2_spatial_composition("cv:3143864", None),
2903            Ltx2SpatialComposition::SinglePass
2904        );
2905        // An explicit non-refining pipeline denoises the requested shape once,
2906        // however capable the checkpoint is.
2907        for mode in [
2908            Ltx2PipelineMode::OneStage,
2909            Ltx2PipelineMode::Retake,
2910            Ltx2PipelineMode::LipDub,
2911        ] {
2912            assert_eq!(
2913                ltx2_spatial_composition("ltx-2-19b-distilled:fp8", Some(mode)),
2914                Ltx2SpatialComposition::SinglePass,
2915                "{mode} denoises once and cannot hold an oversized axis"
2916            );
2917        }
2918        for mode in Ltx2PipelineMode::ALL
2919            .iter()
2920            .filter(|m| m.refines_spatially())
2921        {
2922            assert_eq!(
2923                ltx2_spatial_composition("ltx-2-19b-distilled:fp8", Some(*mode)),
2924                Ltx2SpatialComposition::TiledTwoStage,
2925                "{mode} refines a halved stage 1 and can hold one"
2926            );
2927        }
2928    }
2929
2930    /// End-to-end through the request validator: the same 4K request is
2931    /// admitted on a composing checkpoint and refused on a one-stage one, and
2932    /// the refusal says what would make it work.
2933    #[test]
2934    fn a_4k_request_is_admitted_only_where_the_composition_exists() {
2935        let mut req = valid_req();
2936        req.model = "ltx-2-19b-distilled:fp8".to_string();
2937        req.width = 3_840;
2938        req.height = 2_176;
2939        req.frames = Some(25);
2940        req.fps = Some(24);
2941        req.output_format = Some(OutputFormat::Mp4);
2942        validate_generate_request_with_family(&req, Some("ltx2"))
2943            .expect("a composing checkpoint reaches 4K UHD");
2944
2945        req.model = "cv:3143864".to_string();
2946        let err = validate_generate_request_with_family(&req, Some("ltx2"))
2947            .expect_err("a one-stage checkpoint cannot");
2948        assert!(
2949            err.contains("3840") && err.contains("spatial upsampler"),
2950            "the refusal must name the axis and the way out, got: {err}"
2951        );
2952
2953        // Explicitly asking for a one-stage render is refused on the same
2954        // grounds, even on the composing checkpoint.
2955        req.model = "ltx-2-19b-distilled:fp8".to_string();
2956        req.pipeline = Some(Ltx2PipelineMode::OneStage);
2957        assert!(validate_generate_request_with_family(&req, Some("ltx2")).is_err());
2958    }
2959
2960    /// Every advertised rung has to be admissible under the composition that
2961    /// reaches it — and the composed-only ones have to be refused without it,
2962    /// or a one-stage checkpoint would be offered a size it cannot render.
2963    #[test]
2964    fn every_composed_rung_is_admissible_exactly_under_composition() {
2965        let two_stage = Ltx2SpatialComposition::TiledTwoStage;
2966        for (width, height) in recommended_dimensions_composed("ltx2", two_stage) {
2967            assert!(
2968                validate_generation_dimensions_composed(width, height, Some("ltx2"), two_stage)
2969                    .is_ok(),
2970                "advertised composed preset {width}x{height} must be admissible"
2971            );
2972        }
2973        for rung in LTX2_OUTPUT_RUNGS {
2974            let (width, height) = (rung.width, rung.height);
2975            assert!(
2976                width.is_multiple_of(LTX2_TWO_STAGE_ALIGNMENT)
2977                    && height.is_multiple_of(LTX2_TWO_STAGE_ALIGNMENT),
2978                "{width}x{height} must survive halving onto the 32px latent grid"
2979            );
2980            if !rung.requires_tiled_stage2() {
2981                continue;
2982            }
2983            for shape in [(width, height), (height, width)] {
2984                assert!(
2985                    validate_generation_dimensions(shape.0, shape.1, Some("ltx2")).is_err(),
2986                    "{}x{} must not be offered to a single-pass checkpoint",
2987                    shape.0,
2988                    shape.1
2989                );
2990                assert!(
2991                    recommended_dimensions_composed("ltx2", two_stage).contains(&shape),
2992                    "{}x{} must be advertised to a composing checkpoint",
2993                    shape.0,
2994                    shape.1
2995                );
2996            }
2997        }
2998        // A single-pass model's advertised list is exactly the old one.
2999        assert_eq!(
3000            recommended_dimensions_composed("ltx2", Ltx2SpatialComposition::SinglePass),
3001            recommended_dimensions("ltx2").to_vec()
3002        );
3003    }
3004
3005    /// The ladder's arithmetic: each rung's stage-1 shape is the target halved
3006    /// onto the latent grid, and its tile counts are the fewest tiles that
3007    /// bring every axis back inside the trained span.
3008    #[test]
3009    fn rung_composition_arithmetic_is_exact() {
3010        struct ExpectedRung {
3011            id: &'static str,
3012            stage1: (u32, u32),
3013            /// `(columns, rows)`.
3014            tiles: (u32, u32),
3015            tiled: bool,
3016        }
3017        let expected = [
3018            ExpectedRung {
3019                id: "720p",
3020                stage1: (640, 352),
3021                tiles: (1, 1),
3022                tiled: false,
3023            },
3024            ExpectedRung {
3025                id: "1080p",
3026                stage1: (960, 544),
3027                tiles: (1, 1),
3028                tiled: false,
3029            },
3030            ExpectedRung {
3031                id: "1440p",
3032                stage1: (1_280, 704),
3033                tiles: (2, 1),
3034                tiled: true,
3035            },
3036            ExpectedRung {
3037                id: "4k-uhd",
3038                stage1: (1_920, 1_056),
3039                tiles: (2, 2),
3040                tiled: true,
3041            },
3042        ];
3043        assert_eq!(LTX2_OUTPUT_RUNGS.len(), expected.len());
3044        for (
3045            rung,
3046            ExpectedRung {
3047                id,
3048                stage1,
3049                tiles,
3050                tiled,
3051            },
3052        ) in LTX2_OUTPUT_RUNGS.iter().zip(&expected)
3053        {
3054            let (id, stage1, tiles, tiled) = (*id, *stage1, *tiles, *tiled);
3055            assert_eq!(rung.id, id);
3056            assert_eq!(rung.stage1_shape(), stage1, "{id} stage-1 shape");
3057            assert_eq!(rung.stage2_tiles(), tiles, "{id} stage-2 tile counts");
3058            assert_eq!(rung.requires_tiled_stage2(), tiled, "{id} tiling need");
3059            // A rung is only meaningful if its own advertised shape is
3060            // admissible under the composition that reaches it.
3061            assert!(validate_generation_dimensions_composed(
3062                rung.width,
3063                rung.height,
3064                Some("ltx2"),
3065                Ltx2SpatialComposition::TiledTwoStage,
3066            )
3067            .is_ok());
3068        }
3069    }
3070
3071    /// The composed ceiling is the **x2 rung's**. x1.5 divides by 1.5, so the
3072    /// same 4K output leaves stage 1 at 2560px — out of distribution, with
3073    /// nothing downstream to repair it, because stage 2 tiles the refinement
3074    /// and never stage 1.
3075    #[test]
3076    fn a_smaller_spatial_rung_lowers_the_ceiling_it_can_reach() {
3077        assert_eq!(
3078            ltx2_composed_axis_ceiling(Some(Ltx2SpatialUpscale::X2)),
3079            LTX2_COMPOSED_MAX_AXIS_PIXELS
3080        );
3081        assert_eq!(
3082            ltx2_composed_axis_ceiling(None),
3083            LTX2_COMPOSED_MAX_AXIS_PIXELS
3084        );
3085        assert_eq!(
3086            ltx2_composed_axis_ceiling(Some(Ltx2SpatialUpscale::X1_5)),
3087            3_072
3088        );
3089
3090        // Every ceiling is exactly the largest target its rung can hold, and
3091        // one grid step past it is not.
3092        for upscale in [Some(Ltx2SpatialUpscale::X2), Some(Ltx2SpatialUpscale::X1_5)] {
3093            let ceiling = ltx2_composed_axis_ceiling(upscale);
3094            assert!(
3095                ltx2_stage1_axis_for(ceiling, upscale) <= LTX2_MAX_AXIS_PIXELS,
3096                "{upscale:?} must reach its own ceiling"
3097            );
3098            assert!(
3099                ltx2_stage1_axis_for(ceiling + LTX2_SPATIAL_LATENT_STRIDE, upscale)
3100                    > LTX2_MAX_AXIS_PIXELS,
3101                "{upscale:?} must not reach one grid step past it"
3102            );
3103        }
3104
3105        // 4K on x1.5 is refused, and the refusal names the shape stage 1 would
3106        // have rendered rather than restating the output size.
3107        let err = validate_ltx2_stage1_span(3_840, 2_176, Some(Ltx2SpatialUpscale::X1_5))
3108            .expect_err("x1.5 cannot halve 3840 back inside the span");
3109        assert!(err.contains("2560") && err.contains("3072"), "got: {err}");
3110        // The same output on x2 is fine.
3111        assert!(validate_ltx2_stage1_span(3_840, 2_176, Some(Ltx2SpatialUpscale::X2)).is_ok());
3112        // And x1.5 is fine at its own ceiling.
3113        assert!(validate_ltx2_stage1_span(3_072, 1_728, Some(Ltx2SpatialUpscale::X1_5)).is_ok());
3114    }
3115
3116    /// The whole request decides the pipeline, not just the `pipeline` field.
3117    /// `select_pipeline` routes a retake before it ever considers the
3118    /// checkpoint's upsampler, and a retake denoises once.
3119    #[test]
3120    fn an_implicit_retake_is_admitted_as_single_pass() {
3121        let mut req = valid_req();
3122        req.model = "ltx-2-19b-distilled:fp8".to_string();
3123        req.width = 3_840;
3124        req.height = 2_176;
3125        req.frames = Some(25);
3126        req.fps = Some(24);
3127        req.output_format = Some(OutputFormat::Mp4);
3128        // No explicit pipeline: the composing default admits 4K.
3129        assert_eq!(
3130            ltx2_spatial_composition_for_request(&req),
3131            Ltx2SpatialComposition::TiledTwoStage
3132        );
3133        validate_generate_request_with_family(&req, Some("ltx2")).expect("4K composes");
3134
3135        // Adding a retake range changes what the engine will run, so it has to
3136        // change what admission allows — otherwise this is refused minutes
3137        // later by the engine backstop instead of at the request boundary.
3138        req.retake_range = Some(crate::TimeRange {
3139            start_seconds: 0.0,
3140            end_seconds: 0.5,
3141        });
3142        req.source_video_path = Some("/tmp/clip.mp4".to_string());
3143        assert_eq!(
3144            ltx2_spatial_composition_for_request(&req),
3145            Ltx2SpatialComposition::SinglePass
3146        );
3147        let err = validate_generate_request_with_family(&req, Some("ltx2"))
3148            .expect_err("a retake denoises once and cannot hold a 3840px axis");
3149        assert!(err.contains("3840"), "got: {err}");
3150    }
3151
3152    /// The other implicit selectors all resolve to refining pipelines, so they
3153    /// must not narrow the ceiling.
3154    #[test]
3155    fn implicit_refining_pipelines_keep_the_composed_ceiling() {
3156        let mut req = valid_req();
3157        req.model = "ltx-2-19b-distilled:fp8".to_string();
3158        req.width = 3_840;
3159        req.height = 2_176;
3160        req.frames = Some(25);
3161        req.fps = Some(24);
3162        req.output_format = Some(OutputFormat::Mp4);
3163
3164        let mut with_audio = req.clone();
3165        with_audio.audio_file_path = Some("/tmp/voice.wav".to_string());
3166        assert_eq!(
3167            ltx2_spatial_composition_for_request(&with_audio),
3168            Ltx2SpatialComposition::TiledTwoStage
3169        );
3170
3171        let mut with_source = req.clone();
3172        with_source.source_video_path = Some("/tmp/clip.mp4".to_string());
3173        assert_eq!(
3174            ltx2_spatial_composition_for_request(&with_source),
3175            Ltx2SpatialComposition::TiledTwoStage
3176        );
3177
3178        // An explicit pipeline still wins over every implicit selector.
3179        let mut explicit = with_source.clone();
3180        explicit.pipeline = Some(Ltx2PipelineMode::OneStage);
3181        assert_eq!(
3182            ltx2_spatial_composition_for_request(&explicit),
3183            Ltx2SpatialComposition::SinglePass
3184        );
3185    }
3186
3187    /// A rung is the same rung in either orientation — the composition and its
3188    /// cost are identical under transposition.
3189    #[test]
3190    fn rungs_resolve_in_either_orientation() {
3191        assert_eq!(ltx2_output_rung(3_840, 2_112).map(|r| r.id), Some("4k-uhd"));
3192        assert_eq!(ltx2_output_rung(2_112, 3_840).map(|r| r.id), Some("4k-uhd"));
3193        assert_eq!(ltx2_output_rung(1_920, 1_088).map(|r| r.id), Some("1080p"));
3194        assert_eq!(ltx2_output_rung(1_234, 567), None);
3195    }
3196
3197    /// An over-size rejection has to say what the user *can* have. The pixel
3198    /// ceiling alone says only what they cannot.
3199    #[test]
3200    fn an_oversize_rejection_names_the_largest_reachable_rung() {
3201        assert_eq!(
3202            largest_ltx2_rung_within(LTX2_MAX_AXIS_PIXELS).map(|rung| rung.id),
3203            Some("1080p"),
3204        );
3205        assert_eq!(
3206            largest_ltx2_rung_within(LTX2_COMPOSED_MAX_AXIS_PIXELS).map(|rung| rung.id),
3207            Some("4k-uhd"),
3208        );
3209        assert_eq!(largest_ltx2_rung_within(64), None);
3210
3211        let err = validate_generation_dimensions(3_840, 2_112, Some("ltx2"))
3212            .expect_err("a single-pass render cannot reach 4K");
3213        assert!(err.contains("spatial upsampler"), "got: {err}");
3214        assert!(err.contains("1080p Full HD (1920x1088)"), "got: {err}");
3215
3216        let err = validate_generation_dimensions_composed(
3217            4_160,
3218            2_176,
3219            Some("ltx2"),
3220            Ltx2SpatialComposition::TiledTwoStage,
3221        )
3222        .expect_err("past the composed ceiling");
3223        assert!(
3224            !err.contains("spatial upsampler"),
3225            "a composing render is already using it, got: {err}"
3226        );
3227        assert!(err.contains("4K UHD (3840x2112)"), "got: {err}");
3228    }
3229
3230    /// The issue's named 9:16 shape.
3231    #[test]
3232    fn ltx2_offers_portrait_presets() {
3233        let presets = recommended_dimensions("ltx2");
3234        assert!(
3235            presets.contains(&(704, 1216)),
3236            "704x1216 portrait must be advertised, got: {presets:?}"
3237        );
3238        assert!(
3239            presets.iter().any(|(w, h)| h > w && w * h > 1_000_000),
3240            "a high-resolution portrait preset must be advertised, got: {presets:?}"
3241        );
3242    }
3243
3244    /// The advertised cap must be requestable. A client that clamps to it and
3245    /// submits should not get a 422 for being off the `8n+1` grid.
3246    #[test]
3247    fn ltx2_grid_snapped_cap_is_actually_requestable() {
3248        for fps in [6, 12, 24, 30, 48, 60, 120] {
3249            let cap = ltx2_max_frames_on_grid_at_fps(fps);
3250            assert_eq!(
3251                (cap - 1) % 8,
3252                0,
3253                "the advertised cap at {fps} fps must sit on the 8n+1 grid"
3254            );
3255            assert!(cap <= ltx2_max_frames_at_fps(fps));
3256
3257            let mut req = valid_req();
3258            req.model = "ltx-2-19b-distilled:fp8".to_string();
3259            req.width = 768;
3260            req.height = 512;
3261            req.output_format = Some(OutputFormat::Mp4);
3262            req.frames = Some(cap);
3263            req.fps = Some(fps);
3264            validate_generate_request_with_family(&req, Some("ltx2")).unwrap_or_else(|err| {
3265                panic!("the advertised cap {cap} at {fps} fps must validate, got: {err}")
3266            });
3267        }
3268        // The raw ceilings are off-grid in both directions, which is the bug.
3269        assert_eq!(ltx2_max_frames_at_fps(24), 484);
3270        assert_eq!(ltx2_max_frames_on_grid_at_fps(24), 481);
3271        assert_eq!(ltx2_max_frames_at_fps(48), LTX2_MAX_FRAMES_ABSOLUTE);
3272        assert_eq!(ltx2_max_frames_on_grid_at_fps(48), 601);
3273    }
3274
3275    /// EXR output is only meaningful for the HDR adapter's LogC3 signal.
3276    /// Applying the inverse to an ordinary SDR render would write a
3277    /// wrongly-graded file that looks deliberate — worse than a rejection.
3278    #[test]
3279    fn exr_output_requires_the_hdr_adapter() {
3280        let mut req = valid_req();
3281        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3282        req.output_format = Some(OutputFormat::Mp4);
3283        req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3284
3285        let err = validate_generate_request_with_family(&req, Some("ltx2"))
3286            .expect_err("EXR without the HDR adapter must be rejected");
3287        assert!(err.contains("ic_lora_control=hdr"), "got: {err}");
3288
3289        // With the adapter (and the pipeline it forces) it validates.
3290        req.ic_lora_control = Some("hdr".to_string());
3291        req.pipeline = Some(Ltx2PipelineMode::IcLora);
3292        req.source_video_path = Some("/tmp/reference.mp4".to_string());
3293        req.loras = Some(vec![LoraWeight {
3294            path: "/models/hdr.safetensors".to_string(),
3295            scale: 1.0,
3296
3297            expert: None,
3298        }]);
3299        validate_generate_request_with_family(&req, Some("ltx2"))
3300            .expect("the HDR adapter makes EXR output valid");
3301    }
3302
3303    /// The extend path re-renders through the chain-stage machinery, where a
3304    /// per-clip EXR sequence would misalign with the stitched timeline. The
3305    /// rejection must be direct, not an accident of the ic-lora ⇒
3306    /// source_video ⇒ extend-exclusive implication chain.
3307    #[test]
3308    fn exr_output_rejects_extend_directly() {
3309        let mut req = valid_req();
3310        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3311        req.output_format = Some(OutputFormat::Mp4);
3312        req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3313        req.extend_video_path = Some("/tmp/base.mp4".to_string());
3314
3315        let err = validate_generate_request_with_family(&req, Some("ltx2"))
3316            .expect_err("EXR + extend must be rejected");
3317        assert!(err.contains("extend_video"), "got: {err}");
3318    }
3319
3320    #[test]
3321    fn exr_options_are_rejected_for_non_ltx2_families() {
3322        let mut req = valid_req();
3323        req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3324        assert!(validate_generate_request_with_family(&req, Some("flux")).is_err());
3325    }
3326
3327    #[test]
3328    fn exr_precision_without_an_output_directory_is_rejected() {
3329        let mut req = valid_req();
3330        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3331        req.output_format = Some(OutputFormat::Mp4);
3332        req.hdr_exr_full_float = true;
3333        let err = validate_generate_request_with_family(&req, Some("ltx2"))
3334            .expect_err("a precision knob with nothing to write is a mistake");
3335        assert!(err.contains("hdr_exr_dir"), "got: {err}");
3336    }
3337
3338    /// Every other consumer resolves control ids through
3339    /// `normalize_control_id`, so this gate must accept the same spellings —
3340    /// otherwise `--ic-lora-control HDR` succeeds everywhere except here.
3341    #[test]
3342    fn exr_accepts_any_spelling_the_control_registry_accepts() {
3343        // Case and surrounding whitespace only. A trailing `_` is *not* an
3344        // alias: the normalizer maps `_` to `-`, so `hdr_` becomes `hdr-`,
3345        // which is not a registered id anywhere in the stack.
3346        for spelling in ["hdr", "HDR", " Hdr ", "\tHDR\n"] {
3347            let mut req = valid_req();
3348            req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3349            req.output_format = Some(OutputFormat::Mp4);
3350            req.source_video_path = Some("/tmp/reference.mp4".to_string());
3351            req.pipeline = Some(Ltx2PipelineMode::IcLora);
3352            req.ic_lora_control = Some(spelling.to_string());
3353            req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3354            let result = validate_generate_request_with_family(&req, Some("ltx2"));
3355            assert!(
3356                result.is_ok(),
3357                "spelling {spelling:?} must be accepted, got: {result:?}"
3358            );
3359        }
3360    }
3361
3362    #[test]
3363    fn exr_still_rejects_a_different_control() {
3364        let mut req = valid_req();
3365        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3366        req.output_format = Some(OutputFormat::Mp4);
3367        req.source_video_path = Some("/tmp/reference.mp4".to_string());
3368        req.pipeline = Some(Ltx2PipelineMode::IcLora);
3369        req.ic_lora_control = Some("union".to_string());
3370        req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3371        let err = validate_generate_request_with_family(&req, Some("ltx2"))
3372            .expect_err("only the HDR adapter produces a LogC3 signal");
3373        assert!(err.contains("ic_lora_control=hdr"), "got: {err}");
3374    }
3375
3376    /// The gallery artifact is the tonemapped video, so the sidecar's location
3377    /// is only discoverable from saved metadata.
3378    #[test]
3379    fn saved_metadata_records_where_the_exr_sequence_went() {
3380        let mut req = valid_req();
3381        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
3382        req.ic_lora_control = Some("hdr".to_string());
3383        req.hdr_exr_dir = Some("/tmp/shot_exr".to_string());
3384        req.hdr_exr_full_float = true;
3385
3386        let metadata = crate::OutputMetadata::from_generate_request(&req, 7, None, "test");
3387        assert_eq!(metadata.hdr_exr_dir.as_deref(), Some("/tmp/shot_exr"));
3388        assert!(metadata.hdr_exr_full_float);
3389
3390        let round_tripped: crate::OutputMetadata =
3391            serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
3392        assert_eq!(round_tripped.hdr_exr_dir.as_deref(), Some("/tmp/shot_exr"));
3393        assert!(round_tripped.hdr_exr_full_float);
3394    }
3395
3396    /// An ordinary render must not gain the fields, so existing rows and
3397    /// older readers see exactly the JSON they saw before.
3398    #[test]
3399    fn a_non_hdr_render_serializes_no_exr_fields() {
3400        let metadata = crate::OutputMetadata::from_generate_request(&valid_req(), 7, None, "test");
3401        let json = serde_json::to_string(&metadata).unwrap();
3402        assert!(!json.contains("hdr_exr"), "got: {json}");
3403    }
3404
3405    fn valid_req() -> GenerateRequest {
3406        GenerateRequest {
3407            source_fit: None,
3408            hdr_exr_dir: None,
3409            hdr_exr_full_float: false,
3410            guidance_overrides: None,
3411            sample_shift: None,
3412            distill_strength_high: None,
3413            distill_strength_low: None,
3414            prompt: "a red apple".to_string(),
3415            negative_prompt: None,
3416            model: "test-model".to_string(),
3417            width: 1024,
3418            height: 1024,
3419            steps: 4,
3420            guidance: 0.0,
3421            seed: Some(42),
3422            batch_size: 1,
3423            output_format: Some(OutputFormat::Png),
3424            embed_metadata: None,
3425            scheduler: None,
3426            cfg_plus: None,
3427            source_image: None,
3428            source_image_name: None,
3429            edit_images: None,
3430            references: None,
3431            strength: 0.75,
3432            mask_image: None,
3433            control_image: None,
3434            control_model: None,
3435            control_scale: 1.0,
3436            expand: None,
3437            original_prompt: None,
3438            prompt_transform: None,
3439            batch_id: None,
3440            batch_index: None,
3441            batch_count: None,
3442            lora: None,
3443            frames: None,
3444            fps: None,
3445            upscale_model: None,
3446            gif_preview: false,
3447            enable_audio: None,
3448            audio_file: None,
3449            audio_file_path: None,
3450            source_video: None,
3451            source_video_path: None,
3452            extend_video: None,
3453            extend_video_path: None,
3454            extend_overlap_frames: None,
3455            keyframes: None,
3456            pipeline: None,
3457            ic_lora_control: None,
3458            loras: None,
3459            retake_range: None,
3460            spatial_upscale: None,
3461            temporal_upscale: None,
3462            placement: None,
3463        }
3464    }
3465
3466    #[test]
3467    fn generation_rejects_compliance_gated_model_identity_before_other_validation() {
3468        let mut req = valid_req();
3469        req.model = "hf:MiniMaxAI/MiniMax-H3".to_string();
3470        req.prompt.clear();
3471
3472        let error = validate_generate_request_with_family(&req, None).unwrap_err();
3473        assert!(error.contains(crate::MINIMAX_H3_AUTHORIZATION_REQUIRED));
3474        assert!(!error.contains(&req.model));
3475    }
3476
3477    #[test]
3478    fn generation_rejects_opaque_catalog_id_with_compliance_gated_family() {
3479        let mut req = valid_req();
3480        req.model = "cv:42".to_string();
3481
3482        let error = validate_generate_request_with_family(&req, Some("minimax-h3")).unwrap_err();
3483        assert!(error.contains(crate::MINIMAX_H3_AUTHORIZATION_REQUIRED));
3484    }
3485
3486    fn valid_h3_request(model: &str) -> GenerateRequest {
3487        let mut req = valid_req();
3488        req.model = model.to_string();
3489        req.width = crate::minimax_h3::DEFAULT_WIDTH;
3490        req.height = crate::minimax_h3::DEFAULT_HEIGHT;
3491        req.steps = crate::minimax_h3::DEFAULT_STEPS;
3492        req.frames = Some(crate::minimax_h3::MIN_FRAMES);
3493        req.fps = Some(crate::minimax_h3::FIXED_FPS);
3494        req.output_format = Some(OutputFormat::Mp4);
3495        req.enable_audio = Some(true);
3496        // H3 has no denoise-strength control. The wire field is non-optional
3497        // for legacy families, so activated H3 callers must send its neutral
3498        // value rather than inheriting the generic img2img default.
3499        req.strength = 1.0;
3500        req
3501    }
3502
3503    #[cfg(any(feature = "h3", feature = "h3-private-uat"))]
3504    #[test]
3505    fn private_h3_validation_bypasses_only_activation_for_exact_reviewed_models() {
3506        let req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3507        let public_error =
3508            validate_generate_request_with_family(&req, Some(crate::minimax_h3::FAMILY))
3509                .unwrap_err();
3510        assert!(public_error.contains(crate::MINIMAX_H3_AUTHORIZATION_REQUIRED));
3511        validate_h3_private_uat_request(&req).unwrap();
3512
3513        let mut official = req;
3514        official.model = crate::minimax_h3::FL2VA_OFFICIAL.to_string();
3515        assert!(validate_h3_private_uat_request(&official)
3516            .unwrap_err()
3517            .contains("exact reviewed task model"));
3518    }
3519
3520    #[test]
3521    fn h3_post_activation_fl2va_accepts_first_and_last_boundary_frames() {
3522        let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3523        req.source_image = Some(png_bytes());
3524        req.keyframes = Some(vec![crate::KeyframeCondition {
3525            frame: crate::minimax_h3::MIN_FRAMES - 1,
3526            image: jpeg_bytes(),
3527            name: None,
3528        }]);
3529
3530        assert!(
3531            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY),)
3532                .is_ok()
3533        );
3534    }
3535
3536    #[test]
3537    fn h3_post_activation_ref2va_accepts_image_references() {
3538        let mut req = valid_h3_request(crate::minimax_h3::REF2VA_COMFY);
3539        req.references = Some(vec![crate::GenerationReference::Image {
3540            media: crate::GenerationReferenceAuthority::Inline { data: png_bytes() },
3541            provenance: crate::GenerationReferenceProvenance {
3542                name: Some("reference.png".to_string()),
3543                sha256: None,
3544            },
3545            mime_type: "image/png".to_string(),
3546            width: 1920,
3547            height: 1080,
3548        }]);
3549
3550        assert!(
3551            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY),)
3552                .is_ok()
3553        );
3554    }
3555
3556    #[test]
3557    fn h3_post_activation_rejects_non_boundary_keyframes() {
3558        let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3559        req.keyframes = Some(vec![crate::KeyframeCondition {
3560            frame: 17,
3561            image: png_bytes(),
3562            name: None,
3563        }]);
3564
3565        let error =
3566            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3567                .unwrap_err();
3568        assert!(
3569            error.contains("only frame 0 or final frame"),
3570            "got: {error}"
3571        );
3572    }
3573
3574    #[test]
3575    fn h3_post_activation_rejects_generic_scheduler_and_lora_overrides() {
3576        let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3577        req.scheduler = Some(crate::Scheduler::UniPc);
3578        let error =
3579            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3580                .unwrap_err();
3581        assert!(error.contains("scheduler overrides"), "got: {error}");
3582
3583        req.scheduler = None;
3584        req.lora = Some(crate::LoraWeight {
3585            path: "/tmp/adapter.safetensors".to_string(),
3586            scale: 1.0,
3587
3588            expert: None,
3589        });
3590        let error =
3591            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3592                .unwrap_err();
3593        assert!(error.contains("does not support LoRA"), "got: {error}");
3594
3595        req.lora = None;
3596        req.loras = Some(Vec::new());
3597        let error =
3598            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3599                .unwrap_err();
3600        assert!(error.contains("does not support LoRA"), "got: {error}");
3601    }
3602
3603    #[test]
3604    fn h3_post_activation_preserves_source_and_extend_invariants() {
3605        for strength in [-1.0, 1.01, f64::NAN] {
3606            let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3607            req.source_image = Some(png_bytes());
3608            req.strength = strength;
3609            let error =
3610                validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3611                    .unwrap_err();
3612            assert!(error.contains("finite value in range"), "got: {error}");
3613        }
3614
3615        let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3616        req.extend_overlap_frames = Some(9);
3617        let error =
3618            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3619                .unwrap_err();
3620        assert!(
3621            error.contains("extend_overlap_frames requires extend_video"),
3622            "got: {error}"
3623        );
3624    }
3625
3626    #[test]
3627    fn h3_post_activation_rejects_empty_conditioning_collections() {
3628        let mut req = valid_h3_request(crate::minimax_h3::FL2VA_COMFY);
3629        req.edit_images = Some(Vec::new());
3630        let error =
3631            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3632                .unwrap_err();
3633        assert!(
3634            error.contains("edit_images must not be empty"),
3635            "got: {error}"
3636        );
3637
3638        req.edit_images = None;
3639        req.keyframes = Some(Vec::new());
3640        let error =
3641            validate_generate_request_after_activation(&req, Some(crate::minimax_h3::FAMILY))
3642                .unwrap_err();
3643        assert!(
3644            error.contains("keyframes must not be empty"),
3645            "got: {error}"
3646        );
3647    }
3648
3649    #[test]
3650    fn generation_model_preflight_gates_nested_identities_and_artifacts() {
3651        let root = std::path::Path::new("/Volumes/ExternalStorage/mold-uat/minimax-h3/models");
3652
3653        let mut req = valid_req();
3654        req.control_model = Some("MiniMax-H3-FL2VA".to_string());
3655        assert!(require_generate_request_model_activation(&req, Some(root), Some("flux")).is_err());
3656
3657        req.control_model = None;
3658        req.upscale_model = Some("hf:MiniMaxAI/MiniMax-H3".to_string());
3659        assert!(require_generate_request_model_activation(&req, Some(root), Some("flux")).is_err());
3660
3661        req.upscale_model = None;
3662        req.lora = Some(crate::LoraWeight {
3663            path: root
3664                .join("custom/MiniMax-H3/adapter.safetensors")
3665                .to_string_lossy()
3666                .into_owned(),
3667            scale: 1.0,
3668
3669            expert: None,
3670        });
3671        assert!(require_generate_request_model_activation(&req, Some(root), Some("flux")).is_err());
3672
3673        req.lora.as_mut().unwrap().path = root
3674            .join("flux/ordinary-adapter.safetensors")
3675            .to_string_lossy()
3676            .into_owned();
3677        assert!(require_generate_request_model_activation(&req, Some(root), Some("flux")).is_ok());
3678    }
3679
3680    /// Minimal valid PNG header bytes for testing.
3681    fn png_bytes() -> Vec<u8> {
3682        vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
3683    }
3684
3685    /// Minimal valid JPEG header bytes for testing.
3686    fn jpeg_bytes() -> Vec<u8> {
3687        vec![0xFF, 0xD8, 0xFF, 0xE0]
3688    }
3689
3690    // ── clamp_to_megapixel_limit tests ──────────────────────────────────────
3691
3692    #[test]
3693    fn clamp_noop_within_limit() {
3694        assert_eq!(super::clamp_to_megapixel_limit(1024, 1024), (1024, 1024));
3695    }
3696
3697    #[test]
3698    fn clamp_noop_qwen_image_native_resolution() {
3699        // Qwen-Image trains at 1328x1328 (~1.76MP), must fit within MAX_PIXELS
3700        assert_eq!(super::clamp_to_megapixel_limit(1328, 1328), (1328, 1328));
3701    }
3702
3703    #[test]
3704    fn clamp_noop_qwen_image_landscape() {
3705        // Qwen-Image 16:9 training resolution (1664x928 = ~1.54MP)
3706        assert_eq!(super::clamp_to_megapixel_limit(1664, 928), (1664, 928));
3707    }
3708
3709    #[test]
3710    fn clamp_downscales_oversized() {
3711        let (w, h) = super::clamp_to_megapixel_limit(1888, 1168);
3712        assert!(w % 16 == 0 && h % 16 == 0, "must be multiples of 16");
3713        let pixels = w as u64 * h as u64;
3714        assert!(
3715            pixels <= super::MAX_PIXELS,
3716            "must be within limit: {pixels}"
3717        );
3718        // Aspect ratio roughly preserved
3719        let orig_ratio = 1888.0 / 1168.0;
3720        let new_ratio = w as f64 / h as f64;
3721        assert!(
3722            (orig_ratio - new_ratio).abs() < 0.05,
3723            "aspect ratio drift too large"
3724        );
3725    }
3726
3727    #[test]
3728    fn clamp_large_square() {
3729        let (w, h) = super::clamp_to_megapixel_limit(2048, 2048);
3730        assert!(w % 16 == 0 && h % 16 == 0);
3731        assert!(w as u64 * h as u64 <= super::MAX_PIXELS);
3732    }
3733
3734    #[test]
3735    fn clamp_extreme_aspect_ratio() {
3736        let (w, h) = super::clamp_to_megapixel_limit(4096, 256);
3737        assert!(w % 16 == 0 && h % 16 == 0);
3738        assert!(w as u64 * h as u64 <= super::MAX_PIXELS);
3739        assert!(w > h, "should remain landscape");
3740    }
3741
3742    // ── normalise_output_format tests ────────────────────────────────────────
3743
3744    /// The shared Wan surface-parity fixture (#806) pins the family policy the
3745    /// server default and the CLI's client-side default both derive from, plus
3746    /// the frame grid and the TI2V first/last-frame floor every surface
3747    /// enforces before dispatch. Editing one side without the other fails here.
3748    #[test]
3749    fn wan_surface_parity_fixture_pins_the_core_contracts() {
3750        let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
3751            env!("CARGO_MANIFEST_DIR"),
3752            "/../../tests/fixtures/wan/surface-parity-v1.json"
3753        )))
3754        .expect("fixture parses");
3755
3756        // Container policy: exactly the fixture's family set defaults to MP4.
3757        let mp4_families: Vec<&str> = fixture["container_default"]["mp4_default_families"]
3758            .as_array()
3759            .expect("mp4_default_families")
3760            .iter()
3761            .map(|value| value.as_str().expect("family string"))
3762            .collect();
3763        for family in &mp4_families {
3764            assert!(
3765                crate::family_output_defaults_to_mp4(family),
3766                "{family} must default to mp4"
3767            );
3768        }
3769        for family in ["flux", "sdxl", "qwen-image", "z-image", ""] {
3770            assert!(
3771                !crate::family_output_defaults_to_mp4(family),
3772                "{family:?} must not default to mp4"
3773            );
3774        }
3775
3776        // Server normalisation: unset wan multi-frame → mp4; frames == 1 → png.
3777        let mut req = valid_req();
3778        req.model = "wan22-t2v-a14b:q8".to_string();
3779        req.frames = Some(81);
3780        req.output_format = None;
3781        req.normalise_output_format(Some(fixture["family"].as_str().unwrap()));
3782        assert_eq!(
3783            format!("{:?}", req.resolved_output_format()).to_lowercase(),
3784            fixture["container_default"]["unset_multi_frame"]
3785                .as_str()
3786                .unwrap()
3787        );
3788        let mut still = valid_req();
3789        still.model = "wan22-t2v-a14b:q8".to_string();
3790        still.frames = Some(1);
3791        still.output_format = None;
3792        still.normalise_output_format(Some("wan"));
3793        assert_eq!(
3794            format!("{:?}", still.resolved_output_format()).to_lowercase(),
3795            fixture["container_default"]["wan_single_frame"]
3796                .as_str()
3797                .unwrap()
3798        );
3799
3800        // Frame grid.
3801        assert_eq!(
3802            u64::from(WAN_TEMPORAL_SCALE),
3803            fixture["frame_grid"]["step"].as_u64().unwrap()
3804        );
3805        assert_eq!(
3806            u64::from(frame_offset_for_family("wan").expect("wan has a grid")),
3807            fixture["frame_grid"]["offset"].as_u64().unwrap()
3808        );
3809
3810        // TI2V first/last-frame floor.
3811        assert_eq!(
3812            u64::from(WAN_TI2V_FLF_MIN_FRAMES),
3813            fixture["first_last_frame"]["ti2v_min_frames"]
3814                .as_u64()
3815                .unwrap()
3816        );
3817        assert!(fixture["first_last_frame"]["ti2v_model_prefix"]
3818            .as_str()
3819            .unwrap()
3820            .starts_with("wan22-ti2v-5b"));
3821    }
3822
3823    #[test]
3824    fn normalise_output_format_unset_for_ltx2_picks_mp4() {
3825        let mut req = valid_req();
3826        req.model = "ltx-2-19b-distilled:fp8".to_string();
3827        req.output_format = None;
3828        req.normalise_output_format(Some("ltx2"));
3829        assert_eq!(
3830            req.resolved_output_format(),
3831            OutputFormat::Mp4,
3832            "ltx2 with no explicit format should default to mp4"
3833        );
3834    }
3835
3836    #[test]
3837    fn normalise_output_format_unset_for_ltx2_with_audio_picks_mp4() {
3838        let mut req = valid_req();
3839        req.model = "ltx-2-19b-distilled:fp8".to_string();
3840        req.output_format = None;
3841        req.enable_audio = Some(true);
3842        req.normalise_output_format(Some("ltx2"));
3843        assert_eq!(
3844            req.resolved_output_format(),
3845            OutputFormat::Mp4,
3846            "ltx2 with audio and no explicit format should default to mp4"
3847        );
3848    }
3849
3850    #[test]
3851    fn normalise_output_format_unset_for_ltx_video_picks_mp4() {
3852        let mut req = valid_req();
3853        req.model = "ltx-video:fp16".to_string();
3854        req.output_format = None;
3855        req.normalise_output_format(Some("ltx-video"));
3856        assert_eq!(
3857            req.resolved_output_format(),
3858            OutputFormat::Mp4,
3859            "ltx-video with no explicit format should default to mp4"
3860        );
3861    }
3862
3863    #[test]
3864    fn normalise_output_format_unset_for_flux_picks_png() {
3865        let mut req = valid_req();
3866        req.model = "flux-schnell:q8".to_string();
3867        req.output_format = None;
3868        req.normalise_output_format(Some("flux"));
3869        assert_eq!(
3870            req.resolved_output_format(),
3871            OutputFormat::Png,
3872            "flux with no explicit format should default to png"
3873        );
3874    }
3875
3876    #[test]
3877    fn normalise_output_format_explicit_png_for_ltx2_remains_png_and_validation_rejects_it() {
3878        // When the user explicitly requests PNG for an ltx2 model, normalise
3879        // must leave it as-is so validation can reject it with a clear error.
3880        let mut req = valid_req();
3881        req.model = "ltx-2-19b-distilled:fp8".to_string();
3882        req.output_format = Some(OutputFormat::Png);
3883        req.normalise_output_format(Some("ltx2"));
3884        // normalise must not touch an explicit value
3885        assert_eq!(req.output_format, Some(OutputFormat::Png));
3886        // and validation must still reject explicit PNG on ltx2
3887        let err = validate_generate_request(&req).unwrap_err();
3888        assert!(
3889            err.contains("LTX-2 outputs must use"),
3890            "expected validation error for explicit png on ltx2, got: {err}"
3891        );
3892    }
3893
3894    // ── validate_generate_request tests ──────────────────────────────────────
3895
3896    #[test]
3897    fn valid_request_passes() {
3898        assert!(validate_generate_request(&valid_req()).is_ok());
3899    }
3900
3901    #[test]
3902    fn ltx2_audio_requires_mp4() {
3903        let mut req = valid_req();
3904        req.model = "ltx-2-19b-distilled:fp8".to_string();
3905        req.output_format = Some(OutputFormat::Gif);
3906        req.enable_audio = Some(true);
3907        assert!(validate_generate_request(&req).unwrap_err().contains("mp4"));
3908    }
3909
3910    /// A T2A request produces a WAV and only a WAV. Both directions of the
3911    /// pairing are enforced: `t2a` without `wav` would encode frames that
3912    /// don't exist, and `wav` without `t2a` would ask a video pipeline for a
3913    /// container it never writes.
3914    #[test]
3915    fn ltx2_t2a_requires_wav_output_and_wav_requires_t2a() {
3916        let mut req = valid_req();
3917        req.model = "ltx-2.3-22b-dev:fp8".to_string();
3918        req.pipeline = Some(Ltx2PipelineMode::T2a);
3919        req.output_format = Some(OutputFormat::Wav);
3920        req.width = 0;
3921        req.height = 0;
3922        assert!(validate_generate_request(&req).is_ok());
3923
3924        req.output_format = Some(OutputFormat::Mp4);
3925        let err = validate_generate_request(&req).unwrap_err();
3926        assert!(err.contains("audio only"), "got: {err}");
3927
3928        req.pipeline = None;
3929        req.output_format = Some(OutputFormat::Wav);
3930        req.width = 1024;
3931        req.height = 1024;
3932        let err = validate_generate_request(&req).unwrap_err();
3933        assert!(err.contains("pipeline=t2a"), "got: {err}");
3934    }
3935
3936    #[test]
3937    fn ltx2_t2a_is_dimensionless_and_ignores_a_legacy_raster_canvas() {
3938        let mut req = valid_req();
3939        req.model = "ltx-2.3-22b-dev:fp8".to_string();
3940        req.pipeline = Some(Ltx2PipelineMode::T2a);
3941        req.output_format = Some(OutputFormat::Wav);
3942        req.width = 0;
3943        req.height = 0;
3944        validate_generate_request(&req).unwrap();
3945
3946        // One-release compatibility: older clients still serialize their
3947        // inactive raster fields. They remain irrelevant to T2A admission.
3948        req.width = 1024;
3949        req.height = 576;
3950        validate_generate_request(&req).unwrap();
3951    }
3952
3953    #[test]
3954    fn ltx2_t2a_rejects_every_conditioning_input() {
3955        let base = || {
3956            let mut req = valid_req();
3957            req.model = "ltx-2.3-22b-dev:fp8".to_string();
3958            req.pipeline = Some(Ltx2PipelineMode::T2a);
3959            req.output_format = Some(OutputFormat::Wav);
3960            req.width = 0;
3961            req.height = 0;
3962            req
3963        };
3964
3965        let mut with_image = base();
3966        with_image.source_image = Some(vec![1, 2, 3]);
3967        assert!(validate_generate_request(&with_image)
3968            .unwrap_err()
3969            .contains("source_image"));
3970
3971        let mut with_audio = base();
3972        with_audio.audio_file_path = Some("/srv/voice.wav".to_string());
3973        assert!(validate_generate_request(&with_audio)
3974            .unwrap_err()
3975            .contains("audio_file_path"));
3976
3977        let mut with_upscale = base();
3978        with_upscale.spatial_upscale = Some(crate::Ltx2SpatialUpscale::X2);
3979        assert!(validate_generate_request(&with_upscale)
3980            .unwrap_err()
3981            .contains("spatial_upscale"));
3982
3983        let mut with_post_upscale = base();
3984        with_post_upscale.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
3985        assert!(validate_generate_request(&with_post_upscale)
3986            .unwrap_err()
3987            .contains("upscale_model"));
3988    }
3989
3990    /// ControlNet is refused for `t2a` by the family gate, not by the
3991    /// pipeline's own conditioning list. A ControlNet pair requires an SD1.5
3992    /// family and `pipeline` requires `ltx2`, so the two can never both be
3993    /// satisfied — the audio-only runtime cannot be reached with a control
3994    /// model loaded. Pinned here because the t2a rejection list reads as
3995    /// though it were the only guard, and a future refactor that relaxed
3996    /// `require_controlnet_capable_family` would silently open that door.
3997    #[test]
3998    fn ltx2_t2a_cannot_carry_controlnet_inputs() {
3999        let mut req = valid_req();
4000        req.model = "ltx-2.3-22b-dev:fp8".to_string();
4001        req.pipeline = Some(Ltx2PipelineMode::T2a);
4002        req.output_format = Some(OutputFormat::Wav);
4003        req.width = 0;
4004        req.height = 0;
4005        req.control_image = Some(png_bytes());
4006        req.control_model = Some("controlnet-canny-sd15".to_string());
4007        req.control_scale = 0.8;
4008
4009        let err = validate_generate_request(&req).unwrap_err();
4010        assert!(err.contains("ControlNet"), "got: {err}");
4011
4012        // And the mirror case: a control model without an image is refused on
4013        // the same family grounds rather than reaching the audio pipeline.
4014        req.control_image = None;
4015        let err = validate_generate_request(&req).unwrap_err();
4016        assert!(err.contains("ControlNet"), "got: {err}");
4017    }
4018
4019    #[test]
4020    fn ltx2_t2a_rejects_enable_audio_false() {
4021        let mut req = valid_req();
4022        req.model = "ltx-2.3-22b-dev:fp8".to_string();
4023        req.pipeline = Some(Ltx2PipelineMode::T2a);
4024        req.output_format = Some(OutputFormat::Wav);
4025        req.width = 0;
4026        req.height = 0;
4027        req.enable_audio = Some(false);
4028        let err = validate_generate_request(&req).unwrap_err();
4029        assert!(err.contains("enable_audio=false"), "got: {err}");
4030    }
4031
4032    /// `modality_scale` steers the audio↔video cross-attention. Audio-only has
4033    /// no video branch, so a non-1.0 value cannot be honoured — reject it
4034    /// rather than accept a number that silently does nothing.
4035    #[test]
4036    fn ltx2_t2a_rejects_non_unit_modality_scale_override() {
4037        let mut req = valid_req();
4038        req.model = "ltx-2.3-22b-dev:fp8".to_string();
4039        req.pipeline = Some(Ltx2PipelineMode::T2a);
4040        req.output_format = Some(OutputFormat::Wav);
4041        req.width = 0;
4042        req.height = 0;
4043        req.guidance_overrides = Some(crate::Ltx2GuidanceOverrides {
4044            modality_scale: Some(3.0),
4045            ..Default::default()
4046        });
4047        let err = validate_generate_request(&req).unwrap_err();
4048        assert!(err.contains("modality_scale"), "got: {err}");
4049
4050        req.guidance_overrides = Some(crate::Ltx2GuidanceOverrides {
4051            modality_scale: Some(1.0),
4052            ..Default::default()
4053        });
4054        assert!(validate_generate_request(&req).is_ok());
4055    }
4056
4057    #[test]
4058    fn ltx2_retake_requires_source_video() {
4059        let mut req = valid_req();
4060        req.model = "ltx-2-19b-distilled:fp8".to_string();
4061        req.output_format = Some(OutputFormat::Mp4);
4062        req.retake_range = Some(crate::TimeRange {
4063            start_seconds: 0.0,
4064            end_seconds: 1.0,
4065        });
4066        assert!(validate_generate_request(&req)
4067            .unwrap_err()
4068            .contains("source_video"));
4069    }
4070
4071    #[test]
4072    fn ltx2_audio_file_rejects_inline_payloads_above_limit() {
4073        let mut req = valid_req();
4074        req.model = "ltx-2-19b-distilled:fp8".to_string();
4075        req.output_format = Some(OutputFormat::Mp4);
4076        req.audio_file = Some(vec![0; MAX_INLINE_AUDIO_BYTES + 1]);
4077        let err = validate_generate_request(&req).unwrap_err();
4078        assert!(err.contains("audio_file exceeds"), "got: {err}");
4079        assert!(err.contains("64 MiB"), "got: {err}");
4080    }
4081
4082    #[test]
4083    fn ltx2_source_video_rejects_inline_payloads_above_limit() {
4084        let mut req = valid_req();
4085        req.model = "ltx-2-19b-distilled:fp8".to_string();
4086        req.output_format = Some(OutputFormat::Mp4);
4087        req.source_video = Some(vec![0; MAX_INLINE_SOURCE_VIDEO_BYTES + 1]);
4088        let err = validate_generate_request(&req).unwrap_err();
4089        assert!(err.contains("source_video exceeds"), "got: {err}");
4090        assert!(err.contains("64 MiB"), "got: {err}");
4091    }
4092
4093    #[test]
4094    fn ltx2_audio_file_path_is_family_gated_and_preserves_inline_limit() {
4095        let mut req = valid_req();
4096        req.model = "ltx-2-19b-distilled:fp8".to_string();
4097        req.output_format = Some(OutputFormat::Mp4);
4098        req.audio_file_path = Some("/srv/mold-media/voice.wav".to_string());
4099        assert!(validate_generate_request(&req).is_ok());
4100
4101        req.audio_file = Some(vec![0; MAX_INLINE_AUDIO_BYTES + 1]);
4102        let err = validate_generate_request(&req).unwrap_err();
4103        assert!(
4104            err.contains("audio_file_path cannot be combined"),
4105            "got: {err}"
4106        );
4107
4108        let mut wrong_family = valid_req();
4109        wrong_family.model = "flux-schnell:q8".to_string();
4110        wrong_family.audio_file_path = Some("/srv/mold-media/voice.wav".to_string());
4111        let err = validate_generate_request(&wrong_family).unwrap_err();
4112        assert!(
4113            err.contains("audio_file_path is only supported"),
4114            "got: {err}"
4115        );
4116    }
4117
4118    #[test]
4119    fn ltx2_source_video_path_satisfies_retake_requirements() {
4120        let mut req = valid_req();
4121        req.model = "ltx-2-19b-distilled:fp8".to_string();
4122        req.output_format = Some(OutputFormat::Mp4);
4123        req.source_video_path = Some("/srv/mold-media/clip.mp4".to_string());
4124        req.retake_range = Some(crate::TimeRange {
4125            start_seconds: 0.0,
4126            end_seconds: 1.0,
4127        });
4128
4129        assert!(validate_generate_request(&req).is_ok());
4130
4131        req.source_video = Some(vec![0; MAX_INLINE_SOURCE_VIDEO_BYTES + 1]);
4132        let err = validate_generate_request(&req).unwrap_err();
4133        assert!(
4134            err.contains("source_video_path cannot be combined"),
4135            "got: {err}"
4136        );
4137    }
4138
4139    #[test]
4140    fn ltx2_keyframe_pipeline_requires_multiple_keyframes() {
4141        let mut req = valid_req();
4142        req.model = "ltx-2-19b-distilled:fp8".to_string();
4143        req.output_format = Some(OutputFormat::Mp4);
4144        req.pipeline = Some(crate::Ltx2PipelineMode::Keyframe);
4145        req.frames = Some(17);
4146        req.keyframes = Some(vec![crate::KeyframeCondition {
4147            frame: 0,
4148            image: png_bytes(),
4149            name: None,
4150        }]);
4151        assert!(validate_generate_request(&req)
4152            .unwrap_err()
4153            .contains("at least 2 keyframes"));
4154    }
4155
4156    #[test]
4157    fn keyframes_on_unknown_family_report_unknown_model_family() {
4158        let mut req = valid_req();
4159        req.model = "private-ltx2-style-model".to_string();
4160        req.frames = Some(17);
4161        req.keyframes = Some(vec![
4162            crate::KeyframeCondition {
4163                frame: 0,
4164                image: png_bytes(),
4165                name: None,
4166            },
4167            crate::KeyframeCondition {
4168                frame: 16,
4169                image: png_bytes(),
4170                name: None,
4171            },
4172        ]);
4173        let err = validate_generate_request(&req).unwrap_err();
4174        assert!(err.contains("unknown model family"), "got: {err}");
4175    }
4176
4177    fn ltx2_req_with_overrides(overrides: Ltx2GuidanceOverrides) -> GenerateRequest {
4178        let mut req = valid_req();
4179        req.model = "ltx-2-19b-distilled:fp8".to_string();
4180        req.output_format = Some(OutputFormat::Mp4);
4181        req.frames = Some(17);
4182        req.guidance_overrides = Some(overrides);
4183        req
4184    }
4185
4186    #[test]
4187    fn ltx2_guidance_overrides_accept_upstream_ranges() {
4188        validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4189            stg_scale: Some(1.5),
4190            stg_blocks: Some(vec![28, 29]),
4191            rescale_scale: Some(0.7),
4192            modality_scale: Some(3.0),
4193            skip_step: Some(2),
4194        }))
4195        .unwrap();
4196    }
4197
4198    #[test]
4199    fn ltx2_guidance_overrides_are_family_gated() {
4200        let mut req = valid_req();
4201        req.guidance_overrides = Some(Ltx2GuidanceOverrides {
4202            stg_scale: Some(1.0),
4203            ..Ltx2GuidanceOverrides::default()
4204        });
4205        let err = validate_generate_request(&req).unwrap_err();
4206        assert!(err.contains("guidance_overrides"), "got: {err}");
4207        assert!(err.contains("LTX-2"), "got: {err}");
4208    }
4209
4210    #[test]
4211    fn ltx2_guidance_overrides_reject_empty_objects() {
4212        let err =
4213            validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides::default()))
4214                .unwrap_err();
4215        assert!(err.contains("at least one field"), "got: {err}");
4216    }
4217
4218    #[test]
4219    fn ltx2_guidance_overrides_reject_out_of_range_scales() {
4220        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4221            stg_scale: Some(-0.5),
4222            ..Ltx2GuidanceOverrides::default()
4223        }))
4224        .unwrap_err();
4225        assert!(err.contains("stg_scale"), "got: {err}");
4226
4227        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4228            stg_scale: Some(f64::NAN),
4229            ..Ltx2GuidanceOverrides::default()
4230        }))
4231        .unwrap_err();
4232        assert!(err.contains("finite"), "got: {err}");
4233
4234        // Rescale is an interpolation factor, so its ceiling is 1.0 even
4235        // though the other scales accept much larger values.
4236        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4237            rescale_scale: Some(1.5),
4238            ..Ltx2GuidanceOverrides::default()
4239        }))
4240        .unwrap_err();
4241        assert!(err.contains("rescale_scale"), "got: {err}");
4242        validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4243            modality_scale: Some(1.5),
4244            ..Ltx2GuidanceOverrides::default()
4245        }))
4246        .unwrap();
4247    }
4248
4249    #[test]
4250    fn ltx2_guidance_overrides_reject_unusable_stg_blocks() {
4251        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4252            stg_blocks: Some(Vec::new()),
4253            ..Ltx2GuidanceOverrides::default()
4254        }))
4255        .unwrap_err();
4256        assert!(err.contains("must not be empty"), "got: {err}");
4257
4258        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4259            stg_blocks: Some(vec![MAX_STG_BLOCK_INDEX]),
4260            ..Ltx2GuidanceOverrides::default()
4261        }))
4262        .unwrap_err();
4263        assert!(err.contains("deepest supported"), "got: {err}");
4264
4265        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4266            stg_blocks: Some(vec![29, 29]),
4267            ..Ltx2GuidanceOverrides::default()
4268        }))
4269        .unwrap_err();
4270        assert!(err.contains("more than once"), "got: {err}");
4271    }
4272
4273    #[test]
4274    fn ltx2_guidance_overrides_bound_the_skip_stride() {
4275        let err = validate_generate_request(&ltx2_req_with_overrides(Ltx2GuidanceOverrides {
4276            skip_step: Some(Ltx2GuidanceOverrides::MAX_SKIP_STEP + 1),
4277            ..Ltx2GuidanceOverrides::default()
4278        }))
4279        .unwrap_err();
4280        assert!(err.contains("skip_step"), "got: {err}");
4281    }
4282
4283    #[test]
4284    fn enable_audio_some_false_does_not_trip_family_check() {
4285        // Web form serializes the audio toggle as `Some(false)` whenever the
4286        // checkbox is explicitly off. That must be a no-op for any family
4287        // (including the unknown-family case used by catalog `cv:*` IDs)
4288        // since the user did not ask for audio.
4289        let mut req = valid_req();
4290        req.model = "cv:2781713".to_string();
4291        req.enable_audio = Some(false);
4292        // No family hint provided — exercises the unknown-family branch.
4293        validate_generate_request(&req).unwrap();
4294    }
4295
4296    #[test]
4297    fn enable_audio_some_true_with_family_hint_passes_for_catalog_ltx2() {
4298        // The HTTP server resolves `cv:*` IDs against the catalog DB and
4299        // passes the family through as a hint. With the LTX-2 hint, audio
4300        // is allowed even though the manifest layer has no entry for the
4301        // catalog ID.
4302        let mut req = valid_req();
4303        req.model = "cv:2781713".to_string();
4304        req.output_format = Some(OutputFormat::Mp4);
4305        req.enable_audio = Some(true);
4306        validate_generate_request_with_family(&req, Some("ltx2")).unwrap();
4307    }
4308
4309    #[test]
4310    fn enable_audio_some_true_without_hint_still_errors_on_unknown_family() {
4311        // No hint, no manifest entry — the family gate still fires so the
4312        // user gets a clear 400 instead of an opaque inference-layer error.
4313        let mut req = valid_req();
4314        req.model = "cv:2781713".to_string();
4315        req.output_format = Some(OutputFormat::Mp4);
4316        req.enable_audio = Some(true);
4317        let err = validate_generate_request(&req).unwrap_err();
4318        assert!(err.contains("unknown model family"), "got: {err}");
4319        assert!(err.contains("enable_audio"), "got: {err}");
4320    }
4321
4322    #[test]
4323    fn family_hint_overrides_manifest_lookup() {
4324        // Even when the manifest would resolve the model name to a different
4325        // family, the explicit hint wins. This lets the server pass the
4326        // catalog-resolved family through unconditionally.
4327        let mut req = valid_req();
4328        req.model = "private-name".to_string();
4329        req.output_format = Some(OutputFormat::Mp4);
4330        req.enable_audio = Some(true);
4331        validate_generate_request_with_family(&req, Some("ltx2")).unwrap();
4332    }
4333
4334    #[test]
4335    fn ltx2_allows_temporal_upscale_request() {
4336        let mut req = valid_req();
4337        req.model = "ltx-2-19b-distilled:fp8".to_string();
4338        req.output_format = Some(OutputFormat::Mp4);
4339        req.temporal_upscale = Some(crate::Ltx2TemporalUpscale::X2);
4340        validate_generate_request(&req).unwrap();
4341    }
4342
4343    #[test]
4344    fn ltx2_allows_x1_5_spatial_upscale_request() {
4345        let mut req = valid_req();
4346        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
4347        req.output_format = Some(OutputFormat::Mp4);
4348        req.spatial_upscale = Some(crate::Ltx2SpatialUpscale::X1_5);
4349        validate_generate_request(&req).unwrap();
4350    }
4351
4352    #[test]
4353    fn empty_prompt_rejected() {
4354        // The default `valid_req()` is a text-to-image request with no visual
4355        // conditioning, so the prompt stays mandatory.
4356        let mut req = valid_req();
4357        req.prompt = "   ".to_string();
4358        assert!(validate_generate_request(&req)
4359            .unwrap_err()
4360            .contains("prompt"));
4361    }
4362
4363    /// Baseline LTX-2 video request with no visual conditioning attached.
4364    fn ltx2_video_req() -> GenerateRequest {
4365        let mut req = valid_req();
4366        req.model = "ltx-2-19b-distilled:fp8".to_string();
4367        req.output_format = Some(OutputFormat::Mp4);
4368        req.fps = Some(24);
4369        req.frames = Some(97);
4370        req
4371    }
4372
4373    #[test]
4374    fn empty_prompt_allowed_for_ltx2_with_source_image() {
4375        let mut req = ltx2_video_req();
4376        req.prompt = String::new();
4377        req.source_image = Some(png_bytes());
4378        validate_generate_request(&req).unwrap();
4379
4380        // Whitespace-only is the same case as empty.
4381        req.prompt = "  \n ".to_string();
4382        validate_generate_request(&req).unwrap();
4383
4384        // Catalog IDs only resolve to `ltx2` through the family hint.
4385        let mut catalog = req.clone();
4386        catalog.model = "cv:2781713".to_string();
4387        assert!(validate_generate_request(&catalog).is_err());
4388        validate_generate_request_with_family(&catalog, Some("ltx2")).unwrap();
4389    }
4390
4391    #[test]
4392    fn empty_prompt_allowed_for_ltx2_keyframes_video_and_extend() {
4393        let mut keyframed = ltx2_video_req();
4394        keyframed.prompt = String::new();
4395        keyframed.keyframes = Some(vec![KeyframeCondition {
4396            frame: 0,
4397            image: png_bytes(),
4398            name: None,
4399        }]);
4400        validate_generate_request(&keyframed).unwrap();
4401
4402        let mut from_video = ltx2_video_req();
4403        from_video.prompt = String::new();
4404        from_video.source_video = Some(vec![0, 0, 0, 0x20, b'f', b't', b'y', b'p']);
4405        validate_generate_request(&from_video).unwrap();
4406
4407        // The server validates before `resolve_server_local_media_paths`, so
4408        // the `*_path` variants must count as conditioning too.
4409        let mut from_video_path = ltx2_video_req();
4410        from_video_path.prompt = String::new();
4411        from_video_path.source_video_path = Some("/srv/clips/shot.mp4".to_string());
4412        validate_generate_request(&from_video_path).unwrap();
4413
4414        let mut extended = extend_req();
4415        extended.prompt = String::new();
4416        validate_generate_request(&extended).unwrap();
4417
4418        let mut extended_path = ltx2_video_req();
4419        extended_path.prompt = String::new();
4420        extended_path.extend_video_path = Some("/srv/clips/shot.mp4".to_string());
4421        validate_generate_request(&extended_path).unwrap();
4422    }
4423
4424    #[test]
4425    fn empty_prompt_allowed_for_ltx_video_with_source_image() {
4426        let mut req = valid_req();
4427        req.model = "ltx-video-0.9.8-2b-distilled:bf16".to_string();
4428        req.output_format = Some(OutputFormat::Mp4);
4429        req.prompt = String::new();
4430        req.source_image = Some(png_bytes());
4431        validate_generate_request(&req).unwrap();
4432    }
4433
4434    #[test]
4435    fn empty_prompt_still_rejected_for_ltx2_text_to_video() {
4436        let mut req = ltx2_video_req();
4437        req.prompt = String::new();
4438        assert!(validate_generate_request(&req)
4439            .unwrap_err()
4440            .contains("prompt"));
4441    }
4442
4443    #[test]
4444    fn empty_prompt_still_rejected_for_flux_and_sd() {
4445        // Image families keep the prompt required even with a source image —
4446        // an empty img2img prompt is not a trained context there.
4447        for model in [
4448            "flux-dev:q8",
4449            "sd15:fp16",
4450            "sdxl:fp16",
4451            "z-image-turbo:bf16",
4452        ] {
4453            let mut req = valid_req();
4454            req.model = model.to_string();
4455            req.prompt = String::new();
4456            req.source_image = Some(png_bytes());
4457            assert!(
4458                validate_generate_request(&req)
4459                    .unwrap_err()
4460                    .contains("prompt"),
4461                "{model} must still require a prompt"
4462            );
4463        }
4464    }
4465
4466    #[test]
4467    fn prompt_required_predicate_matches_validation() {
4468        let mut req = ltx2_video_req();
4469        assert!(super::prompt_required_for(&req, None));
4470        req.source_image = Some(png_bytes());
4471        assert!(!super::prompt_required_for(&req, None));
4472
4473        // Unknown family (catalog ID without a hint) stays required.
4474        let mut catalog = req.clone();
4475        catalog.model = "hf:Lightricks/LTX-2".to_string();
4476        assert!(super::prompt_required_for(&catalog, None));
4477        assert!(!super::prompt_required_for(&catalog, Some("ltx2")));
4478    }
4479
4480    #[test]
4481    fn prompt_length_limit_still_enforced_without_a_prompt_requirement() {
4482        let mut req = ltx2_video_req();
4483        req.source_image = Some(png_bytes());
4484        req.prompt = "a".repeat(77_001);
4485        assert!(validate_generate_request(&req)
4486            .unwrap_err()
4487            .contains("77,000"));
4488    }
4489
4490    #[test]
4491    fn zero_dimensions_rejected() {
4492        let mut req = valid_req();
4493        req.width = 0;
4494        assert!(validate_generate_request(&req).is_err());
4495        req.width = 1024;
4496        req.height = 0;
4497        assert!(validate_generate_request(&req).is_err());
4498    }
4499
4500    #[test]
4501    fn dimensions_must_be_multiple_of_16() {
4502        let mut req = valid_req();
4503        req.width = 513; // not multiple of 16
4504        assert!(validate_generate_request(&req)
4505            .unwrap_err()
4506            .contains("multiples of 16"));
4507    }
4508
4509    #[test]
4510    fn ltx2_dimensions_must_be_multiple_of_32() {
4511        let mut req = valid_req();
4512        req.width = 1008; // multiple of 16, but not 32
4513        req.height = 704;
4514
4515        let error = validate_generate_request_with_family(&req, Some("ltx2"))
4516            .expect_err("LTX-2 must reject a 16px-only canvas");
4517
4518        assert!(error.contains("multiples of 32"), "{error}");
4519        assert!(error.contains("ltx2"), "{error}");
4520    }
4521
4522    #[test]
4523    fn ltx2_accepts_custom_32_aligned_dimensions() {
4524        let mut req = valid_req();
4525        req.width = 1056;
4526        req.height = 736;
4527        req.output_format = Some(OutputFormat::Mp4);
4528
4529        assert!(validate_generate_request_with_family(&req, Some("ltx2")).is_ok());
4530    }
4531
4532    #[test]
4533    fn valid_non_square_dimensions() {
4534        let mut req = valid_req();
4535        req.width = 512;
4536        req.height = 768;
4537        assert!(validate_generate_request(&req).is_ok());
4538    }
4539
4540    #[test]
4541    fn oversized_image_rejected() {
4542        let mut req = valid_req();
4543        req.width = 1408;
4544        req.height = 1408; // ~1.98MP > 1.8MP limit
4545        assert!(validate_generate_request(&req)
4546            .unwrap_err()
4547            .contains("megapixels"));
4548    }
4549
4550    #[test]
4551    fn oversized_image_error_reports_current_megapixel_limit() {
4552        let mut req = valid_req();
4553        req.width = 1408;
4554        req.height = 1408;
4555        let err = validate_generate_request(&req).unwrap_err();
4556        assert!(err.contains("1.8MP"), "got: {err}");
4557    }
4558
4559    #[test]
4560    fn zero_steps_rejected() {
4561        let mut req = valid_req();
4562        req.steps = 0;
4563        assert!(validate_generate_request(&req).is_err());
4564    }
4565
4566    #[test]
4567    fn excessive_steps_rejected() {
4568        let mut req = valid_req();
4569        req.steps = 101;
4570        assert!(validate_generate_request(&req).is_err());
4571    }
4572
4573    #[test]
4574    fn valid_step_counts() {
4575        for steps in [1, 4, 20, 28, 50, 100] {
4576            let mut req = valid_req();
4577            req.steps = steps;
4578            assert!(
4579                validate_generate_request(&req).is_ok(),
4580                "steps={steps} should be valid"
4581            );
4582        }
4583    }
4584
4585    #[test]
4586    fn ltx2_frames_must_still_follow_8n_plus_1() {
4587        let mut req = valid_req();
4588        req.model = "ltx-2-19b-distilled:fp8".to_string();
4589        req.output_format = Some(OutputFormat::Mp4);
4590        req.frames = Some(10);
4591        let err = validate_generate_request(&req).unwrap_err();
4592        assert!(err.contains("8n+1"), "got: {err}");
4593        // The message derives its examples from the family's own step so it
4594        // stays correct now that more than one grid exists (LTX 8, Wan 4).
4595        assert!(err.contains("9, 17, 25"), "got: {err}");
4596    }
4597
4598    fn extend_req() -> GenerateRequest {
4599        let mut req = valid_req();
4600        req.model = "ltx-2-19b-distilled:fp8".to_string();
4601        req.output_format = Some(OutputFormat::Mp4);
4602        req.fps = Some(24);
4603        req.frames = Some(97);
4604        req.extend_video = Some(vec![0, 0, 0, 0x20, b'f', b't', b'y', b'p']);
4605        req
4606    }
4607
4608    #[test]
4609    fn extend_accepts_a_video_with_the_default_overlap() {
4610        let req = extend_req();
4611        assert!(validate_generate_request(&req).is_ok());
4612        assert!(req.is_extend());
4613        assert_eq!(
4614            req.effective_extend_overlap_frames(),
4615            DEFAULT_EXTEND_OVERLAP_FRAMES
4616        );
4617        // 97 rendered frames minus the 17-frame overlap that reproduces the
4618        // source tail = 80 genuinely new frames appended.
4619        assert_eq!(req.extend_new_frames(), Some(80));
4620    }
4621
4622    /// An extend carries its source frames in the clip it continues (#783).
4623    ///
4624    /// `validate_extend` forbids pairing `extend_video` with `source_image` or
4625    /// keyframes, so a gate that counted only those two saw *every*
4626    /// continuation as source-less — and refused every Wan I2V extend with
4627    /// "this Wan I2V checkpoint needs a source image", the very contract that
4628    /// makes the checkpoint extend-capable in the first place.
4629    #[test]
4630    fn extend_carries_the_source_frames_the_contract_gate_looks_for() {
4631        use crate::types::SourceImageCapability;
4632
4633        let mut req = extend_req();
4634        req.model = "wan22-i2v-a14b:q8".to_string();
4635        req.width = 832;
4636        req.height = 480;
4637        req.fps = Some(16);
4638        req.frames = Some(49);
4639        assert!(req.source_image.is_none() && req.keyframes.is_none());
4640        assert!(request_carries_source_frames(&req));
4641
4642        // Required + extend is satisfied: admission must let it through.
4643        assert_eq!(
4644            source_image_contract_violation(
4645                Some("wan"),
4646                &req.model,
4647                Some(SourceImageCapability::Required),
4648                request_carries_source_frames(&req),
4649            ),
4650            None
4651        );
4652        // …and a text-to-video checkpoint is refused at admission instead of
4653        // dying in the engine after the UMT5 encode and expert load are paid.
4654        assert!(source_image_contract_violation(
4655            Some("wan"),
4656            "wan22-t2v-a14b:q8",
4657            Some(SourceImageCapability::Unsupported),
4658            request_carries_source_frames(&req),
4659        )
4660        .is_some());
4661
4662        // An ordinary render still carries nothing.
4663        let plain = valid_req();
4664        assert!(!request_carries_source_frames(&plain));
4665    }
4666
4667    /// The overlap default is a property of the family's carryover, not a
4668    /// global scalar (#783): wan's handoff is one frame and its engine refuses
4669    /// anything else, so advertising LTX-2's 17 handed every wan client a
4670    /// value that clears wan's `4k+1` grid check and then fails in the engine.
4671    #[test]
4672    fn extend_overlap_default_follows_the_familys_own_carryover() {
4673        assert_eq!(
4674            default_extend_overlap_frames_for_family(Some("wan")),
4675            WAN_HANDOFF_DUPLICATED_FRAMES
4676        );
4677        assert_eq!(WAN_HANDOFF_DUPLICATED_FRAMES, 1);
4678        assert_eq!(
4679            default_extend_overlap_frames_for_family(Some("ltx2")),
4680            DEFAULT_EXTEND_OVERLAP_FRAMES
4681        );
4682        // An unresolved family keeps the historical scalar.
4683        assert_eq!(
4684            default_extend_overlap_frames_for_family(None),
4685            DEFAULT_EXTEND_OVERLAP_FRAMES
4686        );
4687
4688        let mut req = extend_req();
4689        req.model = "wan22-ti2v-5b:fp16".to_string();
4690        req.width = 704;
4691        req.height = 384;
4692        req.fps = Some(24);
4693        req.frames = Some(49);
4694        assert_eq!(
4695            req.effective_extend_overlap_frames_for_family(Some("wan")),
4696            WAN_HANDOFF_DUPLICATED_FRAMES
4697        );
4698        // With no hint the request resolves its own family from the manifest,
4699        // so metadata provenance records what the engine actually applied.
4700        assert_eq!(
4701            req.effective_extend_overlap_frames(),
4702            WAN_HANDOFF_DUPLICATED_FRAMES
4703        );
4704        assert_eq!(req.extend_new_frames(), Some(48));
4705        assert!(validate_generate_request(&req).is_ok());
4706
4707        // An explicit value is never overridden — validation still owns it.
4708        req.extend_overlap_frames = Some(9);
4709        assert_eq!(
4710            req.effective_extend_overlap_frames_for_family(Some("wan")),
4711            9
4712        );
4713    }
4714
4715    /// A chain seam's carryover is the family's, never the caller's (#783).
4716    ///
4717    /// The server has normalized this since #936, but only the server: a
4718    /// forced-local `--script` run, `mold chain validate`, and `--dry-run`
4719    /// all called the family-generic `normalise()` and passed the requested
4720    /// tail straight through. `17 % 4 == 1`, so LTX-2's default clears wan's
4721    /// own `4k+1` grid check and then silently discards sixteen good frames
4722    /// at every Smooth seam. One authority so the two cannot drift.
4723    #[test]
4724    fn chain_motion_tail_follows_the_checkpoints_carryover_not_the_request() {
4725        use crate::SourceImageCapability::{Optional, Required, Unsupported};
4726
4727        // Wan's seam re-renders exactly the one frame it was seeded with, and
4728        // only an image-conditioned checkpoint can be seeded at all.
4729        for capability in [Required, Optional] {
4730            assert_eq!(
4731                chain_motion_tail_frames_for_family("wan", Some(capability), 17),
4732                WAN_HANDOFF_DUPLICATED_FRAMES,
4733                "{capability:?} carries context, so the tail is one frame"
4734            );
4735        }
4736        assert_eq!(
4737            chain_motion_tail_frames_for_family("wan", Some(Unsupported), 17),
4738            0,
4739            "a text-to-video checkpoint has no channel to be seeded through"
4740        );
4741        // Unclassified is "unknown", never an assumed handoff.
4742        assert_eq!(chain_motion_tail_frames_for_family("wan", None, 17), 0);
4743
4744        // An already-correct request is left exactly as it is.
4745        assert_eq!(
4746            chain_motion_tail_frames_for_family("wan", Some(Required), 1),
4747            WAN_HANDOFF_DUPLICATED_FRAMES
4748        );
4749
4750        // LTX-Video has no img2vid path, so its Smooth seams concatenate.
4751        assert_eq!(
4752            chain_motion_tail_frames_for_family("ltx-video", None, 17),
4753            0
4754        );
4755
4756        // Every other family keeps what the caller asked for — LTX-2's tail
4757        // is a real latent window and the request owns it.
4758        assert_eq!(chain_motion_tail_frames_for_family("ltx2", None, 17), 17);
4759        assert_eq!(chain_motion_tail_frames_for_family("ltx2", None, 9), 9);
4760        assert_eq!(chain_motion_tail_frames_for_family("", None, 17), 17);
4761    }
4762
4763    /// Saved provenance has to name the overlap that actually rendered.
4764    ///
4765    /// `OutputMetadata::from_generate_request` holds no family and resolves
4766    /// one through the manifest, which an installed `cv:` / `hf:` wan
4767    /// checkpoint has none of — so a continuation that ran with wan's single
4768    /// carryover frame was recorded as having used LTX-2's 17 (#783).
4769    /// Admission and the forced-local CLI both know the resolved family and
4770    /// materialize it before metadata is built.
4771    #[test]
4772    fn materializing_the_overlap_makes_saved_provenance_match_the_render() {
4773        let installed_wan = || {
4774            let mut req = extend_req();
4775            // An installed catalog id: `find_manifest` cannot classify it, so
4776            // the family-blind fallback is the wrong 17.
4777            req.model = "cv:2041121".to_string();
4778            req.width = 832;
4779            req.height = 480;
4780            req.fps = Some(16);
4781            req.frames = Some(49);
4782            req
4783        };
4784
4785        let unmaterialized = installed_wan();
4786        assert_eq!(
4787            unmaterialized.effective_extend_overlap_frames(),
4788            DEFAULT_EXTEND_OVERLAP_FRAMES,
4789            "the family-blind fallback is exactly what makes materialization necessary"
4790        );
4791
4792        let mut req = installed_wan();
4793        materialize_extend_overlap_frames(&mut req, Some("wan"));
4794        assert_eq!(
4795            req.extend_overlap_frames,
4796            Some(WAN_HANDOFF_DUPLICATED_FRAMES)
4797        );
4798        let metadata = crate::OutputMetadata::from_generate_request(&req, 7, None, "test");
4799        assert_eq!(
4800            metadata.extend_overlap_frames,
4801            Some(WAN_HANDOFF_DUPLICATED_FRAMES),
4802            "recorded provenance must be the overlap the engine applied"
4803        );
4804        // Net-new frames are derived from the same field, so the recorded
4805        // clip length stops disagreeing with the file too.
4806        assert_eq!(req.extend_new_frames(), Some(48));
4807
4808        // An explicit value is authoritative.
4809        let mut explicit = installed_wan();
4810        explicit.extend_overlap_frames = Some(5);
4811        materialize_extend_overlap_frames(&mut explicit, Some("wan"));
4812        assert_eq!(explicit.extend_overlap_frames, Some(5));
4813
4814        // LTX-2 keeps 17, and an ordinary render is never handed a bare
4815        // overlap — that is a validation error, not a default.
4816        let mut ltx2 = extend_req();
4817        materialize_extend_overlap_frames(&mut ltx2, Some("ltx2"));
4818        assert_eq!(
4819            ltx2.extend_overlap_frames,
4820            Some(DEFAULT_EXTEND_OVERLAP_FRAMES)
4821        );
4822        let mut plain = valid_req();
4823        materialize_extend_overlap_frames(&mut plain, Some("wan"));
4824        assert_eq!(plain.extend_overlap_frames, None);
4825    }
4826
4827    #[test]
4828    fn extend_is_limited_to_families_with_a_continuation_path() {
4829        let mut req = extend_req();
4830        req.model = "ltx-video-0.9.6-distilled:bf16".to_string();
4831        let err = validate_generate_request(&req).unwrap_err();
4832        assert!(err.contains("extend_video"), "got: {err}");
4833    }
4834
4835    /// Wan continues a clip too (#783), but on its own VAE grid.
4836    ///
4837    /// The overlap re-encodes through the family's video VAE, and wan's
4838    /// compresses time by 4 where LTX-2's compresses by 8 — a hardcoded 8
4839    /// rejected every valid wan overlap.
4840    #[test]
4841    fn wan_extend_uses_wans_own_temporal_grid() {
4842        let wan_req = |overlap: Option<u32>| {
4843            let mut req = extend_req();
4844            req.model = "wan22-ti2v-5b:fp16".to_string();
4845            req.width = 704;
4846            req.height = 384;
4847            req.fps = Some(24);
4848            req.frames = Some(49);
4849            req.extend_overlap_frames = overlap;
4850            req
4851        };
4852
4853        // Wan carries exactly one frame — the seed — and 1 is on both grids.
4854        assert!(validate_generate_request(&wan_req(Some(1))).is_ok());
4855        // 5 and 13 are on 4k+1 but not on 8k+1: the old rule refused them.
4856        for overlap in [5u32, 9, 13] {
4857            assert!(
4858                validate_generate_request(&wan_req(Some(overlap))).is_ok(),
4859                "{overlap} is on wan's 4k+1 grid",
4860            );
4861        }
4862        // Off wan's grid, and the message must name wan's step, not LTX-2's.
4863        let err = validate_generate_request(&wan_req(Some(4))).unwrap_err();
4864        assert!(err.contains("4k+1"), "got: {err}");
4865        assert!(!err.contains("8k+1"), "got: {err}");
4866    }
4867
4868    #[test]
4869    fn extend_rejects_both_inline_bytes_and_a_path() {
4870        let mut req = extend_req();
4871        req.extend_video_path = Some("/srv/mold/clip.mp4".to_string());
4872        let err = validate_generate_request(&req).unwrap_err();
4873        assert!(err.contains("cannot be combined"), "got: {err}");
4874    }
4875
4876    #[test]
4877    fn extend_rejects_empty_payloads() {
4878        let mut req = extend_req();
4879        req.extend_video = Some(Vec::new());
4880        assert!(validate_generate_request(&req)
4881            .unwrap_err()
4882            .contains("must not be empty"));
4883
4884        let mut req = extend_req();
4885        req.extend_video = None;
4886        req.extend_video_path = Some("   ".to_string());
4887        assert!(validate_generate_request(&req)
4888            .unwrap_err()
4889            .contains("must not be empty"));
4890    }
4891
4892    /// The overlap re-encodes through the VAE's 8x causal temporal grid, so an
4893    /// off-grid value would not map onto whole latent slots.
4894    #[test]
4895    fn extend_overlap_must_sit_on_the_latent_grid() {
4896        let mut req = extend_req();
4897        req.extend_overlap_frames = Some(12);
4898        let err = validate_generate_request(&req).unwrap_err();
4899        assert!(err.contains("8k+1"), "got: {err}");
4900
4901        for overlap in [1u32, 9, 17, 25] {
4902            let mut req = extend_req();
4903            req.extend_overlap_frames = Some(overlap);
4904            assert!(
4905                validate_generate_request(&req).is_ok(),
4906                "{overlap} is on the 8k+1 grid",
4907            );
4908        }
4909    }
4910
4911    /// An overlap at or above the clip length means every rendered frame
4912    /// reproduces the source and the continuation adds nothing.
4913    #[test]
4914    fn extend_overlap_must_leave_room_for_new_frames() {
4915        let mut req = extend_req();
4916        req.frames = Some(25);
4917        req.extend_overlap_frames = Some(25);
4918        let err = validate_generate_request(&req).unwrap_err();
4919        assert!(err.contains("strictly less than"), "got: {err}");
4920
4921        req.extend_overlap_frames = Some(17);
4922        assert!(validate_generate_request(&req).is_ok());
4923        assert_eq!(req.extend_new_frames(), Some(8));
4924    }
4925
4926    #[test]
4927    fn extend_overlap_requires_a_video_to_extend() {
4928        let mut req = valid_req();
4929        req.model = "ltx-2-19b-distilled:fp8".to_string();
4930        req.output_format = Some(OutputFormat::Mp4);
4931        req.frames = Some(97);
4932        req.extend_overlap_frames = Some(17);
4933        let err = validate_generate_request(&req).unwrap_err();
4934        assert!(err.contains("requires extend_video"), "got: {err}");
4935    }
4936
4937    /// Extend continues one clip's motion; the other conditioning inputs each
4938    /// claim authority over the same opening frames.
4939    #[test]
4940    fn extend_rejects_competing_conditioning_inputs() {
4941        let mut req = extend_req();
4942        req.source_video = Some(vec![1, 2, 3]);
4943        assert!(validate_generate_request(&req)
4944            .unwrap_err()
4945            .contains("source_video"));
4946
4947        let mut req = extend_req();
4948        req.source_image = Some(png_bytes());
4949        assert!(validate_generate_request(&req)
4950            .unwrap_err()
4951            .contains("source_image"));
4952
4953        let mut req = extend_req();
4954        req.keyframes = Some(vec![KeyframeCondition {
4955            frame: 0,
4956            image: png_bytes(),
4957            name: None,
4958        }]);
4959        assert!(validate_generate_request(&req)
4960            .unwrap_err()
4961            .contains("keyframes"));
4962    }
4963
4964    /// An extend clip is an ordinary render, so it is bound by the same
4965    /// duration budget as any other single request.
4966    #[test]
4967    fn extend_respects_the_temporal_budget() {
4968        let mut req = extend_req();
4969        req.frames = Some(481);
4970        assert!(validate_generate_request(&req).is_ok());
4971
4972        req.frames = Some(489);
4973        let err = validate_generate_request(&req).unwrap_err();
4974        assert!(err.contains("RoPE"), "got: {err}");
4975    }
4976
4977    /// Extend provenance must reach saved metadata, and must not appear on
4978    /// ordinary renders where it would read as a continuation that never was.
4979    #[test]
4980    fn extend_provenance_reaches_output_metadata() {
4981        let mut req = extend_req();
4982        req.extend_video = None;
4983        req.extend_video_path = Some("/srv/mold/clip.mp4".to_string());
4984        req.extend_overlap_frames = Some(25);
4985        let metadata = crate::OutputMetadata::from_generate_request(&req, 7, None, "test");
4986        assert_eq!(
4987            metadata.extend_video_path.as_deref(),
4988            Some("/srv/mold/clip.mp4")
4989        );
4990        assert_eq!(metadata.extend_overlap_frames, Some(25));
4991
4992        let plain = crate::OutputMetadata::from_generate_request(&valid_req(), 7, None, "test");
4993        assert_eq!(plain.extend_video_path, None);
4994        assert_eq!(plain.extend_overlap_frames, None);
4995    }
4996
4997    /// The RoPE temporal axis is expressed in *seconds* (`rope.rs`'s
4998    /// `scale_video_time_to_seconds` divides the pixel-frame coordinate by fps
4999    /// before `max_pos` normalization), so the ceiling is a duration and must
5000    /// scale with fps rather than sit at a fixed frame count.
5001    #[test]
5002    fn ltx2_frame_ceiling_tracks_fps() {
5003        // Latent k spans pixel [8k-7, 8k+1] after the causal fix, so F frames
5004        // put the last RoPE midpoint at (F-4)/fps seconds: F = 20*fps + 4.
5005        assert_eq!(ltx2_max_frames_at_fps(24), 484);
5006        assert_eq!(ltx2_max_frames_at_fps(25), 504);
5007        assert_eq!(ltx2_max_frames_at_fps(12), 244);
5008        assert_eq!(ltx2_max_frames_at_fps(8), 164);
5009        // Low fps is *tighter* than the old flat 153: 6 fps only buys 20s of
5010        // runtime, which the previous constant silently over-admitted.
5011        assert_eq!(ltx2_max_frames_at_fps(6), 124);
5012        // The absolute resource guard binds before the seconds budget does at
5013        // high frame rates.
5014        assert_eq!(ltx2_max_frames_at_fps(60), LTX2_MAX_FRAMES_ABSOLUTE);
5015        assert_eq!(ltx2_max_frames_at_fps(120), LTX2_MAX_FRAMES_ABSOLUTE);
5016        // fps=0 is rejected elsewhere; the helper must not divide by zero.
5017        assert_eq!(ltx2_max_frames_at_fps(0), ltx2_max_frames_at_fps(1));
5018    }
5019
5020    /// The helper values are what /api/models advertises as max_frames /
5021    /// frame_step; they must agree with what the validator enforces so the
5022    /// wire contract can't drift from the actual rejection rules.
5023    #[test]
5024    fn frame_constraint_helpers_match_validator_behavior() {
5025        // Advertised values are grid-snapped so a client that clamps to them
5026        // can actually submit; the raw duration ceiling is off the 8n+1 grid.
5027        assert_eq!(
5028            max_frames_for_family("ltx2"),
5029            Some(ltx2_max_frames_on_grid_at_fps(LTX2_DEFAULT_FPS))
5030        );
5031        assert_eq!(max_frames_for_family_at_fps("ltx2", 12), Some(241));
5032        assert_eq!(max_frames_for_family_at_fps("ltx-video", 12), Some(257));
5033        assert_eq!(max_frames_for_family("ltx-video"), Some(257));
5034        assert_eq!(max_frames_for_family("flux"), None);
5035        assert_eq!(max_frames_for_family("sdxl"), None);
5036        assert_eq!(frame_step_for_family("ltx2"), Some(8));
5037        assert_eq!(frame_step_for_family("ltx-video"), Some(8));
5038        assert_eq!(frame_step_for_family("flux"), None);
5039        assert_eq!(min_frames_for_family("flux"), None);
5040        assert_eq!(fixed_fps_for_family("flux"), None);
5041
5042        // One grid step past the advertised ltx-video cap must be rejected,
5043        // and the rejection must quote the same cap the wire advertises.
5044        let cap = max_frames_for_family("ltx-video").unwrap();
5045        let mut req = valid_req();
5046        req.model = "ltx-video-0.9.6-distilled:bf16".to_string();
5047        req.output_format = Some(OutputFormat::Mp4);
5048        req.frames = Some(cap + 8); // stays on the 8n+1 grid so only the cap trips
5049        let err = validate_generate_request(&req).unwrap_err();
5050        assert!(err.contains(&cap.to_string()), "got: {err}");
5051
5052        // Same agreement for the ltx2 ceiling, which is fps-dependent, so the
5053        // request has to name the fps the helper was asked about.
5054        let cap = max_frames_for_family_at_fps("ltx2", 12).unwrap();
5055        let mut req = valid_req();
5056        req.model = "ltx-2-19b-distilled:fp8".to_string();
5057        req.output_format = Some(OutputFormat::Mp4);
5058        req.fps = Some(12);
5059        req.frames = Some(249); // first 8n+1 value past the 244-frame cap
5060        let err = validate_generate_request(&req).unwrap_err();
5061        assert!(err.contains(&cap.to_string()), "got: {err}");
5062    }
5063
5064    #[test]
5065    fn h3_post_activation_timing_authority_rejects_short_or_retimed_requests() {
5066        assert_eq!(
5067            min_frames_for_family(crate::minimax_h3::FAMILY),
5068            Some(crate::minimax_h3::MIN_FRAMES)
5069        );
5070        assert_eq!(
5071            fixed_fps_for_family(crate::minimax_h3::FAMILY),
5072            Some(crate::minimax_h3::FIXED_FPS)
5073        );
5074        assert_eq!(
5075            max_frames_for_family(crate::minimax_h3::FAMILY),
5076            Some(crate::minimax_h3::MAX_FRAMES)
5077        );
5078        assert_eq!(
5079            max_runtime_seconds_for_family(crate::minimax_h3::FAMILY),
5080            Some(crate::minimax_h3::MAX_DURATION_SECONDS)
5081        );
5082        assert_eq!(
5083            max_frames_absolute_for_family(crate::minimax_h3::FAMILY),
5084            Some(crate::minimax_h3::MAX_FRAMES)
5085        );
5086
5087        let short = validate_family_video_timing_constraints(
5088            Some(crate::minimax_h3::FRAME_OFFSET),
5089            Some(crate::minimax_h3::FIXED_FPS),
5090            Some(crate::minimax_h3::FAMILY),
5091        )
5092        .unwrap_err();
5093        assert!(short.contains("124"), "got: {short}");
5094
5095        let retimed = validate_family_video_timing_constraints(
5096            Some(crate::minimax_h3::MIN_FRAMES),
5097            Some(23),
5098            Some(crate::minimax_h3::FAMILY),
5099        )
5100        .unwrap_err();
5101        assert!(retimed.contains("24 fps"), "got: {retimed}");
5102
5103        assert!(validate_family_video_timing_constraints(
5104            Some(crate::minimax_h3::MIN_FRAMES),
5105            Some(crate::minimax_h3::FIXED_FPS),
5106            Some(crate::minimax_h3::FAMILY),
5107        )
5108        .is_ok());
5109    }
5110
5111    /// Wan advertises a flat frame guard on the `4k+1` grid; the advertised
5112    /// values must agree with what the validator enforces (same drift-proofing
5113    /// as the LTX contract above).
5114    #[test]
5115    fn wan_frame_contract_helpers() {
5116        assert_eq!(frame_step_for_family("wan"), Some(WAN_TEMPORAL_SCALE));
5117        assert_eq!(
5118            max_frames_for_family_at_fps("wan", 16),
5119            Some(MAX_FRAMES_GLOBAL)
5120        );
5121        assert_eq!(max_frames_for_family("wan"), Some(MAX_FRAMES_GLOBAL));
5122        // Wan's ceiling is a flat resource guard, not a duration budget.
5123        assert_eq!(max_runtime_seconds_for_family("wan"), None);
5124        assert_eq!(max_frames_absolute_for_family("wan"), None);
5125        // The advertised maximum must itself sit on the 4k+1 grid so a client
5126        // that clamps a slider to it can actually submit.
5127        assert_eq!((MAX_FRAMES_GLOBAL - 1) % WAN_TEMPORAL_SCALE, 0);
5128    }
5129
5130    #[test]
5131    fn wan_frames_grid_and_cap_enforced() {
5132        // On-grid counts inside the guard are accepted (81 is Wan's default).
5133        let mut req = valid_req();
5134        req.model = "wan22-ti2v-5b:fp16".to_string();
5135        req.output_format = Some(OutputFormat::Mp4);
5136        req.fps = Some(24);
5137        req.frames = Some(81);
5138        assert!(validate_generate_request(&req).is_ok());
5139
5140        // Off the 4k+1 grid is rejected, and the error names Wan's own step —
5141        // not the LTX 8.
5142        req.frames = Some(80);
5143        let err = validate_generate_request(&req).unwrap_err();
5144        assert!(err.contains("4n+1"), "got: {err}");
5145
5146        // One grid step past the flat guard is rejected with the same cap the
5147        // wire advertises.
5148        let cap = max_frames_for_family("wan").unwrap();
5149        req.frames = Some(cap + WAN_TEMPORAL_SCALE);
5150        let err = validate_generate_request(&req).unwrap_err();
5151        assert!(err.contains(&cap.to_string()), "got: {err}");
5152    }
5153
5154    /// Wan is a video family in every consuming authority: the expansion
5155    /// task resolver (twin: `studio/lib/expandTask.ts`) and the output-format
5156    /// default + gate. A miss in either renders wan as an image model.
5157    #[test]
5158    fn wan_routes_through_the_video_authorities() {
5159        use crate::ExpandTask;
5160        assert_eq!(ExpandTask::for_family("wan"), ExpandTask::TextToVideo);
5161        assert_eq!(
5162            ExpandTask::for_conditioning("wan", None, true, false, false, 0, false, None),
5163            ExpandTask::ImageToVideo
5164        );
5165        assert_eq!(
5166            ExpandTask::for_conditioning("wan", None, false, false, false, 0, false, None),
5167            ExpandTask::TextToVideo
5168        );
5169
5170        let mut req = valid_req();
5171        req.model = "wan22-ti2v-5b:fp16".to_string();
5172        req.output_format = None;
5173        req.normalise_output_format(Some("wan"));
5174        assert_eq!(req.resolved_output_format(), OutputFormat::Mp4);
5175
5176        req.output_format = Some(OutputFormat::Png);
5177        let err = validate_generate_request(&req).unwrap_err();
5178        assert!(err.contains("mp4"), "got: {err}");
5179    }
5180
5181    /// #798: a single-frame Wan render is a still. png/jpeg are admitted at
5182    /// exactly `frames == 1`, default to png there, and classify as
5183    /// image-style prompt work — while `frames > 1` (or unset, which defaults
5184    /// to a full clip) keeps the video-only contract and its current error.
5185    /// Twin: `studio/lib/expandTask.ts`.
5186    #[test]
5187    fn wan_single_frame_is_a_still() {
5188        use crate::ExpandTask;
5189
5190        let mut req = valid_req();
5191        req.model = "wan22-t2v-a14b:q5".to_string();
5192        req.frames = Some(1);
5193
5194        // Unset format at frames=1 normalises to a still, and both image
5195        // formats pass admission.
5196        req.output_format = None;
5197        req.normalise_output_format(Some("wan"));
5198        assert_eq!(req.resolved_output_format(), OutputFormat::Png);
5199        assert!(validate_generate_request(&req).is_ok());
5200        req.output_format = Some(OutputFormat::Jpeg);
5201        assert!(validate_generate_request(&req).is_ok());
5202        // Video formats stay allowed at frames=1 — permitting stills must not
5203        // revoke the existing contract.
5204        req.output_format = Some(OutputFormat::Mp4);
5205        assert!(validate_generate_request(&req).is_ok());
5206        // Audio never becomes admissible through the still gate.
5207        req.output_format = Some(OutputFormat::Wav);
5208        assert!(validate_generate_request(&req).is_err());
5209
5210        // frames > 1 and frames unset keep refusing image formats with the
5211        // current message.
5212        for frames in [Some(5), None] {
5213            req.frames = frames;
5214            req.output_format = Some(OutputFormat::Png);
5215            let err = validate_generate_request(&req).unwrap_err();
5216            assert!(err.contains("mp4, gif, apng, or webp"), "got: {err}");
5217            req.output_format = None;
5218            req.normalise_output_format(Some("wan"));
5219            assert_eq!(req.resolved_output_format(), OutputFormat::Mp4);
5220        }
5221
5222        // The expansion task follows: a frames=1 wan request is image-style
5223        // prompt work, not chronological shot direction.
5224        assert_eq!(
5225            ExpandTask::for_conditioning("wan", None, false, false, false, 0, false, Some(1)),
5226            ExpandTask::TextToImage
5227        );
5228        // …unless a source image conditions it: source authority survives the
5229        // still contract (codex review).
5230        assert_eq!(
5231            ExpandTask::for_conditioning("wan", None, true, false, false, 0, false, Some(1)),
5232            ExpandTask::ImageToVideo
5233        );
5234        assert_eq!(
5235            ExpandTask::for_conditioning("wan", None, false, false, false, 0, false, Some(81)),
5236            ExpandTask::TextToVideo
5237        );
5238        // LTX keeps its video classification even at one frame — the still
5239        // contract is wan's.
5240        assert_eq!(
5241            ExpandTask::for_conditioning("ltx2", None, false, false, false, 0, false, Some(1)),
5242            ExpandTask::TextToVideo
5243        );
5244        let mut still_req = valid_req();
5245        still_req.model = "wan22-t2v-a14b:q5".to_string();
5246        still_req.frames = Some(1);
5247        assert_eq!(
5248            ExpandTask::for_generation("wan", &still_req),
5249            ExpandTask::TextToImage
5250        );
5251    }
5252
5253    /// #782 / #795: the wan recipe knobs are admitted for wan and rejected —
5254    /// never ignored — everywhere else, and the shared scheduler slot's two
5255    /// solver families stay disjoint at admission.
5256    #[test]
5257    fn wan_recipe_knobs_gate_by_family() {
5258        use crate::Scheduler;
5259
5260        // sample_shift: wan takes finite positive values only.
5261        let mut wan = valid_req();
5262        wan.model = "wan22-t2v-a14b:q8".to_string();
5263        wan.output_format = Some(OutputFormat::Mp4);
5264        wan.sample_shift = Some(12.0);
5265        assert!(validate_generate_request(&wan).is_ok());
5266        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
5267            wan.sample_shift = Some(bad);
5268            assert!(
5269                validate_generate_request(&wan).is_err(),
5270                "shift {bad} must be rejected"
5271            );
5272        }
5273        wan.sample_shift = None;
5274
5275        // The wan solvers ride the scheduler slot; UNet schedulers are
5276        // refused for wan and the wan solvers are refused off-family.
5277        for solver in [Scheduler::UniPc, Scheduler::Euler, Scheduler::DpmPp] {
5278            wan.scheduler = Some(solver);
5279            assert!(
5280                validate_generate_request(&wan).is_ok(),
5281                "wan must accept {solver}"
5282            );
5283        }
5284        for unet in [Scheduler::Ddim, Scheduler::EulerAncestral] {
5285            wan.scheduler = Some(unet);
5286            let err = validate_generate_request(&wan).unwrap_err();
5287            assert!(err.contains("UNet scheduler"), "got: {err}");
5288        }
5289        wan.scheduler = None;
5290
5291        // The fp8-scaled tier refuses LoRA stacks at admission — its loader
5292        // fails closed, but only after the UMT5 encode. GGUF tiers accept.
5293        wan.model = "wan22-t2v-a14b:fp8".to_string();
5294        wan.loras = Some(vec![crate::LoraWeight {
5295            path: "distill.safetensors".to_string(),
5296            scale: 1.0,
5297
5298            expert: None,
5299        }]);
5300        let err = validate_generate_request(&wan).unwrap_err();
5301        assert!(err.contains("fp8-scaled"), "got: {err}");
5302        wan.model = "wan22-t2v-a14b:q8".to_string();
5303        assert!(validate_generate_request(&wan).is_ok());
5304        wan.loras = None;
5305
5306        // Distill strengths: the community band is accepted, typos are not.
5307        wan.distill_strength_high = Some(1.8);
5308        wan.distill_strength_low = Some(1.0);
5309        assert!(validate_generate_request(&wan).is_ok());
5310        wan.distill_strength_high = Some(4.5);
5311        assert!(validate_generate_request(&wan).is_err());
5312        wan.distill_strength_high = Some(0.0);
5313        assert!(validate_generate_request(&wan).is_err());
5314
5315        // Every knob is rejected, not ignored, for a non-wan family.
5316        let mut flux = valid_req();
5317        flux.sample_shift = Some(5.0);
5318        let err = validate_generate_request(&flux).unwrap_err();
5319        assert!(err.contains("sample_shift"), "got: {err}");
5320        flux.sample_shift = None;
5321        flux.distill_strength_high = Some(1.5);
5322        let err = validate_generate_request(&flux).unwrap_err();
5323        assert!(err.contains("distill_strength_high"), "got: {err}");
5324        flux.distill_strength_high = None;
5325        flux.scheduler = Some(Scheduler::Euler);
5326        let err = validate_generate_request(&flux).unwrap_err();
5327        assert!(err.contains("Wan sample solver"), "got: {err}");
5328        // The UNet schedulers keep working off-family.
5329        flux.scheduler = Some(Scheduler::Ddim);
5330        assert!(validate_generate_request(&flux).is_ok());
5331    }
5332
5333    /// #779: wan admits exactly the first/last endpoint keyframe pair, and
5334    /// classifies it as boundary-preserving prompt work — parity twin:
5335    /// `studio/lib/expandTask.ts`.
5336    #[test]
5337    fn wan_keyframes_admit_only_the_endpoint_pair() {
5338        use crate::{ExpandTask, KeyframeCondition};
5339        let keyframe = |frame: u32| KeyframeCondition {
5340            frame,
5341            // A real PNG header so the image-format check passes.
5342            image: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
5343            name: None,
5344        };
5345
5346        let mut req = valid_req();
5347        req.model = "wan22-i2v-a14b:q5".to_string();
5348        req.output_format = Some(OutputFormat::Mp4);
5349        req.frames = Some(33);
5350        req.keyframes = Some(vec![keyframe(0), keyframe(32)]);
5351        assert!(validate_generate_request(&req).is_ok());
5352
5353        // Wrong count, wrong anchors, and the ambiguous source+keyframes mix
5354        // are named at admission.
5355        req.keyframes = Some(vec![keyframe(0)]);
5356        let err = validate_generate_request(&req).unwrap_err();
5357        assert!(err.contains("exactly two keyframes"), "got: {err}");
5358        req.keyframes = Some(vec![keyframe(0), keyframe(7)]);
5359        let err = validate_generate_request(&req).unwrap_err();
5360        assert!(err.contains("frames 0 and 32"), "got: {err}");
5361        req.keyframes = Some(vec![keyframe(0), keyframe(32)]);
5362        req.source_image = Some(vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
5363        let err = validate_generate_request(&req).unwrap_err();
5364        assert!(err.contains("not both"), "got: {err}");
5365        req.source_image = None;
5366
5367        // Without an explicit frames count the closing anchor is uncheckable
5368        // here, and the engine would reject a mismatch only after loading.
5369        req.frames = None;
5370        req.keyframes = Some(vec![keyframe(0), keyframe(32)]);
5371        let err = validate_generate_request(&req).unwrap_err();
5372        assert!(err.contains("explicit frames count"), "got: {err}");
5373
5374        // frames=1 renders a still — its endpoints coincide, so the pair is
5375        // refused by name rather than as a generic duplicate frame.
5376        req.frames = Some(1);
5377        req.keyframes = Some(vec![keyframe(0), keyframe(0)]);
5378        let err = validate_generate_request(&req).unwrap_err();
5379        assert!(err.contains("multi-frame clip"), "got: {err}");
5380        req.frames = Some(33);
5381        req.keyframes = Some(vec![keyframe(0), keyframe(32)]);
5382
5383        // TI2V pins endpoints in latent space: a 5-frame pixel clip is two
5384        // latent frames, both anchored — refused before the 10 GB load. The
5385        // A14B channel-concat path has no such floor (checked above at 33).
5386        let mut ti2v = valid_req();
5387        ti2v.model = "wan22-ti2v-5b:fp16".to_string();
5388        ti2v.output_format = Some(OutputFormat::Mp4);
5389        ti2v.width = 1280;
5390        ti2v.height = 704;
5391        ti2v.frames = Some(5);
5392        ti2v.keyframes = Some(vec![keyframe(0), keyframe(4)]);
5393        let err = validate_generate_request(&ti2v).unwrap_err();
5394        assert!(err.contains("at least 9 frames"), "got: {err}");
5395        ti2v.frames = Some(9);
5396        ti2v.keyframes = Some(vec![keyframe(0), keyframe(8)]);
5397        assert!(validate_generate_request(&ti2v).is_ok());
5398
5399        // The expansion task treats the pair as boundary anchors, exactly as
5400        // LTX-2 keyframes classify.
5401        assert_eq!(
5402            ExpandTask::for_generation("wan", &req),
5403            ExpandTask::KeyframeInterpolation
5404        );
5405
5406        // Non-video families keep rejecting keyframes outright.
5407        let mut flux = valid_req();
5408        flux.keyframes = Some(vec![keyframe(0), keyframe(8)]);
5409        assert!(validate_generate_request(&flux).is_err());
5410    }
5411
5412    /// Buckets are advertised per checkpoint: the 480p-only 1.3B and the
5413    /// 704-grid TI2V-5B must not inherit each other's sizes, while unknown
5414    /// wan checkpoints keep the family fallback.
5415    #[test]
5416    fn wan_recommended_dimensions_are_per_checkpoint() {
5417        assert_eq!(
5418            wan_recommended_dimensions("wan21-t2v-1.3b"),
5419            &[(832, 480), (480, 832)]
5420        );
5421        assert_eq!(
5422            wan_recommended_dimensions("wan22-ti2v-5b:fp16"),
5423            &[(1280, 704), (704, 1280)]
5424        );
5425        assert_eq!(
5426            wan_recommended_dimensions("cv:someone/some-wan-finetune"),
5427            recommended_dimensions("wan")
5428        );
5429        for model in ["wan21-t2v-1.3b", "wan22-ti2v-5b"] {
5430            for (w, h) in wan_recommended_dimensions(model) {
5431                assert!(
5432                    validate_generation_dimensions(*w, *h, Some("wan")).is_ok(),
5433                    "{model}: advertised {w}x{h} must pass the validator"
5434                );
5435                assert!(
5436                    validate_generation_dimensions_for_model(
5437                        model,
5438                        *w,
5439                        *h,
5440                        Some("wan"),
5441                        Ltx2SpatialComposition::SinglePass,
5442                    )
5443                    .is_ok(),
5444                    "{model}: advertised {w}x{h} must pass its own model-aware validator"
5445                );
5446            }
5447        }
5448    }
5449
5450    /// The grid is per checkpoint too: `wan22-ti2v-5b`'s 2.2 VAE compresses
5451    /// 16x spatially and its DiT patches the latent 2x2, so its pixel grid is
5452    /// 32 while the 2.1-VAE checkpoints keep the family's 16.
5453    #[test]
5454    fn wan_dimension_alignment_is_per_checkpoint() {
5455        assert_eq!(wan_dimension_alignment("wan22-ti2v-5b"), 32);
5456        assert_eq!(wan_dimension_alignment("wan22-ti2v-5b:fp16"), 32);
5457        // Variant tags and the legacy dash form must match like
5458        // `wan_recommended_dimensions` — a `:q8` install of the same
5459        // checkpoint has the same VAE.
5460        assert_eq!(wan_dimension_alignment("wan22-ti2v-5b:q8"), 32);
5461        assert_eq!(wan_dimension_alignment("wan22-ti2v-5b-fp16"), 32);
5462        assert_eq!(wan_dimension_alignment("wan21-t2v-1.3b"), 16);
5463        assert_eq!(wan_dimension_alignment("wan22-t2v-a14b:q5"), 16);
5464        assert_eq!(wan_dimension_alignment("wan22-i2v-a14b:q8"), 16);
5465        // Unknown catalog installs keep the family fallback; deriving the
5466        // grid from a sidecar-described VAE is follow-up work.
5467        assert_eq!(wan_dimension_alignment("cv:someone/some-wan-finetune"), 16);
5468    }
5469
5470    #[test]
5471    fn dimension_alignment_for_model_dispatches_wan_checkpoints() {
5472        assert_eq!(
5473            dimension_alignment_for_model("wan22-ti2v-5b", Some("wan")),
5474            32
5475        );
5476        // Manifest models resolve their family without a hint.
5477        assert_eq!(
5478            dimension_alignment_for_model("wan22-ti2v-5b:fp16", None),
5479            32
5480        );
5481        assert_eq!(
5482            dimension_alignment_for_model("wan21-t2v-1.3b", Some("wan")),
5483            16
5484        );
5485        assert_eq!(
5486            dimension_alignment_for_model("cv:someone/some-wan-finetune", Some("wan")),
5487            16
5488        );
5489        // Every other family keeps its family-wide answer.
5490        assert_eq!(
5491            dimension_alignment_for_model("ltx-2-19b-distilled:fp8", Some("ltx2")),
5492            32
5493        );
5494        assert_eq!(
5495            dimension_alignment_for_model("flux-dev:q4", Some("flux")),
5496            16
5497        );
5498    }
5499
5500    /// 1280x720 is on the family's 16 px grid but off the 5B's 32 px grid.
5501    /// Admission must reject it before a 10 GB model load, with the engine's
5502    /// own number; the same canvas stays valid for the 2.1-VAE checkpoints.
5503    #[test]
5504    fn wan22_ti2v_5b_off_grid_dimensions_rejected_at_admission() {
5505        let mut req = valid_req();
5506        req.model = "wan22-ti2v-5b".to_string();
5507        req.output_format = Some(OutputFormat::Mp4);
5508        req.width = 1280;
5509        req.height = 720;
5510        let err = validate_generate_request_with_family(&req, Some("wan")).unwrap_err();
5511        assert!(err.contains("multiples of 32"), "got: {err}");
5512
5513        req.width = 704;
5514        req.height = 1280;
5515        validate_generate_request_with_family(&req, Some("wan"))
5516            .expect("the 5B's native portrait bucket is on its 32px grid");
5517
5518        req.model = "wan21-t2v-1.3b".to_string();
5519        req.width = 1280;
5520        req.height = 720;
5521        validate_generate_request_with_family(&req, Some("wan"))
5522            .expect("the 2.1-VAE checkpoints keep the family's 16px grid");
5523    }
5524
5525    #[test]
5526    fn validate_generation_dimensions_for_model_uses_the_checkpoint_grid() {
5527        let err = validate_generation_dimensions_for_model(
5528            "wan22-ti2v-5b",
5529            1280,
5530            720,
5531            Some("wan"),
5532            Ltx2SpatialComposition::SinglePass,
5533        )
5534        .unwrap_err();
5535        assert!(err.contains("multiples of 32"), "got: {err}");
5536        validate_generation_dimensions_for_model(
5537            "wan22-ti2v-5b",
5538            1280,
5539            704,
5540            Some("wan"),
5541            Ltx2SpatialComposition::SinglePass,
5542        )
5543        .expect("1280x704 sits on the 32px grid");
5544        // The family-only validator deliberately keeps the compatible 16px
5545        // answer for callers that cannot name a model.
5546        assert!(validate_generation_dimensions(1280, 720, Some("wan")).is_ok());
5547    }
5548
5549    #[test]
5550    fn wan_recommended_dimensions_fit_their_own_contracts() {
5551        let dims = recommended_dimensions("wan");
5552        assert!(!dims.is_empty());
5553        for (w, h) in dims {
5554            assert!(
5555                w.is_multiple_of(16) && h.is_multiple_of(16),
5556                "{w}x{h} must sit on the family's 16px grid"
5557            );
5558            assert!(
5559                u64::from(*w) * u64::from(*h) <= MAX_PIXELS,
5560                "{w}x{h} must fit the generic pixel budget"
5561            );
5562            assert!(
5563                validate_generation_dimensions(*w, *h, Some("wan")).is_ok(),
5564                "{w}x{h} must pass the validator it is advertised against"
5565            );
5566        }
5567    }
5568
5569    #[test]
5570    fn ltx2_frames_at_rope_budget_accepted() {
5571        let mut req = valid_req();
5572        req.model = "ltx-2-19b-distilled:fp8".to_string();
5573        req.output_format = Some(OutputFormat::Mp4);
5574        req.fps = Some(24);
5575        // 481 = 20s at 24 fps on the 8n+1 grid (484 is the exact ceiling).
5576        req.frames = Some(481);
5577        assert!(validate_generate_request(&req).is_ok());
5578    }
5579
5580    /// The old flat 153 was a floor, not a ceiling, at the default frame rate:
5581    /// LTX-2.3 advertises ~20s single-shot generation and the checkpoint budget
5582    /// agrees. Frame counts that used to be rejected out of hand must pass.
5583    #[test]
5584    fn ltx2_frames_over_the_old_flat_cap_are_accepted_within_the_duration_budget() {
5585        for frames in [161u32, 193, 257, 401] {
5586            let mut req = valid_req();
5587            req.model = "ltx-2-19b-distilled:fp8".to_string();
5588            req.output_format = Some(OutputFormat::Mp4);
5589            req.fps = Some(24);
5590            req.frames = Some(frames);
5591            assert!(
5592                validate_generate_request(&req).is_ok(),
5593                "{frames} frames at 24 fps is {:.1}s, inside the {LTX2_MAX_RUNTIME_SECONDS}s budget",
5594                frames as f64 / 24.0,
5595            );
5596        }
5597    }
5598
5599    #[test]
5600    fn ltx2_frames_over_rope_budget_rejected() {
5601        let mut req = valid_req();
5602        req.model = "ltx-2-19b-distilled:fp8".to_string();
5603        req.output_format = Some(OutputFormat::Mp4);
5604        req.fps = Some(24);
5605        req.frames = Some(489); // 20.2s at 24 fps — one grid step past the budget
5606        let err = validate_generate_request(&req).unwrap_err();
5607        assert!(err.contains("489"), "got: {err}");
5608        // The quoted ceiling is grid-snapped so it is directly usable: 484 is
5609        // the exact budget but 483 % 8 == 3, so retrying at 484 would fail again.
5610        assert!(err.contains("481"), "got: {err}");
5611        assert!(err.contains("RoPE"), "got: {err}");
5612    }
5613
5614    /// The same frame count can be inside or outside the budget depending on
5615    /// fps — this is the whole point of deriving the ceiling instead of fixing
5616    /// it. 193 frames is 8s at 24 fps but 32s at 6 fps.
5617    #[test]
5618    fn ltx2_frame_budget_is_a_duration_not_a_frame_count() {
5619        let mut req = valid_req();
5620        req.model = "ltx-2-19b-distilled:fp8".to_string();
5621        req.output_format = Some(OutputFormat::Mp4);
5622        req.frames = Some(193);
5623
5624        req.fps = Some(24);
5625        assert!(validate_generate_request(&req).is_ok());
5626
5627        req.fps = Some(6);
5628        let err = validate_generate_request(&req).unwrap_err();
5629        // 20s at 6 fps is 124 frames; 121 is that budget on the 8n+1 grid.
5630        assert!(err.contains("121"), "got: {err}");
5631    }
5632
5633    #[test]
5634    fn ltx2_absolute_frame_guard_binds_above_thirty_fps() {
5635        let mut req = valid_req();
5636        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
5637        req.output_format = Some(OutputFormat::Mp4);
5638        req.fps = Some(120);
5639        req.frames = Some(609); // first 8n+1 value past the 604-frame guard
5640        let err = validate_generate_request(&req).unwrap_err();
5641        // The absolute guard binds here, quoted on the 8n+1 grid (604 -> 601).
5642        assert!(
5643            err.contains(&ltx2_max_frames_on_grid_at_fps(120).to_string()),
5644            "got: {err}"
5645        );
5646        assert_eq!(ltx2_max_frames_on_grid_at_fps(120), 601);
5647    }
5648
5649    #[test]
5650    fn ltx_video_family_is_not_subject_to_the_ltx2_rope_cap() {
5651        let mut req = valid_req();
5652        req.model = "ltx-video-0.9.6-distilled:bf16".to_string();
5653        req.output_format = Some(OutputFormat::Mp4);
5654        req.frames = Some(161);
5655        assert!(validate_generate_request(&req).is_ok());
5656    }
5657
5658    /// `ltx-video` keeps the flat global ceiling; only `ltx2` publishes a
5659    /// duration budget, so the two families must not share a cap.
5660    #[test]
5661    fn ltx_video_keeps_the_flat_global_ceiling() {
5662        let mut req = valid_req();
5663        req.model = "ltx-video-0.9.6-distilled:bf16".to_string();
5664        req.output_format = Some(OutputFormat::Mp4);
5665        req.fps = Some(30);
5666        req.frames = Some(MAX_FRAMES_GLOBAL + 8);
5667        let err = validate_generate_request(&req).unwrap_err();
5668        assert!(err.contains(&MAX_FRAMES_GLOBAL.to_string()), "got: {err}");
5669    }
5670
5671    /// `derive_stage1_render_shape` halves the frame count *and* the fps, so an
5672    /// x2 temporal upscale renders the same runtime — it never buys duration.
5673    #[test]
5674    fn ltx2_temporal_upscale_x2_does_not_extend_the_duration_budget() {
5675        let mut req = valid_req();
5676        req.model = "ltx-2-19b-distilled:fp8".to_string();
5677        req.output_format = Some(OutputFormat::Mp4);
5678        req.fps = Some(24);
5679        req.temporal_upscale = Some(crate::Ltx2TemporalUpscale::X2);
5680
5681        // stage 1 = (481-1)/2+1 = 241 frames at 12 fps, ceiling 244 → fits.
5682        req.frames = Some(481);
5683        assert!(validate_generate_request(&req).is_ok());
5684
5685        // 20.4s of runtime is over budget with or without temporal upscaling.
5686        req.frames = Some(497);
5687        let err = validate_generate_request(&req).unwrap_err();
5688        assert!(err.contains("RoPE"), "got: {err}");
5689    }
5690
5691    #[test]
5692    fn non_ltx_models_do_not_apply_the_ltx_frame_grid_rule() {
5693        let mut req = valid_req();
5694        req.frames = Some(10);
5695        assert!(validate_generate_request(&req).is_ok());
5696    }
5697
5698    #[test]
5699    fn zero_batch_rejected() {
5700        let mut req = valid_req();
5701        req.batch_size = 0;
5702        assert!(validate_generate_request(&req).is_err());
5703    }
5704
5705    #[test]
5706    fn large_batch_accepted() {
5707        let mut req = valid_req();
5708        req.batch_size = 100;
5709        assert!(validate_generate_request(&req).is_ok());
5710    }
5711
5712    #[test]
5713    fn negative_guidance_rejected() {
5714        let mut req = valid_req();
5715        req.guidance = -1.0;
5716        assert!(validate_generate_request(&req).is_err());
5717    }
5718
5719    #[test]
5720    fn zero_guidance_valid() {
5721        let mut req = valid_req();
5722        req.guidance = 0.0;
5723        assert!(validate_generate_request(&req).is_ok());
5724    }
5725
5726    #[test]
5727    fn high_guidance_valid() {
5728        let mut req = valid_req();
5729        req.guidance = 20.0;
5730        assert!(validate_generate_request(&req).is_ok());
5731    }
5732
5733    #[test]
5734    fn guidance_over_100_rejected() {
5735        let mut req = valid_req();
5736        req.guidance = 100.1;
5737        assert!(validate_generate_request(&req)
5738            .unwrap_err()
5739            .contains("guidance"));
5740    }
5741
5742    #[test]
5743    fn guidance_at_100_valid() {
5744        let mut req = valid_req();
5745        req.guidance = 100.0;
5746        assert!(validate_generate_request(&req).is_ok());
5747    }
5748
5749    #[test]
5750    fn prompt_too_long_rejected() {
5751        let mut req = valid_req();
5752        req.prompt = "x".repeat(77_001);
5753        assert!(validate_generate_request(&req)
5754            .unwrap_err()
5755            .contains("77,000"));
5756    }
5757
5758    #[test]
5759    fn prompt_at_limit_valid() {
5760        let mut req = valid_req();
5761        req.prompt = "x".repeat(77_000);
5762        assert!(validate_generate_request(&req).is_ok());
5763    }
5764
5765    #[test]
5766    fn negative_prompt_too_long_rejected() {
5767        let mut req = valid_req();
5768        req.negative_prompt = Some("x".repeat(77_001));
5769        assert!(validate_generate_request(&req)
5770            .unwrap_err()
5771            .contains("negative_prompt"));
5772    }
5773
5774    #[test]
5775    fn negative_prompt_at_limit_valid() {
5776        let mut req = valid_req();
5777        req.negative_prompt = Some("x".repeat(77_000));
5778        assert!(validate_generate_request(&req).is_ok());
5779    }
5780
5781    #[test]
5782    fn negative_prompt_none_valid() {
5783        let req = valid_req();
5784        assert!(req.negative_prompt.is_none());
5785        assert!(validate_generate_request(&req).is_ok());
5786    }
5787
5788    #[test]
5789    fn negative_prompt_empty_valid() {
5790        let mut req = valid_req();
5791        req.negative_prompt = Some(String::new());
5792        assert!(validate_generate_request(&req).is_ok());
5793    }
5794
5795    #[test]
5796    fn seed_is_optional() {
5797        let mut req = valid_req();
5798        req.seed = None;
5799        assert!(validate_generate_request(&req).is_ok());
5800    }
5801
5802    // ── img2img validation tests ────────────────────────────────────────────
5803
5804    #[test]
5805    fn img2img_strength_zero_accepted() {
5806        let mut req = valid_req();
5807        req.source_image = Some(png_bytes());
5808        req.strength = 0.0;
5809        assert!(validate_generate_request(&req).is_ok());
5810    }
5811
5812    #[test]
5813    fn img2img_strength_negative_rejected() {
5814        let mut req = valid_req();
5815        req.source_image = Some(png_bytes());
5816        req.strength = -0.1;
5817        assert!(validate_generate_request(&req)
5818            .unwrap_err()
5819            .contains("strength"));
5820    }
5821
5822    #[test]
5823    fn img2img_strength_one_accepted() {
5824        let mut req = valid_req();
5825        req.source_image = Some(png_bytes());
5826        req.strength = 1.0;
5827        assert!(validate_generate_request(&req).is_ok());
5828    }
5829
5830    #[test]
5831    fn img2img_strength_half_accepted() {
5832        let mut req = valid_req();
5833        req.source_image = Some(png_bytes());
5834        req.strength = 0.5;
5835        assert!(validate_generate_request(&req).is_ok());
5836    }
5837
5838    #[test]
5839    fn img2img_invalid_magic_bytes_rejected() {
5840        let mut req = valid_req();
5841        req.source_image = Some(vec![0x00, 0x01, 0x02, 0x03]);
5842        req.strength = 0.75;
5843        assert!(validate_generate_request(&req)
5844            .unwrap_err()
5845            .contains("PNG or JPEG"));
5846    }
5847
5848    #[test]
5849    fn img2img_jpeg_accepted() {
5850        let mut req = valid_req();
5851        req.source_image = Some(jpeg_bytes());
5852        req.strength = 0.75;
5853        assert!(validate_generate_request(&req).is_ok());
5854    }
5855
5856    #[test]
5857    fn img2img_no_source_image_skips_strength_check() {
5858        let mut req = valid_req();
5859        req.source_image = None;
5860        req.strength = 0.0; // Would fail if source_image present, but should pass without
5861        assert!(validate_generate_request(&req).is_ok());
5862    }
5863
5864    #[test]
5865    fn qwen_image_edit_requires_edit_images() {
5866        let mut req = valid_req();
5867        req.model = "qwen-image-edit:q4".to_string();
5868        let err = validate_generate_request(&req).unwrap_err();
5869        assert_eq!(
5870            err,
5871            "Qwen Image Edit needs at least one image. Add a Target image and try again."
5872        );
5873    }
5874
5875    #[test]
5876    fn qwen_image_edit_rejects_batch_size_above_one() {
5877        let mut req = valid_req();
5878        req.model = "qwen-image-edit:q4".to_string();
5879        req.edit_images = Some(vec![png_bytes()]);
5880        req.batch_size = 2;
5881        let err = validate_generate_request(&req).unwrap_err();
5882        assert!(err.contains("batch_size = 1"), "got: {err}");
5883    }
5884
5885    #[test]
5886    fn qwen_image_edit_accepts_edit_images() {
5887        let mut req = valid_req();
5888        req.model = "qwen-image-edit:q4".to_string();
5889        req.edit_images = Some(vec![png_bytes()]);
5890        req.guidance = 4.0;
5891        assert!(validate_generate_request(&req).is_ok());
5892    }
5893
5894    #[test]
5895    fn flux2_dev_accepts_text_only_and_ordered_references() {
5896        let mut req = valid_req();
5897        req.model = "flux2-dev:bf16".to_string();
5898        req.guidance = 4.0;
5899        assert!(validate_generate_request(&req).is_ok());
5900
5901        req.edit_images = Some(vec![png_bytes(), jpeg_bytes()]);
5902        assert!(validate_generate_request(&req).is_ok());
5903    }
5904
5905    #[test]
5906    fn flux2_dev_catalog_id_accepts_references_but_rejects_img2img_fields() {
5907        let mut req = valid_req();
5908        req.model = "hf:black-forest-labs/FLUX.2-dev".to_string();
5909        req.edit_images = Some(vec![png_bytes()]);
5910        assert!(validate_generate_request_with_family(&req, Some("flux2")).is_ok());
5911
5912        req.source_image = Some(png_bytes());
5913        let error = validate_generate_request_with_family(&req, Some("flux2")).unwrap_err();
5914        assert!(error.contains("edit_images instead of source_image"));
5915    }
5916
5917    #[test]
5918    fn flux2_dev_bounds_reference_count_and_rejects_lora() {
5919        let mut req = valid_req();
5920        req.model = "flux2-dev:bf16".to_string();
5921        req.edit_images = Some(vec![png_bytes(); FLUX2_DEV_MAX_REFERENCE_IMAGES + 1]);
5922        assert!(validate_generate_request(&req)
5923            .unwrap_err()
5924            .contains("at most"));
5925
5926        req.edit_images = None;
5927        req.lora = Some(LoraWeight {
5928            path: "adapter.safetensors".into(),
5929            scale: 1.0,
5930
5931            expert: None,
5932        });
5933        assert_eq!(
5934            validate_generate_request(&req).unwrap_err(),
5935            "flux2-dev does not support LoRA"
5936        );
5937    }
5938
5939    #[test]
5940    fn qwen_image_edit_rejects_source_image_field() {
5941        let mut req = valid_req();
5942        req.model = "qwen-image-edit:q4".to_string();
5943        req.edit_images = Some(vec![png_bytes()]);
5944        req.source_image = Some(png_bytes());
5945        let err = validate_generate_request(&req).unwrap_err();
5946        assert!(
5947            err.contains("edit_images instead of source_image"),
5948            "got: {err}"
5949        );
5950    }
5951
5952    #[test]
5953    fn non_edit_models_reject_edit_images() {
5954        let mut req = valid_req();
5955        req.model = "flux-schnell:q8".to_string();
5956        req.edit_images = Some(vec![png_bytes()]);
5957        let err = validate_generate_request(&req).unwrap_err();
5958        assert!(
5959            err.contains("only supported for qwen-image-edit"),
5960            "got: {err}"
5961        );
5962    }
5963
5964    #[test]
5965    fn non_edit_models_reject_edit_images_before_format_validation() {
5966        let mut req = valid_req();
5967        req.model = "flux-schnell:q8".to_string();
5968        req.edit_images = Some(vec![b"not-an-image".to_vec()]);
5969        let err = validate_generate_request(&req).unwrap_err();
5970        assert!(
5971            err.contains("only supported for qwen-image-edit"),
5972            "got: {err}"
5973        );
5974    }
5975
5976    // ── ControlNet validation tests ────────────────────────────────────────
5977
5978    #[test]
5979    fn controlnet_valid_request() {
5980        let mut req = valid_req();
5981        req.model = "dreamshaper-v8:fp16".to_string();
5982        req.control_image = Some(png_bytes());
5983        req.control_model = Some("controlnet-canny-sd15".to_string());
5984        req.control_scale = 0.8;
5985        assert!(validate_generate_request(&req).is_ok());
5986    }
5987
5988    #[test]
5989    fn controlnet_image_without_model_rejected() {
5990        let mut req = valid_req();
5991        req.model = "dreamshaper-v8:fp16".to_string();
5992        req.control_image = Some(png_bytes());
5993        req.control_model = None;
5994        assert!(validate_generate_request(&req)
5995            .unwrap_err()
5996            .contains("control_model"));
5997    }
5998
5999    #[test]
6000    fn controlnet_model_without_image_rejected() {
6001        let mut req = valid_req();
6002        req.model = "dreamshaper-v8:fp16".to_string();
6003        req.control_image = None;
6004        req.control_model = Some("controlnet-canny-sd15".to_string());
6005        assert!(validate_generate_request(&req)
6006            .unwrap_err()
6007            .contains("control_image"));
6008    }
6009
6010    #[test]
6011    fn controlnet_invalid_image_rejected() {
6012        let mut req = valid_req();
6013        req.model = "dreamshaper-v8:fp16".to_string();
6014        req.control_image = Some(vec![0x00, 0x01, 0x02, 0x03]);
6015        req.control_model = Some("controlnet-canny-sd15".to_string());
6016        assert!(validate_generate_request(&req)
6017            .unwrap_err()
6018            .contains("PNG or JPEG"));
6019    }
6020
6021    #[test]
6022    fn controlnet_negative_scale_rejected() {
6023        let mut req = valid_req();
6024        req.model = "dreamshaper-v8:fp16".to_string();
6025        req.control_image = Some(png_bytes());
6026        req.control_model = Some("controlnet-canny-sd15".to_string());
6027        req.control_scale = -0.1;
6028        assert!(validate_generate_request(&req)
6029            .unwrap_err()
6030            .contains("control_scale"));
6031    }
6032
6033    #[test]
6034    fn controlnet_zero_scale_accepted() {
6035        let mut req = valid_req();
6036        req.model = "dreamshaper-v8:fp16".to_string();
6037        req.control_image = Some(png_bytes());
6038        req.control_model = Some("controlnet-canny-sd15".to_string());
6039        req.control_scale = 0.0;
6040        assert!(validate_generate_request(&req).is_ok());
6041    }
6042
6043    #[test]
6044    fn controlnet_high_scale_accepted() {
6045        let mut req = valid_req();
6046        req.model = "dreamshaper-v8:fp16".to_string();
6047        req.control_image = Some(png_bytes());
6048        req.control_model = Some("controlnet-canny-sd15".to_string());
6049        req.control_scale = 2.0;
6050        assert!(validate_generate_request(&req).is_ok());
6051    }
6052
6053    #[test]
6054    fn controlnet_jpeg_accepted() {
6055        let mut req = valid_req();
6056        req.model = "dreamshaper-v8:fp16".to_string();
6057        req.control_image = Some(jpeg_bytes());
6058        req.control_model = Some("controlnet-canny-sd15".to_string());
6059        assert!(validate_generate_request(&req).is_ok());
6060    }
6061
6062    #[test]
6063    fn controlnet_rejected_for_non_sd15_family() {
6064        let mut req = valid_req();
6065        req.model = "sdxl:fp16".to_string();
6066        req.control_image = Some(png_bytes());
6067        req.control_model = Some("controlnet-canny-sd15".to_string());
6068
6069        let err = validate_generate_request(&req).unwrap_err();
6070        assert!(err.contains("SD1.5"), "got: {err}");
6071    }
6072    // ── Inpainting validation tests ───────────────────────────────────────
6073
6074    #[test]
6075    fn mask_without_source_image_rejected() {
6076        let mut req = valid_req();
6077        req.mask_image = Some(png_bytes());
6078        assert!(validate_generate_request(&req)
6079            .unwrap_err()
6080            .contains("mask_image requires source_image"));
6081    }
6082
6083    #[test]
6084    fn mask_with_source_image_accepted() {
6085        let mut req = valid_req();
6086        req.source_image = Some(png_bytes());
6087        req.mask_image = Some(png_bytes());
6088        assert!(validate_generate_request(&req).is_ok());
6089    }
6090
6091    #[test]
6092    fn mask_jpeg_accepted() {
6093        let mut req = valid_req();
6094        req.source_image = Some(png_bytes());
6095        req.mask_image = Some(jpeg_bytes());
6096        assert!(validate_generate_request(&req).is_ok());
6097    }
6098
6099    #[test]
6100    fn mask_invalid_bytes_rejected() {
6101        let mut req = valid_req();
6102        req.source_image = Some(png_bytes());
6103        req.mask_image = Some(vec![0x00, 0x01, 0x02, 0x03]);
6104        assert!(validate_generate_request(&req)
6105            .unwrap_err()
6106            .contains("mask_image must be a PNG or JPEG"));
6107    }
6108
6109    #[test]
6110    fn no_mask_no_source_passes() {
6111        let req = valid_req();
6112        assert!(validate_generate_request(&req).is_ok());
6113    }
6114
6115    // ── fit_to_model_dimensions tests ────────────────────────────────────
6116
6117    #[test]
6118    fn fit_same_aspect_downscale() {
6119        // 1024x1024 source -> 512x512 SD1.5 model
6120        assert_eq!(fit_to_model_dimensions(1024, 1024, 512, 512), (512, 512));
6121    }
6122
6123    #[test]
6124    fn fit_wide_source_downscale() {
6125        // 1920x1080 source -> 512x512 SD1.5 model
6126        // width-limited: w=512, h=512/1.778=287.9 -> 288 (16px aligned)
6127        assert_eq!(fit_to_model_dimensions(1920, 1080, 512, 512), (512, 288));
6128    }
6129
6130    #[test]
6131    fn fit_small_source_upscale_to_model_native() {
6132        // 512x512 source -> 1024x1024 FLUX model (upscale to native)
6133        assert_eq!(fit_to_model_dimensions(512, 512, 1024, 1024), (1024, 1024));
6134    }
6135
6136    #[test]
6137    fn fit_portrait_source() {
6138        // 768x1024 source -> 512x512 model
6139        // height-limited: h=512, w=512*0.75=384
6140        assert_eq!(fit_to_model_dimensions(768, 1024, 512, 512), (384, 512));
6141    }
6142
6143    #[test]
6144    fn fit_identity() {
6145        assert_eq!(
6146            fit_to_model_dimensions(1024, 1024, 1024, 1024),
6147            (1024, 1024)
6148        );
6149    }
6150
6151    #[test]
6152    fn fit_extreme_landscape() {
6153        // 3840x720 -> 1024x1024 model
6154        // width-limited: w=1024, h=1024/5.333=192
6155        assert_eq!(fit_to_model_dimensions(3840, 720, 1024, 1024), (1024, 192));
6156    }
6157
6158    #[test]
6159    fn fit_non_square_model_bounds() {
6160        // 1920x1080 -> 1024x768 model
6161        // src_ratio=1.778, model_ratio=1.333, width-limited: w=1024, h=1024/1.778=575.8 -> 576
6162        assert_eq!(fit_to_model_dimensions(1920, 1080, 1024, 768), (1024, 576));
6163    }
6164
6165    #[test]
6166    fn fit_dimensions_are_16px_aligned() {
6167        let (w, h) = fit_to_model_dimensions(1000, 600, 512, 512);
6168        assert!(w % 16 == 0, "width {w} must be 16px aligned");
6169        assert!(h % 16 == 0, "height {h} must be 16px aligned");
6170    }
6171
6172    #[test]
6173    fn fit_within_megapixel_limit() {
6174        let (w, h) = fit_to_model_dimensions(4096, 4096, 2048, 2048);
6175        let pixels = w as u64 * h as u64;
6176        assert!(
6177            pixels <= MAX_PIXELS,
6178            "{}x{} = {} pixels exceeds limit",
6179            w,
6180            h,
6181            pixels
6182        );
6183    }
6184
6185    #[test]
6186    fn fit_tiny_source_gets_model_native() {
6187        // 64x64 source -> 1024x1024 model
6188        assert_eq!(fit_to_model_dimensions(64, 64, 1024, 1024), (1024, 1024));
6189    }
6190
6191    #[test]
6192    fn fit_to_model_dimensions_aligned_rounds_to_the_models_grid() {
6193        // 1617x1000 into the 5B's 1280x704 canvas is height-limited:
6194        // h=704, w=704*1.617=1138.4 — which floors differently per grid.
6195        assert_eq!(
6196            fit_to_model_dimensions_aligned(1617, 1000, 1280, 704, 32),
6197            (1120, 704)
6198        );
6199        assert_eq!(
6200            fit_to_model_dimensions_aligned(1617, 1000, 1280, 704, 16),
6201            (1136, 704)
6202        );
6203        // The family-only helper stays the /16 compatibility path.
6204        assert_eq!(fit_to_model_dimensions(1617, 1000, 1280, 704), (1136, 704));
6205    }
6206
6207    #[test]
6208    fn fit_to_target_area_preserves_ratio_and_alignment() {
6209        let (w, h) = fit_to_target_area(1600, 900, 1024 * 1024, 16);
6210        assert_eq!((w, h), (1360, 768));
6211    }
6212
6213    // ── LoRA validation tests ──────────────────────────────────────────────
6214
6215    /// Build a FLUX-model request — the only family that supports LoRAs
6216    /// today. Tests that exercise LoRA value-validation (scale, extension)
6217    /// must use a LoRA-capable family or they fail on the upstream
6218    /// family-gate before the value check can trip.
6219    fn valid_flux_req() -> GenerateRequest {
6220        GenerateRequest {
6221            model: "flux-dev".to_string(),
6222            ..valid_req()
6223        }
6224    }
6225
6226    #[test]
6227    fn lora_none_valid() {
6228        let req = valid_req();
6229        assert!(req.lora.is_none());
6230        assert!(validate_generate_request(&req).is_ok());
6231    }
6232
6233    #[test]
6234    fn lora_scale_too_low_rejected() {
6235        let mut req = valid_flux_req();
6236        req.lora = Some(crate::LoraWeight {
6237            path: "adapter.safetensors".to_string(),
6238            scale: -0.1,
6239
6240            expert: None,
6241        });
6242        let err = validate_generate_request(&req).unwrap_err();
6243        assert!(
6244            err.contains("lora scale"),
6245            "expected lora scale error: {err}"
6246        );
6247    }
6248
6249    #[test]
6250    fn lora_scale_too_high_rejected() {
6251        let mut req = valid_flux_req();
6252        req.lora = Some(crate::LoraWeight {
6253            path: "adapter.safetensors".to_string(),
6254            scale: 2.1,
6255
6256            expert: None,
6257        });
6258        let err = validate_generate_request(&req).unwrap_err();
6259        assert!(
6260            err.contains("lora scale"),
6261            "expected lora scale error: {err}"
6262        );
6263    }
6264
6265    #[test]
6266    fn lora_scale_boundary_valid() {
6267        for scale in [0.0, 1.0, 2.0] {
6268            let mut req = valid_flux_req();
6269            req.lora = Some(crate::LoraWeight {
6270                path: "adapter.safetensors".to_string(),
6271                scale,
6272
6273                expert: None,
6274            });
6275            assert!(
6276                validate_generate_request(&req).is_ok(),
6277                "scale={scale} should be valid"
6278            );
6279        }
6280    }
6281
6282    #[test]
6283    fn lora_path_not_found_passes_validation() {
6284        // Path existence is checked at the inference layer, not validation,
6285        // so remote LoRA paths (server-side files) work correctly.
6286        let mut req = valid_flux_req();
6287        req.lora = Some(crate::LoraWeight {
6288            path: "/nonexistent/path/adapter.safetensors".to_string(),
6289            scale: 1.0,
6290
6291            expert: None,
6292        });
6293        assert!(validate_generate_request(&req).is_ok());
6294    }
6295
6296    #[test]
6297    fn lora_wrong_extension_rejected() {
6298        let mut req = valid_flux_req();
6299        req.lora = Some(crate::LoraWeight {
6300            path: "/some/path/adapter.bin".to_string(),
6301            scale: 1.0,
6302
6303            expert: None,
6304        });
6305        let err = validate_generate_request(&req).unwrap_err();
6306        assert!(
6307            err.contains("safetensors"),
6308            "expected safetensors error: {err}"
6309        );
6310    }
6311
6312    fn valid_sdxl_req() -> GenerateRequest {
6313        // Pick a real manifest-known SDXL name so `model_family` resolves to
6314        // `sdxl`. The test surface mirrors `valid_flux_req` / `valid_ltx2_req`.
6315        GenerateRequest {
6316            model: "sdxl-base:fp16".to_string(),
6317            ..valid_req()
6318        }
6319    }
6320
6321    /// SDXL gained LoRA support in Wave 1 of the LoRA-all-families work —
6322    /// `mold-inference::sdxl::lora` wraps the UNet `VarBuilder` with an
6323    /// `SdxlLoraBackend` that merges `W' = W + scale·(B @ A)` on the fly.
6324    /// The validator must now accept LoRAs on SDXL.
6325    #[test]
6326    fn lora_on_sdxl_accepted() {
6327        let mut req = valid_sdxl_req();
6328        req.lora = Some(crate::LoraWeight {
6329            path: "adapter.safetensors".to_string(),
6330            scale: 1.0,
6331
6332            expert: None,
6333        });
6334        assert!(
6335            validate_generate_request(&req).is_ok(),
6336            "SDXL + LoRA must pass validation now that sdxl/lora.rs is live"
6337        );
6338    }
6339
6340    #[test]
6341    fn loras_plural_on_sdxl_accepted() {
6342        let mut req = valid_sdxl_req();
6343        req.loras = Some(vec![
6344            crate::LoraWeight {
6345                path: "a.safetensors".to_string(),
6346                scale: 0.8,
6347
6348                expert: None,
6349            },
6350            crate::LoraWeight {
6351                path: "b.safetensors".to_string(),
6352                scale: 0.4,
6353
6354                expert: None,
6355            },
6356        ]);
6357        assert!(
6358            validate_generate_request(&req).is_ok(),
6359            "SDXL + plural LoRAs (multi-LoRA stack) must pass validation"
6360        );
6361    }
6362
6363    /// Wan gained LoRA support with the A14B Lightning distills (#747) and
6364    /// community `.diff`/`.diff_b` deltas (#781) — `wan/lora.rs` merges pairs
6365    /// and deltas on both the safetensors and GGUF weight paths — but this
6366    /// gate was never updated, so the server 400'd every explicit wan LoRA
6367    /// request while the engine loaded the same files happily.
6368    #[test]
6369    fn lora_on_wan_accepted() {
6370        let mut req = valid_req();
6371        req.model = "wan21-t2v-1.3b".to_string();
6372        req.output_format = Some(OutputFormat::Mp4);
6373        req.fps = Some(16);
6374        req.frames = Some(33);
6375        req.lora = Some(crate::LoraWeight {
6376            path: "adapter.safetensors".to_string(),
6377            scale: 1.0,
6378
6379            expert: None,
6380        });
6381        assert!(
6382            validate_generate_request(&req).is_ok(),
6383            "Wan + LoRA must pass validation now that wan/lora.rs is live"
6384        );
6385
6386        req.lora = None;
6387        req.loras = Some(vec![
6388            crate::LoraWeight {
6389                path: "a.safetensors".to_string(),
6390                scale: 0.8,
6391
6392                expert: None,
6393            },
6394            crate::LoraWeight {
6395                path: "b.safetensors".to_string(),
6396                scale: 0.4,
6397
6398                expert: None,
6399            },
6400        ]);
6401        assert!(
6402            validate_generate_request(&req).is_ok(),
6403            "Wan + plural LoRAs (multi-LoRA stack) must pass validation"
6404        );
6405    }
6406
6407    #[test]
6408    fn loras_plural_on_flux_valid() {
6409        // Multi-LoRA is supported on FLUX. The validator must not block
6410        // the plural form just because the singular form already gates.
6411        let mut req = valid_flux_req();
6412        req.loras = Some(vec![
6413            crate::LoraWeight {
6414                path: "a.safetensors".into(),
6415                scale: 0.8,
6416
6417                expert: None,
6418            },
6419            crate::LoraWeight {
6420                path: "b.safetensors".into(),
6421                scale: 0.4,
6422
6423                expert: None,
6424            },
6425        ]);
6426        assert!(validate_generate_request(&req).is_ok());
6427    }
6428
6429    fn valid_ltx2_req() -> GenerateRequest {
6430        GenerateRequest {
6431            model: "ltx-2-19b-distilled:fp8".to_string(),
6432            output_format: Some(OutputFormat::Mp4),
6433            ..valid_req()
6434        }
6435    }
6436
6437    #[test]
6438    fn lora_on_ltx2_accepted() {
6439        // LTX-2 has a full LoRA engine path (ltx2/lora.rs) — the validator
6440        // must not block it.
6441        let mut req = valid_ltx2_req();
6442        req.lora = Some(crate::LoraWeight {
6443            path: "LTX2.3_Crisp_Enhance.safetensors".to_string(),
6444            scale: 1.0,
6445
6446            expert: None,
6447        });
6448        assert!(
6449            validate_generate_request(&req).is_ok(),
6450            "LTX-2 + LoRA must pass validation"
6451        );
6452    }
6453
6454    #[test]
6455    fn loras_plural_on_ltx2_accepted() {
6456        // The loras-plural path routes through the same gate; confirm LTX-2
6457        // passes there too.
6458        let mut req = valid_ltx2_req();
6459        req.loras = Some(vec![
6460            crate::LoraWeight {
6461                path: "a.safetensors".into(),
6462                scale: 0.8,
6463
6464                expert: None,
6465            },
6466            crate::LoraWeight {
6467                path: "b.safetensors".into(),
6468                scale: 0.4,
6469
6470                expert: None,
6471            },
6472        ]);
6473        assert!(
6474            validate_generate_request(&req).is_ok(),
6475            "LTX-2 + loras plural must pass validation"
6476        );
6477    }
6478
6479    fn valid_zimage_req() -> GenerateRequest {
6480        GenerateRequest {
6481            model: "z-image-turbo:bf16".to_string(),
6482            ..valid_req()
6483        }
6484    }
6485
6486    fn valid_sd3_req() -> GenerateRequest {
6487        GenerateRequest {
6488            model: "sd3.5-large".to_string(),
6489            ..valid_req()
6490        }
6491    }
6492
6493    #[test]
6494    fn lora_on_sd3_accepted() {
6495        // SD3.5 has a full LoRA engine path (sd3/lora.rs) — the validator
6496        // must not block it.
6497        let mut req = valid_sd3_req();
6498        req.lora = Some(crate::LoraWeight {
6499            path: "sd35_style.safetensors".to_string(),
6500            scale: 1.0,
6501
6502            expert: None,
6503        });
6504        assert!(
6505            validate_generate_request(&req).is_ok(),
6506            "SD3 + LoRA must pass validation: {:?}",
6507            validate_generate_request(&req)
6508        );
6509    }
6510
6511    #[test]
6512    fn loras_plural_on_sd3_accepted() {
6513        let mut req = valid_sd3_req();
6514        req.loras = Some(vec![
6515            crate::LoraWeight {
6516                path: "a.safetensors".into(),
6517                scale: 0.8,
6518
6519                expert: None,
6520            },
6521            crate::LoraWeight {
6522                path: "b.safetensors".into(),
6523                scale: 0.4,
6524
6525                expert: None,
6526            },
6527        ]);
6528        assert!(
6529            validate_generate_request(&req).is_ok(),
6530            "SD3 + loras plural must pass validation"
6531        );
6532    }
6533
6534    #[test]
6535    fn lora_rejection_message_lists_sd3() {
6536        // The rejection message must enumerate every supported family.
6537        // wuerstchen has no LoRA path so the request is rejected; the message
6538        // must include SD3 in the supported list.
6539        let mut req = valid_req();
6540        req.model = "wuerstchen-c".to_string();
6541        req.lora = Some(crate::LoraWeight {
6542            path: "adapter.safetensors".to_string(),
6543            scale: 1.0,
6544
6545            expert: None,
6546        });
6547        let err = validate_generate_request(&req).unwrap_err();
6548        assert!(
6549            err.to_lowercase().contains("sd3"),
6550            "rejection message must list SD3 alongside FLUX/LTX-2: {err}"
6551        );
6552    }
6553
6554    #[test]
6555    fn lora_on_zimage_accepted() {
6556        // Z-Image grew a LoRA engine path (zimage/lora.rs) — the validator
6557        // must let it through.
6558        let mut req = valid_zimage_req();
6559        req.lora = Some(crate::LoraWeight {
6560            path: "NSFW_master_ZIT_000017532.safetensors".to_string(),
6561            scale: 1.0,
6562
6563            expert: None,
6564        });
6565        assert!(
6566            validate_generate_request(&req).is_ok(),
6567            "Z-Image + LoRA must pass validation"
6568        );
6569    }
6570
6571    #[test]
6572    fn loras_plural_on_zimage_accepted() {
6573        let mut req = valid_zimage_req();
6574        req.loras = Some(vec![
6575            crate::LoraWeight {
6576                path: "a.safetensors".into(),
6577                scale: 0.8,
6578
6579                expert: None,
6580            },
6581            crate::LoraWeight {
6582                path: "b.safetensors".into(),
6583                scale: 0.4,
6584
6585                expert: None,
6586            },
6587        ]);
6588        assert!(
6589            validate_generate_request(&req).is_ok(),
6590            "Z-Image + loras plural must pass validation"
6591        );
6592    }
6593
6594    #[test]
6595    fn lora_on_flux2_accepted() {
6596        // Flux.2 has a full LoRA engine path (flux2/lora.rs) — the validator
6597        // must not block it. The validator only sees the family resolved
6598        // from the model name; Flux.2 LoRAs from Civitai (cv:2682864 and
6599        // siblings) reach this code via the `family_hint` carried by the
6600        // catalog, but a stable model name like `flux2-klein` works the
6601        // same way.
6602        let mut req = valid_req();
6603        req.model = "flux2-klein".to_string();
6604        req.lora = Some(crate::LoraWeight {
6605            path: "DarkKlein9b.safetensors".to_string(),
6606            scale: 1.0,
6607
6608            expert: None,
6609        });
6610        assert!(
6611            validate_generate_request(&req).is_ok(),
6612            "Flux.2 + LoRA must pass validation"
6613        );
6614    }
6615
6616    #[test]
6617    fn loras_plural_on_flux2_accepted() {
6618        // The plural loras stack must also pass on Flux.2.
6619        let mut req = valid_req();
6620        req.model = "flux2-klein-9b".to_string();
6621        req.loras = Some(vec![
6622            crate::LoraWeight {
6623                path: "lora-a.safetensors".into(),
6624                scale: 0.8,
6625
6626                expert: None,
6627            },
6628            crate::LoraWeight {
6629                path: "lora-b.safetensors".into(),
6630                scale: 0.4,
6631
6632                expert: None,
6633            },
6634        ]);
6635        assert!(
6636            validate_generate_request(&req).is_ok(),
6637            "Flux.2 + loras plural must pass validation"
6638        );
6639    }
6640
6641    #[test]
6642    fn lora_on_unsupported_family_lists_sdxl_in_message() {
6643        // SD3 / Qwen-Image still lack a LoRA engine path. The validator must
6644        // reject and the message must enumerate every supported family so
6645        // the user knows what to pick instead.
6646        let mut req = valid_req();
6647        req.model = "wuerstchen-c".to_string();
6648        req.lora = Some(crate::LoraWeight {
6649            path: "adapter.safetensors".to_string(),
6650            scale: 1.0,
6651
6652            expert: None,
6653        });
6654        let err = validate_generate_request(&req).unwrap_err();
6655        assert!(
6656            err.to_lowercase().contains("flux"),
6657            "error must mention FLUX: {err}"
6658        );
6659        assert!(
6660            err.to_lowercase().contains("flux.2") || err.to_lowercase().contains("flux2"),
6661            "error must mention Flux.2: {err}"
6662        );
6663        assert!(
6664            err.to_lowercase().contains("ltx-2") || err.to_lowercase().contains("ltx2"),
6665            "error must mention LTX-2: {err}"
6666        );
6667        assert!(
6668            err.to_lowercase().contains("sdxl"),
6669            "error must mention SDXL: {err}"
6670        );
6671        assert!(
6672            err.to_lowercase().contains("qwen-image"),
6673            "error must mention Qwen-Image: {err}"
6674        );
6675    }
6676
6677    /// Qwen-Image gained LoRA support in feat/lora-all-families. The
6678    /// validator must let `qwen-image` through.
6679    #[test]
6680    fn lora_on_qwen_image_accepted() {
6681        let mut req = valid_req();
6682        req.model = "qwen-image-2512".to_string();
6683        req.lora = Some(crate::LoraWeight {
6684            path: "adapter.safetensors".to_string(),
6685            scale: 1.0,
6686
6687            expert: None,
6688        });
6689        assert!(
6690            validate_generate_request(&req).is_ok(),
6691            "Qwen-Image + LoRA must pass validation",
6692        );
6693    }
6694
6695    /// `qwen-image-edit` shares the LoRA family gate with `qwen-image`.
6696    /// The edit family also requires a target image separately, so this
6697    /// test exercises just the LoRA gate by inspecting the rejection
6698    /// message: it must NOT mention LoRA when the only non-LoRA failure
6699    /// is the missing target image.
6700    #[test]
6701    fn lora_on_qwen_image_edit_passes_lora_gate() {
6702        let mut req = valid_req();
6703        req.model = "qwen-image-edit-2511:q4".to_string();
6704        req.lora = Some(crate::LoraWeight {
6705            path: "adapter.safetensors".to_string(),
6706            scale: 1.0,
6707
6708            expert: None,
6709        });
6710        // The request fails on its target-image requirement, but the
6711        // LoRA gate is permissive.
6712        let err = validate_generate_request(&req).unwrap_err();
6713        assert!(
6714            !err.to_lowercase().contains("lora"),
6715            "LoRA gate must not reject qwen-image-edit; remaining failure should be on the target image: {err}",
6716        );
6717        assert!(
6718            err.contains("Add a Target image"),
6719            "expected the only failure to be the target-image requirement: {err}",
6720        );
6721    }
6722
6723    #[test]
6724    fn loras_plural_on_qwen_image_accepted() {
6725        let mut req = valid_req();
6726        req.model = "qwen-image-2512".to_string();
6727        req.loras = Some(vec![
6728            crate::LoraWeight {
6729                path: "a.safetensors".into(),
6730                scale: 0.8,
6731
6732                expert: None,
6733            },
6734            crate::LoraWeight {
6735                path: "b.safetensors".into(),
6736                scale: 0.4,
6737
6738                expert: None,
6739            },
6740        ]);
6741        assert!(
6742            validate_generate_request(&req).is_ok(),
6743            "Qwen-Image + multi-LoRA must pass validation",
6744        );
6745    }
6746
6747    #[test]
6748    fn lora_on_unknown_family_still_rejected() {
6749        // family: None (no manifest match) must still produce an error.
6750        let mut req = valid_req();
6751        req.model = "some-unknown-model-xyz".to_string();
6752        req.lora = Some(crate::LoraWeight {
6753            path: "adapter.safetensors".to_string(),
6754            scale: 1.0,
6755
6756            expert: None,
6757        });
6758        let err = validate_generate_request(&req).unwrap_err();
6759        assert!(
6760            !err.is_empty(),
6761            "unknown family with LoRA must produce an error: {err}"
6762        );
6763    }
6764
6765    /// SD1.5 LoRA support landed in `crates/mold-inference/src/sd15/lora.rs` —
6766    /// the validator must accept it, just like FLUX and LTX-2.
6767    #[test]
6768    fn lora_on_sd15_accepted() {
6769        let mut req = valid_req();
6770        req.model = "sd15:fp16".to_string();
6771        req.width = 512;
6772        req.height = 512;
6773        req.guidance = 7.0;
6774        req.lora = Some(crate::LoraWeight {
6775            path: "adapter.safetensors".to_string(),
6776            scale: 0.8,
6777
6778            expert: None,
6779        });
6780        assert!(
6781            validate_generate_request(&req).is_ok(),
6782            "SD1.5 + LoRA must pass validation"
6783        );
6784    }
6785
6786    /// The plural `loras` form must accept SD1.5 too — the gate must apply
6787    /// uniformly to both shapes.
6788    #[test]
6789    fn loras_plural_on_sd15_accepted() {
6790        let mut req = valid_req();
6791        req.model = "sd15:fp16".to_string();
6792        req.width = 512;
6793        req.height = 512;
6794        req.guidance = 7.0;
6795        req.loras = Some(vec![
6796            crate::LoraWeight {
6797                path: "a.safetensors".into(),
6798                scale: 0.8,
6799
6800                expert: None,
6801            },
6802            crate::LoraWeight {
6803                path: "b.safetensors".into(),
6804                scale: 0.4,
6805
6806                expert: None,
6807            },
6808        ]);
6809        assert!(
6810            validate_generate_request(&req).is_ok(),
6811            "SD1.5 + loras plural must pass validation"
6812        );
6813    }
6814
6815    /// The rejection message lists every supported family; SDXL still isn't
6816    /// supported, so a SDXL request with a LoRA should mention SD1.5 in the
6817    /// list of available alternatives.
6818    #[test]
6819    fn lora_on_sdxl_message_now_lists_sd15() {
6820        let mut req = valid_req();
6821        req.model = "sdxl".to_string();
6822        req.lora = Some(crate::LoraWeight {
6823            path: "adapter.safetensors".to_string(),
6824            scale: 1.0,
6825
6826            expert: None,
6827        });
6828        let err = validate_generate_request(&req).unwrap_err();
6829        assert!(
6830            err.to_lowercase().contains("sd1.5")
6831                || err.to_lowercase().contains("sd15")
6832                || err.to_lowercase().contains("sd 1.5"),
6833            "error must list SD1.5 as a supported family: {err}"
6834        );
6835    }
6836
6837    // ── dimension_warning tests ────────────────────────────────────────────
6838
6839    #[test]
6840    fn dimension_warning_matching_returns_none() {
6841        assert!(dimension_warning(1024, 1024, "flux").is_none());
6842        assert!(dimension_warning(512, 512, "sd15").is_none());
6843        assert!(dimension_warning(1024, 1024, "sdxl").is_none());
6844        assert!(dimension_warning(1024, 1024, "wuerstchen").is_none());
6845    }
6846
6847    #[test]
6848    fn dimension_warning_non_matching_returns_some() {
6849        let warning = dimension_warning(256, 256, "flux");
6850        assert!(warning.is_some());
6851        let msg = warning.unwrap();
6852        assert!(msg.contains("256x256"), "should mention requested dims");
6853        assert!(msg.contains("flux"), "should mention model family");
6854        assert!(msg.contains("Suggested"), "should include suggestions");
6855    }
6856
6857    #[test]
6858    fn dimension_warning_unknown_family_returns_none() {
6859        assert!(dimension_warning(256, 256, "unknown-model").is_none());
6860    }
6861
6862    #[test]
6863    fn dimension_warning_empty_family_returns_none() {
6864        assert!(dimension_warning(512, 512, "").is_none());
6865    }
6866
6867    #[test]
6868    fn dimension_warning_sd15_at_1024_warns() {
6869        let warning = dimension_warning(1024, 1024, "sd15");
6870        assert!(warning.is_some(), "SD1.5 at 1024x1024 should warn");
6871        assert!(warning.unwrap().contains("512x512"));
6872    }
6873
6874    #[test]
6875    fn dimension_warning_sdxl_buckets_accepted() {
6876        for (w, h) in recommended_dimensions("sdxl") {
6877            assert!(
6878                dimension_warning(*w, *h, "sdxl").is_none(),
6879                "SDXL bucket {w}x{h} should not warn"
6880            );
6881        }
6882    }
6883
6884    #[test]
6885    fn dimension_warning_qwen_image_uses_upstream_aspect_presets() {
6886        assert_eq!(recommended_dimensions("qwen-image").len(), 7);
6887        assert_eq!(dimension_warning(1328, 1328, "qwen-image"), None);
6888        assert_eq!(dimension_warning(1664, 928, "qwen-image"), None);
6889        assert_eq!(dimension_warning(928, 1664, "qwen-image"), None);
6890        assert!(dimension_warning(512, 512, "qwen-image").is_some());
6891    }
6892
6893    #[test]
6894    fn dimension_warning_qwen_image_edit_reuses_qwen_dimensions() {
6895        assert_eq!(
6896            recommended_dimensions("qwen-image-edit"),
6897            recommended_dimensions("qwen-image")
6898        );
6899        assert_eq!(dimension_warning(1328, 1328, "qwen-image-edit"), None);
6900    }
6901
6902    #[test]
6903    fn dimension_warning_flux2_uses_flux_dims() {
6904        assert_eq!(
6905            recommended_dimensions("flux2"),
6906            recommended_dimensions("flux"),
6907            "flux2 should share FLUX dimensions"
6908        );
6909    }
6910
6911    #[test]
6912    fn every_family_native_in_recommendations() {
6913        // Each family with a qualified recommendation set includes its native
6914        // resolution. Z-Image and Qwen both expose their qualified upstream
6915        // aspect sets through the same shared profile registry.
6916        let families = &[
6917            ("sd15", 512, 512),
6918            ("sdxl", 1024, 1024),
6919            ("sd3", 1024, 1024),
6920            ("flux", 1024, 1024),
6921            ("flux2", 1024, 1024),
6922            ("wuerstchen", 1024, 1024),
6923            ("ltx-video", 768, 512),
6924            ("minimax-h3", 1344, 768),
6925            ("z-image", 1024, 1024),
6926            ("qwen-image", 1328, 1328),
6927            ("qwen-image-edit", 1328, 1328),
6928        ];
6929        for (family, w, h) in families {
6930            let dims = recommended_dimensions(family);
6931            assert!(
6932                dims.contains(&(*w, *h)),
6933                "{family} native {w}x{h} missing from recommended list"
6934            );
6935        }
6936    }
6937
6938    #[test]
6939    fn h3_recommendations_are_the_official_product_ratios_on_the_oracle_canvas() {
6940        assert_eq!(
6941            recommended_dimensions(crate::minimax_h3::FAMILY),
6942            &[
6943                (1536, 672),
6944                (1344, 768),
6945                (1024, 768),
6946                (768, 768),
6947                (768, 1024),
6948                (768, 1344),
6949            ]
6950        );
6951    }
6952
6953    #[test]
6954    fn dimension_warning_message_format() {
6955        let msg = dimension_warning(800, 600, "sd15").unwrap();
6956        assert!(msg.contains("800x600"));
6957        assert!(msg.contains("sd15"));
6958        assert!(msg.contains("Suggested:"));
6959        // Should list known alternatives
6960        assert!(msg.contains("512x512"));
6961    }
6962
6963    #[test]
6964    fn dimension_warning_truncates_long_lists() {
6965        // SDXL has 9 buckets but warning should show at most 4 + "N total"
6966        let msg = dimension_warning(800, 600, "sdxl").unwrap();
6967        assert!(msg.contains("total"), "long lists should show total count");
6968    }
6969
6970    // ── validate_upscale_request tests ────────────────────────────────────
6971
6972    fn valid_upscale_req() -> crate::UpscaleRequest {
6973        crate::UpscaleRequest {
6974            model: "real-esrgan-x4plus:fp16".to_string(),
6975            image: png_bytes(),
6976            output_format: crate::OutputFormat::Png,
6977            tile_size: None,
6978            metadata: None,
6979        }
6980    }
6981
6982    #[test]
6983    fn upscale_valid_request_passes() {
6984        assert!(validate_upscale_request(&valid_upscale_req()).is_ok());
6985    }
6986
6987    #[test]
6988    fn upscale_empty_model_rejected() {
6989        let mut req = valid_upscale_req();
6990        req.model = "  ".to_string();
6991        assert!(validate_upscale_request(&req)
6992            .unwrap_err()
6993            .contains("model"));
6994    }
6995
6996    #[test]
6997    fn upscale_empty_image_rejected() {
6998        let mut req = valid_upscale_req();
6999        req.image = vec![];
7000        assert!(validate_upscale_request(&req)
7001            .unwrap_err()
7002            .contains("empty"));
7003    }
7004
7005    #[test]
7006    fn upscale_invalid_image_format_rejected() {
7007        let mut req = valid_upscale_req();
7008        req.image = vec![0x00, 0x01, 0x02, 0x03];
7009        assert!(validate_upscale_request(&req)
7010            .unwrap_err()
7011            .contains("PNG or JPEG"));
7012    }
7013
7014    #[test]
7015    fn upscale_jpeg_accepted() {
7016        let mut req = valid_upscale_req();
7017        req.image = jpeg_bytes();
7018        assert!(validate_upscale_request(&req).is_ok());
7019    }
7020
7021    #[test]
7022    fn upscale_tile_size_too_small_rejected() {
7023        let mut req = valid_upscale_req();
7024        req.tile_size = Some(32);
7025        assert!(validate_upscale_request(&req)
7026            .unwrap_err()
7027            .contains("tile_size"));
7028    }
7029
7030    #[test]
7031    fn upscale_tile_size_zero_accepted() {
7032        let mut req = valid_upscale_req();
7033        req.tile_size = Some(0);
7034        assert!(validate_upscale_request(&req).is_ok());
7035    }
7036
7037    #[test]
7038    fn upscale_tile_size_64_accepted() {
7039        let mut req = valid_upscale_req();
7040        req.tile_size = Some(64);
7041        assert!(validate_upscale_request(&req).is_ok());
7042    }
7043
7044    #[test]
7045    fn upscale_tile_size_none_accepted() {
7046        let req = valid_upscale_req();
7047        assert!(validate_upscale_request(&req).is_ok());
7048    }
7049
7050    #[test]
7051    fn built_in_ic_lora_control_requires_video_pipeline_and_reserves_a_stack_slot() {
7052        let mut req = valid_req();
7053        req.model = "ltx-2-19b-distilled:fp8".to_string();
7054        req.output_format = Some(crate::OutputFormat::Mp4);
7055        req.frames = Some(97);
7056        req.ic_lora_control = Some("union".to_string());
7057        assert!(validate_generate_request(&req)
7058            .unwrap_err()
7059            .contains("pipeline=ic-lora"));
7060
7061        req.pipeline = Some(crate::Ltx2PipelineMode::IcLora);
7062        assert!(validate_generate_request(&req)
7063            .unwrap_err()
7064            .contains("source_video"));
7065        req.source_video_path = Some("/guides/canny.mp4".to_string());
7066        assert!(validate_generate_request(&req).is_ok());
7067
7068        req.loras = Some(
7069            (0..4)
7070                .map(|index| crate::LoraWeight {
7071                    path: format!("/loras/{index}.safetensors"),
7072                    scale: 1.0,
7073
7074                    expert: None,
7075                })
7076                .collect(),
7077        );
7078        assert!(validate_generate_request(&req)
7079            .unwrap_err()
7080            .contains("four-LoRA"));
7081    }
7082
7083    // ── lip-dub ─────────────────────────────────────────────────────────────
7084
7085    fn lip_dub_req() -> GenerateRequest {
7086        let mut req = valid_req();
7087        req.model = "ltx-2.3-22b-distilled:fp8".to_string();
7088        req.output_format = Some(OutputFormat::Mp4);
7089        req.width = 1216;
7090        req.height = 704;
7091        req.pipeline = Some(Ltx2PipelineMode::LipDub);
7092        req.ic_lora_control = Some("lipdub".to_string());
7093        req.source_video_path = Some("/clips/speaker.mp4".to_string());
7094        req
7095    }
7096
7097    #[test]
7098    fn snap_frames_to_8k1_rounds_down_never_up() {
7099        // Exactly on the grid stays put.
7100        for on_grid in [1, 9, 17, 97, 121, 481] {
7101            assert_eq!(super::snap_frames_to_8k1(on_grid), on_grid);
7102        }
7103        // Everything between two grid points falls back to the lower one, so a
7104        // dub never asks for frames the reference video does not have.
7105        assert_eq!(super::snap_frames_to_8k1(2), 1);
7106        assert_eq!(super::snap_frames_to_8k1(8), 1);
7107        assert_eq!(super::snap_frames_to_8k1(16), 9);
7108        assert_eq!(super::snap_frames_to_8k1(96), 89);
7109        assert_eq!(super::snap_frames_to_8k1(100), 97);
7110        assert_eq!(super::snap_frames_to_8k1(0), 1);
7111        // The advertised LTX-2 ceiling is derived from the same snap.
7112        assert_eq!(super::ltx2_max_frames_on_grid_at_fps(24), 481);
7113    }
7114
7115    /// A reference clip that could drive a dub, unless a test says otherwise.
7116    fn lip_dub_reference(frames: u32, fps: u32) -> super::LipDubReference {
7117        super::LipDubReference {
7118            frames,
7119            fps,
7120            has_audio: true,
7121        }
7122    }
7123
7124    #[test]
7125    fn lip_dub_timing_comes_from_the_reference_video() {
7126        let timing = super::resolve_lip_dub_timing(lip_dub_reference(120, 25), None, None).unwrap();
7127        assert_eq!(timing.frames, 113);
7128        assert_eq!(timing.fps, 25);
7129        assert_eq!(timing.warnings.len(), 1, "{:?}", timing.warnings);
7130        assert!(timing.warnings[0].contains("113"));
7131
7132        // Already on the grid at the requested values: nothing to say.
7133        let timing =
7134            super::resolve_lip_dub_timing(lip_dub_reference(97, 24), Some(97), Some(24)).unwrap();
7135        assert_eq!((timing.frames, timing.fps), (97, 24));
7136        assert!(timing.warnings.is_empty());
7137    }
7138
7139    #[test]
7140    fn lip_dub_timing_overrides_and_reports_conflicting_requests() {
7141        let timing =
7142            super::resolve_lip_dub_timing(lip_dub_reference(97, 24), Some(241), Some(30)).unwrap();
7143        assert_eq!((timing.frames, timing.fps), (97, 24));
7144        assert_eq!(timing.warnings.len(), 2, "{:?}", timing.warnings);
7145        assert!(timing.warnings[0].contains("241") && timing.warnings[0].contains("97"));
7146        assert!(timing.warnings[1].contains("30") && timing.warnings[1].contains("24"));
7147    }
7148
7149    #[test]
7150    fn lip_dub_timing_rejects_unusable_references() {
7151        assert!(
7152            super::resolve_lip_dub_timing(lip_dub_reference(97, 0), None, None)
7153                .unwrap_err()
7154                .contains("frame rate")
7155        );
7156        assert!(
7157            super::resolve_lip_dub_timing(lip_dub_reference(8, 24), None, None)
7158                .unwrap_err()
7159                .contains("too short")
7160        );
7161        // A silent reference is refused here, at the request boundary, rather
7162        // than minutes later when the audio VAE has nothing to encode.
7163        let silent = super::LipDubReference {
7164            has_audio: false,
7165            ..lip_dub_reference(97, 24)
7166        };
7167        assert!(super::resolve_lip_dub_timing(silent, None, None)
7168            .unwrap_err()
7169            .contains("no audio track"));
7170    }
7171
7172    #[test]
7173    fn lip_dub_requires_a_reference_video_and_the_adapter() {
7174        let mut req = lip_dub_req();
7175        req.source_video_path = None;
7176        assert!(validate_generate_request(&req)
7177            .unwrap_err()
7178            .contains("source_video"));
7179
7180        let mut req = lip_dub_req();
7181        req.ic_lora_control = None;
7182        assert!(validate_generate_request(&req)
7183            .unwrap_err()
7184            .contains("ic_lora_control=lipdub"));
7185
7186        assert!(validate_generate_request(&lip_dub_req()).is_ok());
7187    }
7188
7189    #[test]
7190    fn lip_dub_rejects_dimensions_that_are_not_multiples_of_64() {
7191        // 1216x704 is fine; 1216x736 is a multiple of 32 but not of 64, which
7192        // is exactly the case a one-stage-only check would let through.
7193        let mut req = lip_dub_req();
7194        req.height = 736;
7195        let err = validate_generate_request(&req).unwrap_err();
7196        assert!(err.contains("multiples of 64"), "{err}");
7197
7198        let mut req = lip_dub_req();
7199        req.width = 1184;
7200        assert!(validate_generate_request(&req)
7201            .unwrap_err()
7202            .contains("multiples of 64"));
7203    }
7204
7205    #[test]
7206    fn lip_dub_control_id_routes_to_the_lip_dub_pipeline_not_ic_lora() {
7207        use crate::ltx2_control::pipeline_for_control_id;
7208        assert_eq!(pipeline_for_control_id("lipdub"), Ltx2PipelineMode::LipDub);
7209        assert_eq!(pipeline_for_control_id("LipDub"), Ltx2PipelineMode::LipDub);
7210        assert_eq!(pipeline_for_control_id("union"), Ltx2PipelineMode::IcLora);
7211
7212        // Asking for the lip-dub adapter on the generic in-context pipeline is
7213        // a mistake worth naming: the weights would load and the wrong graph
7214        // would run.
7215        let mut req = lip_dub_req();
7216        req.pipeline = Some(Ltx2PipelineMode::IcLora);
7217        assert!(validate_generate_request(&req)
7218            .unwrap_err()
7219            .contains("requires pipeline=lip-dub"));
7220
7221        let mut req = lip_dub_req();
7222        req.ic_lora_control = Some("union".to_string());
7223        assert!(validate_generate_request(&req)
7224            .unwrap_err()
7225            .contains("requires pipeline=ic-lora"));
7226    }
7227
7228    #[test]
7229    fn lip_dub_rejects_conflicting_conditioning_modes() {
7230        let mut req = lip_dub_req();
7231        req.retake_range = Some(crate::TimeRange {
7232            start_seconds: 0.0,
7233            end_seconds: 1.0,
7234        });
7235        assert!(validate_generate_request(&req)
7236            .unwrap_err()
7237            .contains("retake_range"));
7238
7239        let mut req = lip_dub_req();
7240        req.keyframes = Some(vec![KeyframeCondition {
7241            frame: 0,
7242            image: png_bytes(),
7243            name: None,
7244        }]);
7245        assert!(validate_generate_request(&req)
7246            .unwrap_err()
7247            .contains("keyframes"));
7248
7249        // Upscaling would change the output shape out from under the clip the
7250        // dub has to line up with.
7251        let mut req = lip_dub_req();
7252        req.spatial_upscale = Some(crate::Ltx2SpatialUpscale::X2);
7253        assert!(validate_generate_request(&req)
7254            .unwrap_err()
7255            .contains("spatial_upscale"));
7256
7257        let mut req = lip_dub_req();
7258        req.temporal_upscale = Some(crate::Ltx2TemporalUpscale::X2);
7259        assert!(validate_generate_request(&req)
7260            .unwrap_err()
7261            .contains("temporal_upscale"));
7262    }
7263}