1use crate::{
2 GenerateRequest, KeyframeCondition, LoraWeight, Ltx2GuidanceOverrides, Ltx2PipelineMode,
3 Ltx2SpatialUpscale, OutputFormat, UpscaleRequest,
4};
5
6pub const MAX_PIXELS: u64 = 1_800_000;
9pub const LTX2_MAX_PIXELS: u64 = 1_920 * 1_088;
14pub const LTX2_MAX_AXIS_PIXELS: u32 = 2_048;
23
24pub const LTX2_COMPOSED_MAX_AXIS_PIXELS: u32 = 2 * LTX2_MAX_AXIS_PIXELS;
36
37pub 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;
49pub const FLUX2_DEV_SINGLE_REFERENCE_MAX_PIXELS: u64 = 2_024 * 2_024;
52pub 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
71pub const LTX2_MAX_RUNTIME_SECONDS: u32 = 20;
80
81pub const LTX2_DEFAULT_FPS: u32 = 24;
85
86pub const LTX2_MAX_FRAMES_ABSOLUTE: u32 = LTX2_MAX_RUNTIME_SECONDS * 30 + 4;
93
94pub const MAX_FRAMES_GLOBAL: u32 = 257;
97
98pub const DEFAULT_EXTEND_OVERLAP_FRAMES: u32 = 17;
106
107pub 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
121pub 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
142pub 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
187pub const MAX_INLINE_EXTEND_VIDEO_BYTES: usize = MAX_INLINE_SOURCE_VIDEO_BYTES;
189
190pub const MAX_STG_BLOCK_INDEX: u32 = 64;
195
196pub const MAX_STG_BLOCKS: usize = 8;
199
200pub 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
215pub fn ltx2_max_frames_on_grid_at_fps(fps: u32) -> u32 {
223 snap_frames_to_8k1(ltx2_max_frames_at_fps(fps))
224}
225
226pub const LTX2_TWO_STAGE_ALIGNMENT: u32 = 64;
232
233pub const LTX2_TEMPORAL_SCALE: u32 = 8;
237
238pub 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
252pub const WAN_TEMPORAL_SCALE: u32 = 4;
257
258pub const WAN_HANDOFF_DUPLICATED_FRAMES: u32 = 1;
271
272pub const WAN_TI2V_FLF_MIN_FRAMES: u32 = 9;
281
282#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct LipDubTiming {
286 pub frames: u32,
288 pub fps: u32,
290 pub warnings: Vec<String>,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub struct LipDubReference {
303 pub frames: u32,
304 pub fps: u32,
305 pub has_audio: bool,
308}
309
310pub 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 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
382pub fn max_frames_for_family_at_fps(family: &str, fps: u32) -> Option<u32> {
389 match family {
390 "ltx2" => Some(ltx2_max_frames_on_grid_at_fps(fps)),
394 "ltx-video" => Some(MAX_FRAMES_GLOBAL),
395 "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
405pub fn max_frames_for_family(family: &str) -> Option<u32> {
408 max_frames_for_family_at_fps(family, LTX2_DEFAULT_FPS)
409}
410
411pub 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
417pub 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
423pub 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
435pub 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
444pub 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
455pub 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
467fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
505pub enum Ltx2SpatialComposition {
506 #[default]
509 SinglePass,
510 TiledTwoStage,
513}
514
515fn 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
535pub 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 None => true,
557 };
558 if refines {
559 Ltx2SpatialComposition::TiledTwoStage
560 } else {
561 Ltx2SpatialComposition::SinglePass
562 }
563}
564
565fn 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
588pub 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
601pub fn max_pixels_for_family(family: Option<&str>) -> u64 {
607 max_pixels_for_family_composed(family, Ltx2SpatialComposition::SinglePass)
608}
609
610pub 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
623pub fn max_axis_pixels_for_family(family: Option<&str>) -> Option<u32> {
625 max_axis_pixels_for_family_composed(family, Ltx2SpatialComposition::SinglePass)
626}
627
628pub 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
642pub 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
656pub 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
672pub 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
691pub 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
712pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub struct Ltx2OutputRung {
809 pub id: &'static str,
811 pub label: &'static str,
813 pub width: u32,
814 pub height: u32,
815}
816
817impl Ltx2OutputRung {
818 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 pub const fn requires_tiled_stage2(&self) -> bool {
836 self.width > LTX2_MAX_AXIS_PIXELS || self.height > LTX2_MAX_AXIS_PIXELS
837 }
838
839 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
853pub 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
883pub 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 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
909pub fn validate_ltx2_stage1_span(
916 width: u32,
917 height: u32,
918 upscale: Option<Ltx2SpatialUpscale>,
919) -> Result<(), String> {
920 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
947const 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
960pub const LTX2_SPATIAL_LATENT_STRIDE: u32 = 32;
962
963pub 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
1011pub 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
1023pub 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
1038pub fn clamp_to_megapixel_limit(w: u32, h: u32) -> (u32, u32) {
1042 clamp_to_family_pixel_limit(w, h, None)
1043}
1044
1045pub 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 (new_w.max(align), new_h.max(align))
1084}
1085
1086pub 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
1111pub 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 (model_w as f64, model_w as f64 / src_ratio)
1128 } else {
1129 (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
1138pub 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
1152fn 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
1173fn 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
1185pub 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
1209pub 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
1224pub 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
1251fn 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 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 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 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 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
1375fn 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 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
1453fn 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 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 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
1538pub 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
1579fn 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
1626pub fn validate_generate_request(req: &GenerateRequest) -> Result<(), String> {
1635 validate_generate_request_with_family(req, None)
1636}
1637
1638pub 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
1668pub 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
1688pub 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
1731pub 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#[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
1761fn 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 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 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 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 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 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 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 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 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 if matches!(family, Some("ltx2")) {
2071 let fps = req.fps.unwrap_or(LTX2_DEFAULT_FPS).max(1);
2072 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
2559pub 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
2566pub 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
2587pub fn wan_recommended_dimensions(model: &str) -> &'static [(u32, u32)] {
2597 crate::generation_profile::presets_for_identity(model, "wan", None)
2598}
2599
2600pub 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
2619pub fn recommended_dimensions(family: &str) -> &'static [(u32, u32)] {
2624 crate::generation_profile::family_presets(family)
2625}
2626
2627pub 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 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
2654pub fn dimension_warning(width: u32, height: u32, family: &str) -> Option<String> {
2659 dimension_warning_composed(width, height, family, Ltx2SpatialComposition::SinglePass)
2660}
2661
2662pub 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 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 #[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 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 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 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 assert!(require_expert_routable_model(
2747 &lora(Some(crate::LoraExpert::High)),
2748 "cv:123456",
2749 Some("wan"),
2750 )
2751 .is_ok());
2752
2753 assert!(require_expert_routable_model(&lora(None), "flux-dev:q8", Some("flux")).is_ok());
2755 }
2756
2757 #[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 #[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 assert!(validate_generation_dimensions(512, 3200, Some("ltx2")).is_err());
2794 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 #[test]
2819 fn single_pass_admission_is_byte_for_byte_unchanged() {
2820 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 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 #[test]
2847 fn the_axis_threshold_fires_exactly_at_the_trained_span() {
2848 assert!(validate_generation_dimensions(2048, 512, Some("ltx2")).is_ok());
2851 assert!(validate_generation_dimensions(2080, 512, Some("ltx2")).is_err());
2852
2853 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 #[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 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 #[test]
2894 fn the_composed_ceiling_requires_a_checkpoint_that_can_compose() {
2895 assert_eq!(
2897 ltx2_spatial_composition("ltx-2-19b-distilled:fp8", None),
2898 Ltx2SpatialComposition::TiledTwoStage
2899 );
2900 assert_eq!(
2902 ltx2_spatial_composition("cv:3143864", None),
2903 Ltx2SpatialComposition::SinglePass
2904 );
2905 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 #[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 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 #[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 assert_eq!(
3000 recommended_dimensions_composed("ltx2", Ltx2SpatialComposition::SinglePass),
3001 recommended_dimensions("ltx2").to_vec()
3002 );
3003 }
3004
3005 #[test]
3009 fn rung_composition_arithmetic_is_exact() {
3010 struct ExpectedRung {
3011 id: &'static str,
3012 stage1: (u32, u32),
3013 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 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 #[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 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 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 assert!(validate_ltx2_stage1_span(3_840, 2_176, Some(Ltx2SpatialUpscale::X2)).is_ok());
3112 assert!(validate_ltx2_stage1_span(3_072, 1_728, Some(Ltx2SpatialUpscale::X1_5)).is_ok());
3114 }
3115
3116 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[test]
3342 fn exr_accepts_any_spelling_the_control_registry_accepts() {
3343 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 #[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 #[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 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 fn png_bytes() -> Vec<u8> {
3682 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
3683 }
3684
3685 fn jpeg_bytes() -> Vec<u8> {
3687 vec![0xFF, 0xD8, 0xFF, 0xE0]
3688 }
3689
3690 #[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 assert_eq!(super::clamp_to_megapixel_limit(1328, 1328), (1328, 1328));
3701 }
3702
3703 #[test]
3704 fn clamp_noop_qwen_image_landscape() {
3705 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 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 #[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 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 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 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 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 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 assert_eq!(req.output_format, Some(OutputFormat::Png));
3886 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 #[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 #[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 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 #[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 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 #[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(<x2_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(<x2_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(<x2_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(<x2_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 let err = validate_generate_request(<x2_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(<x2_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(<x2_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(<x2_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(<x2_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(<x2_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 let mut req = valid_req();
4290 req.model = "cv:2781713".to_string();
4291 req.enable_audio = Some(false);
4292 validate_generate_request(&req).unwrap();
4294 }
4295
4296 #[test]
4297 fn enable_audio_some_true_with_family_hint_passes_for_catalog_ltx2() {
4298 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 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 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 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 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 req.prompt = " \n ".to_string();
4382 validate_generate_request(&req).unwrap();
4383
4384 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 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 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 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; 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; 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; 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 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 assert_eq!(req.extend_new_frames(), Some(80));
4620 }
4621
4622 #[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 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 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 let plain = valid_req();
4664 assert!(!request_carries_source_frames(&plain));
4665 }
4666
4667 #[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 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 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 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 #[test]
4724 fn chain_motion_tail_follows_the_checkpoints_carryover_not_the_request() {
4725 use crate::SourceImageCapability::{Optional, Required, Unsupported};
4726
4727 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 assert_eq!(chain_motion_tail_frames_for_family("wan", None, 17), 0);
4743
4744 assert_eq!(
4746 chain_motion_tail_frames_for_family("wan", Some(Required), 1),
4747 WAN_HANDOFF_DUPLICATED_FRAMES
4748 );
4749
4750 assert_eq!(
4752 chain_motion_tail_frames_for_family("ltx-video", None, 17),
4753 0
4754 );
4755
4756 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 #[test]
4772 fn materializing_the_overlap_makes_saved_provenance_match_the_render() {
4773 let installed_wan = || {
4774 let mut req = extend_req();
4775 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 assert_eq!(req.extend_new_frames(), Some(48));
4807
4808 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 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 #[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 assert!(validate_generate_request(&wan_req(Some(1))).is_ok());
4855 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
5002 fn ltx2_frame_ceiling_tracks_fps() {
5003 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 assert_eq!(ltx2_max_frames_at_fps(6), 124);
5012 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 assert_eq!(ltx2_max_frames_at_fps(0), ltx2_max_frames_at_fps(1));
5018 }
5019
5020 #[test]
5024 fn frame_constraint_helpers_match_validator_behavior() {
5025 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 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); let err = validate_generate_request(&req).unwrap_err();
5050 assert!(err.contains(&cap.to_string()), "got: {err}");
5051
5052 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); 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 #[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 assert_eq!(max_runtime_seconds_for_family("wan"), None);
5124 assert_eq!(max_frames_absolute_for_family("wan"), None);
5125 assert_eq!((MAX_FRAMES_GLOBAL - 1) % WAN_TEMPORAL_SCALE, 0);
5128 }
5129
5130 #[test]
5131 fn wan_frames_grid_and_cap_enforced() {
5132 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 req.frames = Some(80);
5143 let err = validate_generate_request(&req).unwrap_err();
5144 assert!(err.contains("4n+1"), "got: {err}");
5145
5146 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 #[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 #[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 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 req.output_format = Some(OutputFormat::Mp4);
5205 assert!(validate_generate_request(&req).is_ok());
5206 req.output_format = Some(OutputFormat::Wav);
5208 assert!(validate_generate_request(&req).is_err());
5209
5210 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 assert_eq!(
5225 ExpandTask::for_conditioning("wan", None, false, false, false, 0, false, Some(1)),
5226 ExpandTask::TextToImage
5227 );
5228 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 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 #[test]
5257 fn wan_recipe_knobs_gate_by_family() {
5258 use crate::Scheduler;
5259
5260 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 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 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 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 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 flux.scheduler = Some(Scheduler::Ddim);
5330 assert!(validate_generate_request(&flux).is_ok());
5331 }
5332
5333 #[test]
5337 fn wan_keyframes_admit_only_the_endpoint_pair() {
5338 use crate::{ExpandTask, KeyframeCondition};
5339 let keyframe = |frame: u32| KeyframeCondition {
5340 frame,
5341 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 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 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 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 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 assert_eq!(
5402 ExpandTask::for_generation("wan", &req),
5403 ExpandTask::KeyframeInterpolation
5404 );
5405
5406 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 #[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 #[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 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 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 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 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 #[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 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 req.frames = Some(481);
5577 assert!(validate_generate_request(&req).is_ok());
5578 }
5579
5580 #[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); let err = validate_generate_request(&req).unwrap_err();
5607 assert!(err.contains("489"), "got: {err}");
5608 assert!(err.contains("481"), "got: {err}");
5611 assert!(err.contains("RoPE"), "got: {err}");
5612 }
5613
5614 #[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 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); let err = validate_generate_request(&req).unwrap_err();
5641 assert!(
5643 err.contains(<x2_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 #[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 #[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 req.frames = Some(481);
5683 assert!(validate_generate_request(&req).is_ok());
5684
5685 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 #[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; 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 #[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 #[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 #[test]
6118 fn fit_same_aspect_downscale() {
6119 assert_eq!(fit_to_model_dimensions(1024, 1024, 512, 512), (512, 512));
6121 }
6122
6123 #[test]
6124 fn fit_wide_source_downscale() {
6125 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 assert_eq!(fit_to_model_dimensions(512, 512, 1024, 1024), (1024, 1024));
6134 }
6135
6136 #[test]
6137 fn fit_portrait_source() {
6138 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 assert_eq!(fit_to_model_dimensions(3840, 720, 1024, 1024), (1024, 192));
6156 }
6157
6158 #[test]
6159 fn fit_non_square_model_bounds() {
6160 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 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 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 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 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 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 GenerateRequest {
6316 model: "sdxl-base:fp16".to_string(),
6317 ..valid_req()
6318 }
6319 }
6320
6321 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 assert!(msg.contains("512x512"));
6961 }
6962
6963 #[test]
6964 fn dimension_warning_truncates_long_lists() {
6965 let msg = dimension_warning(800, 600, "sdxl").unwrap();
6967 assert!(msg.contains("total"), "long lists should show total count");
6968 }
6969
6970 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 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 for on_grid in [1, 9, 17, 97, 121, 481] {
7101 assert_eq!(super::snap_frames_to_8k1(on_grid), on_grid);
7102 }
7103 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 assert_eq!(super::ltx2_max_frames_on_grid_at_fps(24), 481);
7113 }
7114
7115 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 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 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 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 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 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}