1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::{
12 validation, GuidanceCapabilities, Ltx2PipelineMode, OutputFormat, Scheduler,
13 SourceImageCapability,
14};
15
16pub const GENERATION_PROFILE_SCHEMA_VERSION: u32 = 1;
17
18#[derive(
19 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
20)]
21#[serde(rename_all = "kebab-case")]
22pub enum ResolutionDomain {
23 Dynamic,
24 Buckets,
25 SourceDriven,
26 None,
27}
28
29#[derive(
39 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
40)]
41#[serde(rename_all = "kebab-case")]
42#[ts(rename_all = "kebab-case")]
43pub enum OffBucketPolicy {
44 #[default]
45 Reject,
46 Warn,
47}
48
49#[derive(
50 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
51)]
52#[serde(rename_all = "kebab-case")]
53pub enum ControlMode {
54 Adjustable,
55 Fixed,
56 Hidden,
57}
58
59#[derive(
60 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS,
61)]
62#[serde(rename_all = "kebab-case")]
63pub enum ProvenanceKind {
64 Upstream,
65 MoldPolicy,
66 Derived,
67 DeliveryLimit,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
71pub struct ProfileProvenance {
72 pub kind: ProvenanceKind,
73 pub source: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub revision: Option<String>,
76 pub qualified: bool,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub evidence: Option<String>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct ResolutionQualificationRecord {
92 pub family: &'static str,
93 pub source: &'static str,
94 pub revision: &'static str,
95 pub qualified: bool,
96 pub evidence: &'static str,
97 pub candidates: &'static [(u32, u32)],
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
101#[ts(rename = "ProfileResolutionPreset")]
102pub struct ResolutionPreset {
103 pub id: String,
104 pub width: u32,
105 pub height: u32,
106 pub tier: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
110#[ts(rename = "ProfileAspectGroup")]
111pub struct AspectGroup {
112 pub id: String,
113 pub label: String,
114 pub presets: Vec<ResolutionPreset>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
118pub struct ResolutionProfile {
119 pub domain: ResolutionDomain,
120 pub alignment: u32,
121 pub min_width: u32,
122 pub min_height: u32,
123 #[ts(type = "number")]
124 pub max_pixels: u64,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub max_axis_pixels: Option<u32>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub min_aspect_ratio: Option<f64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub max_aspect_ratio: Option<f64>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub off_bucket: Option<OffBucketPolicy>,
135 pub aspect_groups: Vec<AspectGroup>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
139pub struct IntegerControl {
140 pub default: u32,
141 pub min: u32,
142 pub max: u32,
143 pub step: u32,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub recommended: Vec<u32>,
146 pub mode: ControlMode,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
150pub struct FloatControl {
151 pub default: f64,
152 pub min: f64,
153 pub max: f64,
154 pub step: f64,
155 pub mode: ControlMode,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
159#[serde(tag = "mode", rename_all = "kebab-case")]
160#[ts(rename = "ProfileFpsControl")]
161pub enum FpsControl {
162 Fixed {
163 value: u32,
164 },
165 Adjustable {
166 default: u32,
167 min: u32,
168 max: u32,
169 step: u32,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
174pub struct TemporalProfile {
175 pub frames: IntegerControl,
176 pub frame_offset: u32,
177 pub fps: FpsControl,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub max_duration_seconds: Option<u32>,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
183pub struct GenerationDefaultsProfile {
184 pub width: u32,
185 pub height: u32,
186 pub steps: u32,
187 pub guidance: f64,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub frames: Option<u32>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub fps: Option<u32>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub negative_prompt: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
197pub struct RecipeSelector {
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub pipeline: Option<Ltx2PipelineMode>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
204pub struct FeatureControlProfile {
205 pub mode: ControlMode,
206 pub required: bool,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub reason: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
213pub struct AdapterControlProfile {
214 pub mode: ControlMode,
215 pub max_count: u32,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub reason: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
221pub struct OutputCapabilitiesProfile {
222 pub default_format: OutputFormat,
223 pub formats: Vec<OutputFormat>,
224 pub audio_requires_mp4: bool,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub delivery_reason: Option<String>,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct GenerationDeliveryCapabilities {
236 pub mp4: bool,
237 pub webp: bool,
238}
239
240impl GenerationDeliveryCapabilities {
241 pub const fn new(mp4: bool, webp: bool) -> Self {
242 Self { mp4, webp }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
247pub struct WanRecipeCapabilitiesProfile {
248 pub mode: ControlMode,
249 pub supports_distill_strength: bool,
250 pub supports_first_last_frame: bool,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub first_last_frame_min_frames: Option<u32>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub reason: Option<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
258pub struct GenerationCapabilitiesProfile {
259 pub guidance: GuidanceCapabilities,
260 pub negative_prompt: FeatureControlProfile,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub source_image: Option<SourceImageCapability>,
263 pub supports_lora: bool,
264 pub supports_controlnet: bool,
265 pub supports_sequence: bool,
266 pub supports_extend: bool,
267 pub supports_audio: bool,
268 pub source_video: FeatureControlProfile,
269 pub mask: FeatureControlProfile,
270 pub keyframes: FeatureControlProfile,
271 pub audio: FeatureControlProfile,
272 pub lora: AdapterControlProfile,
273 pub controlnet: AdapterControlProfile,
274 pub output: OutputCapabilitiesProfile,
275 pub wan_recipe: WanRecipeCapabilitiesProfile,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub schedulers: Vec<Scheduler>,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
281pub struct GenerationRecipeProfile {
282 pub id: String,
283 pub label: String,
284 pub request_selector: RecipeSelector,
285 pub defaults: GenerationDefaultsProfile,
286 pub resolution: ResolutionProfile,
287 pub steps: IntegerControl,
288 pub guidance: FloatControl,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub temporal: Option<TemporalProfile>,
291 pub capabilities: GenerationCapabilitiesProfile,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub provenance: Vec<ProfileProvenance>,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, ts_rs::TS)]
297pub struct GenerationProfileSet {
298 pub schema_version: u32,
299 pub profile_id: String,
300 pub profile_hash: String,
301 pub default_recipe_id: String,
302 pub recipes: Vec<GenerationRecipeProfile>,
303}
304
305impl GenerationProfileSet {
306 pub fn default_recipe(&self) -> Option<&GenerationRecipeProfile> {
307 self.recipes
308 .iter()
309 .find(|recipe| recipe.id == self.default_recipe_id)
310 }
311
312 pub fn recipe_for_pipeline(
313 &self,
314 pipeline: Option<Ltx2PipelineMode>,
315 ) -> Option<&GenerationRecipeProfile> {
316 match pipeline {
317 Some(pipeline) => self
318 .recipes
319 .iter()
320 .find(|recipe| recipe.request_selector.pipeline == Some(pipeline)),
321 None => self.default_recipe(),
322 }
323 }
324
325 pub fn refresh_hash(&mut self) {
329 self.profile_hash.clear();
330 let encoded = serde_json::to_vec(self).expect("generation profile must serialize");
331 self.profile_hash = format!("{:x}", Sha256::digest(encoded));
332 }
333}
334
335pub fn qualify_generation_profile_delivery(
342 profile: &mut GenerationProfileSet,
343 delivery: GenerationDeliveryCapabilities,
344) {
345 for recipe in &mut profile.recipes {
346 recipe
347 .capabilities
348 .output
349 .formats
350 .retain(|format| match format {
351 OutputFormat::Mp4 => delivery.mp4,
352 OutputFormat::Webp => delivery.webp,
353 _ => true,
354 });
355 if !recipe
356 .capabilities
357 .output
358 .formats
359 .contains(&recipe.capabilities.output.default_format)
360 {
361 if let Some(format) = recipe.capabilities.output.formats.first().copied() {
362 recipe.capabilities.output.default_format = format;
363 }
364 }
365 if recipe.capabilities.output.audio_requires_mp4 && !delivery.mp4 {
366 recipe.capabilities.supports_audio = false;
367 }
368 }
369
370 profile
371 .recipes
372 .retain(|recipe| !recipe.capabilities.output.formats.is_empty());
373 if !profile
374 .recipes
375 .iter()
376 .any(|recipe| recipe.id == profile.default_recipe_id)
377 {
378 profile.default_recipe_id = profile
379 .recipes
380 .first()
381 .map(|recipe| recipe.id.clone())
382 .unwrap_or_default();
383 }
384 profile.refresh_hash();
385}
386
387pub fn generation_profile_default_output_format(
389 profile: &GenerationProfileSet,
390 pipeline: Option<Ltx2PipelineMode>,
391) -> Result<OutputFormat, String> {
392 let recipe = profile.recipe_for_pipeline(pipeline).ok_or_else(|| {
393 if let Some(pipeline) = pipeline {
394 format!("pipeline '{pipeline}' is not available for this model")
395 } else {
396 format!(
397 "generation profile '{}' has no default recipe",
398 profile.profile_id
399 )
400 }
401 })?;
402 Ok(recipe.capabilities.output.default_format)
403}
404
405pub fn materialize_generation_profile_output_default(
407 profile: &GenerationProfileSet,
408 request: &mut crate::GenerateRequest,
409) -> Result<(), String> {
410 if request.output_format.is_none() {
411 request.output_format = Some(generation_profile_default_output_format(
412 profile,
413 request.pipeline,
414 )?);
415 }
416 Ok(())
417}
418
419pub fn validate_request_against_generation_profile(
423 profile: &GenerationProfileSet,
424 request: &crate::GenerateRequest,
425) -> Result<(), String> {
426 let recipe = if let Some(pipeline) = request.pipeline {
427 profile
428 .recipes
429 .iter()
430 .find(|recipe| recipe.request_selector.pipeline == Some(pipeline))
431 .ok_or_else(|| format!("pipeline '{}' is not available for this model", pipeline))?
432 } else {
433 profile.default_recipe().ok_or_else(|| {
434 format!(
435 "generation profile '{}' has no default recipe",
436 profile.profile_id
437 )
438 })?
439 };
440 validate_request_against_recipe(recipe, request)
441}
442
443pub fn validate_request_against_recipe(
444 recipe: &GenerationRecipeProfile,
445 request: &crate::GenerateRequest,
446) -> Result<(), String> {
447 validate_integer("steps", request.steps, &recipe.steps)?;
448 validate_float("guidance", request.guidance, &recipe.guidance)?;
449 if let Some(scheduler) = request.scheduler {
450 let advertised = &recipe.capabilities.schedulers;
451 if !advertised.contains(&scheduler) {
452 return Err(format!(
453 "scheduler '{scheduler}' is not available for this recipe"
454 ));
455 }
456 }
457 if let Some(output_format) = request.output_format {
458 if !recipe.capabilities.output.formats.contains(&output_format) {
459 return Err(format!(
460 "output format '{}' is not available for this recipe",
461 output_format.extension()
462 ));
463 }
464 }
465
466 let resolution = &recipe.resolution;
467 if resolution.domain != ResolutionDomain::None {
468 validate_resolution(resolution, request.width, request.height)?;
469 }
470
471 if let Some(temporal) = &recipe.temporal {
472 let frames = request.frames.unwrap_or(temporal.frames.default);
473 let effective_fps = request.fps.unwrap_or(match temporal.fps {
474 FpsControl::Fixed { value } => value,
475 FpsControl::Adjustable { default, .. } => default,
476 });
477 let mut effective_frames = temporal.frames.clone();
478 if let Some(seconds) = temporal.max_duration_seconds {
479 let raw_duration_cap = seconds
480 .saturating_mul(effective_fps.max(1))
481 .saturating_add(temporal.frame_offset);
482 let grid_cap = raw_duration_cap.saturating_sub(temporal.frame_offset)
483 / temporal.frames.step
484 * temporal.frames.step
485 + temporal.frame_offset;
486 effective_frames.max = effective_frames.max.min(grid_cap);
487 }
488 validate_integer("frames", frames, &effective_frames)?;
489 match temporal.fps {
490 FpsControl::Fixed { value } => {
491 if request.fps.is_some_and(|fps| fps != value) {
492 return Err(format!("fps is fixed at {value} for this recipe"));
493 }
494 }
495 FpsControl::Adjustable { min, max, step, .. } => {
496 if let Some(fps) = request.fps {
497 if !(min..=max).contains(&fps) || !(fps - min).is_multiple_of(step) {
498 return Err(format!(
499 "fps must be {min} through {max} in steps of {step}"
500 ));
501 }
502 }
503 }
504 }
505 } else if request.frames.is_some() || request.fps.is_some() {
506 return Err("frames and fps are not supported by this recipe".to_string());
507 }
508 Ok(())
509}
510
511pub fn validate_dimensions_against_recipe(
512 recipe: &GenerationRecipeProfile,
513 width: u32,
514 height: u32,
515) -> Result<(), String> {
516 if recipe.resolution.domain == ResolutionDomain::None {
517 return Err("resolution is not available for this recipe".to_string());
518 }
519 validate_resolution(&recipe.resolution, width, height)
520}
521
522pub fn off_bucket_resolution_warning(
527 recipe: &GenerationRecipeProfile,
528 width: u32,
529 height: u32,
530) -> Option<String> {
531 let profile = &recipe.resolution;
532 if profile.domain != ResolutionDomain::Buckets
533 || profile.off_bucket.unwrap_or_default() != OffBucketPolicy::Warn
534 || validate_resolution(profile, width, height).is_err()
535 {
536 return None;
537 }
538 let exact = profile
539 .aspect_groups
540 .iter()
541 .flat_map(|group| &group.presets)
542 .any(|preset| preset.width == width && preset.height == height);
543 (!exact).then(|| format!("This model isn't optimized for {width}x{height} — results may vary."))
544}
545
546pub fn resolution_advisory(
552 recipe: &GenerationRecipeProfile,
553 width: u32,
554 height: u32,
555) -> Option<String> {
556 match validate_dimensions_against_recipe(recipe, width, height) {
557 Err(error) => Some(format!("{error} — the server may reject this size")),
558 Ok(()) => off_bucket_resolution_warning(recipe, width, height),
559 }
560}
561
562fn validate_resolution(profile: &ResolutionProfile, width: u32, height: u32) -> Result<(), String> {
563 if width < profile.min_width || height < profile.min_height {
564 return Err(format!(
565 "width and height must each be at least {}x{} for this recipe",
566 profile.min_width, profile.min_height
567 ));
568 }
569 if !width.is_multiple_of(profile.alignment) || !height.is_multiple_of(profile.alignment) {
570 return Err(format!(
571 "width and height must be multiples of {} for this recipe",
572 profile.alignment
573 ));
574 }
575 let pixels = u64::from(width) * u64::from(height);
576 if pixels > profile.max_pixels {
577 return Err(format!(
578 "resolution {width}x{height} exceeds this recipe's {} pixel limit",
579 profile.max_pixels
580 ));
581 }
582 if let Some(max_axis) = profile.max_axis_pixels {
583 if width > max_axis || height > max_axis {
584 return Err(format!(
585 "width and height must not exceed {max_axis} for this recipe"
586 ));
587 }
588 }
589 let aspect = f64::from(width) / f64::from(height);
590 if profile
591 .min_aspect_ratio
592 .is_some_and(|minimum| aspect < minimum)
593 || profile
594 .max_aspect_ratio
595 .is_some_and(|maximum| aspect > maximum)
596 {
597 return Err(format!(
598 "resolution {width}x{height} is outside this recipe's aspect-ratio range"
599 ));
600 }
601 if profile.domain == ResolutionDomain::Buckets
602 && profile.off_bucket.unwrap_or_default() == OffBucketPolicy::Reject
603 && !profile
604 .aspect_groups
605 .iter()
606 .flat_map(|group| &group.presets)
607 .any(|preset| preset.width == width && preset.height == height)
608 {
609 return Err(format!(
610 "resolution {width}x{height} is not an available bucket for this recipe"
611 ));
612 }
613 Ok(())
614}
615
616fn validate_integer(name: &str, value: u32, control: &IntegerControl) -> Result<(), String> {
617 if control.mode == ControlMode::Fixed && value != control.default {
618 return Err(format!(
619 "{name} is fixed at {} for this recipe",
620 control.default
621 ));
622 }
623 if !(control.min..=control.max).contains(&value)
624 || !(value - control.min).is_multiple_of(control.step)
625 {
626 return Err(format!(
627 "{name} must be {} through {} in steps of {}",
628 control.min, control.max, control.step
629 ));
630 }
631 Ok(())
632}
633
634fn validate_float(name: &str, value: f64, control: &FloatControl) -> Result<(), String> {
635 if !value.is_finite() {
636 return Err(format!("{name} must be finite"));
637 }
638 if control.mode == ControlMode::Fixed && (value - control.default).abs() > f64::EPSILON {
639 return Err(format!(
640 "{name} is fixed at {} for this recipe",
641 control.default
642 ));
643 }
644 if value < control.min || value > control.max {
645 return Err(format!(
646 "{name} must be {} through {}",
647 control.min, control.max
648 ));
649 }
650 if control.step <= 0.0 || !control.step.is_finite() {
651 return Err(format!("{name} has an invalid profile step"));
652 }
653 let steps = (value - control.min) / control.step;
654 let tolerance = f64::EPSILON * 16.0 * steps.abs().max(1.0);
655 if (steps - steps.round()).abs() > tolerance {
656 return Err(format!(
657 "{name} must be {} through {} in steps of {}",
658 control.min, control.max, control.step
659 ));
660 }
661 Ok(())
662}
663
664#[derive(Debug, Clone)]
665pub struct GenerationProfileInput<'a> {
666 pub model: &'a str,
667 pub family: &'a str,
668 pub sub_family: Option<&'a str>,
669 pub default_width: u32,
670 pub default_height: u32,
671 pub default_steps: u32,
672 pub default_guidance: f64,
673 pub default_frames: Option<u32>,
674 pub default_fps: Option<u32>,
675 pub default_negative_prompt: Option<String>,
676 pub source_image: Option<SourceImageCapability>,
677 pub supports_sequence: bool,
678 pub supports_extend: bool,
679 pub supports_audio: bool,
680}
681
682pub fn generation_profile_for_manifest(
688 manifest: &crate::manifest::ModelManifest,
689) -> GenerationProfileSet {
690 let family = manifest.family.as_str();
691 generation_profile_for_manifest_with_defaults(
692 manifest,
693 GenerationDefaultsProfile {
694 width: manifest.defaults.width,
695 height: manifest.defaults.height,
696 steps: manifest.defaults.steps,
697 guidance: manifest.defaults.guidance,
698 frames: manifest.defaults.frames,
699 fps: manifest.defaults.fps,
700 negative_prompt: crate::manifest::default_negative_prompt_for_family(family)
701 .map(str::to_string),
702 },
703 )
704}
705
706pub fn generation_profile_for_manifest_with_defaults(
710 manifest: &crate::manifest::ModelManifest,
711 defaults: GenerationDefaultsProfile,
712) -> GenerationProfileSet {
713 let family = manifest.family.as_str();
714 resolve_generation_profile(GenerationProfileInput {
715 model: &manifest.name,
716 family,
717 sub_family: None,
718 default_width: defaults.width,
719 default_height: defaults.height,
720 default_steps: defaults.steps,
721 default_guidance: defaults.guidance,
722 default_frames: defaults.frames,
723 default_fps: defaults.fps,
724 default_negative_prompt: defaults.negative_prompt,
725 source_image: manifest.defaults.source_image,
726 supports_sequence: crate::catalog::chain_capable_family(family),
727 supports_extend: crate::catalog::extend_capable_model(
728 family,
729 manifest.defaults.source_image,
730 ),
731 supports_audio: family == "ltx2",
732 })
733}
734
735const SD15: &[(u32, u32)] = &[(512, 512), (512, 768), (768, 512), (384, 512), (512, 384)];
736const SDXL: &[(u32, u32)] = &[
737 (1024, 1024),
738 (1152, 896),
739 (896, 1152),
740 (1216, 832),
741 (832, 1216),
742 (1344, 768),
743 (768, 1344),
744 (1536, 640),
745 (640, 1536),
746];
747const SD3: &[(u32, u32)] = &[
748 (1024, 1024),
749 (1152, 896),
750 (896, 1152),
751 (1216, 832),
752 (832, 1216),
753 (1344, 768),
754 (768, 1344),
755];
756const FLUX: &[(u32, u32)] = &[
757 (1024, 1024),
758 (1024, 768),
759 (768, 1024),
760 (1024, 576),
761 (576, 1024),
762 (768, 768),
763];
764const Z_IMAGE_UPSTREAM_CANDIDATES: &[(u32, u32)] = &[
766 (1024, 1024),
767 (1152, 896),
768 (896, 1152),
769 (1152, 864),
770 (864, 1152),
771 (1248, 832),
772 (832, 1248),
773 (1280, 720),
774 (720, 1280),
775 (1344, 576),
776 (576, 1344),
777];
778const QWEN_UPSTREAM_CANDIDATES: &[(u32, u32)] = &[
780 (1328, 1328),
781 (1664, 928),
782 (928, 1664),
783 (1472, 1104),
784 (1104, 1472),
785 (1584, 1056),
786 (1056, 1584),
787];
788const WUERSTCHEN: &[(u32, u32)] = &[(1024, 1024)];
789const LTX_VIDEO: &[(u32, u32)] = &[
790 (704, 480),
791 (768, 512),
792 (512, 512),
793 (1024, 576),
794 (1216, 704),
795 (576, 1024),
796 (768, 768),
797 (512, 768),
798];
799const LTX2: &[(u32, u32)] = &[
800 (704, 480),
801 (768, 512),
802 (512, 512),
803 (1024, 576),
804 (1216, 704),
805 (704, 1216),
806 (576, 1024),
807 (768, 768),
808 (512, 768),
809 (1536, 1024),
810 (1024, 1536),
811 (1920, 1088),
812 (1088, 1920),
813];
814const WAN_480: &[(u32, u32)] = &[(832, 480), (480, 832)];
815const WAN_480_720: &[(u32, u32)] = &[(832, 480), (480, 832), (1280, 720), (720, 1280)];
816const WAN_TI2V: &[(u32, u32)] = &[(1280, 704), (704, 1280)];
817const H3: &[(u32, u32)] = &[
818 (1536, 672),
819 (1344, 768),
820 (1024, 768),
821 (768, 768),
822 (768, 1024),
823 (768, 1344),
824];
825
826const Z_IMAGE_QUALIFICATION: ResolutionQualificationRecord =
827 ResolutionQualificationRecord {
828 family: "z-image",
829 source: "https://huggingface.co/spaces/Tongyi-MAI/Z-Image-Turbo/blob/768cb50d847cdbba97c89533ae976be69cf5a5b8/app.py",
830 revision: "768cb50d847cdbba97c89533ae976be69cf5a5b8",
831 qualified: true,
832 evidence: "docs/qualification/z-image-1024-tier-metal-q4.json: exact-size Q4 Metal generation and decoded PNG delivery for every 1024-tier candidate",
833 candidates: Z_IMAGE_UPSTREAM_CANDIDATES,
834 };
835
836const QWEN_IMAGE_QUALIFICATION: ResolutionQualificationRecord =
837 ResolutionQualificationRecord {
838 family: "qwen-image",
839 source: "https://github.com/QwenLM/Qwen-Image/blob/6b5e1f5cec987d404be5ac6657db3b9aacb56a89/README.md",
840 revision: "6b5e1f5cec987d404be5ac6657db3b9aacb56a89",
841 qualified: true,
842 evidence: "contract qualification: pinned upstream README.md aspect_ratios oracle; Mold dynamic /16 admission and common decoded-image delivery are resolution-generic; no per-size runtime-performance claim",
843 candidates: QWEN_UPSTREAM_CANDIDATES,
844 };
845
846pub fn resolution_qualification_record(
849 family: &str,
850) -> Option<&'static ResolutionQualificationRecord> {
851 match canonical_family(family) {
852 "z-image" => Some(&Z_IMAGE_QUALIFICATION),
853 "qwen-image" => Some(&QWEN_IMAGE_QUALIFICATION),
854 _ => None,
855 }
856}
857
858pub fn family_presets(family: &str) -> &'static [(u32, u32)] {
859 match canonical_family(family) {
860 "sd15" => SD15,
861 "sdxl" => SDXL,
862 "sd3" => SD3,
863 "flux" | "flux2" => FLUX,
864 "z-image" => Z_IMAGE_UPSTREAM_CANDIDATES,
865 "qwen-image" | "qwen-image-edit" => QWEN_UPSTREAM_CANDIDATES,
866 "wuerstchen" => WUERSTCHEN,
867 "ltx-video" => LTX_VIDEO,
868 "ltx2" => LTX2,
869 "wan" => WAN_480_720,
870 "minimax-h3" => H3,
871 _ => &[],
872 }
873}
874
875pub fn family_aspect_groups(family: &str) -> Vec<AspectGroup> {
879 let family = canonical_family(family);
880 aspect_groups(family, family_presets(family))
881}
882
883pub fn presets_for_identity<'a>(
884 model: &str,
885 family: &str,
886 sub_family: Option<&str>,
887) -> &'a [(u32, u32)] {
888 let family = canonical_family(family);
889 if family != "wan" {
890 return family_presets(family);
891 }
892 let identity = format!(
893 "{} {}",
894 crate::manifest::resolve_model_name(model).to_ascii_lowercase(),
895 sub_family.unwrap_or_default().to_ascii_lowercase()
896 );
897 if identity.contains("ti2v-5b") {
898 WAN_TI2V
899 } else if identity.contains("1.3b") {
900 WAN_480
901 } else {
902 WAN_480_720
903 }
904}
905
906pub fn canonical_family(family: &str) -> &str {
907 match family.trim() {
908 "ltx-2" => "ltx2",
909 "flux.2" | "flux-2" => "flux2",
910 "minimax_h3" | "minimaxh3" => "minimax-h3",
911 other => other,
912 }
913}
914
915pub fn resolve_generation_profile(input: GenerationProfileInput<'_>) -> GenerationProfileSet {
916 let family = canonical_family(input.family);
917 let profile_id = input
918 .sub_family
919 .map(|sub| format!("{family}.{sub}"))
920 .unwrap_or_else(|| format!("{family}.{}", crate::manifest::model_base_name(input.model)));
921 let mut recipes = if family == "ltx2" {
922 let mut recipes = vec![recipe(&input, "auto", "Auto", None)];
923 for pipeline in Ltx2PipelineMode::ALL {
924 recipes.push(recipe(
925 &input,
926 pipeline.as_str(),
927 &pipeline_label(pipeline),
928 Some(pipeline),
929 ));
930 }
931 recipes
932 } else {
933 vec![recipe(&input, "default", "Default", None)]
934 };
935 recipes.retain(|recipe| {
936 !(family == "ltx2"
937 && recipe.request_selector.pipeline == Some(Ltx2PipelineMode::T2a)
938 && !input.supports_audio)
939 });
940 let mut set = GenerationProfileSet {
941 schema_version: GENERATION_PROFILE_SCHEMA_VERSION,
942 profile_id,
943 profile_hash: String::new(),
944 default_recipe_id: if family == "ltx2" { "auto" } else { "default" }.to_string(),
945 recipes,
946 };
947 set.refresh_hash();
948 set
949}
950
951fn recipe(
952 input: &GenerationProfileInput<'_>,
953 id: &str,
954 label: &str,
955 pipeline: Option<Ltx2PipelineMode>,
956) -> GenerationRecipeProfile {
957 let family = canonical_family(input.family);
958 let audio_only = pipeline == Some(Ltx2PipelineMode::T2a);
959 let source_driven = family == "qwen-image-edit"
960 || matches!(
961 pipeline,
962 Some(Ltx2PipelineMode::Retake | Ltx2PipelineMode::LipDub)
963 );
964 let composed = family == "ltx2"
965 && pipeline
966 .map(Ltx2PipelineMode::refines_spatially)
967 .unwrap_or_else(|| {
968 validation::ltx2_spatial_composition(input.model, None)
969 == validation::Ltx2SpatialComposition::TiledTwoStage
970 });
971 let composition = if composed {
972 validation::Ltx2SpatialComposition::TiledTwoStage
973 } else {
974 validation::Ltx2SpatialComposition::SinglePass
975 };
976 let alignment = if composed {
977 validation::LTX2_TWO_STAGE_ALIGNMENT
978 } else if family == "wan" {
979 identity_alignment(input.model, family, input.sub_family)
980 } else {
981 validation::dimension_alignment_for_family(Some(family))
982 };
983 let mut dimensions = presets_for_identity(input.model, family, input.sub_family).to_vec();
984 if composed {
985 for rung in validation::LTX2_OUTPUT_RUNGS
986 .iter()
987 .filter(|rung| rung.requires_tiled_stage2())
988 {
989 dimensions.push((rung.width, rung.height));
990 dimensions.push((rung.height, rung.width));
991 }
992 }
993 dimensions.retain(|(width, height)| {
994 width.is_multiple_of(alignment)
995 && height.is_multiple_of(alignment)
996 && validation::validate_generation_dimensions_composed(
997 *width,
998 *height,
999 Some(family),
1000 composition,
1001 )
1002 .is_ok()
1003 });
1004 let resolution = if audio_only {
1005 ResolutionProfile {
1006 domain: ResolutionDomain::None,
1007 alignment: 1,
1008 min_width: 0,
1009 min_height: 0,
1010 max_pixels: 0,
1011 max_axis_pixels: None,
1012 min_aspect_ratio: None,
1013 max_aspect_ratio: None,
1014 off_bucket: None,
1015 aspect_groups: Vec::new(),
1016 }
1017 } else {
1018 ResolutionProfile {
1019 domain: if source_driven {
1020 ResolutionDomain::SourceDriven
1021 } else if family == "wan" {
1022 ResolutionDomain::Buckets
1023 } else {
1024 ResolutionDomain::Dynamic
1025 },
1026 alignment,
1027 min_width: alignment.max(64),
1028 min_height: alignment.max(64),
1029 max_pixels: validation::max_pixels_for_family_composed(Some(family), composition),
1030 max_axis_pixels: validation::max_axis_pixels_for_family_composed(
1031 Some(family),
1032 composition,
1033 ),
1034 min_aspect_ratio: (family == "minimax-h3")
1035 .then_some(crate::minimax_h3::MIN_ASPECT_RATIO),
1036 max_aspect_ratio: (family == "minimax-h3")
1037 .then_some(crate::minimax_h3::MAX_ASPECT_RATIO),
1038 off_bucket: (family == "wan").then_some(OffBucketPolicy::Warn),
1042 aspect_groups: aspect_groups(family, &dimensions),
1043 }
1044 };
1045
1046 let guidance_caps = if family == "minimax-h3" {
1047 GuidanceCapabilities {
1048 adjustable: false,
1049 supports_negative_prompt: false,
1050 fixed_scale: Some(0.0),
1051 }
1052 } else {
1053 GuidanceCapabilities::for_recipe(family, input.model, pipeline)
1054 };
1055 let effective_guidance = guidance_caps.fixed_scale.unwrap_or(input.default_guidance);
1056 let temporal = temporal_profile(input, family);
1057 let flux2_dev = family == "flux2"
1058 && crate::manifest::resolve_model_name(input.model)
1059 .to_ascii_lowercase()
1060 .contains("dev");
1061 let wan = family == "wan";
1062 let normalized_model = crate::manifest::resolve_model_name(input.model).to_ascii_lowercase();
1063 let lora_supported = validation::family_supports_lora(family)
1064 && !flux2_dev
1065 && !(wan && normalized_model.ends_with("a14b:fp8"));
1066 let source_video_required = matches!(
1067 pipeline,
1068 Some(Ltx2PipelineMode::IcLora | Ltx2PipelineMode::Retake | Ltx2PipelineMode::LipDub)
1069 );
1070 let source_video_supported = family == "ltx2" && !audio_only;
1071 let keyframes_required = pipeline == Some(Ltx2PipelineMode::Keyframe);
1072 let keyframes_supported = family == "ltx2" || wan;
1073 let audio_input_required = pipeline == Some(Ltx2PipelineMode::A2Vid);
1074 let audio_input_supported = family == "ltx2" && !audio_only;
1075 let mask_supported = !audio_only
1076 && !matches!(
1077 family,
1078 "ltx-video" | "ltx2" | "wan" | "qwen-image-edit" | "minimax-h3"
1079 )
1080 && !flux2_dev
1081 && input.source_image != Some(SourceImageCapability::Unsupported);
1082 let controlnet_supported = family == "sd15";
1083 let output = if audio_only {
1084 OutputCapabilitiesProfile {
1085 default_format: OutputFormat::Wav,
1086 formats: vec![OutputFormat::Wav],
1087 audio_requires_mp4: false,
1088 delivery_reason: Some("Audio-only delivery uses WAV.".to_string()),
1089 }
1090 } else if family == "minimax-h3" {
1091 OutputCapabilitiesProfile {
1092 default_format: OutputFormat::Mp4,
1093 formats: vec![OutputFormat::Mp4],
1094 audio_requires_mp4: true,
1095 delivery_reason: Some("Synchronized H3 audio/video delivery requires MP4.".to_string()),
1096 }
1097 } else if temporal.is_some() {
1098 OutputCapabilitiesProfile {
1099 default_format: OutputFormat::Mp4,
1100 formats: vec![
1101 OutputFormat::Mp4,
1102 OutputFormat::Gif,
1103 OutputFormat::Apng,
1104 OutputFormat::Webp,
1105 ],
1106 audio_requires_mp4: family == "ltx2",
1107 delivery_reason: (family == "ltx2")
1108 .then(|| "Audio-enabled video delivery requires MP4.".to_string()),
1109 }
1110 } else {
1111 OutputCapabilitiesProfile {
1112 default_format: OutputFormat::Png,
1113 formats: vec![OutputFormat::Png, OutputFormat::Jpeg, OutputFormat::Webp],
1114 audio_requires_mp4: false,
1115 delivery_reason: None,
1116 }
1117 };
1118 let defaults = GenerationDefaultsProfile {
1119 width: if audio_only { 0 } else { input.default_width },
1120 height: if audio_only { 0 } else { input.default_height },
1121 steps: input.default_steps,
1122 guidance: effective_guidance,
1123 frames: temporal.as_ref().map(|profile| profile.frames.default),
1124 fps: temporal.as_ref().map(|profile| match profile.fps {
1125 FpsControl::Fixed { value } => value,
1126 FpsControl::Adjustable { default, .. } => default,
1127 }),
1128 negative_prompt: input.default_negative_prompt.clone(),
1129 };
1130 GenerationRecipeProfile {
1131 id: id.to_string(),
1132 label: label.to_string(),
1133 request_selector: RecipeSelector { pipeline },
1134 defaults,
1135 resolution,
1136 steps: IntegerControl {
1137 default: input.default_steps,
1138 min: if family == "minimax-h3" { 2 } else { 1 },
1139 max: 100,
1140 step: 1,
1141 recommended: vec![input.default_steps],
1142 mode: ControlMode::Adjustable,
1143 },
1144 guidance: FloatControl {
1145 default: effective_guidance,
1146 min: if guidance_caps.adjustable {
1147 0.0
1148 } else {
1149 effective_guidance
1150 },
1151 max: if guidance_caps.adjustable {
1152 100.0
1153 } else {
1154 effective_guidance
1155 },
1156 step: 0.1,
1157 mode: if guidance_caps.adjustable {
1158 ControlMode::Adjustable
1159 } else {
1160 ControlMode::Fixed
1161 },
1162 },
1163 temporal,
1164 capabilities: GenerationCapabilitiesProfile {
1165 guidance: guidance_caps,
1166 negative_prompt: feature_control(
1167 guidance_caps.supports_negative_prompt,
1168 false,
1169 "This recipe does not encode a negative prompt.",
1170 ),
1171 source_image: input.source_image,
1172 supports_lora: lora_supported,
1173 supports_controlnet: controlnet_supported,
1174 supports_sequence: input.supports_sequence && !audio_only,
1175 supports_extend: input.supports_extend && !audio_only,
1176 supports_audio: input.supports_audio || audio_only,
1177 source_video: feature_control(
1178 source_video_supported,
1179 source_video_required,
1180 "This recipe does not accept a source video.",
1181 ),
1182 mask: feature_control(
1183 mask_supported,
1184 false,
1185 "This model does not accept an inpainting mask.",
1186 ),
1187 keyframes: feature_control(
1188 keyframes_supported,
1189 keyframes_required,
1190 "This model does not accept keyframes.",
1191 ),
1192 audio: feature_control(
1193 audio_input_supported,
1194 audio_input_required,
1195 "This recipe does not accept source audio.",
1196 ),
1197 lora: AdapterControlProfile {
1198 mode: if lora_supported {
1199 ControlMode::Adjustable
1200 } else {
1201 ControlMode::Hidden
1202 },
1203 max_count: if lora_supported {
1204 if pipeline == Some(Ltx2PipelineMode::IcLora) {
1205 3
1206 } else {
1207 4
1208 }
1209 } else {
1210 0
1211 },
1212 reason: (!lora_supported)
1213 .then(|| "This model does not accept LoRA adapters.".to_string()),
1214 },
1215 controlnet: AdapterControlProfile {
1216 mode: if controlnet_supported {
1217 ControlMode::Adjustable
1218 } else {
1219 ControlMode::Hidden
1220 },
1221 max_count: u32::from(controlnet_supported),
1222 reason: (!controlnet_supported)
1223 .then(|| "ControlNet generation is available for SD1.5 models.".to_string()),
1224 },
1225 output,
1226 wan_recipe: WanRecipeCapabilitiesProfile {
1227 mode: if wan {
1228 ControlMode::Adjustable
1229 } else {
1230 ControlMode::Hidden
1231 },
1232 supports_distill_strength: wan
1233 && (normalized_model.ends_with("a14b:q4")
1234 || normalized_model.ends_with("a14b:q5")),
1235 supports_first_last_frame: wan
1236 && input.source_image != Some(SourceImageCapability::Unsupported),
1237 first_last_frame_min_frames: wan.then_some(validation::WAN_TI2V_FLF_MIN_FRAMES),
1238 reason: (!wan)
1239 .then(|| "Wan sampler controls apply only to Wan models.".to_string()),
1240 },
1241 schedulers: match family {
1242 "sd15" | "sdxl" => {
1243 vec![Scheduler::Ddim, Scheduler::EulerAncestral, Scheduler::UniPc]
1244 }
1245 "wan" => vec![Scheduler::UniPc, Scheduler::Euler, Scheduler::DpmPp],
1246 _ => Vec::new(),
1247 },
1248 },
1249 provenance: provenance(family),
1250 }
1251}
1252
1253fn feature_control(
1254 supported: bool,
1255 required: bool,
1256 unsupported_reason: &'static str,
1257) -> FeatureControlProfile {
1258 FeatureControlProfile {
1259 mode: if supported {
1260 ControlMode::Adjustable
1261 } else {
1262 ControlMode::Hidden
1263 },
1264 required: supported && required,
1265 reason: (!supported).then(|| unsupported_reason.to_string()),
1266 }
1267}
1268
1269fn temporal_profile(input: &GenerationProfileInput<'_>, family: &str) -> Option<TemporalProfile> {
1270 let step = validation::frame_step_for_family(family)?;
1271 let offset = validation::frame_offset_for_family(family).unwrap_or(1);
1272 let fps = input.default_fps.unwrap_or(validation::LTX2_DEFAULT_FPS);
1273 let max = if family == "minimax-h3" {
1274 345
1275 } else if family == "ltx2" {
1276 validation::max_frames_for_family_at_fps(family, 120)?
1280 } else {
1281 validation::max_frames_for_family_at_fps(family, fps)?
1282 };
1283 let min = validation::min_frames_for_family(family).unwrap_or(offset);
1284 let default = input.default_frames.unwrap_or(min).clamp(min, max);
1285 Some(TemporalProfile {
1286 frames: IntegerControl {
1287 default,
1288 min,
1289 max,
1290 step,
1291 recommended: vec![default],
1292 mode: ControlMode::Adjustable,
1293 },
1294 frame_offset: offset,
1295 fps: if let Some(fixed) = validation::fixed_fps_for_family(family) {
1296 FpsControl::Fixed { value: fixed }
1297 } else {
1298 FpsControl::Adjustable {
1299 default: fps,
1300 min: 1,
1301 max: 120,
1302 step: 1,
1303 }
1304 },
1305 max_duration_seconds: validation::max_runtime_seconds_for_family(family),
1306 })
1307}
1308
1309fn aspect_groups(family: &str, dimensions: &[(u32, u32)]) -> Vec<AspectGroup> {
1310 let mut groups: Vec<AspectGroup> = Vec::new();
1311 for &(width, height) in dimensions {
1312 let id = authored_aspect_label(family, width, height);
1313 let preset = ResolutionPreset {
1314 id: format!("{width}x{height}"),
1315 width,
1316 height,
1317 tier: "recommended".to_string(),
1318 };
1319 if let Some(group) = groups.iter_mut().find(|group| group.id == id) {
1320 group.presets.push(preset);
1321 } else {
1322 groups.push(AspectGroup {
1323 label: id.clone(),
1324 id,
1325 presets: vec![preset],
1326 });
1327 }
1328 }
1329 for group in &mut groups {
1330 group
1331 .presets
1332 .sort_by_key(|preset| preset.width * preset.height);
1333 }
1334 groups
1335}
1336
1337fn authored_aspect_label(family: &str, width: u32, height: u32) -> String {
1338 match (canonical_family(family), width, height) {
1339 ("qwen-image" | "qwen-image-edit", 1664, 928) => "≈16:9".to_string(),
1340 ("qwen-image" | "qwen-image-edit", 928, 1664) => "≈9:16".to_string(),
1341 _ => {
1342 let divisor = gcd(width, height);
1343 format!("{}:{}", width / divisor, height / divisor)
1344 }
1345 }
1346}
1347
1348fn gcd(mut left: u32, mut right: u32) -> u32 {
1349 while right != 0 {
1350 (left, right) = (right, left % right);
1351 }
1352 left.max(1)
1353}
1354
1355fn identity_alignment(model: &str, family: &str, sub_family: Option<&str>) -> u32 {
1356 if family == "wan"
1357 && format!("{} {}", model, sub_family.unwrap_or_default())
1358 .to_ascii_lowercase()
1359 .contains("ti2v-5b")
1360 {
1361 32
1362 } else {
1363 validation::dimension_alignment_for_model(model, Some(family))
1364 }
1365}
1366
1367fn pipeline_label(pipeline: Ltx2PipelineMode) -> String {
1368 pipeline
1369 .as_str()
1370 .split('-')
1371 .map(|part| {
1372 let mut chars = part.chars();
1373 chars
1374 .next()
1375 .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
1376 .unwrap_or_default()
1377 })
1378 .collect::<Vec<_>>()
1379 .join(" ")
1380}
1381
1382fn provenance(family: &str) -> Vec<ProfileProvenance> {
1383 if canonical_family(family) == "qwen-image-edit" {
1384 return vec![ProfileProvenance {
1385 kind: ProvenanceKind::MoldPolicy,
1386 source: "Mold source-driven Qwen Image Edit guidance".to_string(),
1387 revision: None,
1388 qualified: true,
1389 evidence: Some(
1390 "source fitting preserves the input aspect on the dynamic /16 canvas; optional shape presets reuse Mold's qualified Qwen Image aspect set"
1391 .to_string(),
1392 ),
1393 }];
1394 }
1395 if let Some(record) = resolution_qualification_record(family) {
1396 return vec![ProfileProvenance {
1397 kind: ProvenanceKind::Upstream,
1398 source: record.source.to_string(),
1399 revision: Some(record.revision.to_string()),
1400 qualified: record.qualified,
1401 evidence: Some(record.evidence.to_string()),
1402 }];
1403 }
1404 let (source, revision, evidence) = match family {
1405 "ltx-video" => (
1406 "https://github.com/Lightricks/LTX-Video",
1407 Some("4b2d053057623ddd4d0a1d3e9cd28890e9ef487f"),
1408 "mold.generation-profile.v1",
1409 ),
1410 "ltx2" => (
1411 "https://github.com/Lightricks/LTX-2",
1412 Some("4f8905737aac86a554637cac86c178877a39c744"),
1413 "mold.generation-profile.v1",
1414 ),
1415 "wan" => (
1416 "https://github.com/Wan-Video/Wan2.2",
1417 Some("42bf4cfaa384bc21833865abc2f9e6c0e67233dc"),
1418 "mold.generation-profile.v1",
1419 ),
1420 "minimax-h3" => (
1421 "https://github.com/MiniMax-AI/MiniMax-H3",
1422 Some("fa6891ff7cdaaa03fa4497e89ac64ff169219acf"),
1423 "mold.generation-profile.v1",
1424 ),
1425 _ => (
1426 "mold-qualified compatibility profile",
1427 None,
1428 "mold.generation-profile.v1",
1429 ),
1430 };
1431 vec![ProfileProvenance {
1432 kind: if source.starts_with("http") {
1433 ProvenanceKind::Upstream
1434 } else {
1435 ProvenanceKind::MoldPolicy
1436 },
1437 source: source.to_string(),
1438 revision: revision.map(str::to_string),
1439 qualified: true,
1440 evidence: Some(evidence.to_string()),
1441 }]
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 fn input<'a>(model: &'a str, family: &'a str) -> GenerationProfileInput<'a> {
1449 GenerationProfileInput {
1450 model,
1451 family,
1452 sub_family: None,
1453 default_width: 1024,
1454 default_height: 1024,
1455 default_steps: 20,
1456 default_guidance: 3.5,
1457 default_frames: None,
1458 default_fps: None,
1459 default_negative_prompt: None,
1460 source_image: None,
1461 supports_sequence: false,
1462 supports_extend: false,
1463 supports_audio: false,
1464 }
1465 }
1466
1467 #[test]
1468 fn qualified_z_image_candidates_are_profile_recommendations() {
1469 let profile = resolve_generation_profile(input("z-image-turbo:q4", "z-image"));
1470 let recipe = profile.default_recipe().unwrap();
1471 let presets = recipe
1472 .resolution
1473 .aspect_groups
1474 .iter()
1475 .flat_map(|group| &group.presets)
1476 .map(|preset| (preset.width, preset.height))
1477 .collect::<std::collections::HashSet<_>>();
1478 let candidates = resolution_qualification_record("z-image").unwrap();
1479 assert!(candidates.qualified);
1480 assert_eq!(presets.len(), candidates.candidates.len());
1481 assert!(presets.contains(&(1280, 720)));
1482 assert!(presets.contains(&(720, 1280)));
1483 }
1484
1485 #[test]
1486 fn wan_subfamily_selects_exact_checkpoint_contract() {
1487 let mut wan = input("cv:opaque", "wan");
1488 wan.sub_family = Some("wan22-ti2v-5b");
1489 let recipe = resolve_generation_profile(wan)
1490 .default_recipe()
1491 .unwrap()
1492 .clone();
1493 assert_eq!(recipe.resolution.alignment, 32);
1494 assert_eq!(recipe.resolution.aspect_groups.len(), 2);
1495 assert!(recipe.resolution.aspect_groups.iter().all(|group| {
1496 group
1497 .presets
1498 .iter()
1499 .all(|preset| preset.width == 1280 || preset.height == 1280)
1500 }));
1501 }
1502
1503 #[test]
1504 fn h3_temporal_ceiling_is_valid_on_both_grids() {
1505 let mut h3 = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1506 h3.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1507 h3.default_fps = Some(24);
1508 let temporal = resolve_generation_profile(h3)
1509 .default_recipe()
1510 .unwrap()
1511 .temporal
1512 .clone()
1513 .unwrap();
1514 assert_eq!(temporal.frames.max, 345);
1515 assert_eq!(
1516 (temporal.frames.max - temporal.frame_offset) % temporal.frames.step,
1517 0
1518 );
1519 assert!(temporal.frames.max <= 15 * 24);
1520 }
1521
1522 #[test]
1523 fn profile_hash_is_stable_and_content_addressed() {
1524 let left = resolve_generation_profile(input("flux-dev:q4", "flux"));
1525 let right = resolve_generation_profile(input("flux-dev:q4", "flux"));
1526 assert_eq!(left.profile_hash, right.profile_hash);
1527 assert_eq!(left.profile_hash.len(), 64);
1528 }
1529
1530 #[test]
1531 fn advanced_and_delivery_controls_are_recipe_owned() {
1532 let sd15 = resolve_generation_profile(input("sd15-base:q4", "sd15"));
1533 let sd15_caps = &sd15.default_recipe().unwrap().capabilities;
1534 assert_eq!(sd15_caps.controlnet.mode, ControlMode::Adjustable);
1535 assert_eq!(sd15_caps.controlnet.max_count, 1);
1536 assert_eq!(
1537 sd15_caps.output.formats,
1538 vec![OutputFormat::Png, OutputFormat::Jpeg, OutputFormat::Webp]
1539 );
1540
1541 let wan = resolve_generation_profile(input("wan22-t2v-a14b:fp8", "wan"));
1542 let wan_caps = &wan.default_recipe().unwrap().capabilities;
1543 assert_eq!(wan_caps.lora.mode, ControlMode::Hidden);
1544 assert_eq!(wan_caps.controlnet.mode, ControlMode::Hidden);
1545 assert_eq!(wan_caps.wan_recipe.mode, ControlMode::Adjustable);
1546 assert!(!wan_caps.wan_recipe.supports_distill_strength);
1547 assert_eq!(
1548 wan_caps.wan_recipe.first_last_frame_min_frames,
1549 Some(validation::WAN_TI2V_FLF_MIN_FRAMES)
1550 );
1551 }
1552
1553 #[test]
1554 fn explicit_pipeline_lookup_never_falls_back_to_default_recipe() {
1555 let mut ltx = input("ltx2-distilled:q4", "ltx2");
1556 ltx.default_frames = Some(121);
1557 ltx.default_fps = Some(24);
1558 let profile = resolve_generation_profile(ltx);
1559 assert!(profile.recipe_for_pipeline(None).is_some());
1560 assert!(profile
1561 .recipe_for_pipeline(Some(Ltx2PipelineMode::T2a))
1562 .is_none());
1563 }
1564
1565 #[test]
1566 fn t2a_dimensionless_profile_reaches_family_admission_with_zero_canvas() {
1567 let mut ltx = input("ltx-2.3-22b-dev:fp8", "ltx2");
1568 ltx.default_frames = Some(97);
1569 ltx.default_fps = Some(24);
1570 ltx.supports_audio = true;
1571 let profile = resolve_generation_profile(ltx);
1572 let recipe = profile
1573 .recipe_for_pipeline(Some(Ltx2PipelineMode::T2a))
1574 .unwrap();
1575 assert_eq!(recipe.resolution.domain, ResolutionDomain::None);
1576 let request: crate::GenerateRequest = serde_json::from_value(serde_json::json!({
1577 "prompt": "rain on a tin roof",
1578 "model": "ltx-2.3-22b-dev:fp8",
1579 "width": 0,
1580 "height": 0,
1581 "steps": recipe.defaults.steps,
1582 "guidance": recipe.defaults.guidance,
1583 "frames": recipe.defaults.frames,
1584 "fps": recipe.defaults.fps,
1585 "pipeline": "t2a",
1586 "output_format": "wav"
1587 }))
1588 .unwrap();
1589 validate_request_against_generation_profile(&profile, &request).unwrap();
1590 crate::validation::validate_generate_request_with_family(&request, Some("ltx2")).unwrap();
1591 }
1592
1593 #[test]
1594 fn scheduler_contract_matches_engine_solver_families() {
1595 let sd = resolve_generation_profile(input("sdxl-base:q4", "sdxl"));
1596 assert_eq!(
1597 sd.default_recipe().unwrap().capabilities.schedulers,
1598 vec![Scheduler::Ddim, Scheduler::EulerAncestral, Scheduler::UniPc]
1599 );
1600
1601 let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1602 wan_input.default_frames = Some(81);
1603 wan_input.default_fps = Some(16);
1604 let wan = resolve_generation_profile(wan_input);
1605 assert_eq!(
1606 wan.default_recipe().unwrap().capabilities.schedulers,
1607 vec![Scheduler::UniPc, Scheduler::Euler, Scheduler::DpmPp]
1608 );
1609 let mut request = request_for(&wan, 1280, 720);
1610 request.scheduler = Some(Scheduler::Ddim);
1611 assert!(validate_request_against_generation_profile(&wan, &request)
1612 .unwrap_err()
1613 .contains("not available"));
1614
1615 let flux = resolve_generation_profile(input("flux-dev:q4", "flux"));
1616 let mut flux_request = request_for(&flux, 1024, 1024);
1617 flux_request.scheduler = Some(Scheduler::Euler);
1618 assert!(
1619 validate_request_against_generation_profile(&flux, &flux_request)
1620 .unwrap_err()
1621 .contains("not available")
1622 );
1623 }
1624
1625 #[test]
1626 fn z_and_qwen_provenance_is_pinned_and_qualification_is_explicit() {
1627 for (model, family, revision, qualified, evidence_fragment) in [
1628 (
1629 "z-image-turbo:q4",
1630 "z-image",
1631 "768cb50d847cdbba97c89533ae976be69cf5a5b8",
1632 true,
1633 "docs/qualification/z-image-1024-tier-metal-q4.json",
1634 ),
1635 (
1636 "qwen-image:q4",
1637 "qwen-image",
1638 "6b5e1f5cec987d404be5ac6657db3b9aacb56a89",
1639 true,
1640 "no per-size runtime-performance claim",
1641 ),
1642 ] {
1643 let profile = resolve_generation_profile(input(model, family));
1644 let provenance = &profile.default_recipe().unwrap().provenance[0];
1645 assert_eq!(provenance.qualified, qualified);
1646 assert_eq!(provenance.revision.as_deref(), Some(revision));
1647 assert!(provenance.source.contains(revision));
1648 let evidence = provenance.evidence.as_deref().unwrap();
1649 assert!(evidence.contains(evidence_fragment));
1650 }
1651 }
1652
1653 #[test]
1654 fn qwen_image_edit_presets_are_mold_source_fitting_guidance() {
1655 let profile =
1656 resolve_generation_profile(input("qwen-image-edit-2511:q4", "qwen-image-edit"));
1657 let recipe = profile.default_recipe().unwrap();
1658 assert_eq!(recipe.resolution.domain, ResolutionDomain::SourceDriven);
1659 assert_eq!(recipe.provenance[0].kind, ProvenanceKind::MoldPolicy);
1660 assert!(recipe.provenance[0].source.contains("source-driven"));
1661 assert!(resolution_qualification_record("qwen-image-edit").is_none());
1662 }
1663
1664 #[test]
1665 fn qwen_candidates_are_profile_recommendations() {
1666 let profile = resolve_generation_profile(input("qwen-image:q4", "qwen-image"));
1667 let presets = profile
1668 .default_recipe()
1669 .unwrap()
1670 .resolution
1671 .aspect_groups
1672 .iter()
1673 .flat_map(|group| &group.presets)
1674 .map(|preset| (preset.width, preset.height))
1675 .collect::<std::collections::HashSet<_>>();
1676 let candidates = resolution_qualification_record("qwen-image").unwrap();
1677 assert!(candidates.qualified);
1678 assert_eq!(candidates.candidates, QWEN_UPSTREAM_CANDIDATES);
1679 assert_eq!(presets.len(), candidates.candidates.len());
1680 assert!(presets.contains(&(1664, 928)));
1681 assert!(presets.contains(&(928, 1664)));
1682 }
1683
1684 #[test]
1685 fn adjustable_float_controls_enforce_the_advertised_step() {
1686 let profile = resolve_generation_profile(input("flux-dev:q4", "flux"));
1687 let mut request = request_for(&profile, 1024, 1024);
1688 request.guidance = 3.6;
1689 validate_request_against_generation_profile(&profile, &request).unwrap();
1690 request.guidance = 3.65;
1691 assert!(
1692 validate_request_against_generation_profile(&profile, &request)
1693 .unwrap_err()
1694 .contains("steps of 0.1")
1695 );
1696 }
1697
1698 #[test]
1699 fn ltx2_frame_ceiling_tracks_the_requested_fps() {
1700 let mut ltx = input("ltx2-distilled:q4", "ltx2");
1701 ltx.default_width = 768;
1702 ltx.default_height = 512;
1703 ltx.default_frames = Some(121);
1704 ltx.default_fps = Some(24);
1705 let profile = resolve_generation_profile(ltx);
1706 assert_eq!(
1707 profile
1708 .default_recipe()
1709 .unwrap()
1710 .temporal
1711 .as_ref()
1712 .unwrap()
1713 .frames
1714 .max,
1715 validation::LTX2_MAX_FRAMES_ABSOLUTE - 3
1716 );
1717
1718 let mut request = request_for(&profile, 768, 512);
1719 request.fps = Some(12);
1720 request.frames = Some(241);
1721 validate_request_against_generation_profile(&profile, &request).unwrap();
1722 request.frames = Some(249);
1723 assert!(
1724 validate_request_against_generation_profile(&profile, &request)
1725 .unwrap_err()
1726 .contains("frames must be")
1727 );
1728
1729 request.fps = Some(120);
1730 request.frames = Some(601);
1731 validate_request_against_generation_profile(&profile, &request).unwrap();
1732 }
1733
1734 #[test]
1735 fn every_shipped_manifest_profile_is_internally_admissible() {
1736 for manifest in crate::manifest::known_manifests()
1737 .iter()
1738 .filter(|manifest| manifest.is_generation_model())
1739 {
1740 let profile = generation_profile_for_manifest(manifest);
1741 assert_eq!(profile.schema_version, GENERATION_PROFILE_SCHEMA_VERSION);
1742 assert_eq!(profile.profile_hash.len(), 64, "{}", manifest.name);
1743 assert!(profile.default_recipe().is_some(), "{}", manifest.name);
1744
1745 for recipe in &profile.recipes {
1746 let context = format!("{} recipe {}", manifest.name, recipe.id);
1747 assert!(
1748 (recipe.steps.min..=recipe.steps.max).contains(&recipe.steps.default),
1749 "{context}: step default is outside its control"
1750 );
1751 assert!(
1752 (recipe.guidance.min..=recipe.guidance.max).contains(&recipe.guidance.default),
1753 "{context}: guidance default is outside its control"
1754 );
1755
1756 if let Some(temporal) = &recipe.temporal {
1757 assert!(
1758 (temporal.frames.min..=temporal.frames.max)
1759 .contains(&temporal.frames.default),
1760 "{context}: frame default is outside its control"
1761 );
1762 assert_eq!(
1763 (temporal.frames.default - temporal.frame_offset) % temporal.frames.step,
1764 0,
1765 "{context}: frame default is off-grid"
1766 );
1767 }
1768
1769 if recipe.resolution.domain == ResolutionDomain::None {
1770 assert!(recipe.resolution.aspect_groups.is_empty(), "{context}");
1771 continue;
1772 }
1773 assert_resolution(
1774 &context,
1775 recipe,
1776 recipe.defaults.width,
1777 recipe.defaults.height,
1778 );
1779 let mut preset_ids = std::collections::HashSet::new();
1780 for group in &recipe.resolution.aspect_groups {
1781 for preset in &group.presets {
1782 assert!(preset_ids.insert(&preset.id), "{context}: duplicate preset");
1783 assert_resolution(&context, recipe, preset.width, preset.height);
1784 }
1785 }
1786 if recipe.resolution.domain == ResolutionDomain::Buckets {
1787 assert!(
1788 recipe
1789 .resolution
1790 .aspect_groups
1791 .iter()
1792 .flat_map(|group| &group.presets)
1793 .any(|preset| {
1794 preset.width == recipe.defaults.width
1795 && preset.height == recipe.defaults.height
1796 }),
1797 "{context}: bucket default is not advertised"
1798 );
1799 }
1800 }
1801 }
1802 }
1803
1804 fn assert_resolution(context: &str, recipe: &GenerationRecipeProfile, width: u32, height: u32) {
1805 let resolution = &recipe.resolution;
1806 assert!(width >= resolution.min_width, "{context}: width too small");
1807 assert!(
1808 height >= resolution.min_height,
1809 "{context}: height too small"
1810 );
1811 assert_eq!(width % resolution.alignment, 0, "{context}: width off-grid");
1812 assert_eq!(
1813 height % resolution.alignment,
1814 0,
1815 "{context}: height off-grid"
1816 );
1817 assert!(
1818 u64::from(width) * u64::from(height) <= resolution.max_pixels,
1819 "{context}: pixel ceiling exceeded"
1820 );
1821 if let Some(max_axis) = resolution.max_axis_pixels {
1822 assert!(
1823 width <= max_axis && height <= max_axis,
1824 "{context}: axis exceeded"
1825 );
1826 }
1827 let aspect = f64::from(width) / f64::from(height);
1828 if let Some(min) = resolution.min_aspect_ratio {
1829 assert!(aspect >= min, "{context}: aspect below minimum");
1830 }
1831 if let Some(max) = resolution.max_aspect_ratio {
1832 assert!(aspect <= max, "{context}: aspect above maximum");
1833 }
1834 }
1835
1836 fn request_for(
1837 profile: &GenerationProfileSet,
1838 width: u32,
1839 height: u32,
1840 ) -> crate::GenerateRequest {
1841 let recipe = profile.default_recipe().unwrap();
1842 serde_json::from_value(serde_json::json!({
1843 "prompt": "test",
1844 "model": "test",
1845 "width": width,
1846 "height": height,
1847 "steps": recipe.defaults.steps,
1848 "guidance": recipe.defaults.guidance,
1849 "frames": recipe.defaults.frames,
1850 "fps": recipe.defaults.fps
1851 }))
1852 .unwrap()
1853 }
1854
1855 #[test]
1856 fn profile_admission_accepts_z_wide_and_rejects_non_bucket_wan() {
1857 let z = resolve_generation_profile(input("z-image-turbo:q4", "z-image"));
1858 let z_request = request_for(&z, 1280, 720);
1859 validate_request_against_generation_profile(&z, &z_request).unwrap();
1860
1861 let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1862 wan_input.default_width = 1280;
1863 wan_input.default_height = 720;
1864 wan_input.default_frames = Some(81);
1865 wan_input.default_fps = Some(16);
1866 let wan = resolve_generation_profile(wan_input);
1867 let valid = request_for(&wan, 1280, 720);
1868 validate_request_against_generation_profile(&wan, &valid).unwrap();
1869
1870 let off_bucket = request_for(&wan, 1024, 768);
1874 validate_request_against_generation_profile(&wan, &off_bucket).unwrap();
1875
1876 let mut strict = wan.clone();
1879 for recipe in &mut strict.recipes {
1880 recipe.resolution.off_bucket = Some(OffBucketPolicy::Reject);
1881 }
1882 assert!(
1883 validate_request_against_generation_profile(&strict, &off_bucket)
1884 .unwrap_err()
1885 .contains("not an available bucket")
1886 );
1887
1888 let recipe = wan.default_recipe().unwrap();
1892 assert!(off_bucket_resolution_warning(recipe, 1024, 768)
1893 .unwrap()
1894 .contains("results may vary"));
1895 assert!(off_bucket_resolution_warning(recipe, 1280, 720).is_none());
1896 assert!(off_bucket_resolution_warning(recipe, 1023, 768).is_none());
1897 let strict_recipe = strict.default_recipe().unwrap();
1898 assert!(off_bucket_resolution_warning(strict_recipe, 1024, 768).is_none());
1899 }
1900
1901 #[test]
1902 fn resolution_advisory_downgrades_refusals_for_client_surfaces() {
1903 let mut wan_input = input("wan22-t2v-a14b:q5", "wan");
1908 wan_input.default_width = 1280;
1909 wan_input.default_height = 720;
1910 wan_input.default_frames = Some(81);
1911 wan_input.default_fps = Some(16);
1912 let wan = resolve_generation_profile(wan_input);
1913 let recipe = wan.default_recipe().unwrap();
1914 let refused = resolution_advisory(recipe, 1023, 768).unwrap();
1915 assert!(refused.contains("server may reject"));
1916 assert!(resolution_advisory(recipe, 1024, 768)
1917 .unwrap()
1918 .contains("results may vary"));
1919 assert!(resolution_advisory(recipe, 1280, 720).is_none());
1920 }
1921
1922 #[test]
1923 fn profile_admission_rejects_unadvertised_output_format() {
1924 let profile = resolve_generation_profile(input("flux-dev:q4", "flux"));
1925 let mut request = request_for(&profile, 1024, 1024);
1926 request.output_format = Some(OutputFormat::Mp4);
1927 assert!(
1928 validate_request_against_generation_profile(&profile, &request)
1929 .unwrap_err()
1930 .contains("output format 'mp4' is not available")
1931 );
1932 }
1933
1934 #[test]
1935 fn delivery_qualification_repairs_defaults_and_withholds_mp4_only_recipes() {
1936 let mut ltx2_input = input("ltx2:bf16", "ltx2");
1937 ltx2_input.default_frames = Some(97);
1938 ltx2_input.default_fps = Some(24);
1939 let mut ltx2 = resolve_generation_profile(ltx2_input);
1940 let authored_hash = ltx2.profile_hash.clone();
1941 qualify_generation_profile_delivery(
1942 &mut ltx2,
1943 GenerationDeliveryCapabilities::new(false, false),
1944 );
1945 assert_ne!(ltx2.profile_hash, authored_hash);
1946 assert_eq!(
1947 generation_profile_default_output_format(<x2, None).unwrap(),
1948 OutputFormat::Gif
1949 );
1950 let default = ltx2.default_recipe().unwrap();
1951 assert_eq!(
1952 default.capabilities.output.formats,
1953 vec![OutputFormat::Gif, OutputFormat::Apng]
1954 );
1955 assert!(!default.capabilities.supports_audio);
1956
1957 let mut request = request_for(<x2, 1216, 704);
1958 request.output_format = None;
1959 materialize_generation_profile_output_default(<x2, &mut request).unwrap();
1960 assert_eq!(request.output_format, Some(OutputFormat::Gif));
1961 request.output_format = Some(OutputFormat::Apng);
1962 materialize_generation_profile_output_default(<x2, &mut request).unwrap();
1963 assert_eq!(request.output_format, Some(OutputFormat::Apng));
1964
1965 let mut h3_input = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1966 h3_input.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1967 h3_input.default_fps = Some(crate::minimax_h3::FIXED_FPS);
1968 let mut h3 = resolve_generation_profile(h3_input);
1969 qualify_generation_profile_delivery(
1970 &mut h3,
1971 GenerationDeliveryCapabilities::new(false, false),
1972 );
1973 assert!(h3.recipes.is_empty());
1974 assert!(generation_profile_default_output_format(&h3, None)
1975 .unwrap_err()
1976 .contains("no default recipe"));
1977 }
1978
1979 #[test]
1980 fn profile_admission_enforces_h3_fixed_controls_and_frame_cap() {
1981 let mut h3_input = input("minimax-h3-fl2va:official-bf16", "minimax-h3");
1982 h3_input.default_width = 768;
1983 h3_input.default_height = 768;
1984 h3_input.default_frames = Some(crate::minimax_h3::MIN_FRAMES);
1985 h3_input.default_fps = Some(crate::minimax_h3::FIXED_FPS);
1986 let h3 = resolve_generation_profile(h3_input);
1987 let mut request = request_for(&h3, 768, 768);
1988 request.frames = Some(crate::minimax_h3::MAX_FRAMES);
1989 validate_request_against_generation_profile(&h3, &request).unwrap();
1990
1991 request.frames = Some(362);
1992 assert!(validate_request_against_generation_profile(&h3, &request)
1993 .unwrap_err()
1994 .contains("frames must be"));
1995 request.frames = Some(crate::minimax_h3::MAX_FRAMES);
1996 request.guidance = 1.0;
1997 assert!(validate_request_against_generation_profile(&h3, &request)
1998 .unwrap_err()
1999 .contains("guidance is fixed"));
2000 }
2001}