BareGit
# Standalone Z-Image Turbo CLI Design

## Purpose

This document describes the first implementation milestone for this
repository: a standalone Python command line program that generates one
non-gibberish image from local Z-Image Turbo weights.

The goal is intentionally narrower than full ComfyUI parity. The first
working version only needs to prove that the project can load the local
model stack, run text-to-image inference without importing ComfyUI at
runtime, decode the latent, and write a PNG. LoRA support is explicitly
out of scope for this milestone.

The ComfyUI workflow is still the reference for model filenames, image
size, sampler defaults, prompt handling, and generation parameters. The
runtime program must not depend on ComfyUI as a package or import from
ComfyUI modules during generation.

## Requirements

The product requirements in `prd.md` say the tool must:

- Be a Python CLI.
- Generate images using the local Z-Image Turbo model stack referenced
  by `~/programs/ComfyUI/user/default/workflows/Z-image Turbo.json`.
- Avoid any runtime dependency on ComfyUI.
- Use the ComfyUI workflow only as a reference.
- Avoid downloading model files or other files from the internet.
- Assume model files are already local.
- Use `uv` for dependency and virtual environment management.
- Support prompt, negative prompt, seed, width, height, batch size,
  steps, CFG, output path, and device options.
- First prove one image can be generated from local weights without
  importing ComfyUI.

The current user-level clarification narrows the milestone:

- The first output only needs to be an image that is not gibberish.
- LoRA support should be skipped for now.
- ComfyUI source should be used as implementation reference material.

## Reference Workflow

The workflow file is:

`/home/mw/programs/ComfyUI/user/default/workflows/Z-image Turbo.json`

The active path in that graph uses these nodes:

- `UNETLoader`
- `CLIPLoader`
- `VAELoader`
- `ModelSamplingAuraFlow`
- `CLIPTextEncode`
- `EmptyLatentImage`
- `KSampler`
- `VAEDecode`
- `SaveImage`

The workflow also contains LoRA nodes, but this design ignores them for
the first milestone.

### Model Files

ComfyUI resolves models through:

`/home/mw/programs/ComfyUI/extra_model_paths.yaml`

That file maps the default model root to:

`/home/mw/documents/ai/diffusion/models`

For the first milestone, the CLI should use this model root by default
and allow it to be overridden with a command line option.

Required files:

| Role | Relative Path |
| --- | --- |
| Diffusion model | `diffusion_models/z-image_turbo/moodyPornMix_zitV3.safetensors` |
| Text encoder | `text_encoders/z-image_turbo/qwen_3_4b.safetensors` |
| VAE | `vae/z-image_turbo/ae.safetensors` |

The workflow also references an all-in-one checkpoint:

`checkpoints/z-image_turbo/redcraftRedzimageUpdatedDEC03_redzimage15AIO.safetensors`

That checkpoint node is not connected to the active generation path, so
the CLI should not use it in the first milestone.

### Generation Defaults

The first CLI defaults should match the active workflow:

| Setting | Value |
| --- | --- |
| Width | `832` |
| Height | `1248` |
| Batch size | `1` |
| Steps | `10` |
| CFG | `1.0` |
| Sampler | `seeds_3` |
| Scheduler | `beta` |
| Denoise | `1.0` |
| Model sampling shift | `3.0` |
| Positive prompt | User supplied |
| Negative prompt | `text, watermark, full-body` |

## Important ComfyUI Findings

This section records the ComfyUI source behavior that matters for the
standalone implementation.

### Z-Image Model Configuration

ComfyUI defines `ZImage` in:

`/home/mw/programs/ComfyUI/comfy/supported_models.py`

The relevant behavior is:

- Z-Image is a `Lumina2` model variant.
- Its `unet_config` contains `image_model = "lumina2"` and `dim = 3840`.
- Its sampling settings are `multiplier = 1.0` and `shift = 3.0`.
- Its supported inference dtypes are `bfloat16` and `float32`, with
  optional `float16` only when ComfyUI detects extended fp16 support.
- It uses the same latent format as `Lumina2`, which is `Flux`.

The standalone implementation should run on CUDA only for the first
milestone. It should start with `bfloat16` if the selected CUDA device
supports it, then fall back to `float32` if needed. This choice is
conservative because ComfyUI lists bf16/fp32 as supported.

### Text Encoder

ComfyUI defines Z-Image text encoding in:

`/home/mw/programs/ComfyUI/comfy/text_encoders/z_image.py`

The relevant behavior is:

- The tokenizer is based on `Qwen2Tokenizer` from `transformers`.
- The text encoder model is `Qwen3_4B`.
- The prompt is wrapped with a chat-style template before tokenization:

```text
<|im_start|>user
{prompt}<|im_end|>
<|im_start|>assistant
```

- Token weighting is disabled for this tokenizer path.
- The model returns attention masks.
- The default selected hidden layer is `-2`.

The standalone implementation must reproduce the prompt template. If it
does not, the model can receive text embeddings in a distribution it was
not trained to use, which is a common cause of visually incoherent
images.

### Latent Format

ComfyUI's `Lumina2` model uses `latent_formats.Flux`.

That means:

- The diffusion latent has 16 channels.
- The spatial downscale ratio is 8.
- A latent for `832x1248` should have shape:

```text
[batch_size, 16, 156, 104]
```

The order is `[N, C, H, W]`. The latent height is image height divided
by 8. The latent width is image width divided by 8.

The `Flux` latent format also applies scaling when entering and leaving
the diffusion model. The implementation must port the exact scale,
shift, or mean/std logic from ComfyUI's `latent_formats.Flux` class
rather than assuming vanilla Stable Diffusion VAE scaling.

### Model Sampling

The workflow node `ModelSamplingAuraFlow` calls the same patch method as
ComfyUI's SD3-style model sampling node, but with:

```text
shift = 3.0
multiplier = 1.0
```

The implementation maps to ComfyUI's `ModelSamplingDiscreteFlow` in:

`/home/mw/programs/ComfyUI/comfy/model_sampling.py`

The important methods are:

```python
def time_snr_shift(alpha, t):
    if alpha == 1.0:
        return t
    return alpha * t / (1 + (alpha - 1) * t)

def sigma(timestep):
    return time_snr_shift(shift, timestep / multiplier)

def timestep(sigma):
    return sigma * multiplier
```

For this workflow, `multiplier` is `1.0`, so the sigma schedule is a
flow schedule in the `[0, 1]` range shifted by `shift = 3.0`.

### Scheduler

The workflow scheduler is `beta`.

ComfyUI implements this in:

`/home/mw/programs/ComfyUI/comfy/samplers.py`

Conceptually:

1. Build the model sampling object.
2. Read its `sigmas` table.
3. Generate `steps` values using a beta distribution percent point
   function with alpha `0.6` and beta `0.6`.
4. Convert those values into timestep indices.
5. Deduplicate adjacent repeated timesteps.
6. Append a final zero sigma.

This requires `scipy.stats.beta.ppf`, so `scipy` should be a dependency
unless the beta PPF is reimplemented. Using SciPy is simpler and less
risky for the first milestone.

### Sampler

The workflow sampler is `seeds_3`.

ComfyUI implements `sample_seeds_3` in:

`/home/mw/programs/ComfyUI/comfy/k_diffusion/sampling.py`

SEEDS-3 is a three-stage stochastic solver. At each sigma interval, it:

1. Calls the denoising model at the current latent.
2. Converts sigma to half-log-SNR.
3. Builds two intermediate points at one-third and two-thirds through
   the interval.
4. Calls the denoising model at each intermediate point.
5. Combines the denoised predictions into the next latent.
6. Optionally injects stochastic noise when `eta` and `s_noise` are
   positive.

The sampler depends on model-specific conversions between sigma and
half-log-SNR. Those conversions need to be ported together with the
sampler math. Porting only the visible loop is not enough.

For the first milestone, the implementation may expose `--sampler
seeds_3` as the default but keep the sampler code private and minimal.
It does not need every sampler ComfyUI supports.

### VAE Decode

ComfyUI's `VAE` wrapper is in:

`/home/mw/programs/ComfyUI/comfy/sd.py`

The active VAE file is:

`vae/z-image_turbo/ae.safetensors`

The VAE loader detects the VAE architecture from state dict keys. For
Z-Image Turbo, the first implementation should inspect the `ae` state
dict and choose the exact matching VAE class or a compatible local
implementation. It should not assume SD 1.x VAE layout unless the keys
prove that layout.

The decode output should be converted from model output range to image
range, then written as a PNG:

1. Model output tensor is expected in `[-1, 1]`.
2. Convert to `[0, 1]` with `(image + 1) / 2`.
3. Clamp to `[0, 1]`.
4. Convert to `uint8`.
5. Save with Pillow.

This is the default ComfyUI postprocess path for image VAEs.

## Architecture

The CLI should be organized as a small Python package. The package
should isolate command parsing, configuration, model loading, sampling,
and image saving so that each part can be tested independently.

Proposed file layout:

```text
diffusion_cli/
    __init__.py
    cli.py
    config.py
    errors.py
    image_io.py
    paths.py
    sampling.py
    text_encoder.py
    zimage_model.py
    vae.py
tests/
    test_paths.py
    test_sampling.py
    test_cli.py
pyproject.toml
README.md
```

### `cli.py`

This module owns the command line interface.

It should use Python's standard `argparse` library. `argparse` is part
of the Python standard library and is designed for command line option
parsing, so it avoids adding a CLI framework dependency. See the
[argparse documentation](https://docs.python.org/3/library/argparse.html).

The command should be exposed through a `pyproject.toml` console script:

```toml
[project.scripts]
diffusion-cli = "diffusion_cli.cli:main"
```

The CLI should accept:

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `--prompt` | string | required | Positive prompt. |
| `--negative-prompt` | string | workflow default | Negative prompt. |
| `--seed` | int | random if absent | Noise seed. |
| `--width` | int | `832` | Output width. |
| `--height` | int | `1248` | Output height. |
| `--batch-size` | int | `1` | Number of images. |
| `--steps` | int | `10` | Denoising steps. |
| `--cfg` | float | `1.0` | Classifier-free guidance scale. |
| `--output` | path | `output.png` | Output file path. |
| `--device` | string | `cuda` | CUDA device id or alias. |
| `--model-root` | path | local model root | Model root directory. |
| `--tokenizer-path` | path | required | Qwen tokenizer directory. |
| `--dtype` | string | `auto` | `auto`, `bf16`, `fp32`. |

Validation rules:

- `width` and `height` must be positive multiples of 8.
- `batch-size` must be at least 1.
- `steps` must be at least 1.
- `cfg` must be non-negative.
- `output` parent directory must exist or be creatable.
- The first milestone is CUDA-only. CPU execution should fail with a
  clear unsupported-device error rather than attempting a very slow run.
- `device=cuda` must fail clearly if CUDA is unavailable.
- `dtype=bf16` must fail clearly if unsupported on the selected device.
- `tokenizer-path` must exist and contain the tokenizer files required
  by `transformers`, including `vocab.json`, `merges.txt`, and
  `tokenizer_config.json`.

### `config.py`

This module should hold immutable configuration objects.

Use dataclasses:

```python
@dataclass(frozen=True)
class ModelFiles:
    diffusion_model: Path
    text_encoder: Path
    vae: Path

@dataclass(frozen=True)
class GenerationConfig:
    prompt: str
    negative_prompt: str
    seed: int
    width: int
    height: int
    batch_size: int
    steps: int
    cfg: float
    device: torch.device
    dtype: torch.dtype
    output: Path
```

The value object should not load models. It only records the user's
intent after validation. Keeping configuration separate from loading
makes errors easier to test.

### `paths.py`

This module should resolve model paths.

Default model root:

```text
/home/mw/documents/ai/diffusion/models
```

Resolution flow:

1. Read `--model-root` if provided.
2. Otherwise read an environment variable such as
   `DIFFUSION_CLI_MODEL_ROOT`.
3. Otherwise use the default root above.
4. Join the required relative model paths.
5. Check each path exists and is a regular file.
6. Return a `ModelFiles` instance.

The implementation should not parse ComfyUI's YAML config at runtime.
Parsing it would make the new tool's behavior depend on a ComfyUI
installation. The default root can be documented and overridden.

### `zimage_model.py`

This module should own the diffusion model architecture and state dict
loading.

The first milestone has two viable implementation strategies:

#### Strategy A: Minimal Local Port

Port the specific Z-Image/Lumina2 model pieces needed by this workflow:

- Lumina2/NextDiT architecture.
- Z-Image state dict key mapping if needed.
- Model forward pass for text-to-image.
- Flux latent format scaling.
- DiscreteFlow model sampling.

This gives maximum control and avoids requiring upstream `diffusers`
support that may not exist or may not match ComfyUI's checkpoint format.

The downside is implementation time. The architecture is not tiny.

#### Strategy B: External Pipeline If Available

Use a library implementation only if it can load the exact local
safetensors files without downloading and without ComfyUI imports.

This should be investigated during implementation, but the design should
not rely on it. Current local evidence shows ComfyUI has custom Z-Image
support, and the public Diffusers documentation does not obviously
document a Z-Image pipeline.

The first implementation should therefore plan for Strategy A, while
keeping the loader boundary narrow enough that Strategy B can replace it
later.

### `text_encoder.py`

This module should load the Qwen3-4B text encoder and produce text
conditioning tensors.

Required behavior:

1. Load `qwen_3_4b.safetensors` with `safetensors`.
2. Instantiate the Qwen3-4B architecture.
3. Use the tokenizer vocabulary expected by ComfyUI's
   `qwen25_tokenizer`.
4. Wrap prompts with the Z-Image chat template.
5. Tokenize without weighted prompt syntax.
6. Run the text encoder under `torch.inference_mode`.
7. Select the `-2` hidden layer.
8. Return hidden states and attention masks in the format expected by
   the diffusion model.

Using `torch.inference_mode` is appropriate because generation does not
need gradients. PyTorch documents it as a context manager for inference
workloads that disables autograd-related overhead:
[torch.inference_mode](https://docs.pytorch.org/docs/stable/generated/torch.inference_mode.html).

### `sampling.py`

This module should contain only the sampler and scheduler needed for the
first image.

Components:

- `ModelSamplingDiscreteFlow`
- `betaScheduler`
- sigma/log-SNR conversion helpers
- `sampleSeeds3`

The implementation should use names that follow the project style:

- Public classes in `CapCase`.
- Functions in `camelCase` when multi-word.
- Variables in `snake_case`.

The sampling flow should be:

1. Build initial Gaussian noise with shape
   `[batch_size, 16, height // 8, width // 8]`.
2. Use a seeded generator to create deterministic initial noise on the
   selected CUDA device.
3. Build an empty latent image of zeros with the same shape.
4. Build the shifted flow sigma schedule with shift `3.0`.
5. Build beta scheduler sigmas for `steps`.
6. Run SEEDS-3 over those sigmas.
7. Return the final latent.

CFG behavior:

- At `cfg = 1.0`, conditional and unconditional guidance should reduce
  to the positive prediction.
- Even though CFG is 1.0 by default, the CLI should still compute the
  negative prompt path unless a later profiling pass proves it can be
  skipped safely.
- Keeping the negative path initially makes the code match the workflow
  structure and avoids special cases.

### `vae.py`

This module should load and decode the VAE.

Required behavior:

1. Load `ae.safetensors`.
2. Detect the architecture from state dict keys.
3. Instantiate the matching VAE decoder.
4. Load weights.
5. Move to selected device and dtype.
6. Decode final latent.
7. Return an image tensor in `[0, 1]`.

The first milestone only needs decoding. Encoding can be omitted.

### `image_io.py`

This module should save image tensors as PNG.

Input tensor:

```text
[batch_size, 3, height, width]
```

Save behavior:

- If `batch_size == 1`, write exactly the requested output path.
- If `batch_size > 1`, append an index before the suffix:
  `output_0000.png`, `output_0001.png`, and so on.
- Convert tensor layout from `NCHW` to `NHWC`.
- Clamp before converting to `uint8`.

## Dependency Plan

Use `uv` as the project manager. The official uv documentation describes
uv as a Python project and package manager with lockfile support:
[uv documentation](https://docs.astral.sh/uv/).

Initial dependencies:

- `torch`
- `safetensors`
- `transformers`
- `numpy`
- `scipy`
- `pillow`
- `tqdm`

Potential dependencies:

- `einops`, if the local model port needs shape rearrangement helpers.
- `accelerate`, only if memory management becomes difficult.

Do not add `diffusers` unless implementation research proves it can load
the exact local Z-Image Turbo files without downloads and without format
conversion. Adding a large dependency without using it would obscure the
actual first milestone.

Use `safetensors` for all three model files. Hugging Face documents
Safetensors as a safe tensor storage format and shows `safe_open` for
loading tensors:
[Safetensors documentation](https://huggingface.co/docs/safetensors/index).

## Licensing

ComfyUI source files inspected for this design include GPL license text.
For this first milestone, licensing is not a blocker. The project owner
has explicitly allowed copying ComfyUI code directly if that is the
fastest way to generate the first image.

The runtime constraint still matters: copied or adapted code may live in
this repository, but the CLI should not import ComfyUI as an installed
application or require a working ComfyUI runtime. This keeps the
milestone aligned with the product requirement that the tool stand
alone.

## Implementation Plan

### Phase 1: Project Scaffolding

Create:

- `pyproject.toml`
- `diffusion_cli/`
- `tests/`
- minimal `README.md`

Add a console script named `diffusion-cli`.

Success criteria:

- `uv run diffusion-cli --help` works.
- CLI arguments are visible and have correct defaults.
- No model loading happens during `--help`.

### Phase 2: Path and Config Validation

Implement model path resolution and config validation.

Success criteria:

- Missing model files produce a direct error listing the missing path.
- Invalid dimensions produce a direct error.
- Output path validation catches unwritable or invalid paths early.

### Phase 3: Tensor Loading Probe

Implement safetensors loading utilities and a diagnostic command or
internal debug function that prints:

- number of tensors,
- top-level key patterns,
- detected dtype summary,
- selected architecture guess.

Success criteria:

- The diffusion model, text encoder, and VAE files can be inspected
  without running full inference.
- The program can identify the expected Z-Image/Qwen/VAE key patterns.
- No download attempt occurs.

### Phase 4: Text Encoder

Implement prompt templating and Qwen3-4B conditioning.

Success criteria:

- A short prompt produces a conditioning tensor.
- The conditioning tensor has plausible shape.
- Positive and negative prompt encodings both complete.
- Encoding runs under `torch.inference_mode`.

### Phase 5: Diffusion Model

Implement or integrate the Z-Image/Lumina2 diffusion model.

Success criteria:

- The model loads the local diffusion state dict.
- A single forward pass accepts:
  - latent noise,
  - sigma/timestep,
  - text conditioning,
  - attention mask,
  - token count.
- The forward output shape matches the latent shape.
- No NaN or Inf values appear in the output.

### Phase 6: Scheduler and Sampler

Implement beta scheduler and SEEDS-3.

Success criteria:

- Sigma schedule starts at the expected high-noise value and ends at
  zero.
- The sampler runs for 10 steps.
- The latent remains finite.
- The implementation can run with `cfg=1.0`.

### Phase 7: VAE Decode and PNG Save

Implement VAE decode and image saving.

Success criteria:

- Final decoded tensor has shape `[1, 3, 1248, 832]`.
- Pixel range after postprocess is `[0, 1]`.
- Output PNG exists.
- The image is not obvious random noise or severe gibberish.

## Runtime Flow

The final command should look like:

```bash
uv run diffusion-cli \
    --prompt "a cinematic photo of a red apple on a wooden table" \
    --output output.png
```

Detailed execution:

1. Parse CLI arguments.
2. Validate generation config.
3. Resolve model file paths.
4. Select device and dtype.
5. Load text encoder.
6. Encode positive prompt.
7. Encode negative prompt.
8. Load diffusion model.
9. Build initial latent noise.
10. Run sampler.
11. Load VAE.
12. Decode latent to image.
13. Save PNG.
14. Print the output path.

The implementation may load VAE before sampling if that is simpler, but
sampling first can reduce peak memory if the text encoder or diffusion
model can be unloaded before VAE decode. The first milestone should
favor clarity over aggressive memory management.

## Error Handling

Errors should be direct and actionable.

Examples:

- `Missing diffusion model: /path/to/file.safetensors`
- `Width must be a positive multiple of 8: got 830`
- `CUDA requested but torch.cuda.is_available() is false`
- `bf16 requested but selected device does not support bf16`
- `Unsupported VAE state dict: no known decoder keys found`

Do not print Python tracebacks for expected user errors. Raise custom
exceptions internally and catch them in `cli.main`.

Unexpected programming errors may still show tracebacks. That is useful
during this early milestone.

## Testing Strategy

Unit tests should cover the parts that do not require loading the full
models:

- CLI parsing.
- Path resolution.
- Dimension validation.
- Batch output naming.
- Beta scheduler shape and final zero sigma.
- Deterministic seed noise shape.

Integration tests with the full model should be manual at first because
the model files are large and local to this machine.

Manual test command:

```bash
uv run diffusion-cli \
    --prompt "a simple studio photo of a ceramic mug" \
    --negative-prompt "text, watermark, full-body" \
    --seed 1234 \
    --steps 10 \
    --cfg 1 \
    --width 832 \
    --height 1248 \
    --output test_output.png
```

Manual acceptance checklist:

- Program exits with code 0.
- Output file exists.
- Output dimensions are `832x1248`.
- Image has recognizable structure.
- Image is not pure noise, blank, all black, all white, or tiled garbage.
- Re-running with the same seed produces the same output on the same
  device and dtype.

## Known Risks

### Model Architecture Complexity

The Z-Image/Lumina2 model architecture is the biggest risk. The CLI
surface is easy; model execution is the hard part.

Mitigation:

- Start by loading state dict keys and matching them to ComfyUI's
  detection path.
- Port only the architecture pieces needed for this exact model.
- Validate one forward pass before implementing the full sampler.

### Tokenizer Assets

ComfyUI's tokenizer path is local to its source tree:

`comfy/text_encoders/qwen25_tokenizer`

On this machine that directory contains the tokenizer assets:

- `vocab.json`
- `merges.txt`
- `tokenizer_config.json`

The `.safetensors` text encoder file contains model weights, not the
tokenizer vocabulary. The standalone CLI must therefore receive a
tokenizer directory separately.

Because the PRD says not to download files, the implementation must not
fetch tokenizer files from Hugging Face at runtime.

Recommended first approach:

- Add `--tokenizer-path`.
- Require the user to specify the tokenizer directory for now.
- Validate that the directory contains `vocab.json`, `merges.txt`, and
  `tokenizer_config.json`.
- Later copy the Qwen tokenizer assets out of ComfyUI into this
  repository so the CLI is more self-contained.

### Memory

The workflow uses a large diffusion model and a Qwen3-4B text encoder.
Running all components on GPU at once may exceed VRAM.

Mitigation:

- Load and run the text encoder first.
- Move text encoder back to CPU or delete it before loading diffusion.
- Decode VAE after sampling.
- Keep batch size default at 1.
- Prefer bf16 on CUDA.
- Do not support CPU execution in the first milestone. If the selected
  device is not CUDA, fail early with a clear message.

### Exact ComfyUI Parity

The first milestone does not require exact bitwise or visual parity with
ComfyUI. It only requires a coherent image.

Mitigation:

- Match the highest-impact pieces first:
  - prompt template,
  - latent shape,
  - model sampling shift,
  - scheduler,
  - sampler,
  - VAE decode.
- Defer LoRA, UI workflow parity, and multi-sampler support.

## Out Of Scope

The first milestone should not include:

- LoRA loading.
- ComfyUI workflow execution.
- Image-to-image.
- ControlNet.
- Multiple samplers.
- Multiple schedulers.
- Model downloads.
- Web UI.
- Exact ComfyUI output parity.
- Prompt weighting syntax.

## Open Questions

No blocking open questions remain for the first milestone.

Resolved decisions:

- Tokenizer assets: require `--tokenizer-path` for now. Later, copy the
  Qwen tokenizer assets out of ComfyUI into this repository.
- Licensing: do not block on licensing for the first milestone. Copy
  ComfyUI code directly if needed.
- Device support: CUDA-only for the first milestone.
- Diffusion model: hard-code
  `diffusion_models/z-image_turbo/moodyPornMix_zitV3.safetensors` for
  the first image proof.
- All-in-one checkpoints: out of scope for the first milestone, but the
  design should leave room to support them later.

## Recommended Next Step

Start with scaffolding and model inspection, not the full sampler.

The first concrete implementation task should create:

- `pyproject.toml`
- CLI argument parsing
- path resolution
- safetensors inspection for the three model files

That gives immediate feedback about local model compatibility and avoids
spending time on sampler code before confirming the exact state dict
formats.