Skip to main content

pictor_image/
pipeline.rs

1//! End-to-end Pure-Rust text-to-image orchestration.
2//!
3//! [`text_to_image`] chains the whole Bonsai-Image pipeline behind a single
4//! library entry point so both the `generate` example and the `pictor image`
5//! CLI subcommand share one code path (DRY):
6//!
7//! 1. Load the DiT GGUF ([`DitWeights::open`]), the SMALL VAE weights
8//!    ([`crate::vae::VaeWeights::open`]/[`crate::vae::VaeDecoder::from_weights`]), and the Qwen3-4B text
9//!    encoder ([`crate::te::TeWeights`] + [`crate::te::TextEncoder`]).
10//! 2. Tokenize the prompt ([`crate::te::Qwen3Tokenizer`]) → TE forward → `cond_7680()`
11//!    `[seq_txt, joint_dim]` conditioning.
12//! 3. Native sampling scaffolding from [`crate::sample`]: seeded initial noise
13//!    (`sample::create_noise`), the `img_ids` / `txt_ids` RoPE grids, and the
14//!    flow-match `(timesteps, sigmas)` schedule — or, in parity mode
15//!    ([`GoldenOverride`]), load those from a golden `.npy` dump instead.
16//! 4. Euler sample ([`DitForward::sample`]) → final latent `[seq_img,
17//!    in_channels]`.
18//! 5. Reshape to packed NCHW (`latent_seq_to_packed_nchw`) → VAE decode
19//!    ([`crate::vae::VaeDecoder::decode_packed_latents`]) → `clip(x/2 + 0.5)` → `u8`
20//!    (NCHW → HWC) → PNG ([`encode_rgb8`]).
21//!
22//! All failures are surfaced as [`PipelineError`] (no `unwrap`/`expect`/`panic`).
23//! The native noise is byte-exact against the golden `init_latents.npy` (see the
24//! [`crate::sample`] module docs), so the seed-42 native path reproduces the
25//! golden-input render.
26
27use std::path::PathBuf;
28
29use crate::png::encode_rgb8;
30use crate::sample;
31use crate::te::{Qwen3Tokenizer, TeError, TeWeights, TextEncoder};
32use crate::vae::{VaeDecoder, VaeError, VaeWeights};
33use crate::{DitError, DitForward, DitWeights, PngError, StepTap};
34
35/// Where the text-encoder weights come from.
36#[derive(Debug, Clone)]
37pub enum TeSource {
38    /// The native 4-bit MLX `model.safetensors` (≈2.1 GB), loaded via
39    /// [`TeWeights::open_mlx_4bit`].
40    Mlx4bit(PathBuf),
41    /// A directory of f32 `.npy` weight dumps (≈15 GB), loaded via
42    /// [`TeWeights::open`].
43    NpyDir(PathBuf),
44}
45
46/// Parity-mode overrides: load the DiT inputs (initial latent, position ids, and
47/// the sampler schedule) — and optionally the whole conditioning / a pre-sampled
48/// latent — from a golden `.npy` dump instead of generating them natively.
49///
50/// This preserves the `generate` example's `PICTOR_USE_GOLDEN_LATENT` /
51/// golden-cond byte-for-byte parity behaviour. When `None`, everything is
52/// generated natively from `(prompt, seed, width, height)`.
53#[derive(Debug, Clone)]
54pub struct GoldenOverride {
55    /// Directory containing the bf16 golden tensors (`tf_in_hidden_states.npy`,
56    /// `img_ids.npy`, `txt_ids.npy`, `timesteps.npy`, `sigmas.npy`,
57    /// `cond.npy`, `latent_after_step3.npy`, …).
58    pub golden_dir: PathBuf,
59    /// Use `cond.npy` from `golden_dir` instead of the native TE conditioning.
60    pub use_golden_cond: bool,
61    /// Skip the DiT sampler and use `latent_after_step3.npy` directly.
62    pub use_golden_latent: bool,
63    /// Optional VAE golden dir (`vae_decoded.npy`) for the decode-cosine tap.
64    pub vae_golden_dir: Option<PathBuf>,
65}
66
67/// Configuration for a single text-to-image generation.
68#[derive(Debug, Clone)]
69pub struct TextToImageCfg {
70    /// The text prompt.
71    pub prompt: String,
72    /// RNG seed for the initial noise (drives generation natively).
73    pub seed: u64,
74    /// Number of Euler sampler steps.
75    pub steps: usize,
76    /// Target image width in pixels.
77    pub width: usize,
78    /// Target image height in pixels.
79    pub height: usize,
80    /// Guidance scale (reserved; the current DiT forward is unconditional —
81    /// kept for API stability and surfaced in logs).
82    pub guidance: f32,
83    /// Path to the DiT GGUF file.
84    pub dit_gguf: PathBuf,
85    /// VAE weights source: either a `.safetensors` file (the native FLUX.2
86    /// `AutoencoderKLFlux2` checkpoint) or a directory of exported `.npy`
87    /// tensors — [`VaeWeights::open`] auto-detects which.
88    pub vae_weights_dir: PathBuf,
89    /// Where the text-encoder weights come from.
90    pub te_source: TeSource,
91    /// Directory containing `tokenizer.json`.
92    pub tokenizer_dir: PathBuf,
93    /// When `Some`, run in golden-parity mode (see [`GoldenOverride`]).
94    pub golden_override: Option<GoldenOverride>,
95}
96
97/// The result of a generation.
98pub struct TextToImageOut {
99    /// The encoded PNG byte stream.
100    pub png: Vec<u8>,
101    /// Image width in pixels.
102    pub width: usize,
103    /// Image height in pixels.
104    pub height: usize,
105    /// Per-stage cosine similarities vs the golden (`(label, cosine)`). Empty
106    /// unless `golden_override` was set and the corresponding golden tensors
107    /// were available.
108    pub stage_cosines: Vec<(String, f32)>,
109}
110
111/// Errors that can occur while running [`text_to_image`].
112#[derive(Debug, thiserror::Error)]
113pub enum PipelineError {
114    /// A DiT load / forward error.
115    #[error("DiT error: {0}")]
116    Dit(#[from] DitError),
117    /// A text-encoder load / forward / tokenizer error.
118    #[error("text-encoder error: {0}")]
119    Te(#[from] TeError),
120    /// A VAE load / decode error.
121    #[error("VAE error: {0}")]
122    Vae(#[from] VaeError),
123    /// A PNG-encoding error.
124    #[error("PNG error: {0}")]
125    Png(#[from] PngError),
126    /// An I/O error (reading a golden tensor, a missing weights path, …).
127    #[error("I/O error for {path}: {source}")]
128    Io {
129        /// The path that triggered the error.
130        path: String,
131        /// The underlying I/O error.
132        source: std::io::Error,
133    },
134    /// A shape / configuration invariant was violated.
135    #[error("pipeline shape error: {0}")]
136    Shape(String),
137    /// A required input path was missing.
138    #[error("missing input: {0}")]
139    MissingInput(String),
140}
141
142/// A loaded `.npy` tensor (f32, C-order).
143struct Npy {
144    data: Vec<f32>,
145    shape: Vec<usize>,
146}
147
148impl Npy {
149    fn numel(&self) -> usize {
150        self.shape.iter().product()
151    }
152}
153
154/// Minimal NumPy `.npy` reader: v1.0/2.0 header, `descr == '<f4'`, handling
155/// `fortran_order == True` by reordering to C (row-major). Used only by the
156/// golden-parity path.
157fn read_npy(path: &std::path::Path) -> Result<Npy, PipelineError> {
158    let bytes = std::fs::read(path).map_err(|e| PipelineError::Io {
159        path: path.display().to_string(),
160        source: e,
161    })?;
162    if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" {
163        return Err(PipelineError::Shape(format!(
164            "{}: bad npy magic",
165            path.display()
166        )));
167    }
168    let major = bytes[6];
169    let (header_start, header_len) = if major >= 2 {
170        let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
171        (12usize, len)
172    } else {
173        let len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
174        (10usize, len)
175    };
176    let header_end = header_start + header_len;
177    if bytes.len() < header_end {
178        return Err(PipelineError::Shape(format!(
179            "{}: truncated npy header",
180            path.display()
181        )));
182    }
183    let header = std::str::from_utf8(&bytes[header_start..header_end])
184        .map_err(|e| PipelineError::Shape(format!("{}: header utf8: {e}", path.display())))?;
185    if !header.contains("'<f4'") {
186        return Err(PipelineError::Shape(format!(
187            "{}: descr is not '<f4': {header}",
188            path.display()
189        )));
190    }
191    let fortran = header.contains("'fortran_order': True");
192    let s_idx = header
193        .find("'shape':")
194        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape key", path.display())))?;
195    let open = header[s_idx..]
196        .find('(')
197        .map(|o| s_idx + o + 1)
198        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape open paren", path.display())))?;
199    let close = header[open..]
200        .find(')')
201        .map(|c| open + c)
202        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape close paren", path.display())))?;
203    let shape: Vec<usize> = header[open..close]
204        .split(',')
205        .map(str::trim)
206        .filter(|s| !s.is_empty())
207        .map(|s| {
208            s.parse::<usize>()
209                .map_err(|e| PipelineError::Shape(format!("{}: shape parse: {e}", path.display())))
210        })
211        .collect::<Result<_, _>>()?;
212    let payload = &bytes[header_end..];
213    if payload.len() % 4 != 0 {
214        return Err(PipelineError::Shape(format!(
215            "{}: payload not f32-aligned",
216            path.display()
217        )));
218    }
219    let raw: Vec<f32> = payload
220        .chunks_exact(4)
221        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
222        .collect();
223    let numel: usize = shape.iter().product();
224    if raw.len() < numel {
225        return Err(PipelineError::Shape(format!(
226            "{}: payload short ({} < {numel})",
227            path.display(),
228            raw.len()
229        )));
230    }
231    let data = if fortran && shape.len() > 1 {
232        fortran_to_c(&raw[..numel], &shape)
233    } else {
234        raw
235    };
236    Ok(Npy { data, shape })
237}
238
239/// Reorder a Fortran-stored (column-major) buffer into C (row-major) order.
240fn fortran_to_c(src: &[f32], shape: &[usize]) -> Vec<f32> {
241    let ndim = shape.len();
242    let numel: usize = shape.iter().product();
243    let mut f_stride = vec![1usize; ndim];
244    for d in 1..ndim {
245        f_stride[d] = f_stride[d - 1] * shape[d - 1];
246    }
247    let mut out = vec![0.0f32; numel];
248    for (c_pos, slot) in out.iter_mut().enumerate() {
249        let mut rem = c_pos;
250        let mut f_off = 0usize;
251        for d in 0..ndim {
252            let stride_c: usize = shape[d + 1..].iter().product();
253            let idx = rem / stride_c;
254            rem %= stride_c;
255            f_off += idx * f_stride[d];
256        }
257        *slot = src[f_off];
258    }
259    out
260}
261
262/// Load a named golden tensor from `dir` (`<dir>/<name>.npy`).
263fn load_golden(dir: &std::path::Path, name: &str) -> Result<Npy, PipelineError> {
264    read_npy(&dir.join(format!("{name}.npy")))
265}
266
267/// Cosine similarity between two equal-length slices (f64 accumulation).
268fn cosine(a: &[f32], b: &[f32]) -> f64 {
269    let mut dot = 0.0f64;
270    let mut na = 0.0f64;
271    let mut nb = 0.0f64;
272    for (&x, &y) in a.iter().zip(b.iter()) {
273        let (x, y) = (x as f64, y as f64);
274        dot += x * y;
275        na += x * x;
276        nb += y * y;
277    }
278    if na > 0.0 && nb > 0.0 {
279        dot / (na.sqrt() * nb.sqrt())
280    } else {
281        0.0
282    }
283}
284
285/// Reshape the DiT's final latent `[seq_img, in_channels]` (row-major,
286/// sequence-major) into the VAE's packed NCHW latent `[1, in_channels, ph, pw]`.
287///
288/// This mirrors `flux2_klein`'s `reshape(1, ph, pw, in_channels).transpose(0, 3,
289/// 1, 2)`: the sequence index `s = hh*pw + ww` (h-major over a `pw`-wide grid)
290/// becomes the spatial position and the channel becomes the leading axis.
291///
292/// # Errors
293/// [`PipelineError::Shape`] if `seq_img != ph*pw` or the latent length is wrong.
294pub fn latent_seq_to_packed_nchw(
295    latent: &[f32],
296    seq_img: usize,
297    in_channels: usize,
298    ph: usize,
299    pw: usize,
300) -> Result<Vec<f32>, PipelineError> {
301    if seq_img != ph * pw {
302        return Err(PipelineError::Shape(format!(
303            "seq_img {seq_img} != ph*pw ({ph}*{pw})"
304        )));
305    }
306    if latent.len() != seq_img * in_channels {
307        return Err(PipelineError::Shape(format!(
308            "latent len {} != seq_img*in_channels ({seq_img}*{in_channels})",
309            latent.len()
310        )));
311    }
312    let plane = ph * pw;
313    let mut packed = vec![0.0f32; in_channels * plane];
314    for hh in 0..ph {
315        for ww in 0..pw {
316            let s = hh * pw + ww;
317            let src_base = s * in_channels;
318            for ch in 0..in_channels {
319                packed[ch * plane + hh * pw + ww] = latent[src_base + ch];
320            }
321        }
322    }
323    Ok(packed)
324}
325
326/// Compute the `[seq_txt, joint_dim]` conditioning natively from the prompt.
327fn compute_cond(
328    cfg: &TextToImageCfg,
329    seq_txt: usize,
330    joint_dim: usize,
331) -> Result<Vec<f32>, PipelineError> {
332    let tok = Qwen3Tokenizer::open(&cfg.tokenizer_dir)?;
333    let toks = tok.tokenize(&cfg.prompt, seq_txt)?;
334
335    let weights = match &cfg.te_source {
336        TeSource::Mlx4bit(path) => TeWeights::open_mlx_4bit(path)?,
337        TeSource::NpyDir(dir) => TeWeights::open(dir)?,
338    };
339    let encoder = TextEncoder::new(&weights);
340    let out = encoder.forward(&toks.input_ids, &toks.attention_mask)?;
341    let cond = out.cond_7680()?;
342    if cond.len() != seq_txt * joint_dim {
343        return Err(PipelineError::Shape(format!(
344            "TE cond len {} != seq_txt*joint_dim ({seq_txt}*{joint_dim})",
345            cond.len()
346        )));
347    }
348    Ok(cond)
349}
350
351/// Whether a VAE weights `path` can be loaded by [`VaeWeights::open`].
352///
353/// [`VaeWeights::open`] accepts **either** a `.safetensors` file (the native
354/// FLUX.2 `AutoencoderKLFlux2` checkpoint) **or** a directory of exported `.npy`
355/// tensors, so the pipeline precheck must accept both (mirroring the loader,
356/// rather than the stricter `is_dir` that rejected a documented file path —
357/// see issue #9). Any existing path passes here; the loader then validates the
358/// contents.
359fn vae_path_present(path: &std::path::Path) -> bool {
360    path.is_file() || path.is_dir()
361}
362
363/// Convert a VAE-decoded planar CHW f32 tensor into a row-major HWC u8 RGB
364/// buffer: `px = clip(x / 2 + 0.5, 0, 1) * 255`.
365///
366/// Shared by [`text_to_image`] and [`crate::session::ImageSession`] so both
367/// produce byte-identical pixels. Returns `(width, height, rgb)`.
368///
369/// # Errors
370/// [`PipelineError::Shape`] if `c != 3`.
371pub(crate) fn decoded_chw_to_rgb8(
372    c: usize,
373    h: usize,
374    w: usize,
375    data: &[f32],
376) -> Result<(usize, usize, Vec<u8>), PipelineError> {
377    if c != 3 {
378        return Err(PipelineError::Shape(format!(
379            "expected 3 output channels, got {c}"
380        )));
381    }
382    let plane = h * w;
383    let mut rgb = vec![0u8; data.len()];
384    for y in 0..h {
385        for x in 0..w {
386            let hw = y * w + x;
387            let dst = (y * w + x) * 3;
388            for ch in 0..3 {
389                let v = (data[ch * plane + hw] / 2.0 + 0.5).clamp(0.0, 1.0);
390                rgb[dst + ch] = (v * 255.0).round() as u8;
391            }
392        }
393    }
394    Ok((w, h, rgb))
395}
396
397/// Run the whole text→image pipeline and return the encoded PNG.
398///
399/// See the [module docs](self) for the stage-by-stage flow.
400///
401/// # Errors
402/// [`PipelineError`] wrapping whichever stage failed (DiT/TE/VAE/PNG/IO/shape).
403pub fn text_to_image(cfg: &TextToImageCfg) -> Result<TextToImageOut, PipelineError> {
404    let mut stage_cosines: Vec<(String, f32)> = Vec::new();
405    let timing = std::env::var("PICTOR_IMAGE_TIMING").is_ok();
406
407    // ── 1. Load DiT + VAE ──
408    if !cfg.dit_gguf.exists() {
409        return Err(PipelineError::MissingInput(format!(
410            "DiT GGUF not found: {}",
411            cfg.dit_gguf.display()
412        )));
413    }
414    if !vae_path_present(&cfg.vae_weights_dir) {
415        return Err(PipelineError::MissingInput(format!(
416            "VAE weights not found: {}",
417            cfg.vae_weights_dir.display()
418        )));
419    }
420    let t_load_dit = std::time::Instant::now();
421    let dit_weights = DitWeights::open(&cfg.dit_gguf)?;
422    let dcfg = dit_weights.config();
423    let in_channels = dcfg.in_channels as usize;
424    let joint_dim = dcfg.joint_attention_dim as usize;
425    let num_axes = dcfg.axes_dims_rope.len();
426    if timing {
427        eprintln!(
428            "[timing] load DiT: {:.2}s",
429            t_load_dit.elapsed().as_secs_f64()
430        );
431    }
432
433    let t_load_vae = std::time::Instant::now();
434    let vae_weights = VaeWeights::open(&cfg.vae_weights_dir)?;
435    let vae = VaeDecoder::from_weights(&vae_weights)?;
436    if timing {
437        eprintln!(
438            "[timing] load VAE: {:.2}s",
439            t_load_vae.elapsed().as_secs_f64()
440        );
441    }
442
443    // ── 2. Geometry from the requested resolution ──
444    let (lat_h, lat_w) = sample::latent_grid(cfg.height, cfg.width);
445    let seq_img = lat_h * lat_w;
446    // The text sequence length is the tokenizer's fixed pad length.
447    let seq_txt = 512usize;
448
449    let golden = cfg.golden_override.as_ref();
450
451    // ── 3. Conditioning: native TE or golden cond ──
452    let cond_vec: Vec<f32> = match golden {
453        Some(g) if g.use_golden_cond => {
454            let c = load_golden(&g.golden_dir, "cond")?;
455            if c.numel() < seq_txt * joint_dim {
456                return Err(PipelineError::Shape(format!(
457                    "golden cond numel {} < {seq_txt}*{joint_dim}",
458                    c.numel()
459                )));
460            }
461            c.data[..seq_txt * joint_dim].to_vec()
462        }
463        _ => {
464            let t_te = std::time::Instant::now();
465            let cond = compute_cond(cfg, seq_txt, joint_dim)?;
466            if timing {
467                eprintln!("[timing] TE encode: {:.2}s", t_te.elapsed().as_secs_f64());
468            }
469            // Optional cosine vs golden cond (sanity), if a golden dir is present.
470            if let Some(g) = golden {
471                if let Ok(gc) = load_golden(&g.golden_dir, "cond") {
472                    if gc.numel() >= cond.len() {
473                        let cos = cosine(&cond, &gc.data[..cond.len()]) as f32;
474                        stage_cosines.push(("cond_vs_golden".to_string(), cos));
475                    }
476                }
477            }
478            cond
479        }
480    };
481
482    // ── 4. Final latent [seq_img, in_channels] ──
483    let final_latent: Vec<f32> = match golden {
484        Some(g) if g.use_golden_latent => {
485            let glat = load_golden(&g.golden_dir, "latent_after_step3")?;
486            if glat.numel() != seq_img * in_channels {
487                return Err(PipelineError::Shape(format!(
488                    "latent_after_step3 numel {} != {seq_img}*{in_channels}",
489                    glat.numel()
490                )));
491            }
492            glat.data
493        }
494        _ => {
495            let fwd = DitForward::new(&dit_weights);
496
497            // Initial latent + position ids + schedule: golden (parity) or native.
498            let (init, img_ids, txt_ids, timesteps, sigmas) = match golden {
499                Some(g) => {
500                    let init = load_golden(&g.golden_dir, "tf_in_hidden_states")?;
501                    let img_ids = load_golden(&g.golden_dir, "img_ids")?;
502                    let txt_ids = load_golden(&g.golden_dir, "txt_ids")?;
503                    let timesteps = load_golden(&g.golden_dir, "timesteps")?;
504                    let sigmas = load_golden(&g.golden_dir, "sigmas")?;
505                    (
506                        init.data[..seq_img * in_channels].to_vec(),
507                        img_ids.data[..seq_img * num_axes].to_vec(),
508                        txt_ids.data[..seq_txt * num_axes].to_vec(),
509                        timesteps.data,
510                        sigmas.data,
511                    )
512                }
513                None => {
514                    let init = sample::create_noise(cfg.seed, cfg.height, cfg.width);
515                    if init.len() != seq_img * in_channels {
516                        return Err(PipelineError::Shape(format!(
517                            "native noise len {} != {seq_img}*{in_channels} \
518                             (in_channels {in_channels} vs PACKED_CHANNELS {})",
519                            init.len(),
520                            sample::PACKED_CHANNELS
521                        )));
522                    }
523                    let img_ids = sample::img_ids(lat_h, lat_w);
524                    let txt_ids = sample::txt_ids(seq_txt);
525                    let (timesteps, sigmas) = sample::flow_match_schedule(seq_img, cfg.steps);
526                    (init, img_ids, txt_ids, timesteps, sigmas)
527                }
528            };
529
530            let mut steps: Vec<StepTap> = Vec::new();
531            let t_sample = std::time::Instant::now();
532            let latent = fwd.sample(
533                &init,
534                &cond_vec,
535                &img_ids,
536                &txt_ids,
537                seq_img,
538                seq_txt,
539                &timesteps,
540                &sigmas,
541                Some(&mut steps),
542            )?;
543            if timing {
544                eprintln!(
545                    "[timing] DiT sample ({} steps): {:.2}s",
546                    timesteps.len(),
547                    t_sample.elapsed().as_secs_f64()
548                );
549            }
550
551            // Optional cosine vs golden step-3 latent.
552            if let Some(g) = golden {
553                if let Ok(glat) = load_golden(&g.golden_dir, "latent_after_step3") {
554                    if glat.numel() == latent.len() {
555                        let cos = cosine(&latent, &glat.data) as f32;
556                        stage_cosines.push(("latent_vs_golden".to_string(), cos));
557                    }
558                }
559            }
560            latent
561        }
562    };
563
564    // ── 5. Reshape → packed NCHW, VAE-decode ──
565    let (ph, pw) = (lat_h, lat_w);
566    let packed = latent_seq_to_packed_nchw(&final_latent, seq_img, in_channels, ph, pw)?;
567    let t_vae = std::time::Instant::now();
568    let decoded = vae.decode_packed_latents(&packed, ph, pw, None)?;
569    if timing {
570        eprintln!("[timing] VAE decode: {:.2}s", t_vae.elapsed().as_secs_f64());
571    }
572
573    if let Some(g) = golden {
574        if let Some(vae_golden_dir) = &g.vae_golden_dir {
575            if let Ok(gd) = load_golden(vae_golden_dir, "vae_decoded") {
576                if gd.numel() == decoded.data.len() {
577                    let cos = cosine(&decoded.data, &gd.data) as f32;
578                    stage_cosines.push(("vae_vs_golden".to_string(), cos));
579                }
580            }
581        }
582    }
583
584    // ── 6. px = clip(x/2 + 0.5, 0, 1); NCHW → HWC; u8 ──
585    let (w, h, rgb) = decoded_chw_to_rgb8(decoded.c, decoded.h, decoded.w, &decoded.data)?;
586
587    // ── 7. PNG-encode ──
588    let t_png = std::time::Instant::now();
589    let png = encode_rgb8(w, h, &rgb)?;
590    if timing {
591        eprintln!("[timing] PNG encode: {:.2}s", t_png.elapsed().as_secs_f64());
592    }
593
594    Ok(TextToImageOut {
595        png,
596        width: w,
597        height: h,
598        stage_cosines,
599    })
600}
601
602#[cfg(test)]
603mod tests {
604    use super::vae_path_present;
605    use std::path::PathBuf;
606
607    /// Build a unique scratch path under the system temp dir (policy: tests must
608    /// use `std::env::temp_dir()`), tagged by `label` and the test's process id
609    /// + nanos so concurrent / repeated runs never collide.
610    fn scratch(label: &str) -> PathBuf {
611        let nanos = std::time::SystemTime::now()
612            .duration_since(std::time::UNIX_EPOCH)
613            .map(|d| d.as_nanos())
614            .unwrap_or(0);
615        std::env::temp_dir().join(format!(
616            "pictor_issue9_{label}_{}_{nanos}",
617            std::process::id()
618        ))
619    }
620
621    /// Regression test for issue #9: passing `--vae` as a `.safetensors` FILE
622    /// (exactly as docs/CLI.md and docs/IMAGEN.md instruct, and exactly what
623    /// `VaeWeights::open` accepts) must be accepted by the pipeline precheck.
624    ///
625    /// Before the fix the precheck was `cfg.vae_weights_dir.is_dir()`, so a real
626    /// `.safetensors` file returned `false` here → the pipeline aborted with
627    /// "VAE weights dir not found" before any loading. This asserts the predicate
628    /// now returns `true` for such a file.
629    #[test]
630    fn test_issue_9_vae_safetensors_file_accepted() -> std::io::Result<()> {
631        let file = scratch("vae").with_extension("safetensors");
632        // A real, existing file on disk (contents irrelevant to the precheck —
633        // the precheck only gates existence; `VaeWeights::open` validates bytes).
634        std::fs::write(&file, b"not-a-real-safetensors-but-a-real-file")?;
635
636        let present = vae_path_present(&file);
637        std::fs::remove_file(&file)?;
638
639        assert!(
640            present,
641            "issue #9: a .safetensors FILE must pass the VAE precheck (got false), \
642             path = {}",
643            file.display()
644        );
645        Ok(())
646    }
647
648    /// Companion: a directory of exported `.npy` weights (the original dev-time
649    /// source) must still be accepted — the fix widens the predicate, it must not
650    /// regress the directory case.
651    #[test]
652    fn test_issue_9_vae_directory_still_accepted() -> std::io::Result<()> {
653        let dir = scratch("vae_dir");
654        std::fs::create_dir_all(&dir)?;
655
656        let present = vae_path_present(&dir);
657        std::fs::remove_dir_all(&dir)?;
658
659        assert!(
660            present,
661            "a VAE weights directory must still pass the precheck, path = {}",
662            dir.display()
663        );
664        Ok(())
665    }
666
667    /// Companion: a path that exists as neither a file nor a directory must still
668    /// be rejected, so a genuine typo / missing input is still caught early.
669    #[test]
670    fn test_issue_9_vae_nonexistent_rejected() {
671        let missing = scratch("vae_missing").with_extension("safetensors");
672        // Deliberately never created.
673        assert!(
674            !vae_path_present(&missing),
675            "a nonexistent path must be rejected by the precheck, path = {}",
676            missing.display()
677        );
678    }
679}