Diffusion image generation on Apple Silicon, under a unified-memory budget.
Whether a model fits in unified memory is the easy question. The harder one is what else is resident while it runs, for how long, and what that constraint does to the shape of the code around it. This repository is one set of answers, small enough to read in an afternoon.
One queue, one worker, three endpoints, a fingerprint, a ledger and two benchmarks.
Nothing under src/ knows what it is generating: an engine is a checkpoint and its
limits, a profile binds an engine to an ordered adapter stack, and
config/profiles.toml is the only file allowed to name a subject.
uv sync --extra dev
uv run pytest # 144 tests, ~3 s, no weights, no torch, no networkThat command is the point of the repository as much as anything in src/. See
Testing without weights.
POST /images upload an image so it can be a parent -> 201
POST /generate prompt -> image -> 202
POST /edit parent image + prompt -> image -> 202
POST /mask parent image + prompt -> region repainted -> 202
GET /jobs/{id} status, metrics, errors
GET /healthz profile, model state, queue depth
One job runs at a time. A single GPU gains nothing from concurrent denoising, so the queue exists to keep exactly one job in flight and to make the wait visible rather than to parallelise anything.
An engine is a checkpoint plus its text encoder and its limits, and knows nothing about any subject. A profile binds an engine to an ordered stack of LoRA adapters. Adding a subject is a config entry, never a code change.
| Profile | Engine | Adapters | Runs out of the box |
|---|---|---|---|
plain-4b |
FLUX.2 Klein 4B, stock encoder | none | yes |
subject-4b |
Klein 4B, replacement encoder | one local adapter | no, fictional paths |
subject-4b-baked |
same, bake = true |
same | no, fictional paths |
subject-9b |
Klein 9B, replacement encoder | general, then subject | no, fictional paths |
The subject-* profiles are documentation that executes. They exist to exercise the
two config values most able to fail silently — the order of an adapter stack, and
the shape of a replacement encoder — and the test suite asserts against the real
catalogue file, so a careless edit to it fails the build.
A profile is chosen at startup, not per request: adapters are baked or bound at construction, so switching profiles means reloading everything. Serving two means two processes on two ports.
bake. Folding adapters into the weights happens once at load instead of on every
forward pass. On a sub-8-bit checkpoint the fold cannot stay at the original
precision, so the touched layers are re-promoted — mflux names the count in its load
output. Whether that costs or saves time depends on how many layers your adapter
touches, which is why it is a catalogue value rather than an implementation detail,
and why every profile can be paired with an identical one that only differs by this
flag. bake is part of the fingerprint, so the two runs cannot overwrite each other.
guidance. A distilled engine locks it. Above the locked value a real
classifier-free guidance activates and evaluates the model twice per step, so it is
never only a quality knob. The engine refuses other values before anything loads.
There is no negative branch at all, so a negation in a prompt is never encoded as an
exclusion — it only introduces the concept it names.
Neither of those carries a number here on purpose. Both are measurable against their
own baseline in one command: bench/RESULTS.md.
The model load dominates the cost of one image, so the unit of work is a sweep rather than a request. Outputs are named by a fingerprint of everything that changes the pixels, so a rerun does only what is missing and a run killed halfway continues.
imagegen-sweep sweeps/example.json --out runs/harbour --dry-run # print the plan
imagegen-sweep sweeps/example.json --out runs/harbour # one model load
imagegen-sheet runs/harbour # one page to judgeA sweep file crosses prompts with seeds, and an entry may override the seed list:
{
"profile": "plain-4b",
"width": 768, "height": 1024,
"common_prompt": "cinematic photograph",
"seeds": [1, 2, 3],
"prompts": [
{ "id": "harbour", "prompt": "a stone harbour at first light" },
{ "id": "market", "prompt": "a night market", "seeds": [7] }
]
}Keep the frame size fixed across a sweep. Drafting small and regenerating large with the same seed does not give the same image, because the latent shape changes. And keep resolutions to multiples of 16 — otherwise mflux crops, silently.
sweeps/one-variable.json is the other way to use this: same prompt, same seeds, one
thing changing. Two variables at once and the result is unreadable.
POST /mask takes an image and a prompt and derives the region from the text with
CLIPSeg, which runs on the CPU so it never competes with the generator for memory.
Pass target as well as prompt whenever you can. You segment the dress and
generate a blue silk dress; CLIPSeg degrades on long styled prompts. Omitting it
returns a warning.
The region is cropped with padding, its pixels are erased with deterministic noise so
the model cannot simply copy what was there, the crop is denoised, and the result is
pasted back through the mask. Every job records outside_mask_max_delta, which must
be zero, and writes the heatmap, the mask, the crop, the erased condition and the
composite mask as diagnostics.
A mask that is empty, that covers more of the frame than allowed, or that the segmenter is not confident about fails the job with a reason.
uv sync --extra dev # no weights, no torch, no mflux
IMAGEGEN_RUNNER=stub imagegen-serve --profile plain-4bFor real images, install the extras and point at the weights:
uv sync --extra dev --extra mflux --extra segment
export IMAGEGEN_WEIGHTS=/path/to/weights
IMAGEGEN_RUNNER=mflux imagegen-serve --profile plain-4b --port 4242| Variable | Meaning |
|---|---|
IMAGEGEN_RUNNER |
stub or mflux. Defaults to stub, so weights are opt in. |
IMAGEGEN_WEIGHTS |
Directory holding local adapter files. |
IMAGEGEN_PROFILE |
Boot profile. --profile wins. |
IMAGEGEN_RUNS |
Where images, manifests and the ledger go. |
IMAGEGEN_SEGMENTER |
clipseg or box. |
IMAGEGEN_RESIDENT |
both keeps the text and edit wrappers loaded, one evicts. |
IMAGEGEN_PROFILES |
Path to the catalogue. |
Two scripts, because the settings that matter here are not the ones that look like they matter, and nobody should take that on faith from a README.
uv run python bench/step_time.py --profiles plain-4b --sizes 576x576 768x1024
uv run python bench/memory.py --profile plain-4bstep_time.py reports seconds per step, comparable across resolutions, and records
latent_tokens — (width/16) x (height/16), which is what cost actually tracks. At
768x1024 that is 3 072 tokens for the whole frame, which is why a tight crop beats a
larger canvas when detail is the goal.
memory.py reports the MLX peak around each phase, including the encoder swap, which
is the one operation whose transient peak can exceed a machine the steady state fits
in comfortably.
Every write is a temporary file followed by a rename, so a reader never sees a partial
file. Every job transition is a line in runs/ledger.jsonl, replayed at boot. A job
that was running when the process died comes back as interrupted rather than
vanishing, and identifiers are never reused, so a crash cannot overwrite past output.
The fingerprint hashes the pixel-affecting identity of a run: the normalised prompt, the checkpoint, the encoder, the ordered adapter stack with scales, the bake mode, the frame, the steps, the guidance, and the seed mode rather than a resolved random seed — so a random slot keeps a stable identity across runs.
The stub runner honours the same contract as the real one and writes a deterministic placeholder. That single seam is what makes the queue, the API, the sweep planner, the contact sheet and the entire masked-edit pipeline testable on a machine that must not load a model.
uv run pytest # 144 tests, ~3 s
uv run ruff check src tests benchFour things only real weights can confirm, and they are one manual smoke test:
- Both encoder swaps load. The 4B encoder sits in a subdirectory and the 9B one at the repository root, and that difference has no offline proof.
- The adapter stacks apply in order and visibly change the output.
- CLIPSeg finds sensible regions on real images, and the default threshold, dilation and padding suit your frame sizes.
- Throughput, and whether both model wrappers fit in memory at once.
License Mozilla Public License v2.0 (MPL v2.0). See LICENSE.
Copyright (c) 2026, Lucas Jahier - Stratorys
MPL v2.0 is per-file copyleft: changes to these files stay open, but the code can be combined with differently licensed work without that spreading.