Skip to main content

mold_core/
chain.rs

1//! Wire types for server-side chained video generation.
2//!
3//! A *chain* is a sequence of per-clip render stages stitched into a single
4//! output video. The v1 CLI UX is single-prompt + arbitrary length, but the
5//! wire format is stages-based from day one so the eventual movie-maker
6//! (multi-prompt, keyframes, selective regen) can author stages by hand
7//! without a breaking change.
8//!
9//! The server only ever sees the canonical [`ChainRequest`] shape — a
10//! `Vec<ChainStage>`. Callers can either build that directly or use the
11//! auto-expand form (`prompt` + `total_frames` + `clip_frames`), which
12//! [`ChainRequest::normalise`] collapses into stages.
13//!
14//! See `tasks/render-chain-v1-plan.md` for the full design rationale.
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::{MoldError, Result};
19use crate::types::{DevicePlacement, GenerateRequest, OutputFormat, OutputMetadata, VideoData};
20
21/// How the boundary between the previous stage and this stage is rendered.
22///
23/// - `Smooth`: the engine honors the motion-tail latent carryover from the
24///   prior clip (v1 default behaviour). Produces a visual morph when the
25///   prompt changes.
26/// - `Cut`: fresh latent, no carryover. If the stage has a `source_image`
27///   the engine uses it as the i2v seed; otherwise pure t2v.
28/// - `Fade`: same engine path as `Cut`, plus a post-stitch alpha blend of
29///   the last `fade_frames` of the prior clip with the first `fade_frames`
30///   of this clip.
31///
32/// Stage 0's transition is meaningless (nothing to transition from) and is
33/// coerced to `Smooth` during `ChainRequest::normalise`.
34#[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/// Per-clip provenance recorded into gallery metadata for chain outputs —
44/// the durable record of what each clip asked for, so a sequence can be
45/// traced (and later re-edited) from the Library. Seeds are the effective
46/// per-stage seeds, encoded as decimal strings (full-range u64).
47#[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    /// Ordered LoRA stack that shaped this clip. Kept per-stage because
57    /// sequence clips may intentionally use different adapters.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub loras: Vec<LoraSpec>,
60}
61
62/// Structured multi-clip provenance block on [`crate::OutputMetadata`]
63/// (additive; absent for single generations and legacy rows).
64#[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/// Optional provenance supplied by the caller of
72/// [`ChainRequest::stitched_output_metadata`]: the durable job id (absent
73/// on the ephemeral shim and CLI local renders) and the effective per-stage
74/// seeds once rendering has assigned them.
75#[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/// Per-stage LoRA adapter spec.
82#[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/// Per-stage named reference character/style. **Reserved for sub-project
91/// B** — populating this causes `ChainRequest::normalise` to return 422.
92#[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/// A single rendered clip in a chain. Concatenated in order with motion-tail
100/// trimming on continuations (stages with `idx >= 1` drop the leading
101/// `motion_tail_frames` pixel frames of their output because those duplicate
102/// the tail of the previous stage that the engine carried across as
103/// latent-space conditioning).
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
105pub struct ChainStage {
106    /// Prompt used for this stage. In v1 all stages receive the same prompt
107    /// (auto-expand form replicates it); the movie-maker UI in v2 will let
108    /// users author per-stage prompts.
109    #[schema(example = "a cat walking through autumn leaves")]
110    pub prompt: String,
111
112    /// Frame count for this stage. Must be `8k+1` (LTX-2 pipeline constraint:
113    /// 9, 17, 25, …, 97).
114    #[schema(example = 97)]
115    pub frames: u32,
116
117    /// Optional starting image (raw PNG/JPEG bytes, base64 in JSON). In v1
118    /// this is only meaningful on `stages[0]`; later stages draw their
119    /// conditioning from the prior stage's motion-tail latents instead.
120    #[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    /// Optional negative prompt for CFG-based stages. v1 LTX-2 ignores this
128    /// (the distilled family doesn't use CFG); the field is reserved so the
129    /// movie-maker can round-trip it without re-migrating the wire format.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub negative_prompt: Option<String>,
132
133    /// Optional per-stage seed offset. `None` in v1 — the orchestrator
134    /// derives each stage's seed from the chain's base seed. Reserved as the
135    /// v2 movie-maker override hook for "regenerate just this stage with a
136    /// different seed".
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub seed_offset: Option<u64>,
139
140    // NEW in multi-prompt v2 ───────────────────────────────────────────
141    /// Boundary style between the previous stage and this stage.
142    /// Stage 0's value is coerced to `Smooth` in `normalise`.
143    #[serde(default)]
144    pub transition: TransitionMode,
145
146    /// Length in pixel frames of the crossfade when `transition == Fade`.
147    /// `None` means use the server-announced default (8 frames). Capped
148    /// at `fade_frames_max` from `/api/capabilities/chain-limits`.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub fade_frames: Option<u32>,
151
152    // RESERVED for C — populated values are rejected by normalise ─────
153    /// **Reserved for sub-project C.** Populating this in a request
154    /// produces 422 in this release.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub model: Option<String>,
157
158    /// Ordered LoRA stack applied while rendering this clip.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub loras: Vec<LoraSpec>,
161
162    /// **Reserved for sub-project B.** Non-empty values produce 422.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub references: Vec<NamedRef>,
165}
166
167/// Chained generation request. Server accepts either the canonical form
168/// (`stages` non-empty) or the auto-expand form (`prompt` + `total_frames` +
169/// `clip_frames`); [`ChainRequest::normalise`] collapses the latter into the
170/// former so downstream code only deals with `stages`.
171#[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    /// Canonical stages list. Empty triggers auto-expand from
177    /// `prompt`/`total_frames`/`clip_frames`.
178    #[serde(default)]
179    pub stages: Vec<ChainStage>,
180
181    /// Pixel frames of motion-tail overlap between consecutive stages.
182    /// `0` = no overlap (simple concat). `>0` = the final K pixel frames of
183    /// stage N's latents are threaded into stage N+1's conditioning, and
184    /// stage N+1's leading K output frames are dropped at stitch time.
185    ///
186    /// Defaults to `17` (matches the CLI `--motion-tail` and SPA defaults):
187    /// `1 + 16` lands on the LTX-2 VAE's `1 + 8k` causal-grid for a clean
188    /// re-encode of the carryover RGB frames. Values that do not satisfy
189    /// `1 + 8k` will fail the receiving stage's tail re-encode at the VAE.
190    /// Must be strictly less than each stage's `frames`.
191    #[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    /// Chain base seed. Per-stage seeds are derived as
204    /// `base_seed ^ ((stage_idx as u64) << 32)` by the orchestrator so the
205    /// whole chain is reproducible from a single seed value.
206    #[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    /// Denoising strength for `stages[0].source_image`. Ignored when the
217    /// first stage has no source image. Continuation stages are always
218    /// full-strength conditioned via motion-tail latents.
219    #[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    /// Original source prompt shared by a client-prepared sibling batch.
230    #[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    /// Durable prepared-batch identity and one-based sibling position.
235    #[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    // ── Auto-expand form ────────────────────────────────────────────────
243    // These are only read when `stages` is empty; `normalise` clears them
244    // after expansion so the canonical form only ever carries `stages`.
245    /// Auto-expand: single prompt replicated across all stages.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub prompt: Option<String>,
248
249    /// Auto-expand: total pixel frames the stitched output should cover.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub total_frames: Option<u32>,
252
253    /// Auto-expand: per-clip frame count. Defaults to `97` (LTX-2 19B/22B
254    /// distilled cap). Must be `8k+1`.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub clip_frames: Option<u32>,
257
258    /// Auto-expand: starting image for `stages[0]`.
259    #[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    /// Generate per-stage audio and mux it into the final stitched output.
267    /// Only meaningful for AV-capable families (LTX-2 / LTX-2.3); the server
268    /// rejects `Some(true)` for non-AV models. `None` means "no preference"
269    /// and resolves to off — chains opt in to audio explicitly so existing
270    /// callers don't suddenly start producing audio they didn't ask for.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub enable_audio: Option<bool>,
273}
274
275/// Canonical TOML-shaped projection of a normalised [`ChainRequest`].
276///
277/// Echoed back in [`ChainResponse::script`] so clients can save the exact
278/// form that was rendered without re-serialising the request body (which
279/// carries auto-expand sugar and other transport-only fields).
280#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
281pub struct ChainScript {
282    pub schema: String, // always "mold.chain.v1"
283    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    /// Echo of [`ChainRequest::enable_audio`]. Omitted from TOML when unset
302    /// so v1 scripts (no audio) deserialise unchanged.
303    #[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/// VRAM feasibility estimate — populated by sub-project D. `None` in this
330/// release.
331#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
332pub struct VramEstimate {
333    /// Peak VRAM the heaviest single stage is predicted to reach — the max
334    /// over stages, never their sum. Sequence stages execute strictly one at
335    /// a time, so no two working sets are ever co-resident; summing would
336    /// report roughly N times the truth and make a long sequence look
337    /// infeasible on any card, which is the exact opposite of the signal a
338    /// user needs. A long sequence costs time, not memory.
339    pub worst_case_bytes: u64,
340    /// Whether every stage fit the roomiest sampled device at validation
341    /// time. **Advisory only.** Admission re-derives placement from live
342    /// device facts, so this must never gate submission — VRAM freed between
343    /// validate and submit would strand a job that would have run.
344    pub fits: bool,
345}
346
347/// One normalized stage in a chain validation response. Media and negative
348/// prompt contents are deliberately not echoed; callers only need to know
349/// which conditioning inputs survived normalization.
350#[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/// Read-only normalized plan returned by
363/// `POST /api/generate/chain/validate`. This endpoint never creates a durable
364/// job or starts downloads.
365#[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    /// Reserved until the server's chain estimator is populated. Kept in the
378    /// stable response now so clients can render it additively when available.
379    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    /// Attach an advisory VRAM estimate. Separate from `from_normalized`
428    /// because the estimate needs live device facts the core crate cannot see.
429    #[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/// Response from a chained generation request. The `video` is the stitched
437/// output; individual per-stage clips are not returned.
438#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
439pub struct ChainResponse {
440    pub video: VideoData,
441    /// Number of stages that actually ran (matches `request.stages.len()`
442    /// after normalisation).
443    #[schema(example = 5)]
444    pub stage_count: u32,
445    /// GPU ordinal that handled the chain (multi-GPU servers only).
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub gpu: Option<usize>,
448
449    // NEW ──────────────────────────────────────────────────────────────
450    /// Canonical TOML-shaped echo of the rendered script. Clients can save
451    /// this directly as a `.toml` file.
452    pub script: ChainScript,
453
454    /// Reserved for sub-project D; `None` in this release.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub vram_estimate: Option<VramEstimate>,
457}
458
459/// SSE completion event for a successful chain run. Streamed as the final
460/// `data:` frame under the `event: complete` SSE type. The payload is
461/// base64-encoded to stay JSON-safe; clients decode it into `VideoData`.
462///
463/// This is a sibling to [`crate::types::SseCompleteEvent`] rather than an
464/// extension so image/video vs. chain completion shapes stay independent
465/// and can evolve separately.
466#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
467pub struct SseChainCompleteEvent {
468    /// Base64-encoded stitched video bytes (format per `format` field), or an
469    /// empty string when `X-Mold-SSE-Payload: metadata-only` was requested.
470    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    /// Base64-encoded first-frame PNG thumbnail.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub thumbnail: Option<String>,
483    /// Base64-encoded animated GIF preview (always emitted for gallery UI).
484    #[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    /// Number of stages that ran end-to-end.
495    #[schema(example = 5)]
496    pub stage_count: u32,
497    /// GPU ordinal that handled the chain (multi-GPU only).
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub gpu: Option<usize>,
500    /// Wall-clock elapsed time across all stages + stitching.
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub generation_time_ms: Option<u64>,
503    /// Canonical echo of the normalised chain request, so streaming clients
504    /// can save/reload the rendered script without re-serialising the
505    /// transport-only fields in the submitted request body.
506    #[serde(default)]
507    pub script: ChainScript,
508    /// Reserved for sub-project D; `None` in this release.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub vram_estimate: Option<VramEstimate>,
511    /// Filename this stitched output was saved under in the server gallery.
512    /// Present for servers that persist chain output and absent on older
513    /// servers or when gallery output is disabled.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub filename: Option<String>,
516    /// The exact metadata recorded for the saved stitched output. Streaming
517    /// clients can use this with `filename` instead of reconstructing chain
518    /// provenance from the request or encoded media.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    #[schema(value_type = Object)]
521    pub metadata: Option<Box<OutputMetadata>>,
522}
523
524/// Chain-specific SSE progress event. Streamed as `data:` JSON frames from
525/// `POST /api/generate/chain/stream` under the `event: progress` SSE type.
526///
527/// Per-stage denoise steps are wrapped with `stage_idx` so consumers can
528/// render stacked progress bars (overall chain + per-stage) without a
529/// separate subscription. Non-denoise engine events (weight load, cache
530/// hits, etc.) are intentionally not forwarded through this enum in v1 —
531/// they're scoped to individual stages and the UX goal for v1 is per-stage
532/// progress, not per-component telemetry.
533#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, PartialEq, Eq)]
534#[serde(tag = "type", rename_all = "snake_case")]
535pub enum ChainProgressEvent {
536    /// Emitted once at the start of the chain, after normalisation. Gives
537    /// consumers the final stage count and the target pre-trim frame total
538    /// so they can size progress bars up front.
539    ChainStart {
540        stage_count: u32,
541        estimated_total_frames: u32,
542    },
543    /// Stage `stage_idx` (0-indexed) has started its denoise loop.
544    StageStart { stage_idx: u32 },
545    /// Per-step denoise progress for the active stage.
546    DenoiseStep {
547        stage_idx: u32,
548        step: u32,
549        total: u32,
550    },
551    /// Stage finished generating; `frames_emitted` is the raw clip frame
552    /// count before motion-tail trim at stitch time.
553    StageDone { stage_idx: u32, frames_emitted: u32 },
554    /// All stages complete; stitching/encoding the final MP4.
555    Stitching { total_frames: u32 },
556}
557
558/// Structured error payload returned in the 502 response body when a chain
559/// stage fails mid-run. Allows UIs to show actionable retry hints (e.g.,
560/// "stage 2 of 5 failed — retry from here").
561#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
562pub struct ChainFailure {
563    /// Human-readable summary of where the failure landed.
564    #[schema(example = "stage render failed")]
565    pub error: String,
566    /// Zero-based index of the stage whose render returned Err.
567    #[schema(example = 2)]
568    pub failed_stage_idx: u32,
569    /// Number of stages that completed successfully before the failure.
570    #[schema(example = 2)]
571    pub elapsed_stages: u32,
572    /// Cumulative generation time across the completed stages, in ms.
573    #[schema(example = 12_340)]
574    pub elapsed_ms: u64,
575    /// Inner error message from the orchestrator (`format!("{e:#}")`).
576    #[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
596/// Maximum number of stages the v1 orchestrator will accept in a single
597/// chain. 16 × 97-frame clips ≈ 1552 frames ≈ 64 s at 24 fps — comfortably
598/// past the 400-frame target without risking runaway jobs.
599pub const MAX_CHAIN_STAGES: usize = 16;
600
601impl ChainRequest {
602    /// Build a synthetic single-clip `GenerateRequest` describing the
603    /// stitched output, so gallery rows and embedded metadata can reuse
604    /// the existing single-clip schema. `stages[0]` supplies the prompt,
605    /// negative prompt, and source image (the row only has one prompt
606    /// field — continuation prompts are dropped, acceptable for v1).
607    ///
608    /// Callers must pass a normalised request (`stages` non-empty).
609    /// `actual_format` is the container after encode fallbacks (e.g. a
610    /// WebP request that fell back to APNG records APNG).
611    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        // A sequence with distinct clip prompts must not be recorded under
622        // clip 1's prompt alone — join them (one line per clip) so gallery
623        // search matches any clip. Uniform prompts (auto-expanded chains)
624        // keep the single prompt.
625        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            // Sequences render every clip through the chain stage path, which
687            // keeps the pipeline's own guider constants. There is no chain
688            // wire field to carry an override, so recording one here would
689            // claim a setting the render never used.
690            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    /// Gallery/PNG metadata for the stitched chain output, derived from
699    /// [`Self::synthetic_generate_request`] via
700    /// `OutputMetadata::from_generate_request` so chain rows can never
701    /// drift from the single-clip metadata semantics (e.g. `strength` is
702    /// only recorded when the chain starts from a source image).
703    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    /// Collapse the auto-expand form into a canonical `Vec<ChainStage>` and
741    /// validate the result. Called once on the server side immediately after
742    /// JSON parsing, before any engine work kicks off.
743    ///
744    /// Post-conditions on a successful return:
745    /// - `self.stages` is non-empty.
746    /// - Each stage's `frames` is `8k+1` and `> 0`.
747    /// - `self.stages.len() <= MAX_CHAIN_STAGES`.
748    /// - All auto-expand fields are `None` (caller must use `self.stages`).
749    pub fn normalise(self) -> Result<Self> {
750        self.normalise_with_family(None)
751    }
752
753    /// [`normalise`](Self::normalise) with the family already resolved.
754    ///
755    /// The manifest cannot classify an installed `cv:` / `hf:` id, so a
756    /// manifest-only lookup fell back to the LTX `8k+1` grid and rejected a
757    /// 53-frame wan stage as "this family requires 8k+1" — immediately after
758    /// the server had resolved it as wan from the sidecar overlay (#783).
759    /// Callers holding a resolved family pass it; `None` keeps the historical
760    /// manifest-only behaviour for callers that do not.
761    pub fn normalise_with_family(mut self, family_hint: Option<&str>) -> Result<Self> {
762        // Resolve the family from the manifest rather than passing `None`.
763        // With a family-aware ceiling and grid, a `None` check is not merely
764        // loose — it is a different answer: it would reject a 1920x1088 LTX-2
765        // sequence the HTTP path admits, and accept a 16-aligned size the
766        // LTX-2 VAE's /32 grid cannot render.
767        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        // A sequence clip is one generation, so it is bound by exactly the
775        // same ceiling — including the composed one. Resolving the
776        // composition from the model keeps a 4K sequence admissible wherever a
777        // 4K single shot is, and refused wherever it is not.
778        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            // The grid is the family's, not a constant: wan's VAE compresses
813            // time by 4 where the LTX families compress by 8. A hardcoded 8
814            // rejected every wan auto-chain, including the 53-frame routing
815            // default the CLI itself picks.
816            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        // The carryover frames re-encode through the family's own video VAE,
855        // so the tail sits on that VAE's temporal grid — 8x causal for LTX-2,
856        // 4x for wan.
857        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        // Per-stage LoRAs use the same wire-level constraints as ordinary
887        // generation. Paths are server-local except for built-in
888        // `camera-control:<preset>` aliases, which the server materializes
889        // before execution.
890        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        // Stage 0's transition is meaningless (nothing to transition from).
923        // Coerce to Smooth with a warn so scripts survive reorders.
924        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        // Canonicalise: clear auto-expand fields so downstream code only
935        // ever reads from `stages`.
936        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    /// Predicted stitched frame count *before* any top-level `total_frames`
945    /// trim. Used by UIs for the footer summary and by the server to size
946    /// the final buffer.
947    ///
948    /// Per-boundary rule:
949    /// - smooth: drop leading `motion_tail_frames` of the incoming clip
950    /// - cut: no trim
951    /// - fade: replace `2 * fade_len` frames (trailing of prior + leading of
952    ///   next) with `fade_len` blended frames → net `-fade_len`
953    ///
954    /// Sums [`stage_contributed_frames`] so the per-stage boundary math
955    /// exists in exactly one place (the chain-job runner persists the same
956    /// values as `frames_emitted`).
957    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
976/// Default crossfade length in pixel frames when a `Fade` stage omits
977/// `fade_frames`. Announced to clients via `/api/capabilities/chain-limits`.
978pub const DEFAULT_FADE_FRAMES: u32 = 8;
979
980/// Frames stage `idx` contributes to the final stitched video after boundary
981/// accounting — the single home of the per-stage boundary math.
982/// [`ChainRequest::estimated_total_frames`] sums it and the chain-job runner
983/// persists it as each stage's `frames_emitted`.
984///
985/// Attribution matches the persisted `frames_emitted` wire meaning:
986/// - a continuation stage entering with `Smooth` loses its leading
987///   `motion_tail_frames` (they duplicate the prior stage's carried tail);
988/// - a stage whose NEXT boundary is `Fade` loses its trailing `fade_len`
989///   (the blended block replaces them and is attributed to the incoming
990///   stage);
991/// - a stage entering with `Fade` keeps its full frame count (its leading
992///   `fade_len` frames are replaced by the blend in place, not dropped).
993pub 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/// Returns `true` iff `n` has the form `8k + 1` for some non-negative integer
1012/// `k` (1, 9, 17, 25, …). The LTX-2 pipeline has this constraint on pixel
1013/// frame counts due to the VAE's 8× temporal compression with a causal first
1014/// frame.
1015///
1016/// Test-only since the Wan wave (#783): production grid checks are family-
1017/// derived through [`crate::validation::frame_step_for_family`], because Wan's
1018/// grid is `4k + 1`. This stays as the literal spelling of LTX-2's own
1019/// constraint so the tests below assert it independently of that lookup.
1020#[cfg(test)]
1021fn is_ltx2_frame_count(n: u32) -> bool {
1022    n % 8 == 1
1023}
1024
1025/// Compute the stage count and per-stage frame allocation for the auto-
1026/// expand form, matching the chain stitch math:
1027///
1028/// - Stage 0 contributes `clip_frames` pixel frames.
1029/// - Each continuation contributes `clip_frames - motion_tail_frames` new
1030///   frames (the leading `motion_tail_frames` are dropped at stitch time
1031///   because they duplicate the prior stage's latent tail).
1032///
1033/// Returns enough stages so the stitched total reaches at least
1034/// `total_frames`; over-production is trimmed from the tail at stitch time
1035/// per the signed-off decision 2026-04-20.
1036fn 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        // Single stage: match the user's requested length exactly so we
1045        // don't render 97 frames and throw most of them away. The frame
1046        // count will still be validated as 8k+1 by the caller.
1047        (1u32, total_frames)
1048    } else {
1049        let effective = clip_frames - motion_tail_frames;
1050        // effective > 0 because the caller has already ensured
1051        // motion_tail_frames < clip_frames.
1052        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        // Every stage carries the starting image: stage 0 uses it as the
1068        // i2v replacement at frame 0, and continuation stages use it as a
1069        // soft identity anchor through the append path (see
1070        // `Ltx2Engine::render_chain_stage`). Keeping a durable reference
1071        // across stages is what stops scene/identity drift past the first
1072        // clip, whose effects were traced in render-chain v1 as the
1073        // dominant cause of "strange" continuations — the motion tail
1074        // alone only carries ~0.7 s of pixel context, nowhere near enough
1075        // for the model to remember the scene across an 8-stage chain.
1076        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    /// An installed catalog wan checkpoint normalises on wan's grid (#783).
1097    ///
1098    /// `normalise` resolved the family from the built-in manifest alone, and
1099    /// `find_manifest` cannot classify a `cv:` / `hf:` id — so the grid fell
1100    /// back to `8k+1` and a 53-frame wan stage was rejected with "this family
1101    /// requires 8k+1", immediately after the server had correctly resolved it
1102    /// as wan from the sidecar overlay. Catalog-installed checkpoints are
1103    /// exactly the models the sequence work targets.
1104    #[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        // Without the hint the id is opaque and the LTX grid rejects it —
1120        // this is the defect, kept explicit so the fix cannot silently lapse.
1121        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        // The server and CLI both resolve the family before calling, so the
1128        // hint is what they actually have in hand.
1129        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        // Wan's grid is still enforced, just wan's and not LTX-2's.
1137        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        // An explicit hint never overrides a model the manifest does know.
1145        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    /// Build a minimal auto-expand request with the given knobs. All other
1165    /// fields use their v1 defaults so tests can focus on the logic under
1166    /// exercise.
1167    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        // total=400, clip=97, tail=9 → effective=88, remainder=303,
1245        // N = 1 + ceil(303/88) = 1 + 4 = 5 stages of 97 frames each.
1246        // Stitched = 97 + 4*88 = 449, which will be trimmed to 400 at
1247        // stitch time (per the signed-off "trim from tail" decision).
1248        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        // Auto-expand fields are cleared post-normalisation.
1263        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            // Every stage must carry the starting image. Stage 0 uses it
1279            // as the i2v replacement at frame 0; continuations use it as a
1280            // soft identity anchor through the append path so scene and
1281            // subject identity stay coherent past the motion-tail window.
1282            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        // No auto-expand fields either.
1294        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        // Canonical form with a stage whose frames violates the 8k+1
1307        // constraint.
1308        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        // Caller already built stages; normalise should validate and clear
1319        // the (already-empty) auto-expand fields without touching stages.
1320        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        // total=9 fits in one clip; don't render a full 97-frame stage and
1334        // throw most of it away. Use motion_tail=1 (smallest valid 1+8k)
1335        // so the strict-less-than-stage-frames invariant still holds for
1336        // the lone 9-frame stage.
1337        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        // 17 canonical stages exceeds MAX_CHAIN_STAGES (16).
1347        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        // 16 × 97 = 1552 max stitched frames before trim; asking for
1360        // 4000 frames should blow the guardrail.
1361        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        // motion_tail must leave at least one new frame per continuation.
1388        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        // Wire-conservative default: chains opt in to audio explicitly. A
1400        // request that omits the field stays None (engine-side resolves to
1401        // false), so existing chain callers don't suddenly get audio they
1402        // didn't ask for. Setting `enable_audio: true` on the request must
1403        // round-trip into the canonical script echo so clients can save and
1404        // re-render the same chain with audio enabled.
1405        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        // Server JSON default must satisfy `1 + 8k` so chain tail RGB frames
1443        // re-encode cleanly through the LTX-2 video VAE. CLI and SPA already
1444        // default to 17; pin the JSON deserialiser to the same value.
1445        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        // motion_tail_frames=4 is what the JSON default used to be — it does
1461        // NOT satisfy `1 + 8k`, so the carryover VAE re-encode would fail
1462        // deep in the engine with a shape mismatch. Reject with a clear
1463        // message at the wire boundary instead.
1464        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        // motion_tail=0 means hard concat, no overlap, no carryover encode.
1477        // Must be valid so cut/fade chains can opt out of the grid entirely.
1478        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        // total_frames omitted.
1489        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        // Auto-expand must produce enough stages that the stitch delivers
1562        // at least `total_frames` pixel frames. Stitch math:
1563        //   delivered = clip_frames + (N - 1) * (clip_frames - motion_tail)
1564        let cases = [
1565            (400u32, 97u32, 9u32, 5u32), // 97 + 4*88 = 449 ≥ 400
1566            (200, 97, 9, 3),             // 97 + 2*88 = 273 ≥ 200
1567            (97, 97, 9, 1),              // single clip hits 97 exactly
1568            (300, 97, 0, 4),             // zero tail, 4*97 = 388 ≥ 300
1569        ];
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        // name omitted
1619        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        // base64-encoded image via the existing base64 helper
1630        assert!(json.contains(r#""name":"hero""#));
1631        assert!(json.contains(r#""image":"#));
1632    }
1633
1634    #[test]
1635    fn chain_stage_defaults_are_backcompat() {
1636        // Parsing a v1-shaped stage (no new fields) yields the same structure
1637        // with defaults applied.
1638        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, // should coerce
1728                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, // preserved
1740                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        // 3 × 97-frame smooth = 97 + (97-25) + (97-25) = 241
1881        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        // 97 + 97 (cut, no trim) + (97-25) (smooth after cut) = 266
1892        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    /// The per-stage boundary math must live in exactly one place:
1901    /// `stage_contributed_frames`. `estimated_total_frames` sums it, and the
1902    /// chain-job runner persists it as `frames_emitted` — attribution matches
1903    /// the persisted wire meaning (a stage followed by a Fade loses its
1904    /// trailing `fade_len`; a stage entering with Fade keeps its full count).
1905    #[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), // default fade len 8
1913        ]);
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        // Stage 1 is followed by an explicit 8-frame fade (97-8), stage 3 by
1931        // a default-length fade (89-25 smooth trim, then -8 outgoing fade).
1932        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        // 97 + 97 + (97 - fade 8) fade consumes from both sides, net -fade_len
1943        // Actually: fade replaces the trailing fade_len of clip N + leading
1944        // fade_len of clip N+1 with fade_len blended frames.
1945        // Emission = sum - 2*fade_len + fade_len = sum - fade_len
1946        // = 97+97+97 - 8 = 283
1947        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    /// Ported from the CLI's synth_generate_request regression test:
1956    /// stage-0 prompt / source image / negative prompt must land in the
1957    /// synthetic request verbatim, not smeared from the request level.
1958    #[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    /// A sequence whose clips carry distinct prompts must not record the
2019    /// whole video under clip 1's prompt alone — the gallery row joins
2020    /// every clip prompt (one line per clip) so search matches any of them.
2021    /// Uniform prompts (auto-expanded chains) keep the single prompt.
2022    #[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    /// Chain outputs must carry structured per-clip provenance so the
2049    /// Library can trace a sequence back to its clips (and, later, its
2050    /// durable job). Stage seeds are recorded as decimal strings.
2051    #[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    /// Without provenance (legacy shim path, CLI local render) the chain
2088    /// block is still recorded — job id and seeds simply stay absent.
2089    #[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    /// The recorded output_format must be the ACTUAL post-fallback
2102    /// container, not the requested one (a WebP request that fell back to
2103    /// APNG previously recorded WebP on the server path).
2104    #[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    /// `strength` is only meaningful when the chain starts from a source
2116    /// image — text-to-video chains must not record a phantom strength
2117    /// (the server copies previously wrote Some(strength) unconditionally).
2118    #[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    /// Field-parity guard: the stitched metadata must agree with a
2142    /// hand-derived from_generate_request over the same synthetic request,
2143    /// so future OutputMetadata fields can't silently diverge.
2144    #[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        // The chain block is the one deliberate addition over the synthetic
2157        // single-clip projection; everything else must stay in lockstep.
2158        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}