1use serde::{Deserialize, Serialize};
17
18use crate::error::{MoldError, Result};
19use crate::types::{DevicePlacement, GenerateRequest, OutputFormat, OutputMetadata, VideoData};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
35#[serde(rename_all = "snake_case")]
36pub enum TransitionMode {
37 #[default]
38 Smooth,
39 Cut,
40 Fade,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
48pub struct ChainStageMetadata {
49 pub prompt: String,
50 pub frames: u32,
51 pub transition: TransitionMode,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub fade_frames: Option<u32>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub seed: Option<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub loras: Vec<LoraSpec>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
65pub struct ChainOutputMetadata {
66 pub stage_count: u32,
67 pub motion_tail_frames: u32,
68 pub stages: Vec<ChainStageMetadata>,
69}
70
71#[derive(Debug, Clone, Copy)]
76pub struct ChainProvenance<'a> {
77 pub chain_job_id: Option<&'a str>,
78 pub stage_seeds: Option<&'a [u64]>,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
83pub struct LoraSpec {
84 pub path: String,
85 pub scale: f64,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub name: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
93pub struct NamedRef {
94 pub name: String,
95 #[serde(with = "crate::types::base64_bytes")]
96 pub image: Vec<u8>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
105pub struct ChainStage {
106 #[schema(example = "a cat walking through autumn leaves")]
110 pub prompt: String,
111
112 #[schema(example = 97)]
115 pub frames: u32,
116
117 #[serde(
121 default,
122 skip_serializing_if = "Option::is_none",
123 with = "crate::types::base64_opt"
124 )]
125 pub source_image: Option<Vec<u8>>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub negative_prompt: Option<String>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub seed_offset: Option<u64>,
139
140 #[serde(default)]
144 pub transition: TransitionMode,
145
146 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub fade_frames: Option<u32>,
151
152 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub model: Option<String>,
157
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub loras: Vec<LoraSpec>,
161
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub references: Vec<NamedRef>,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
172pub struct ChainRequest {
173 #[schema(example = "ltx-2-19b-distilled:fp8")]
174 pub model: String,
175
176 #[serde(default)]
179 pub stages: Vec<ChainStage>,
180
181 #[serde(default = "default_motion_tail_frames")]
192 #[schema(example = 17)]
193 pub motion_tail_frames: u32,
194
195 #[schema(example = 1216)]
196 pub width: u32,
197 #[schema(example = 704)]
198 pub height: u32,
199 #[serde(default = "default_fps")]
200 #[schema(example = 24)]
201 pub fps: u32,
202
203 #[serde(default, skip_serializing_if = "Option::is_none")]
207 #[schema(example = 42)]
208 pub seed: Option<u64>,
209
210 #[schema(example = 8)]
211 pub steps: u32,
212
213 #[schema(example = 3.0)]
214 pub guidance: f64,
215
216 #[serde(default = "default_strength")]
220 #[schema(example = 1.0)]
221 pub strength: f64,
222
223 #[serde(default = "default_output_format")]
224 pub output_format: OutputFormat,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub placement: Option<DevicePlacement>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub original_prompt: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub prompt_transform: Option<crate::PromptTransformProvenance>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub batch_id: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub batch_index: Option<u32>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub batch_count: Option<u32>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub prompt: Option<String>,
248
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub total_frames: Option<u32>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub clip_frames: Option<u32>,
257
258 #[serde(
260 default,
261 skip_serializing_if = "Option::is_none",
262 with = "crate::types::base64_opt"
263 )]
264 pub source_image: Option<Vec<u8>>,
265
266 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub enable_audio: Option<bool>,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
281pub struct ChainScript {
282 pub schema: String, pub chain: ChainScriptChain,
284 #[serde(rename = "stage")]
285 pub stages: Vec<ChainStage>,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
289pub struct ChainScriptChain {
290 pub model: String,
291 pub width: u32,
292 pub height: u32,
293 pub fps: u32,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub seed: Option<u64>,
296 pub steps: u32,
297 pub guidance: f64,
298 pub strength: f64,
299 pub motion_tail_frames: u32,
300 pub output_format: OutputFormat,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub enable_audio: Option<bool>,
305}
306
307impl From<&ChainRequest> for ChainScript {
308 fn from(req: &ChainRequest) -> Self {
309 ChainScript {
310 schema: "mold.chain.v1".into(),
311 chain: ChainScriptChain {
312 model: req.model.clone(),
313 width: req.width,
314 height: req.height,
315 fps: req.fps,
316 seed: req.seed,
317 steps: req.steps,
318 guidance: req.guidance,
319 strength: req.strength,
320 motion_tail_frames: req.motion_tail_frames,
321 output_format: req.output_format,
322 enable_audio: req.enable_audio,
323 },
324 stages: req.stages.clone(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
332pub struct VramEstimate {
333 pub worst_case_bytes: u64,
340 pub fits: bool,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
351pub struct ChainValidationStage {
352 pub prompt: String,
353 pub frames: u32,
354 pub output_frames: u32,
355 pub transition: TransitionMode,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub fade_frames: Option<u32>,
358 pub has_source_image: bool,
359 pub has_negative_prompt: bool,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
366pub struct ChainValidationResponse {
367 pub model: String,
368 pub width: u32,
369 pub height: u32,
370 pub fps: u32,
371 pub motion_tail_frames: u32,
372 pub stage_count: u32,
373 pub estimated_total_frames: u32,
374 pub estimated_duration_ms: u64,
375 pub stages: Vec<ChainValidationStage>,
376 pub warnings: Vec<String>,
377 pub vram_estimate: Option<VramEstimate>,
380}
381
382impl ChainValidationResponse {
383 pub fn from_normalized(req: &ChainRequest, warnings: Vec<String>) -> Self {
384 let estimated_total_frames = req.estimated_total_frames();
385 let fps = req.fps.max(1);
386 Self {
387 model: req.model.clone(),
388 width: req.width,
389 height: req.height,
390 fps,
391 motion_tail_frames: req.motion_tail_frames,
392 stage_count: req.stages.len() as u32,
393 estimated_total_frames,
394 estimated_duration_ms: u64::from(estimated_total_frames) * 1_000 / u64::from(fps),
395 stages: req
396 .stages
397 .iter()
398 .enumerate()
399 .map(|(idx, stage)| {
400 let next = req.stages.get(idx + 1);
401 ChainValidationStage {
402 prompt: stage.prompt.clone(),
403 frames: stage.frames,
404 output_frames: stage_contributed_frames(
405 idx,
406 stage.frames,
407 stage.transition,
408 next.map(|candidate| candidate.transition),
409 next.and_then(|candidate| candidate.fade_frames),
410 req.motion_tail_frames,
411 ),
412 transition: stage.transition,
413 fade_frames: stage.fade_frames,
414 has_source_image: stage.source_image.is_some(),
415 has_negative_prompt: stage
416 .negative_prompt
417 .as_deref()
418 .is_some_and(|value| !value.trim().is_empty()),
419 }
420 })
421 .collect(),
422 warnings,
423 vram_estimate: None,
424 }
425 }
426
427 #[must_use]
430 pub fn with_vram_estimate(mut self, estimate: Option<VramEstimate>) -> Self {
431 self.vram_estimate = estimate;
432 self
433 }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
439pub struct ChainResponse {
440 pub video: VideoData,
441 #[schema(example = 5)]
444 pub stage_count: u32,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub gpu: Option<usize>,
448
449 pub script: ChainScript,
453
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub vram_estimate: Option<VramEstimate>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
467pub struct SseChainCompleteEvent {
468 pub video: String,
471 pub format: OutputFormat,
472 #[schema(example = 1216)]
473 pub width: u32,
474 #[schema(example = 704)]
475 pub height: u32,
476 #[schema(example = 400)]
477 pub frames: u32,
478 #[schema(example = 24)]
479 pub fps: u32,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub thumbnail: Option<String>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub gif_preview: Option<String>,
486 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
487 pub has_audio: bool,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub duration_ms: Option<u64>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub audio_sample_rate: Option<u32>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub audio_channels: Option<u32>,
494 #[schema(example = 5)]
496 pub stage_count: u32,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub gpu: Option<usize>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub generation_time_ms: Option<u64>,
503 #[serde(default)]
507 pub script: ChainScript,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub vram_estimate: Option<VramEstimate>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub filename: Option<String>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
520 #[schema(value_type = Object)]
521 pub metadata: Option<Box<OutputMetadata>>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, PartialEq, Eq)]
534#[serde(tag = "type", rename_all = "snake_case")]
535pub enum ChainProgressEvent {
536 ChainStart {
540 stage_count: u32,
541 estimated_total_frames: u32,
542 },
543 StageStart { stage_idx: u32 },
545 DenoiseStep {
547 stage_idx: u32,
548 step: u32,
549 total: u32,
550 },
551 StageDone { stage_idx: u32, frames_emitted: u32 },
554 Stitching { total_frames: u32 },
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
562pub struct ChainFailure {
563 #[schema(example = "stage render failed")]
565 pub error: String,
566 #[schema(example = 2)]
568 pub failed_stage_idx: u32,
569 #[schema(example = 2)]
571 pub elapsed_stages: u32,
572 #[schema(example = 12_340)]
574 pub elapsed_ms: u64,
575 #[schema(example = "simulated GPU OOM on stage 2")]
577 pub stage_error: String,
578}
579
580fn default_motion_tail_frames() -> u32 {
581 17
582}
583
584fn default_fps() -> u32 {
585 24
586}
587
588fn default_strength() -> f64 {
589 1.0
590}
591
592fn default_output_format() -> OutputFormat {
593 OutputFormat::Mp4
594}
595
596pub const MAX_CHAIN_STAGES: usize = 16;
600
601impl ChainRequest {
602 pub fn synthetic_generate_request(
612 &self,
613 actual_format: OutputFormat,
614 frames: u32,
615 fps: u32,
616 ) -> GenerateRequest {
617 let first = self
618 .stages
619 .first()
620 .expect("synthetic_generate_request requires a normalised ChainRequest");
621 let prompt = if self.stages.iter().all(|stage| stage.prompt == first.prompt) {
626 first.prompt.clone()
627 } else {
628 self.stages
629 .iter()
630 .map(|stage| stage.prompt.as_str())
631 .collect::<Vec<_>>()
632 .join("\n")
633 };
634 GenerateRequest {
635 source_fit: None,
636 hdr_exr_dir: None,
637 hdr_exr_full_float: false,
638 prompt,
639 negative_prompt: first.negative_prompt.clone(),
640 model: self.model.clone(),
641 width: self.width,
642 height: self.height,
643 steps: self.steps,
644 guidance: self.guidance,
645 seed: self.seed,
646 batch_size: 1,
647 output_format: Some(actual_format),
648 embed_metadata: Some(false),
649 scheduler: None,
650 cfg_plus: None,
651 edit_images: None,
652 references: None,
653 source_image: first.source_image.clone(),
654 source_image_name: None,
655 strength: self.strength,
656 mask_image: None,
657 control_image: None,
658 control_model: None,
659 control_scale: 1.0,
660 expand: None,
661 original_prompt: self.original_prompt.clone(),
662 prompt_transform: self.prompt_transform.clone(),
663 batch_id: self.batch_id.clone(),
664 batch_index: self.batch_index,
665 batch_count: self.batch_count,
666 lora: None,
667 frames: Some(frames),
668 fps: Some(fps),
669 upscale_model: None,
670 gif_preview: false,
671 enable_audio: self.enable_audio,
672 audio_file: None,
673 audio_file_path: None,
674 source_video: None,
675 source_video_path: None,
676 extend_video: None,
677 extend_video_path: None,
678 extend_overlap_frames: None,
679 keyframes: None,
680 pipeline: None,
681 ic_lora_control: None,
682 loras: None,
683 retake_range: None,
684 spatial_upscale: None,
685 temporal_upscale: None,
686 guidance_overrides: None,
691 sample_shift: None,
692 distill_strength_high: None,
693 distill_strength_low: None,
694 placement: self.placement.clone(),
695 }
696 }
697
698 pub fn stitched_output_metadata(
704 &self,
705 actual_format: OutputFormat,
706 frame_count: u32,
707 provenance: Option<&ChainProvenance>,
708 ) -> OutputMetadata {
709 let synth = self.synthetic_generate_request(actual_format, frame_count, self.fps);
710 let mut metadata = OutputMetadata::from_generate_request(
711 &synth,
712 self.seed.unwrap_or(0),
713 None,
714 crate::build_info::version_string(),
715 );
716 metadata.chain_job_id = provenance.and_then(|p| p.chain_job_id).map(str::to_string);
717 let stage_seeds = provenance.and_then(|p| p.stage_seeds);
718 metadata.chain = Some(ChainOutputMetadata {
719 stage_count: self.stages.len() as u32,
720 motion_tail_frames: self.motion_tail_frames,
721 stages: self
722 .stages
723 .iter()
724 .enumerate()
725 .map(|(idx, stage)| ChainStageMetadata {
726 prompt: stage.prompt.clone(),
727 frames: stage.frames,
728 transition: stage.transition,
729 fade_frames: stage.fade_frames,
730 seed: stage_seeds
731 .and_then(|seeds| seeds.get(idx))
732 .map(u64::to_string),
733 loras: stage.loras.clone(),
734 })
735 .collect(),
736 });
737 metadata
738 }
739
740 pub fn normalise(self) -> Result<Self> {
750 self.normalise_with_family(None)
751 }
752
753 pub fn normalise_with_family(mut self, family_hint: Option<&str>) -> Result<Self> {
762 let family = crate::manifest::find_manifest(&self.model)
768 .map(|m| m.family.clone())
769 .or_else(|| {
770 family_hint
771 .filter(|hint| !hint.is_empty())
772 .map(str::to_string)
773 });
774 let composition = if family.as_deref() == Some("ltx2") {
779 crate::validation::ltx2_spatial_composition(&self.model, None)
780 } else {
781 crate::validation::Ltx2SpatialComposition::SinglePass
782 };
783 crate::validation::validate_generation_dimensions_for_model(
784 &self.model,
785 self.width,
786 self.height,
787 family.as_deref(),
788 composition,
789 )
790 .map_err(MoldError::Validation)?;
791
792 if self.stages.is_empty() {
793 let prompt = self.prompt.take().ok_or_else(|| {
794 MoldError::Validation(
795 "chain request needs either stages[] or prompt + total_frames".into(),
796 )
797 })?;
798 let total_frames = self.total_frames.ok_or_else(|| {
799 MoldError::Validation("chain auto-expand requires total_frames".into())
800 })?;
801 if total_frames == 0 {
802 return Err(MoldError::Validation(
803 "chain total_frames must be > 0".into(),
804 ));
805 }
806 let clip_frames = self.clip_frames.unwrap_or(97);
807 if clip_frames == 0 {
808 return Err(MoldError::Validation(
809 "chain clip_frames must be > 0".into(),
810 ));
811 }
812 let step = family
817 .as_deref()
818 .and_then(crate::validation::frame_step_for_family)
819 .unwrap_or(8);
820 if clip_frames % step != 1 {
821 let examples: Vec<String> = (1..5).map(|k| (k * step + 1).to_string()).collect();
822 return Err(MoldError::Validation(format!(
823 "chain clip_frames ({clip_frames}) must be {step}k+1 ({}, …)",
824 examples.join(", "),
825 )));
826 }
827 let motion_tail = self.motion_tail_frames;
828 if motion_tail >= clip_frames {
829 return Err(MoldError::Validation(format!(
830 "motion_tail_frames ({motion_tail}) must be strictly less than clip_frames ({clip_frames})",
831 )));
832 }
833
834 let source_image = self.source_image.take();
835 self.stages = build_auto_expand_stages(
836 &prompt,
837 total_frames,
838 clip_frames,
839 motion_tail,
840 source_image,
841 )?;
842 }
843
844 if self.stages.is_empty() {
845 return Err(MoldError::Validation("chain request has no stages".into()));
846 }
847 if self.stages.len() > MAX_CHAIN_STAGES {
848 return Err(MoldError::Validation(format!(
849 "chain request has {} stages; maximum is {}",
850 self.stages.len(),
851 MAX_CHAIN_STAGES,
852 )));
853 }
854 let grid_step = family
858 .as_deref()
859 .and_then(crate::validation::frame_step_for_family)
860 .unwrap_or(8);
861 if self.motion_tail_frames != 0 && self.motion_tail_frames % grid_step != 1 {
862 return Err(MoldError::Validation(format!(
863 "motion_tail_frames ({}) must be 0 or {grid_step}k+1 so the carryover RGB frames \
864 re-encode cleanly through this family's video VAE temporal grid",
865 self.motion_tail_frames,
866 )));
867 }
868 for (idx, stage) in self.stages.iter().enumerate() {
869 if stage.frames == 0 {
870 return Err(MoldError::Validation(format!("stage {idx} has 0 frames",)));
871 }
872 if stage.frames % grid_step != 1 {
873 return Err(MoldError::Validation(format!(
874 "stage {idx} has {} frames; this family requires {grid_step}k+1",
875 stage.frames,
876 )));
877 }
878 if self.motion_tail_frames >= stage.frames {
879 return Err(MoldError::Validation(format!(
880 "motion_tail_frames ({}) must be strictly less than stage {idx}'s frames ({})",
881 self.motion_tail_frames, stage.frames,
882 )));
883 }
884 }
885
886 for (idx, stage) in self.stages.iter().enumerate() {
891 if stage.model.is_some() {
892 return Err(MoldError::Validation(format!(
893 "stages[{idx}].model is reserved for sub-project C and not yet supported"
894 )));
895 }
896 if stage.loras.len() > 4 {
897 return Err(MoldError::Validation(format!(
898 "stages[{idx}].loras exceeds the four-LoRA stack limit"
899 )));
900 }
901 for (lora_idx, lora) in stage.loras.iter().enumerate() {
902 if !(0.0..=2.0).contains(&lora.scale) {
903 return Err(MoldError::Validation(format!(
904 "stages[{idx}].loras[{lora_idx}].scale ({}) must be in range [0.0, 2.0]",
905 lora.scale
906 )));
907 }
908 if !lora.path.ends_with(".safetensors") && !lora.path.starts_with("camera-control:")
909 {
910 return Err(MoldError::Validation(format!(
911 "stages[{idx}].loras[{lora_idx}].path must be a .safetensors file or camera-control preset"
912 )));
913 }
914 }
915 if !stage.references.is_empty() {
916 return Err(MoldError::Validation(format!(
917 "stages[{idx}].references is reserved for sub-project B and not yet supported"
918 )));
919 }
920 }
921
922 if let Some(first) = self.stages.first_mut() {
925 if first.transition != TransitionMode::Smooth {
926 tracing::warn!(
927 coerced_from = ?first.transition,
928 "stage 0 transition is meaningless; coercing to Smooth"
929 );
930 first.transition = TransitionMode::Smooth;
931 }
932 }
933
934 self.prompt = None;
937 self.total_frames = None;
938 self.clip_frames = None;
939 self.source_image = None;
940
941 Ok(self)
942 }
943
944 pub fn estimated_total_frames(&self) -> u32 {
958 self.stages
959 .iter()
960 .enumerate()
961 .map(|(idx, stage)| {
962 let next = self.stages.get(idx + 1);
963 stage_contributed_frames(
964 idx,
965 stage.frames,
966 stage.transition,
967 next.map(|next| next.transition),
968 next.and_then(|next| next.fade_frames),
969 self.motion_tail_frames,
970 )
971 })
972 .sum()
973 }
974}
975
976pub const DEFAULT_FADE_FRAMES: u32 = 8;
979
980pub fn stage_contributed_frames(
994 idx: usize,
995 stage_frames: u32,
996 transition: TransitionMode,
997 next_transition: Option<TransitionMode>,
998 next_fade_frames: Option<u32>,
999 motion_tail_frames: u32,
1000) -> u32 {
1001 let mut frames = stage_frames;
1002 if idx > 0 && transition == TransitionMode::Smooth {
1003 frames = frames.saturating_sub(motion_tail_frames);
1004 }
1005 if next_transition == Some(TransitionMode::Fade) {
1006 frames = frames.saturating_sub(next_fade_frames.unwrap_or(DEFAULT_FADE_FRAMES));
1007 }
1008 frames
1009}
1010
1011#[cfg(test)]
1021fn is_ltx2_frame_count(n: u32) -> bool {
1022 n % 8 == 1
1023}
1024
1025fn build_auto_expand_stages(
1037 prompt: &str,
1038 total_frames: u32,
1039 clip_frames: u32,
1040 motion_tail_frames: u32,
1041 source_image: Option<Vec<u8>>,
1042) -> Result<Vec<ChainStage>> {
1043 let (stage_count, per_stage_frames) = if total_frames <= clip_frames {
1044 (1u32, total_frames)
1048 } else {
1049 let effective = clip_frames - motion_tail_frames;
1050 let remainder = total_frames - clip_frames;
1053 let count = 1 + remainder.div_ceil(effective);
1054 (count, clip_frames)
1055 };
1056
1057 let count_usize = stage_count as usize;
1058 if count_usize > MAX_CHAIN_STAGES {
1059 return Err(MoldError::Validation(format!(
1060 "auto-expand would produce {stage_count} stages; maximum is {MAX_CHAIN_STAGES} \
1061 (try reducing total_frames or increasing clip_frames)",
1062 )));
1063 }
1064
1065 let mut stages = Vec::with_capacity(count_usize);
1066 for _ in 0..stage_count {
1067 stages.push(ChainStage {
1077 prompt: prompt.to_string(),
1078 frames: per_stage_frames,
1079 source_image: source_image.clone(),
1080 negative_prompt: None,
1081 seed_offset: None,
1082 transition: TransitionMode::Smooth,
1083 fade_frames: None,
1084 model: None,
1085 loras: vec![],
1086 references: vec![],
1087 });
1088 }
1089 Ok(stages)
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095
1096 #[test]
1105 fn an_installed_catalog_wan_checkpoint_normalises_on_wans_grid() {
1106 let installed_wan = || ChainRequest {
1107 model: "cv:2041121".into(),
1108 stages: vec![
1109 wan_stage("a paper boat drifting down a rain gutter", 53),
1110 wan_stage("the boat reaches a storm drain", 53),
1111 ],
1112 motion_tail_frames: 1,
1113 width: 832,
1114 height: 480,
1115 fps: 16,
1116 ..auto_expand_request("unused", 106, 53, 1, None)
1117 };
1118
1119 let unhinted = installed_wan().normalise();
1122 assert!(
1123 unhinted.is_err(),
1124 "a `cv:` id has no manifest, so an unhinted normalise still cannot know the grid"
1125 );
1126
1127 let normalised = installed_wan()
1130 .normalise_with_family(Some("wan"))
1131 .expect("53 is 4k+1, which is wan's own grid");
1132 assert_eq!(normalised.stages.len(), 2);
1133 assert!(normalised.stages.iter().all(|stage| stage.frames == 53));
1134 assert_eq!(normalised.motion_tail_frames, 1);
1135
1136 let off_grid = ChainRequest {
1138 stages: vec![wan_stage("one", 50), wan_stage("two", 50)],
1139 ..installed_wan()
1140 };
1141 let error = off_grid.normalise_with_family(Some("wan")).unwrap_err();
1142 assert!(error.to_string().contains("4k+1"), "got: {error}");
1143
1144 let ltx2 = auto_expand_request("a drone shot", 194, 97, 17, None);
1146 assert!(ltx2.normalise_with_family(Some("ltx2")).is_ok());
1147 }
1148
1149 fn wan_stage(prompt: &str, frames: u32) -> ChainStage {
1150 ChainStage {
1151 prompt: prompt.into(),
1152 frames,
1153 source_image: None,
1154 negative_prompt: None,
1155 seed_offset: None,
1156 transition: TransitionMode::Smooth,
1157 fade_frames: None,
1158 model: None,
1159 loras: Vec::new(),
1160 references: Vec::new(),
1161 }
1162 }
1163
1164 fn auto_expand_request(
1168 prompt: &str,
1169 total_frames: u32,
1170 clip_frames: u32,
1171 motion_tail_frames: u32,
1172 source_image: Option<Vec<u8>>,
1173 ) -> ChainRequest {
1174 ChainRequest {
1175 model: "ltx-2-19b-distilled:fp8".into(),
1176 stages: Vec::new(),
1177 motion_tail_frames,
1178 width: 1216,
1179 height: 704,
1180 fps: 24,
1181 seed: Some(42),
1182 steps: 8,
1183 guidance: 3.0,
1184 strength: 1.0,
1185 output_format: OutputFormat::Mp4,
1186 placement: None,
1187 original_prompt: None,
1188 prompt_transform: None,
1189 batch_id: None,
1190 batch_index: None,
1191 batch_count: None,
1192 prompt: Some(prompt.into()),
1193 total_frames: Some(total_frames),
1194 clip_frames: Some(clip_frames),
1195 source_image,
1196 enable_audio: None,
1197 }
1198 }
1199
1200 fn canonical_request(stages: Vec<ChainStage>, motion_tail_frames: u32) -> ChainRequest {
1201 ChainRequest {
1202 model: "ltx-2-19b-distilled:fp8".into(),
1203 stages,
1204 motion_tail_frames,
1205 width: 1216,
1206 height: 704,
1207 fps: 24,
1208 seed: Some(42),
1209 steps: 8,
1210 guidance: 3.0,
1211 strength: 1.0,
1212 output_format: OutputFormat::Mp4,
1213 placement: None,
1214 original_prompt: None,
1215 prompt_transform: None,
1216 batch_id: None,
1217 batch_index: None,
1218 batch_count: None,
1219 prompt: None,
1220 total_frames: None,
1221 clip_frames: None,
1222 source_image: None,
1223 enable_audio: None,
1224 }
1225 }
1226
1227 fn make_stage(frames: u32) -> ChainStage {
1228 ChainStage {
1229 prompt: "test".into(),
1230 frames,
1231 source_image: None,
1232 negative_prompt: None,
1233 seed_offset: None,
1234 transition: TransitionMode::Smooth,
1235 fade_frames: None,
1236 model: None,
1237 loras: vec![],
1238 references: vec![],
1239 }
1240 }
1241
1242 #[test]
1243 fn normalise_splits_single_prompt_into_stages() {
1244 let normalised = auto_expand_request("a cat walking", 400, 97, 9, None)
1249 .normalise()
1250 .expect("normalise should succeed");
1251
1252 assert_eq!(
1253 normalised.stages.len(),
1254 5,
1255 "400/97 with a 9-frame motion tail should expand to 5 stages",
1256 );
1257 for stage in &normalised.stages {
1258 assert_eq!(stage.frames, 97);
1259 assert_eq!(stage.prompt, "a cat walking");
1260 assert!(stage.seed_offset.is_none());
1261 }
1262 assert!(normalised.prompt.is_none());
1264 assert!(normalised.total_frames.is_none());
1265 assert!(normalised.clip_frames.is_none());
1266 assert!(normalised.source_image.is_none());
1267 }
1268
1269 #[test]
1270 fn normalise_preserves_starting_image_across_all_stages() {
1271 let png = vec![0x89, 0x50, 0x4e, 0x47, 0xde, 0xad, 0xbe, 0xef];
1272 let normalised = auto_expand_request("test", 200, 97, 9, Some(png.clone()))
1273 .normalise()
1274 .expect("normalise should succeed");
1275
1276 assert!(normalised.stages.len() >= 2);
1277 for (idx, stage) in normalised.stages.iter().enumerate() {
1278 assert_eq!(
1283 stage.source_image.as_deref(),
1284 Some(png.as_slice()),
1285 "stage {idx} must carry the starting image for cross-stage identity anchoring",
1286 );
1287 }
1288 }
1289
1290 #[test]
1291 fn normalise_rejects_empty() {
1292 let mut req = canonical_request(Vec::new(), 9);
1293 req.prompt = None;
1295 req.total_frames = None;
1296
1297 let err = req.normalise().expect_err("empty chain should fail");
1298 assert!(
1299 matches!(err, MoldError::Validation(_)),
1300 "empty chain should be a validation error, got {err:?}",
1301 );
1302 }
1303
1304 #[test]
1305 fn normalise_rejects_non_8k1_frames() {
1306 let req = canonical_request(vec![make_stage(50)], 9);
1309 let err = req.normalise().expect_err("non-8k+1 frames should fail");
1310 assert!(
1311 matches!(err, MoldError::Validation(msg) if msg.contains("8k+1")),
1312 "error must mention the 8k+1 constraint",
1313 );
1314 }
1315
1316 #[test]
1317 fn normalise_accepts_canonical_form_unchanged() {
1318 let stages = vec![make_stage(97), make_stage(97), make_stage(97)];
1321 let normalised = canonical_request(stages.clone(), 9)
1322 .normalise()
1323 .expect("valid canonical form should pass");
1324 assert_eq!(normalised.stages.len(), 3);
1325 for (left, right) in normalised.stages.iter().zip(stages.iter()) {
1326 assert_eq!(left.frames, right.frames);
1327 assert_eq!(left.prompt, right.prompt);
1328 }
1329 }
1330
1331 #[test]
1332 fn normalise_single_stage_when_total_leq_clip() {
1333 let normalised = auto_expand_request("short", 9, 97, 1, None)
1338 .normalise()
1339 .expect("short single-clip chain should pass");
1340 assert_eq!(normalised.stages.len(), 1);
1341 assert_eq!(normalised.stages[0].frames, 9);
1342 }
1343
1344 #[test]
1345 fn normalise_rejects_too_many_stages() {
1346 let stages = (0..17).map(|_| make_stage(97)).collect();
1348 let err = canonical_request(stages, 9)
1349 .normalise()
1350 .expect_err("17-stage chain should fail");
1351 assert!(
1352 matches!(err, MoldError::Validation(msg) if msg.contains("maximum")),
1353 "error must mention the max-stages cap",
1354 );
1355 }
1356
1357 #[test]
1358 fn normalise_rejects_auto_expand_too_long() {
1359 let err = auto_expand_request("too long", 4000, 97, 9, None)
1362 .normalise()
1363 .expect_err("runaway auto-expand should fail");
1364 assert!(
1365 matches!(err, MoldError::Validation(msg) if msg.contains("stages")),
1366 "error must name the stage count guardrail",
1367 );
1368 }
1369
1370 #[test]
1371 fn normalise_preserves_optional_prepared_batch_provenance() {
1372 let mut req = auto_expand_request("expanded prompt", 190, 97, 17, None);
1373 req.original_prompt = Some("source prompt".into());
1374 req.batch_id = Some("prepared-batch-1".into());
1375 req.batch_index = Some(2);
1376 req.batch_count = Some(3);
1377
1378 let normalised = req.normalise().unwrap();
1379 assert_eq!(normalised.original_prompt.as_deref(), Some("source prompt"));
1380 assert_eq!(normalised.batch_id.as_deref(), Some("prepared-batch-1"));
1381 assert_eq!(normalised.batch_index, Some(2));
1382 assert_eq!(normalised.batch_count, Some(3));
1383 }
1384
1385 #[test]
1386 fn normalise_rejects_motion_tail_ge_clip() {
1387 let err = auto_expand_request("bad tail", 200, 97, 97, None)
1389 .normalise()
1390 .expect_err("motion_tail >= clip should fail");
1391 assert!(
1392 matches!(err, MoldError::Validation(msg) if msg.contains("motion_tail_frames")),
1393 "error must name motion_tail_frames",
1394 );
1395 }
1396
1397 #[test]
1398 fn enable_audio_defaults_to_none_and_round_trips_when_set() {
1399 let req: ChainRequest = serde_json::from_value(serde_json::json!({
1406 "model": "ltx-2.3-22b-distilled:fp8",
1407 "stages": [],
1408 "width": 704,
1409 "height": 416,
1410 "steps": 4,
1411 "guidance": 3.0,
1412 }))
1413 .expect("valid minimal chain request");
1414 assert_eq!(req.enable_audio, None);
1415 assert_eq!(req.original_prompt, None);
1416 assert_eq!(req.batch_id, None);
1417 assert_eq!(req.batch_index, None);
1418 assert_eq!(req.batch_count, None);
1419
1420 let req_with_audio: ChainRequest = serde_json::from_value(serde_json::json!({
1421 "model": "ltx-2.3-22b-distilled:fp8",
1422 "stages": [{"prompt": "a bird", "frames": 33}],
1423 "width": 704,
1424 "height": 416,
1425 "steps": 4,
1426 "guidance": 3.0,
1427 "enable_audio": true,
1428 }))
1429 .expect("valid chain request with audio");
1430 assert_eq!(req_with_audio.enable_audio, Some(true));
1431
1432 let script = ChainScript::from(&req_with_audio);
1433 assert_eq!(
1434 script.chain.enable_audio,
1435 Some(true),
1436 "ChainScript echo must preserve enable_audio for round-trip save/reload",
1437 );
1438 }
1439
1440 #[test]
1441 fn motion_tail_default_lands_on_8k_plus_1_grid() {
1442 let req: ChainRequest = serde_json::from_value(serde_json::json!({
1446 "model": "ltx-2.3-22b-distilled:fp8",
1447 "stages": [],
1448 "width": 704,
1449 "height": 416,
1450 "steps": 4,
1451 "guidance": 3.0,
1452 }))
1453 .expect("valid minimal chain request");
1454 assert_eq!(req.motion_tail_frames, 17);
1455 assert!(is_ltx2_frame_count(req.motion_tail_frames));
1456 }
1457
1458 #[test]
1459 fn normalise_rejects_motion_tail_off_grid() {
1460 let req = canonical_request(vec![make_stage(33)], 4);
1465 let err = req
1466 .normalise()
1467 .expect_err("motion_tail_frames=4 must be rejected");
1468 assert!(
1469 matches!(err, MoldError::Validation(msg) if msg.contains("8k+1")),
1470 "error must name the 8k+1 grid constraint",
1471 );
1472 }
1473
1474 #[test]
1475 fn normalise_accepts_motion_tail_zero() {
1476 let mut second = make_stage(33);
1479 second.transition = TransitionMode::Cut;
1480 let req = canonical_request(vec![make_stage(33), second], 0);
1481 req.normalise().expect("motion_tail=0 must be accepted");
1482 }
1483
1484 #[test]
1485 fn normalise_rejects_missing_total_frames_in_auto_expand() {
1486 let mut req = canonical_request(Vec::new(), 4);
1487 req.prompt = Some("missing total".into());
1488 let err = req
1490 .normalise()
1491 .expect_err("missing total_frames should fail");
1492 assert!(
1493 matches!(err, MoldError::Validation(msg) if msg.contains("total_frames")),
1494 "error must name total_frames",
1495 );
1496 }
1497
1498 #[test]
1499 fn is_ltx2_frame_count_matches_8k_plus_1() {
1500 for valid in [1u32, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97] {
1501 assert!(
1502 is_ltx2_frame_count(valid),
1503 "{valid} should be a valid LTX-2 frame count",
1504 );
1505 }
1506 for invalid in [0u32, 2, 8, 10, 16, 50, 96, 98, 100] {
1507 assert!(
1508 !is_ltx2_frame_count(invalid),
1509 "{invalid} must not pass the 8k+1 check",
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn chain_progress_event_roundtrips_json_with_snake_case_tags() {
1516 let cases = [
1517 (
1518 ChainProgressEvent::ChainStart {
1519 stage_count: 5,
1520 estimated_total_frames: 469,
1521 },
1522 r#""type":"chain_start""#,
1523 ),
1524 (
1525 ChainProgressEvent::StageStart { stage_idx: 0 },
1526 r#""type":"stage_start""#,
1527 ),
1528 (
1529 ChainProgressEvent::DenoiseStep {
1530 stage_idx: 2,
1531 step: 4,
1532 total: 8,
1533 },
1534 r#""type":"denoise_step""#,
1535 ),
1536 (
1537 ChainProgressEvent::StageDone {
1538 stage_idx: 3,
1539 frames_emitted: 97,
1540 },
1541 r#""type":"stage_done""#,
1542 ),
1543 (
1544 ChainProgressEvent::Stitching { total_frames: 400 },
1545 r#""type":"stitching""#,
1546 ),
1547 ];
1548 for (event, expected_tag) in cases {
1549 let json = serde_json::to_string(&event).expect("serialize");
1550 assert!(
1551 json.contains(expected_tag),
1552 "missing snake_case tag {expected_tag} in {json}",
1553 );
1554 let roundtrip: ChainProgressEvent = serde_json::from_str(&json).expect("deserialize");
1555 assert_eq!(roundtrip, event, "roundtrip must preserve payload");
1556 }
1557 }
1558
1559 #[test]
1560 fn build_stages_math_matches_stitch_budget() {
1561 let cases = [
1565 (400u32, 97u32, 9u32, 5u32), (200, 97, 9, 3), (97, 97, 9, 1), (300, 97, 0, 4), ];
1570 for (total, clip, tail, expected_n) in cases {
1571 let req = auto_expand_request("m", total, clip, tail, None)
1572 .normalise()
1573 .expect("valid auto-expand should normalise");
1574 assert_eq!(
1575 req.stages.len() as u32,
1576 expected_n,
1577 "expected {expected_n} stages for total={total}, clip={clip}, tail={tail}",
1578 );
1579 let delivered = clip + (expected_n - 1) * (clip - tail);
1580 assert!(
1581 delivered >= total,
1582 "{expected_n} stages deliver {delivered} frames but {total} were requested",
1583 );
1584 }
1585 }
1586
1587 #[test]
1588 fn transition_mode_serializes_snake_case() {
1589 assert_eq!(
1590 serde_json::to_value(TransitionMode::Smooth).unwrap(),
1591 serde_json::Value::String("smooth".into())
1592 );
1593 assert_eq!(
1594 serde_json::to_value(TransitionMode::Cut).unwrap(),
1595 serde_json::Value::String("cut".into())
1596 );
1597 assert_eq!(
1598 serde_json::to_value(TransitionMode::Fade).unwrap(),
1599 serde_json::Value::String("fade".into())
1600 );
1601 }
1602
1603 #[test]
1604 fn transition_mode_defaults_to_smooth() {
1605 assert_eq!(TransitionMode::default(), TransitionMode::Smooth);
1606 }
1607
1608 #[test]
1609 fn lora_spec_serializes_minimal() {
1610 let spec = LoraSpec {
1611 path: "./style.safetensors".into(),
1612 scale: 0.8,
1613 name: None,
1614 };
1615 let json = serde_json::to_string(&spec).unwrap();
1616 assert!(json.contains(r#""path":"./style.safetensors""#));
1617 assert!(json.contains(r#""scale":0.8"#));
1618 assert!(!json.contains(r#""name""#));
1620 }
1621
1622 #[test]
1623 fn named_ref_serializes_minimal() {
1624 let r = NamedRef {
1625 name: "hero".into(),
1626 image: vec![0x89, 0x50],
1627 };
1628 let json = serde_json::to_string(&r).unwrap();
1629 assert!(json.contains(r#""name":"hero""#));
1631 assert!(json.contains(r#""image":"#));
1632 }
1633
1634 #[test]
1635 fn chain_stage_defaults_are_backcompat() {
1636 let json = r#"{
1639 "prompt": "a cat",
1640 "frames": 97
1641 }"#;
1642 let stage: ChainStage = serde_json::from_str(json).unwrap();
1643 assert_eq!(stage.prompt, "a cat");
1644 assert_eq!(stage.frames, 97);
1645 assert_eq!(stage.transition, TransitionMode::Smooth);
1646 assert_eq!(stage.fade_frames, None);
1647 assert!(stage.model.is_none());
1648 assert!(stage.loras.is_empty());
1649 assert!(stage.references.is_empty());
1650 }
1651
1652 #[test]
1653 fn chain_script_projects_from_request() {
1654 let req = ChainRequest {
1655 model: "ltx-2-19b-distilled:fp8".into(),
1656 stages: vec![ChainStage {
1657 prompt: "a".into(),
1658 frames: 97,
1659 source_image: None,
1660 negative_prompt: None,
1661 seed_offset: None,
1662 transition: TransitionMode::Smooth,
1663 fade_frames: None,
1664 model: None,
1665 loras: vec![],
1666 references: vec![],
1667 }],
1668 motion_tail_frames: 25,
1669 width: 1216,
1670 height: 704,
1671 fps: 24,
1672 seed: Some(42),
1673 steps: 8,
1674 guidance: 3.0,
1675 strength: 1.0,
1676 output_format: OutputFormat::Mp4,
1677 placement: None,
1678 original_prompt: None,
1679 prompt_transform: None,
1680 batch_id: None,
1681 batch_index: None,
1682 batch_count: None,
1683 prompt: None,
1684 total_frames: None,
1685 clip_frames: None,
1686 source_image: None,
1687 enable_audio: None,
1688 };
1689 let script = ChainScript::from(&req);
1690 assert_eq!(script.chain.model, "ltx-2-19b-distilled:fp8");
1691 assert_eq!(script.chain.seed, Some(42));
1692 assert_eq!(script.stages.len(), 1);
1693 assert_eq!(script.stages[0].prompt, "a");
1694 }
1695
1696 #[test]
1697 fn chain_stage_roundtrips_all_fields() {
1698 let stage = ChainStage {
1699 prompt: "scene".into(),
1700 frames: 49,
1701 source_image: None,
1702 negative_prompt: None,
1703 seed_offset: None,
1704 transition: TransitionMode::Cut,
1705 fade_frames: Some(12),
1706 model: None,
1707 loras: vec![],
1708 references: vec![],
1709 };
1710 let json = serde_json::to_string(&stage).unwrap();
1711 let back: ChainStage = serde_json::from_str(&json).unwrap();
1712 assert_eq!(back.frames, 49);
1713 assert_eq!(back.transition, TransitionMode::Cut);
1714 assert_eq!(back.fade_frames, Some(12));
1715 }
1716
1717 #[test]
1718 fn normalise_coerces_stage_0_transition_to_smooth() {
1719 let mut req = auto_expand_request("a", 97, 97, 25, None);
1720 req.stages = vec![
1721 ChainStage {
1722 prompt: "scene 0".into(),
1723 frames: 97,
1724 source_image: None,
1725 negative_prompt: None,
1726 seed_offset: None,
1727 transition: TransitionMode::Cut, fade_frames: None,
1729 model: None,
1730 loras: vec![],
1731 references: vec![],
1732 },
1733 ChainStage {
1734 prompt: "scene 1".into(),
1735 frames: 97,
1736 source_image: None,
1737 negative_prompt: None,
1738 seed_offset: None,
1739 transition: TransitionMode::Cut, fade_frames: None,
1741 model: None,
1742 loras: vec![],
1743 references: vec![],
1744 },
1745 ];
1746 let normalised = req.normalise().unwrap();
1747 assert_eq!(normalised.stages[0].transition, TransitionMode::Smooth);
1748 assert_eq!(normalised.stages[1].transition, TransitionMode::Cut);
1749 }
1750
1751 #[test]
1752 fn normalise_rejects_reserved_model_field() {
1753 let mut req = auto_expand_request("a", 97, 97, 25, None);
1754 req.stages = vec![ChainStage {
1755 prompt: "x".into(),
1756 frames: 97,
1757 source_image: None,
1758 negative_prompt: None,
1759 seed_offset: None,
1760 transition: TransitionMode::Smooth,
1761 fade_frames: None,
1762 model: Some("flux-dev:q4".into()),
1763 loras: vec![],
1764 references: vec![],
1765 }];
1766 let err = req.normalise().unwrap_err().to_string();
1767 assert!(err.contains("reserved for sub-project C"), "got: {err}");
1768 }
1769
1770 #[test]
1771 fn normalise_accepts_valid_per_stage_loras() {
1772 let mut req = auto_expand_request("a", 97, 97, 25, None);
1773 req.stages = vec![ChainStage {
1774 prompt: "x".into(),
1775 frames: 97,
1776 source_image: None,
1777 negative_prompt: None,
1778 seed_offset: None,
1779 transition: TransitionMode::Smooth,
1780 fade_frames: None,
1781 model: None,
1782 loras: vec![LoraSpec {
1783 path: "x.safetensors".into(),
1784 scale: 1.0,
1785 name: None,
1786 }],
1787 references: vec![],
1788 }];
1789 let normalised = req.normalise().unwrap();
1790 assert_eq!(normalised.stages[0].loras[0].path, "x.safetensors");
1791 let metadata = normalised.stitched_output_metadata(OutputFormat::Mp4, 97, None);
1792 assert_eq!(
1793 metadata.chain.unwrap().stages[0].loras,
1794 normalised.stages[0].loras
1795 );
1796 }
1797
1798 #[test]
1799 fn normalise_validates_per_stage_loras() {
1800 let base = auto_expand_request("a", 97, 97, 25, None)
1801 .normalise()
1802 .unwrap();
1803
1804 let mut invalid_path = base.clone();
1805 invalid_path.stages[0].loras = vec![LoraSpec {
1806 path: "camera.bin".into(),
1807 scale: 1.0,
1808 name: None,
1809 }];
1810 let err = invalid_path.normalise().unwrap_err().to_string();
1811 assert!(
1812 err.contains("safetensors file or camera-control"),
1813 "got: {err}"
1814 );
1815
1816 let mut invalid_scale = base.clone();
1817 invalid_scale.stages[0].loras = vec![LoraSpec {
1818 path: "camera-control:dolly-in".into(),
1819 scale: 2.1,
1820 name: Some("Dolly in".into()),
1821 }];
1822 let err = invalid_scale.normalise().unwrap_err().to_string();
1823 assert!(err.contains("must be in range [0.0, 2.0]"), "got: {err}");
1824
1825 let mut too_many = base;
1826 too_many.stages[0].loras = (0..5)
1827 .map(|idx| LoraSpec {
1828 path: format!("{idx}.safetensors"),
1829 scale: 1.0,
1830 name: None,
1831 })
1832 .collect();
1833 let err = too_many.normalise().unwrap_err().to_string();
1834 assert!(err.contains("four-LoRA stack limit"), "got: {err}");
1835 }
1836
1837 fn stage_list_request(stages: Vec<(TransitionMode, u32, Option<u32>)>) -> ChainRequest {
1838 ChainRequest {
1839 model: "ltx-2-19b-distilled:fp8".into(),
1840 stages: stages
1841 .into_iter()
1842 .map(|(t, f, fl)| ChainStage {
1843 prompt: "x".into(),
1844 frames: f,
1845 source_image: None,
1846 negative_prompt: None,
1847 seed_offset: None,
1848 transition: t,
1849 fade_frames: fl,
1850 model: None,
1851 loras: vec![],
1852 references: vec![],
1853 })
1854 .collect(),
1855 motion_tail_frames: 25,
1856 width: 1216,
1857 height: 704,
1858 fps: 24,
1859 seed: None,
1860 steps: 8,
1861 guidance: 3.0,
1862 strength: 1.0,
1863 output_format: OutputFormat::Mp4,
1864 placement: None,
1865 original_prompt: None,
1866 prompt_transform: None,
1867 batch_id: None,
1868 batch_index: None,
1869 batch_count: None,
1870 prompt: None,
1871 total_frames: None,
1872 clip_frames: None,
1873 source_image: None,
1874 enable_audio: None,
1875 }
1876 }
1877
1878 #[test]
1879 fn estimated_total_all_smooth() {
1880 let req = stage_list_request(vec![
1882 (TransitionMode::Smooth, 97, None),
1883 (TransitionMode::Smooth, 97, None),
1884 (TransitionMode::Smooth, 97, None),
1885 ]);
1886 assert_eq!(req.estimated_total_frames(), 241);
1887 }
1888
1889 #[test]
1890 fn estimated_total_with_cut() {
1891 let req = stage_list_request(vec![
1893 (TransitionMode::Smooth, 97, None),
1894 (TransitionMode::Cut, 97, None),
1895 (TransitionMode::Smooth, 97, None),
1896 ]);
1897 assert_eq!(req.estimated_total_frames(), 266);
1898 }
1899
1900 #[test]
1906 fn stage_contributed_frames_sums_to_estimated_total() {
1907 let req = stage_list_request(vec![
1908 (TransitionMode::Smooth, 97, None),
1909 (TransitionMode::Cut, 97, None),
1910 (TransitionMode::Fade, 97, Some(8)),
1911 (TransitionMode::Smooth, 89, None),
1912 (TransitionMode::Fade, 97, None), ]);
1914 let per_stage: Vec<u32> = req
1915 .stages
1916 .iter()
1917 .enumerate()
1918 .map(|(idx, stage)| {
1919 let next = req.stages.get(idx + 1);
1920 stage_contributed_frames(
1921 idx,
1922 stage.frames,
1923 stage.transition,
1924 next.map(|s| s.transition),
1925 next.and_then(|s| s.fade_frames),
1926 req.motion_tail_frames,
1927 )
1928 })
1929 .collect();
1930 assert_eq!(per_stage, vec![97, 89, 97, 89 - 25 - 8, 97]);
1933 assert_eq!(
1934 per_stage.iter().sum::<u32>(),
1935 req.estimated_total_frames(),
1936 "estimated_total_frames must be the sum of stage_contributed_frames",
1937 );
1938 }
1939
1940 #[test]
1941 fn estimated_total_with_fade() {
1942 let req = stage_list_request(vec![
1948 (TransitionMode::Smooth, 97, None),
1949 (TransitionMode::Cut, 97, None),
1950 (TransitionMode::Fade, 97, Some(8)),
1951 ]);
1952 assert_eq!(req.estimated_total_frames(), 283);
1953 }
1954
1955 #[test]
1959 fn synthetic_generate_request_reads_stages_zero() {
1960 let mut req = auto_expand_request("stage zero prompt", 190, 97, 17, None);
1961 req.original_prompt = Some("source prompt".into());
1962 req.batch_id = Some("prepared-batch-1".into());
1963 req.batch_index = Some(2);
1964 req.batch_count = Some(3);
1965 req.stages = vec![
1966 ChainStage {
1967 prompt: "stage zero prompt".into(),
1968 frames: 97,
1969 source_image: Some(vec![1, 2, 3, 4]),
1970 negative_prompt: Some("no cats".into()),
1971 seed_offset: None,
1972 transition: TransitionMode::Smooth,
1973 fade_frames: None,
1974 model: None,
1975 loras: vec![],
1976 references: vec![],
1977 },
1978 ChainStage {
1979 prompt: "stage one prompt".into(),
1980 frames: 97,
1981 source_image: Some(vec![9, 9, 9]),
1982 negative_prompt: None,
1983 seed_offset: None,
1984 transition: TransitionMode::Cut,
1985 fade_frames: None,
1986 model: None,
1987 loras: vec![],
1988 references: vec![],
1989 },
1990 ];
1991 req.prompt = None;
1992 req.total_frames = None;
1993 req.clip_frames = None;
1994
1995 let synth = req.synthetic_generate_request(OutputFormat::Mp4, 190, 24);
1996 assert_eq!(
1997 synth.prompt, "stage zero prompt\nstage one prompt",
1998 "distinct clip prompts are joined, one line per clip",
1999 );
2000 assert_eq!(synth.source_image.as_deref(), Some(&[1, 2, 3, 4][..]));
2001 assert_eq!(synth.negative_prompt.as_deref(), Some("no cats"));
2002 assert_eq!(synth.model, "ltx-2-19b-distilled:fp8");
2003 assert_eq!(synth.seed, Some(42));
2004 assert_eq!(synth.frames, Some(190));
2005 assert_eq!(synth.enable_audio, None);
2006 assert_eq!(synth.original_prompt.as_deref(), Some("source prompt"));
2007 assert_eq!(synth.batch_id.as_deref(), Some("prepared-batch-1"));
2008 assert_eq!(synth.batch_index, Some(2));
2009 assert_eq!(synth.batch_count, Some(3));
2010
2011 let metadata = req.stitched_output_metadata(OutputFormat::Mp4, 190, None);
2012 assert_eq!(metadata.original_prompt.as_deref(), Some("source prompt"));
2013 assert_eq!(metadata.batch_id.as_deref(), Some("prepared-batch-1"));
2014 assert_eq!(metadata.batch_index, Some(2));
2015 assert_eq!(metadata.batch_count, Some(3));
2016 }
2017
2018 #[test]
2023 fn synthetic_generate_request_joins_distinct_stage_prompts() {
2024 let uniform = auto_expand_request("one prompt", 190, 97, 17, None)
2025 .normalise()
2026 .unwrap();
2027 assert_eq!(
2028 uniform
2029 .synthetic_generate_request(OutputFormat::Mp4, 190, 24)
2030 .prompt,
2031 "one prompt"
2032 );
2033
2034 let mut distinct = stage_list_request(vec![
2035 (TransitionMode::Smooth, 97, None),
2036 (TransitionMode::Smooth, 33, None),
2037 ]);
2038 distinct.stages[0].prompt = "kingfisher waits".into();
2039 distinct.stages[1].prompt = "it lifts off".into();
2040 assert_eq!(
2041 distinct
2042 .synthetic_generate_request(OutputFormat::Mp4, 113, 24)
2043 .prompt,
2044 "kingfisher waits\nit lifts off"
2045 );
2046 }
2047
2048 #[test]
2052 fn stitched_metadata_records_chain_block_with_stage_provenance() {
2053 let mut req = stage_list_request(vec![
2054 (TransitionMode::Smooth, 97, None),
2055 (TransitionMode::Fade, 33, Some(8)),
2056 ]);
2057 req.stages[0].prompt = "opening".into();
2058 req.stages[1].prompt = "landing".into();
2059
2060 let seeds = [7u64, u64::MAX];
2061 let provenance = ChainProvenance {
2062 chain_job_id: Some("job-123"),
2063 stage_seeds: Some(&seeds),
2064 };
2065 let meta = req.stitched_output_metadata(OutputFormat::Mp4, 122, Some(&provenance));
2066
2067 assert_eq!(meta.chain_job_id.as_deref(), Some("job-123"));
2068 let chain = meta.chain.expect("chain block must be present");
2069 assert_eq!(chain.stage_count, 2);
2070 assert_eq!(chain.motion_tail_frames, req.motion_tail_frames);
2071 assert_eq!(chain.stages.len(), 2);
2072 assert_eq!(chain.stages[0].prompt, "opening");
2073 assert_eq!(chain.stages[0].frames, 97);
2074 assert_eq!(chain.stages[0].transition, TransitionMode::Smooth);
2075 assert_eq!(chain.stages[0].seed.as_deref(), Some("7"));
2076 assert_eq!(chain.stages[1].prompt, "landing");
2077 assert_eq!(chain.stages[1].frames, 33);
2078 assert_eq!(chain.stages[1].transition, TransitionMode::Fade);
2079 assert_eq!(chain.stages[1].fade_frames, Some(8));
2080 assert_eq!(
2081 chain.stages[1].seed.as_deref(),
2082 Some("18446744073709551615"),
2083 "u64 seeds are decimal strings on the wire",
2084 );
2085 }
2086
2087 #[test]
2090 fn stitched_metadata_records_chain_block_without_provenance() {
2091 let req = auto_expand_request("p", 190, 97, 17, None)
2092 .normalise()
2093 .unwrap();
2094 let meta = req.stitched_output_metadata(OutputFormat::Mp4, 190, None);
2095 assert_eq!(meta.chain_job_id, None);
2096 let chain = meta.chain.expect("chain block must be present");
2097 assert_eq!(chain.stage_count as usize, chain.stages.len());
2098 assert!(chain.stages.iter().all(|stage| stage.seed.is_none()));
2099 }
2100
2101 #[test]
2105 fn stitched_metadata_records_actual_format_after_fallback() {
2106 let req = auto_expand_request("p", 190, 97, 17, None)
2107 .normalise()
2108 .unwrap();
2109 let meta = req.stitched_output_metadata(OutputFormat::Apng, 190, None);
2110 assert_eq!(meta.output_format, Some(OutputFormat::Apng));
2111 assert_eq!(meta.frames, Some(190));
2112 assert_eq!(meta.fps, Some(24));
2113 }
2114
2115 #[test]
2119 fn stitched_metadata_strength_only_for_img2img_start() {
2120 let txt2vid = auto_expand_request("p", 190, 97, 17, None)
2121 .normalise()
2122 .unwrap();
2123 assert_eq!(
2124 txt2vid
2125 .stitched_output_metadata(OutputFormat::Mp4, 190, None)
2126 .strength,
2127 None
2128 );
2129
2130 let img2vid = auto_expand_request("p", 190, 97, 17, Some(vec![1, 2, 3]))
2131 .normalise()
2132 .unwrap();
2133 assert_eq!(
2134 img2vid
2135 .stitched_output_metadata(OutputFormat::Mp4, 190, None)
2136 .strength,
2137 Some(1.0)
2138 );
2139 }
2140
2141 #[test]
2145 fn stitched_metadata_matches_from_generate_request() {
2146 let req = auto_expand_request("p", 190, 97, 17, None)
2147 .normalise()
2148 .unwrap();
2149 let synth = req.synthetic_generate_request(OutputFormat::Mp4, 190, req.fps);
2150 let expected = OutputMetadata::from_generate_request(
2151 &synth,
2152 req.seed.unwrap_or(0),
2153 None,
2154 crate::build_info::version_string(),
2155 );
2156 let mut stitched = req.stitched_output_metadata(OutputFormat::Mp4, 190, None);
2159 assert!(stitched.chain.is_some());
2160 stitched.chain = None;
2161 assert_eq!(stitched, expected);
2162 }
2163}