oxibonsai-image 0.2.2

FLUX.2 DiT (bonsai-image) GGUF weight loader and configuration for OxiBonsai
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
//! End-to-end Pure-Rust text-to-image orchestration.
//!
//! [`text_to_image`] chains the whole Bonsai-Image pipeline behind a single
//! library entry point so both the `generate` example and the `oxibonsai image`
//! CLI subcommand share one code path (DRY):
//!
//! 1. Load the DiT GGUF ([`DitWeights::open`]), the SMALL VAE weights
//!    ([`crate::vae::VaeWeights::open`]/[`crate::vae::VaeDecoder::from_weights`]), and the Qwen3-4B text
//!    encoder ([`crate::te::TeWeights`] + [`crate::te::TextEncoder`]).
//! 2. Tokenize the prompt ([`crate::te::Qwen3Tokenizer`]) → TE forward → `cond_7680()`
//!    `[seq_txt, joint_dim]` conditioning.
//! 3. Native sampling scaffolding from [`crate::sample`]: seeded initial noise
//!    (`sample::create_noise`), the `img_ids` / `txt_ids` RoPE grids, and the
//!    flow-match `(timesteps, sigmas)` schedule — or, in parity mode
//!    ([`GoldenOverride`]), load those from a golden `.npy` dump instead.
//! 4. Euler sample ([`DitForward::sample`]) → final latent `[seq_img,
//!    in_channels]`.
//! 5. Reshape to packed NCHW (`latent_seq_to_packed_nchw`) → VAE decode
//!    ([`crate::vae::VaeDecoder::decode_packed_latents`]) → `clip(x/2 + 0.5)` → `u8`
//!    (NCHW → HWC) → PNG ([`encode_rgb8`]).
//!
//! All failures are surfaced as [`PipelineError`] (no `unwrap`/`expect`/`panic`).
//! The native noise is byte-exact against the golden `init_latents.npy` (see the
//! [`crate::sample`] module docs), so the seed-42 native path reproduces the
//! golden-input render.

use std::path::PathBuf;

use crate::png::encode_rgb8;
use crate::sample;
use crate::te::{Qwen3Tokenizer, TeError, TeWeights, TextEncoder};
use crate::vae::{VaeDecoder, VaeError, VaeWeights};
use crate::{DitError, DitForward, DitWeights, PngError, StepTap};

/// Where the text-encoder weights come from.
#[derive(Debug, Clone)]
pub enum TeSource {
    /// The native 4-bit MLX `model.safetensors` (≈2.1 GB), loaded via
    /// [`TeWeights::open_mlx_4bit`].
    Mlx4bit(PathBuf),
    /// A directory of f32 `.npy` weight dumps (≈15 GB), loaded via
    /// [`TeWeights::open`].
    NpyDir(PathBuf),
}

/// Parity-mode overrides: load the DiT inputs (initial latent, position ids, and
/// the sampler schedule) — and optionally the whole conditioning / a pre-sampled
/// latent — from a golden `.npy` dump instead of generating them natively.
///
/// This preserves the `generate` example's `OXI_USE_GOLDEN_LATENT` /
/// golden-cond byte-for-byte parity behaviour. When `None`, everything is
/// generated natively from `(prompt, seed, width, height)`.
#[derive(Debug, Clone)]
pub struct GoldenOverride {
    /// Directory containing the bf16 golden tensors (`tf_in_hidden_states.npy`,
    /// `img_ids.npy`, `txt_ids.npy`, `timesteps.npy`, `sigmas.npy`,
    /// `cond.npy`, `latent_after_step3.npy`, …).
    pub golden_dir: PathBuf,
    /// Use `cond.npy` from `golden_dir` instead of the native TE conditioning.
    pub use_golden_cond: bool,
    /// Skip the DiT sampler and use `latent_after_step3.npy` directly.
    pub use_golden_latent: bool,
    /// Optional VAE golden dir (`vae_decoded.npy`) for the decode-cosine tap.
    pub vae_golden_dir: Option<PathBuf>,
}

/// Configuration for a single text-to-image generation.
#[derive(Debug, Clone)]
pub struct TextToImageCfg {
    /// The text prompt.
    pub prompt: String,
    /// RNG seed for the initial noise (drives generation natively).
    pub seed: u64,
    /// Number of Euler sampler steps.
    pub steps: usize,
    /// Target image width in pixels.
    pub width: usize,
    /// Target image height in pixels.
    pub height: usize,
    /// Guidance scale (reserved; the current DiT forward is unconditional —
    /// kept for API stability and surfaced in logs).
    pub guidance: f32,
    /// Path to the DiT GGUF file.
    pub dit_gguf: PathBuf,
    /// VAE weights source: either a `.safetensors` file (the native FLUX.2
    /// `AutoencoderKLFlux2` checkpoint) or a directory of exported `.npy`
    /// tensors — [`VaeWeights::open`] auto-detects which.
    pub vae_weights_dir: PathBuf,
    /// Where the text-encoder weights come from.
    pub te_source: TeSource,
    /// Directory containing `tokenizer.json`.
    pub tokenizer_dir: PathBuf,
    /// When `Some`, run in golden-parity mode (see [`GoldenOverride`]).
    pub golden_override: Option<GoldenOverride>,
}

/// The result of a generation.
pub struct TextToImageOut {
    /// The encoded PNG byte stream.
    pub png: Vec<u8>,
    /// Image width in pixels.
    pub width: usize,
    /// Image height in pixels.
    pub height: usize,
    /// Per-stage cosine similarities vs the golden (`(label, cosine)`). Empty
    /// unless `golden_override` was set and the corresponding golden tensors
    /// were available.
    pub stage_cosines: Vec<(String, f32)>,
}

/// Errors that can occur while running [`text_to_image`].
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
    /// A DiT load / forward error.
    #[error("DiT error: {0}")]
    Dit(#[from] DitError),
    /// A text-encoder load / forward / tokenizer error.
    #[error("text-encoder error: {0}")]
    Te(#[from] TeError),
    /// A VAE load / decode error.
    #[error("VAE error: {0}")]
    Vae(#[from] VaeError),
    /// A PNG-encoding error.
    #[error("PNG error: {0}")]
    Png(#[from] PngError),
    /// An I/O error (reading a golden tensor, a missing weights path, …).
    #[error("I/O error for {path}: {source}")]
    Io {
        /// The path that triggered the error.
        path: String,
        /// The underlying I/O error.
        source: std::io::Error,
    },
    /// A shape / configuration invariant was violated.
    #[error("pipeline shape error: {0}")]
    Shape(String),
    /// A required input path was missing.
    #[error("missing input: {0}")]
    MissingInput(String),
}

/// A loaded `.npy` tensor (f32, C-order).
struct Npy {
    data: Vec<f32>,
    shape: Vec<usize>,
}

impl Npy {
    fn numel(&self) -> usize {
        self.shape.iter().product()
    }
}

/// Minimal NumPy `.npy` reader: v1.0/2.0 header, `descr == '<f4'`, handling
/// `fortran_order == True` by reordering to C (row-major). Used only by the
/// golden-parity path.
fn read_npy(path: &std::path::Path) -> Result<Npy, PipelineError> {
    let bytes = std::fs::read(path).map_err(|e| PipelineError::Io {
        path: path.display().to_string(),
        source: e,
    })?;
    if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" {
        return Err(PipelineError::Shape(format!(
            "{}: bad npy magic",
            path.display()
        )));
    }
    let major = bytes[6];
    let (header_start, header_len) = if major >= 2 {
        let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
        (12usize, len)
    } else {
        let len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
        (10usize, len)
    };
    let header_end = header_start + header_len;
    if bytes.len() < header_end {
        return Err(PipelineError::Shape(format!(
            "{}: truncated npy header",
            path.display()
        )));
    }
    let header = std::str::from_utf8(&bytes[header_start..header_end])
        .map_err(|e| PipelineError::Shape(format!("{}: header utf8: {e}", path.display())))?;
    if !header.contains("'<f4'") {
        return Err(PipelineError::Shape(format!(
            "{}: descr is not '<f4': {header}",
            path.display()
        )));
    }
    let fortran = header.contains("'fortran_order': True");
    let s_idx = header
        .find("'shape':")
        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape key", path.display())))?;
    let open = header[s_idx..]
        .find('(')
        .map(|o| s_idx + o + 1)
        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape open paren", path.display())))?;
    let close = header[open..]
        .find(')')
        .map(|c| open + c)
        .ok_or_else(|| PipelineError::Shape(format!("{}: no shape close paren", path.display())))?;
    let shape: Vec<usize> = header[open..close]
        .split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| {
            s.parse::<usize>()
                .map_err(|e| PipelineError::Shape(format!("{}: shape parse: {e}", path.display())))
        })
        .collect::<Result<_, _>>()?;
    let payload = &bytes[header_end..];
    if payload.len() % 4 != 0 {
        return Err(PipelineError::Shape(format!(
            "{}: payload not f32-aligned",
            path.display()
        )));
    }
    let raw: Vec<f32> = payload
        .chunks_exact(4)
        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
        .collect();
    let numel: usize = shape.iter().product();
    if raw.len() < numel {
        return Err(PipelineError::Shape(format!(
            "{}: payload short ({} < {numel})",
            path.display(),
            raw.len()
        )));
    }
    let data = if fortran && shape.len() > 1 {
        fortran_to_c(&raw[..numel], &shape)
    } else {
        raw
    };
    Ok(Npy { data, shape })
}

/// Reorder a Fortran-stored (column-major) buffer into C (row-major) order.
fn fortran_to_c(src: &[f32], shape: &[usize]) -> Vec<f32> {
    let ndim = shape.len();
    let numel: usize = shape.iter().product();
    let mut f_stride = vec![1usize; ndim];
    for d in 1..ndim {
        f_stride[d] = f_stride[d - 1] * shape[d - 1];
    }
    let mut out = vec![0.0f32; numel];
    for (c_pos, slot) in out.iter_mut().enumerate() {
        let mut rem = c_pos;
        let mut f_off = 0usize;
        for d in 0..ndim {
            let stride_c: usize = shape[d + 1..].iter().product();
            let idx = rem / stride_c;
            rem %= stride_c;
            f_off += idx * f_stride[d];
        }
        *slot = src[f_off];
    }
    out
}

/// Load a named golden tensor from `dir` (`<dir>/<name>.npy`).
fn load_golden(dir: &std::path::Path, name: &str) -> Result<Npy, PipelineError> {
    read_npy(&dir.join(format!("{name}.npy")))
}

/// Cosine similarity between two equal-length slices (f64 accumulation).
fn cosine(a: &[f32], b: &[f32]) -> f64 {
    let mut dot = 0.0f64;
    let mut na = 0.0f64;
    let mut nb = 0.0f64;
    for (&x, &y) in a.iter().zip(b.iter()) {
        let (x, y) = (x as f64, y as f64);
        dot += x * y;
        na += x * x;
        nb += y * y;
    }
    if na > 0.0 && nb > 0.0 {
        dot / (na.sqrt() * nb.sqrt())
    } else {
        0.0
    }
}

/// Reshape the DiT's final latent `[seq_img, in_channels]` (row-major,
/// sequence-major) into the VAE's packed NCHW latent `[1, in_channels, ph, pw]`.
///
/// This mirrors `flux2_klein`'s `reshape(1, ph, pw, in_channels).transpose(0, 3,
/// 1, 2)`: the sequence index `s = hh*pw + ww` (h-major over a `pw`-wide grid)
/// becomes the spatial position and the channel becomes the leading axis.
///
/// # Errors
/// [`PipelineError::Shape`] if `seq_img != ph*pw` or the latent length is wrong.
pub fn latent_seq_to_packed_nchw(
    latent: &[f32],
    seq_img: usize,
    in_channels: usize,
    ph: usize,
    pw: usize,
) -> Result<Vec<f32>, PipelineError> {
    if seq_img != ph * pw {
        return Err(PipelineError::Shape(format!(
            "seq_img {seq_img} != ph*pw ({ph}*{pw})"
        )));
    }
    if latent.len() != seq_img * in_channels {
        return Err(PipelineError::Shape(format!(
            "latent len {} != seq_img*in_channels ({seq_img}*{in_channels})",
            latent.len()
        )));
    }
    let plane = ph * pw;
    let mut packed = vec![0.0f32; in_channels * plane];
    for hh in 0..ph {
        for ww in 0..pw {
            let s = hh * pw + ww;
            let src_base = s * in_channels;
            for ch in 0..in_channels {
                packed[ch * plane + hh * pw + ww] = latent[src_base + ch];
            }
        }
    }
    Ok(packed)
}

/// Compute the `[seq_txt, joint_dim]` conditioning natively from the prompt.
fn compute_cond(
    cfg: &TextToImageCfg,
    seq_txt: usize,
    joint_dim: usize,
) -> Result<Vec<f32>, PipelineError> {
    let tok = Qwen3Tokenizer::open(&cfg.tokenizer_dir)?;
    let toks = tok.tokenize(&cfg.prompt, seq_txt)?;

    let weights = match &cfg.te_source {
        TeSource::Mlx4bit(path) => TeWeights::open_mlx_4bit(path)?,
        TeSource::NpyDir(dir) => TeWeights::open(dir)?,
    };
    let encoder = TextEncoder::new(&weights);
    let out = encoder.forward(&toks.input_ids, &toks.attention_mask)?;
    let cond = out.cond_7680()?;
    if cond.len() != seq_txt * joint_dim {
        return Err(PipelineError::Shape(format!(
            "TE cond len {} != seq_txt*joint_dim ({seq_txt}*{joint_dim})",
            cond.len()
        )));
    }
    Ok(cond)
}

/// Whether a VAE weights `path` can be loaded by [`VaeWeights::open`].
///
/// [`VaeWeights::open`] accepts **either** a `.safetensors` file (the native
/// FLUX.2 `AutoencoderKLFlux2` checkpoint) **or** a directory of exported `.npy`
/// tensors, so the pipeline precheck must accept both (mirroring the loader,
/// rather than the stricter `is_dir` that rejected a documented file path —
/// see issue #9). Any existing path passes here; the loader then validates the
/// contents.
fn vae_path_present(path: &std::path::Path) -> bool {
    path.is_file() || path.is_dir()
}

/// Convert a VAE-decoded planar CHW f32 tensor into a row-major HWC u8 RGB
/// buffer: `px = clip(x / 2 + 0.5, 0, 1) * 255`.
///
/// Shared by [`text_to_image`] and [`crate::session::ImageSession`] so both
/// produce byte-identical pixels. Returns `(width, height, rgb)`.
///
/// # Errors
/// [`PipelineError::Shape`] if `c != 3`.
pub(crate) fn decoded_chw_to_rgb8(
    c: usize,
    h: usize,
    w: usize,
    data: &[f32],
) -> Result<(usize, usize, Vec<u8>), PipelineError> {
    if c != 3 {
        return Err(PipelineError::Shape(format!(
            "expected 3 output channels, got {c}"
        )));
    }
    let plane = h * w;
    let mut rgb = vec![0u8; data.len()];
    for y in 0..h {
        for x in 0..w {
            let hw = y * w + x;
            let dst = (y * w + x) * 3;
            for ch in 0..3 {
                let v = (data[ch * plane + hw] / 2.0 + 0.5).clamp(0.0, 1.0);
                rgb[dst + ch] = (v * 255.0).round() as u8;
            }
        }
    }
    Ok((w, h, rgb))
}

/// Run the whole text→image pipeline and return the encoded PNG.
///
/// See the [module docs](self) for the stage-by-stage flow.
///
/// # Errors
/// [`PipelineError`] wrapping whichever stage failed (DiT/TE/VAE/PNG/IO/shape).
pub fn text_to_image(cfg: &TextToImageCfg) -> Result<TextToImageOut, PipelineError> {
    let mut stage_cosines: Vec<(String, f32)> = Vec::new();
    let timing = std::env::var("OXI_IMAGE_TIMING").is_ok();

    // ── 1. Load DiT + VAE ──
    if !cfg.dit_gguf.exists() {
        return Err(PipelineError::MissingInput(format!(
            "DiT GGUF not found: {}",
            cfg.dit_gguf.display()
        )));
    }
    if !vae_path_present(&cfg.vae_weights_dir) {
        return Err(PipelineError::MissingInput(format!(
            "VAE weights not found: {}",
            cfg.vae_weights_dir.display()
        )));
    }
    let t_load_dit = std::time::Instant::now();
    let dit_weights = DitWeights::open(&cfg.dit_gguf)?;
    let dcfg = dit_weights.config();
    let in_channels = dcfg.in_channels as usize;
    let joint_dim = dcfg.joint_attention_dim as usize;
    let num_axes = dcfg.axes_dims_rope.len();
    if timing {
        eprintln!(
            "[timing] load DiT: {:.2}s",
            t_load_dit.elapsed().as_secs_f64()
        );
    }

    let t_load_vae = std::time::Instant::now();
    let vae_weights = VaeWeights::open(&cfg.vae_weights_dir)?;
    let vae = VaeDecoder::from_weights(&vae_weights)?;
    if timing {
        eprintln!(
            "[timing] load VAE: {:.2}s",
            t_load_vae.elapsed().as_secs_f64()
        );
    }

    // ── 2. Geometry from the requested resolution ──
    let (lat_h, lat_w) = sample::latent_grid(cfg.height, cfg.width);
    let seq_img = lat_h * lat_w;
    // The text sequence length is the tokenizer's fixed pad length.
    let seq_txt = 512usize;

    let golden = cfg.golden_override.as_ref();

    // ── 3. Conditioning: native TE or golden cond ──
    let cond_vec: Vec<f32> = match golden {
        Some(g) if g.use_golden_cond => {
            let c = load_golden(&g.golden_dir, "cond")?;
            if c.numel() < seq_txt * joint_dim {
                return Err(PipelineError::Shape(format!(
                    "golden cond numel {} < {seq_txt}*{joint_dim}",
                    c.numel()
                )));
            }
            c.data[..seq_txt * joint_dim].to_vec()
        }
        _ => {
            let t_te = std::time::Instant::now();
            let cond = compute_cond(cfg, seq_txt, joint_dim)?;
            if timing {
                eprintln!("[timing] TE encode: {:.2}s", t_te.elapsed().as_secs_f64());
            }
            // Optional cosine vs golden cond (sanity), if a golden dir is present.
            if let Some(g) = golden {
                if let Ok(gc) = load_golden(&g.golden_dir, "cond") {
                    if gc.numel() >= cond.len() {
                        let cos = cosine(&cond, &gc.data[..cond.len()]) as f32;
                        stage_cosines.push(("cond_vs_golden".to_string(), cos));
                    }
                }
            }
            cond
        }
    };

    // ── 4. Final latent [seq_img, in_channels] ──
    let final_latent: Vec<f32> = match golden {
        Some(g) if g.use_golden_latent => {
            let glat = load_golden(&g.golden_dir, "latent_after_step3")?;
            if glat.numel() != seq_img * in_channels {
                return Err(PipelineError::Shape(format!(
                    "latent_after_step3 numel {} != {seq_img}*{in_channels}",
                    glat.numel()
                )));
            }
            glat.data
        }
        _ => {
            let fwd = DitForward::new(&dit_weights);

            // Initial latent + position ids + schedule: golden (parity) or native.
            let (init, img_ids, txt_ids, timesteps, sigmas) = match golden {
                Some(g) => {
                    let init = load_golden(&g.golden_dir, "tf_in_hidden_states")?;
                    let img_ids = load_golden(&g.golden_dir, "img_ids")?;
                    let txt_ids = load_golden(&g.golden_dir, "txt_ids")?;
                    let timesteps = load_golden(&g.golden_dir, "timesteps")?;
                    let sigmas = load_golden(&g.golden_dir, "sigmas")?;
                    (
                        init.data[..seq_img * in_channels].to_vec(),
                        img_ids.data[..seq_img * num_axes].to_vec(),
                        txt_ids.data[..seq_txt * num_axes].to_vec(),
                        timesteps.data,
                        sigmas.data,
                    )
                }
                None => {
                    let init = sample::create_noise(cfg.seed, cfg.height, cfg.width);
                    if init.len() != seq_img * in_channels {
                        return Err(PipelineError::Shape(format!(
                            "native noise len {} != {seq_img}*{in_channels} \
                             (in_channels {in_channels} vs PACKED_CHANNELS {})",
                            init.len(),
                            sample::PACKED_CHANNELS
                        )));
                    }
                    let img_ids = sample::img_ids(lat_h, lat_w);
                    let txt_ids = sample::txt_ids(seq_txt);
                    let (timesteps, sigmas) = sample::flow_match_schedule(seq_img, cfg.steps);
                    (init, img_ids, txt_ids, timesteps, sigmas)
                }
            };

            let mut steps: Vec<StepTap> = Vec::new();
            let t_sample = std::time::Instant::now();
            let latent = fwd.sample(
                &init,
                &cond_vec,
                &img_ids,
                &txt_ids,
                seq_img,
                seq_txt,
                &timesteps,
                &sigmas,
                Some(&mut steps),
            )?;
            if timing {
                eprintln!(
                    "[timing] DiT sample ({} steps): {:.2}s",
                    timesteps.len(),
                    t_sample.elapsed().as_secs_f64()
                );
            }

            // Optional cosine vs golden step-3 latent.
            if let Some(g) = golden {
                if let Ok(glat) = load_golden(&g.golden_dir, "latent_after_step3") {
                    if glat.numel() == latent.len() {
                        let cos = cosine(&latent, &glat.data) as f32;
                        stage_cosines.push(("latent_vs_golden".to_string(), cos));
                    }
                }
            }
            latent
        }
    };

    // ── 5. Reshape → packed NCHW, VAE-decode ──
    let (ph, pw) = (lat_h, lat_w);
    let packed = latent_seq_to_packed_nchw(&final_latent, seq_img, in_channels, ph, pw)?;
    let t_vae = std::time::Instant::now();
    let decoded = vae.decode_packed_latents(&packed, ph, pw, None)?;
    if timing {
        eprintln!("[timing] VAE decode: {:.2}s", t_vae.elapsed().as_secs_f64());
    }

    if let Some(g) = golden {
        if let Some(vae_golden_dir) = &g.vae_golden_dir {
            if let Ok(gd) = load_golden(vae_golden_dir, "vae_decoded") {
                if gd.numel() == decoded.data.len() {
                    let cos = cosine(&decoded.data, &gd.data) as f32;
                    stage_cosines.push(("vae_vs_golden".to_string(), cos));
                }
            }
        }
    }

    // ── 6. px = clip(x/2 + 0.5, 0, 1); NCHW → HWC; u8 ──
    let (w, h, rgb) = decoded_chw_to_rgb8(decoded.c, decoded.h, decoded.w, &decoded.data)?;

    // ── 7. PNG-encode ──
    let t_png = std::time::Instant::now();
    let png = encode_rgb8(w, h, &rgb)?;
    if timing {
        eprintln!("[timing] PNG encode: {:.2}s", t_png.elapsed().as_secs_f64());
    }

    Ok(TextToImageOut {
        png,
        width: w,
        height: h,
        stage_cosines,
    })
}

#[cfg(test)]
mod tests {
    use super::vae_path_present;
    use std::path::PathBuf;

    /// Build a unique scratch path under the system temp dir (policy: tests must
    /// use `std::env::temp_dir()`), tagged by `label` and the test's process id
    /// + nanos so concurrent / repeated runs never collide.
    fn scratch(label: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        std::env::temp_dir().join(format!(
            "oxibonsai_issue9_{label}_{}_{nanos}",
            std::process::id()
        ))
    }

    /// Regression test for issue #9: passing `--vae` as a `.safetensors` FILE
    /// (exactly as docs/CLI.md and docs/IMAGEN.md instruct, and exactly what
    /// `VaeWeights::open` accepts) must be accepted by the pipeline precheck.
    ///
    /// Before the fix the precheck was `cfg.vae_weights_dir.is_dir()`, so a real
    /// `.safetensors` file returned `false` here → the pipeline aborted with
    /// "VAE weights dir not found" before any loading. This asserts the predicate
    /// now returns `true` for such a file.
    #[test]
    fn test_issue_9_vae_safetensors_file_accepted() -> std::io::Result<()> {
        let file = scratch("vae").with_extension("safetensors");
        // A real, existing file on disk (contents irrelevant to the precheck —
        // the precheck only gates existence; `VaeWeights::open` validates bytes).
        std::fs::write(&file, b"not-a-real-safetensors-but-a-real-file")?;

        let present = vae_path_present(&file);
        std::fs::remove_file(&file)?;

        assert!(
            present,
            "issue #9: a .safetensors FILE must pass the VAE precheck (got false), \
             path = {}",
            file.display()
        );
        Ok(())
    }

    /// Companion: a directory of exported `.npy` weights (the original dev-time
    /// source) must still be accepted — the fix widens the predicate, it must not
    /// regress the directory case.
    #[test]
    fn test_issue_9_vae_directory_still_accepted() -> std::io::Result<()> {
        let dir = scratch("vae_dir");
        std::fs::create_dir_all(&dir)?;

        let present = vae_path_present(&dir);
        std::fs::remove_dir_all(&dir)?;

        assert!(
            present,
            "a VAE weights directory must still pass the precheck, path = {}",
            dir.display()
        );
        Ok(())
    }

    /// Companion: a path that exists as neither a file nor a directory must still
    /// be rejected, so a genuine typo / missing input is still caught early.
    #[test]
    fn test_issue_9_vae_nonexistent_rejected() {
        let missing = scratch("vae_missing").with_extension("safetensors");
        // Deliberately never created.
        assert!(
            !vae_path_present(&missing),
            "a nonexistent path must be rejected by the precheck, path = {}",
            missing.display()
        );
    }
}