Ctrl K
ComunidadGuías…

internal/autogen/image.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autogen

import (
	"fmt"
	"os"
	"slices"
	"strings"

	"github.com/quartermaster-labs/quartermaster/internal/config"
)

// imageArchs are general.architecture values that mark an all-in-one diffusion
// (image-generation) GGUF, served by sd-server instead of llama-server.
//
// ponytail: list unverified against real files — extend it when you put an image
// GGUF on disk. An undetected one still emits as a (broken) llama model whose
// YAML "# arch=<x>" comment names the arch to add here.
var imageArchs = map[string]bool{
	"flux":             true,
	"sd1":              true,
	"sd3":              true,
	"sdxl":             true,
	"stable-diffusion": true,
	"qwen_image":       true,
	"moss_image":       true,
	"z_image":          true,
	"lumina":           true, // Lumina2 / Lumina-Next; Z-Image-Turbo reports "lumina2"
	"wan":              true,
	// Generic marker for a GGUF that declares general.type=diffusion but whose
	// general.architecture doesn't self-identify as image (e.g. HiDream-O1 reports
	// arch "qwen"). effectiveImageArch maps such a file to "diffusion". No
	// resolveComponents arm → nothing auto-wired; components come from an override.
	"diffusion": true,
}

// effectiveImageArch returns the arch to classify a gguf as a diffusion model:
// the declared general.architecture when it is a known image arch, else the
// tensor-name-sniffed DiffusionKind ("sdxl"/"sd1"). stable-diffusion.cpp's
// `convert` strips metadata KVs, so a converted SDXL UNet reports arch="" but
// its tensor names still identify it. Falls back to the declared arch (so the
// llama-path "# arch=..." comment still names an unknown arch).
func effectiveImageArch(meta Metadata) string {
	if isImageArch(meta.Architecture) {
		return meta.Architecture
	}
	if meta.DiffusionKind != "" {
		return meta.DiffusionKind
	}
	// A non-image arch that still declares general.type=diffusion (HiDream-O1
	// reports arch "qwen") — classify it image via the generic "diffusion" marker.
	if strings.EqualFold(strings.TrimSpace(meta.GeneralType), "diffusion") {
		return "diffusion"
	}
	return meta.Architecture
}

// fullCheckpointArchs are diffusion families that stable-diffusion.cpp can only
// load (and version-detect) as an all-in-one checkpoint via -m, NOT as a bare
// --diffusion-model UNet plus external CLIPs. sd.cpp fixes the model version
// right after the UNet loads but before the CLIPs (stable-diffusion.cpp ~L650),
// and SDXL/SD is only recognized when the UNet and its 2nd text encoder are seen
// together — so a split load never detects them and dies "get sd version failed".
// Flux/SD3/Qwen/Z-Image self-identify from the UNet alone, so they keep the split
// path (external encoders wired from the pool).
var fullCheckpointArchs = map[string]bool{
	"sd1":              true,
	"sd2":              true,
	"sdxl":             true,
	"stable-diffusion": true,
}

// fluxGuidance returns the distilled guidance scale (--guidance) BFL recommends
// for a flux edit model, matched by name. ok=false means "leave the built-in
// 3.5" (plain flux-dev, schnell, chroma — none need an override).
func fluxGuidance(name string) (float64, bool) {
	n := strings.ToLower(name)
	switch {
	case strings.Contains(n, "kontext"):
		return 2.5, true
	case strings.Contains(n, "fill"):
		return 30, true
	}
	return 0, false
}

func isFullCheckpointArch(arch string) bool {
	a := strings.ToLower(strings.TrimSpace(arch))
	if fullCheckpointArchs[a] {
		return true
	}
	for k := range fullCheckpointArchs {
		if strings.HasPrefix(a, k) {
			return true
		}
	}
	return false
}

// isImageArch reports whether arch identifies a diffusion model. Matches exact
// names plus a prefix fallback so versioned archs ("flux.1", "sd3.5") still hit.
func isImageArch(arch string) bool {
	a := strings.ToLower(strings.TrimSpace(arch))
	if a == "" {
		return false
	}
	if imageArchs[a] {
		return true
	}
	for k := range imageArchs {
		if strings.HasPrefix(a, k) {
			return true
		}
	}
	return false
}

// imageComputeOverheadGB approximates the non-weight VRAM a generation needs on
// top of the resident diffusion weights: activations plus the VAE decode buffer
// (decoding a ~1024px latent is the peak). It is what decides whether the
// weights fit alongside compute or have to be offloaded.
//
// ponytail: flat estimate, not a per-resolution model — the real peak scales
// with image size and step count. Raise it (or set a per-model vramTargetGB)
// if generations OOM; it only needs to be in the right GB ballpark to flip the
// offload decision, since --max-vram does the fine-grained fitting in sd.cpp.
const imageComputeOverheadGB = 1.5

// fileGB is a component file's on-disk size in GiB, or 0 when it cannot be
// stat'd. A missing file is charged nothing on purpose: the emitted config still
// has to be produced for a box whose components are not downloaded yet, and
// over-charging a path that does not exist would shrink --max-vram for a model
// that is about to be reported as broken anyway (see the missing-roles WARNING).
func fileGB(p string) float64 {
	p = strings.TrimSpace(p)
	if p == "" {
		return 0
	}
	fi, err := os.Stat(p)
	if err != nil {
		return 0
	}
	return float64(fi.Size()) / (1 << 30)
}

// graphBudget is the number handed to --max-vram, which is NOT a VRAM cap.
//
// sd-server: "maximum VRAM budget in GiB for graph-cut segmented execution". It
// is the size one MERGED graph segment may reach, so a larger value fuses more
// nodes into a single allocation. Handing it the whole card is therefore not the
// safe direction, it is the dangerous one: the weights are already sitting in
// that VRAM, so sd.cpp merges against space that does not exist and the
// allocation fails at sampling, not at load (params load lazily: --eager-load is
// off by default, so a model reports "ready" seconds before it tries this).
//
// That is a 24GB card OOMing on a 2.7GiB compute buffer while nominally holding
// a 22.3GB budget, which is what LTX-2.5 (14.6GB resident + two VAEs) hit.
//
// So the budget is what is LEFT once everything we told sd-server to keep on the
// GPU is paid for:
//
//   - offload=false: the diffusion weights are resident, so they come off.
//   - offload=true: --offload-to-cpu puts them in RAM (an image model's VAE follows),
//     so the card is free for the graph and the whole budget stands. This is
//     also the only configuration in which --stream-layers does anything.
//   - the text encoder is on CPU by default (te=cpu) and charged only when an
//     override pins it back onto the GPU.
//
// When that subtraction leaves less than 1 GiB the answer is NOT a small
// positive number. A literal --max-vram 1 is a one-gigabyte graph budget, which
// is both the tightest legal value and an immediate GGML_ASSERT on a video
// model, and 0 means something else again ("disables graph splitting"). So the
// floor hands the question to sd-server instead, as --max-vram -1: a negative
// value is the RESERVE, i.e. "use live free VRAM, sparing 1 GiB".
//
// Delegating is not a cop-out here, it is the more accurate answer. The
// arithmetic above prices every component as GPU-resident, but sd-server's own
// placement planner routinely parks the text encoder's and the VAE's params in
// RAM, so `resident` overcharges by several GB and the budget it derives is far
// below what the card will actually have free. Measured on LTX-2.5 (14.6GB DiT
// + two VAEs on a 24GB card): the computed floor emitted --max-vram 1 and
// crashed, while --max-vram -1 yielded a 23386 MiB budget and generated.
func graphBudget(budget float64, row GgufRow, comp imageComponents, ov *Override, offload bool) float64 {
	if offload {
		return budget
	}
	resident := row.SizeGB
	if ov == nil || ov.VaeOnCpu != "on" {
		resident += fileGB(comp.vae) + fileGB(comp.audioVae)
	}
	if ov != nil && ov.TeOnCpu == "off" {
		resident += fileGB(comp.clipL) + fileGB(comp.clipG) + fileGB(comp.t5) + fileGB(comp.llm) + fileGB(comp.llmVision)
	}
	if g := budget - resident; g >= 1 {
		// One decimal: the YAML comment prints this number too, and a raw
		// float64 subtraction renders as 7.689999999999999.
		return float64(int(g*10)) / 10
	}
	return -1
}

// emitImageModel writes an sd-server YAML entry for a diffusion GGUF. The
// capabilities in:[text] out:[image] block is what makes /v1/models report
// image_generation=true, so the UI's Image tab and playground list the model.
//
// Placement is auto-estimated like the LLM sizer instead of hand-tuned per
// model: the budget is TargetVramGB-VramOverheadGB (or a per-model vramTargetGB
// override), and if the diffusion weights plus a compute pad don't fit it, the
// weights are offloaded to RAM (--offload-to-cpu, paged to VRAM on use) and the
// external text encoder is parked on CPU (--backend te=cpu — it runs once per
// generation, so it is the cheapest component to keep off the GPU). --max-vram
// is always passed as the streaming budget; sd.cpp graph-cuts to fit it.
// --diffusion-fa and --vae-tiling are near-free VRAM savers, always on.
//
// Flags are grounded in sd-server --help (stable-diffusion.cpp f440ad9): note
// the listen flags are -l / --listen-port (NOT --host/--port), and the readiness
// probe is "/" (the embedded webui) since sd-server has no /health.
// imageArg normalizes a component path to forward slashes and quotes it when it
// contains spaces, so it survives the whitespace-split command rendering.
func imageArg(p string) string {
	p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
	if p == "" {
		return ""
	}
	if strings.ContainsAny(p, " \t") {
		return fmt.Sprintf("%q", p)
	}
	return p
}

// resolveLoraDir picks the `--lora-model-dir` for one diffusion model: the
// per-model override, else settings.loraDirs[category], else the fleet-wide
// settings.loraDir, else the directory the model file itself sits in. The last
// fallback is what makes a LoRA dropped next to its base checkpoint
// (D:/LLM/Models/flux1/<lora>.safetensors) show up in /sdapi/v1/loras with no
// config at all — sd-server's default is the process cwd, which never contains
// anything useful here.
//
// category splits image from video, because sd-server takes ONE directory and a
// video LoRA and an SDXL LoRA are not interchangeable: a single fleet-wide
// folder forces whoever keeps them apart (every ComfyUI layout does) to fall
// back to per-model overrides. The per-category entry is therefore checked
// first and loraDir remains the fallback for a single-tree setup.
//
// Only the DIRECTORY is a launch flag; which LoRA (and at what strength) is
// per-request — `lora: [{path, multiplier}]` on /sdapi/v1/{txt2img,img2img},
// where path is the file's name inside this dir.
func resolveLoraDir(s Settings, ovDir, category, modelPath string) string {
	if d := strings.TrimSpace(ovDir); d != "" {
		return d
	}
	if d := strings.TrimSpace(s.LoraDirs[category]); d != "" {
		return d
	}
	if d := strings.TrimSpace(s.LoraDir); d != "" {
		return d
	}
	p := strings.ReplaceAll(strings.TrimSpace(modelPath), "\\", "/")
	if i := strings.LastIndex(p, "/"); i > 0 {
		return p[:i]
	}
	return ""
}

// loraCategory maps the emitter's image/video split onto the UI category ids
// settings.loraDirs is keyed by, so the two cannot drift apart by spelling.
func loraCategory(isVideo bool) string {
	if isVideo {
		return "video"
	}
	return "image"
}

// imageComponents are the resolved VAE / text-encoder file paths for one
// diffusion model. Empty = not attached (the family doesn't need it, or it's
// missing from the pool — see resolveComponents' missing list).
type imageComponents struct{ vae, clipL, clipG, t5, llm, llmVision, audioVae, tokenizer string }

// roles lists the components that resolved to a file, under the role names the
// missing list uses (plus "llm_vision" for an auto-paired projector), so a
// caller can show both halves of the answer side by side.
func (c imageComponents) roles() []CompanionHave {
	out := []CompanionHave{}
	for _, r := range []CompanionHave{
		{"vae", c.vae}, {"clip_l", c.clipL}, {"clip_g", c.clipG}, {"t5xxl", c.t5},
		{"llm", c.llm}, {"llm_vision", c.llmVision}, {"tokenizer", c.tokenizer}, {"audio_vae", c.audioVae},
	} {
		if r.Path != "" {
			out = append(out, r)
		}
	}
	return out
}

// imageEncoderArch names the text-encoder ARCHITECTURE a DiT family was
// trained against, where width alone admits a wrong one (see
// EncoderPool.LlmArch). "" = no restriction, the width match decides.
//
// Shipped knowledge, from each publisher's model card, and only for families
// where a same-width impostor is a real, common install: Qwen3-4B and
// Qwen3-VL-4B (2560), Qwen3-8B and Qwen3-VL-8B (4096). Name-matched for the
// same reason resolveComponents' arms are: FLUX.2/3 report arch "flux" and
// Z-Image "lumina2", shared with families that want something else (Lumina
// proper conditions on Gemma-2, so "lumina" is NOT keyed here).
func imageEncoderArch(arch, name string) string {
	switch {
	case strings.Contains(name, "flux-3") || strings.Contains(name, "flux3"):
		return "qwen3vl" // FLUX.3 klein: Qwen3-VL-4B-Instruct
	case strings.Contains(name, "klein"):
		return "qwen3" // FLUX.2 klein: Qwen3-4B (4B) / Qwen3-8B (9B)
	case arch == "z_image" || strings.Contains(name, "z-image") || strings.Contains(name, "z_image"):
		return "qwen3" // Z-Image: Qwen3-4B
	}
	return ""
}

// resolveComponents decides which component files a bare diffusion GGUF needs
// from its architecture, draws them from the shared Settings.Encoders pool, then
// lets an explicit per-model Override path win. It returns the resolved paths
// plus the role names an arch REQUIRES that neither pool nor override supplied
// (surfaced as a WARNING so a misconfigured model is visible, not silently broken).
//
// name disambiguates families an arch tag can't: Chroma reports arch "flux" but
// removes the CLIP encoder (T5 only). SDXL/SD1 GGUFs are typically full
// checkpoints (CLIP+VAE baked), so their VAE is optional (wired only if declared),
// never required.
//
// ponytail: sd3 / qwen_image arms omitted — no such model on disk yet. Add an arm
// (and any new EncoderSet field) when one lands; the "# arch=..." YAML comment on
// the fallthrough names the arch to wire.
func resolveComponents(enc EncoderSet, ov *Override, arch, name string, pool *EncoderPool, condHidden int64, vid videoInfo) (c imageComponents, missing []string) {
	a := strings.ToLower(strings.TrimSpace(arch))
	n := strings.ToLower(name)
	// Fill every blank pool field from what is actually on disk before the arch
	// arms read it, so a machine that declared no settings.encoders at all still
	// wires a complete command.
	enc = fillEncoderSet(enc, pool)
	// The text encoder is picked by MATCHING WIDTHS, not by name: the DiT states
	// the encoder hidden size it was trained against, and each candidate reports
	// its own. A declared EncoderSet.QwenLlm of that width (and, for the families
	// imageEncoderArch keys, of that gguf architecture) wins outright, because
	// width is not an identity: Qwen3-4B-Instruct-2507 and Qwen3-VL-4B-Instruct
	// are both 2560 wide, and only the declaration says which one a given DiT was
	// trained against. Without one the scan's pick is used, since a single global
	// field cannot be right for two models that want different encoders (Z-Image
	// wants Qwen3-4B where LongCat wants Qwen2.5-VL-7B). A per-model Override
	// still wins over both, below.
	wantVision := wantsVisionEncoder(a, n, ov)
	preferLlm := enc.QwenLlm
	if vid.Kind == VideoFamilyLtxAV && !pool.llmCandidate(preferLlm, condHidden, wantVision) {
		// LTX-2.x does not condition on a stock LLM. Its encoder is a
		// Gemma-4-12B republished WITH the caption projection baked in, and a
		// plain Gemma-3-12B is the same 3840 wide, so the width match alone is a
		// coin flip that pool.Llm resolves by file size - which the stock model
		// usually wins. The path hint breaks the tie.
		//
		// The test is "does the declared pin APPLY here", not "is one declared":
		// settings.encoders.qwenLlm is a single global field, and pool.Llm honours
		// it only when it clears the same width and vision gates. A pin aimed at
		// an image model (Qwen3-4B, 2560 wide) is silently ignored for LTX, so
		// treating its mere presence as an answer suppressed this hint in the one
		// configuration that needs it: a populated encoders block, which is what
		// every real install has.
		if h := pool.LlmHinted("ltx"); h != "" {
			preferLlm = h
		}
	}
	encArch := imageEncoderArch(a, n)
	autoLlm, autoVision := pool.LlmArch(condHidden, wantVision, preferLlm, encArch)
	if autoLlm == "" && wantVision {
		// No vision-capable encoder of that width: fall back to a text-only one
		// rather than emitting nothing, and let the missing projector show up as
		// the model producing unconditioned output rather than as a dead server.
		autoLlm, autoVision = pool.LlmArch(condHidden, false, preferLlm, encArch)
	}
	llmDefault := autoLlm
	if llmDefault == "" && !pool.knowsLlm(enc.QwenLlm) {
		// A declared encoder the scan could not classify (kept outside the models
		// root, say) is still the user's explicit choice.
		//
		// A pin the scan DID classify is a different case: Llm already rejected it
		// on width or vision, so substituting it here would wire a DiT to an
		// encoder of the wrong shape and emit no warning, because req() only warns
		// on an empty path. settings.encoders.qwenLlm is one global field shared by
		// every diffusion model on the box, so that is not a hypothetical: it is
		// what a Z-Image pin does to any model that wants a different encoder.
		// Leaving it empty lets the missing-llm warning fire instead.
		llmDefault = enc.QwenLlm
	}
	if wantVision {
		c.llmVision = autoVision
	}
	req := func(role, path string) string {
		if path == "" {
			missing = append(missing, role)
		}
		return path
	}
	switch {
	// Video DiTs come first and are matched STRUCTURALLY (vid is read off the
	// tensor table, not the arch string): MiniMax-H3 carries no metadata at all,
	// and the arch that does say "wan" belongs to ERNIE-Image-Turbo, an image
	// model that must keep taking the flux.2 arm below.
	case vid.is():
		c.vae, c.audioVae, c.t5, c.llm = videoComponents(vid, enc, pool, llmDefault, &missing)
	case strings.Contains(n, "chroma"): // flux-derived, CLIP stripped → T5 only
		c.vae = req("vae", enc.FluxVae)
		c.t5 = req("t5xxl", enc.T5)
	// Flux.2 Klein reports general.architecture "flux" too (verified against a
	// real gguf header — sd.cpp didn't give it its own arch tag), so arch alone
	// can't tell it apart from flux.1; name-detect like chroma. Klein drops
	// clip_l/t5 for an LLM encoder and needs its own 32-ch-latent VAE,
	// incompatible with flux.1's fluxVae.
	//
	// WHICH LLM is not fixed across the family and is NOT decided here: klein 4B
	// wants Qwen3-4B, klein 9B wants Qwen3-8B, and flux.2-dev wants Mistral-Small-3.
	// All three fall out of the width match, because flux.2 states a caption width
	// of 3x its encoder hidden size (klein 9B: txt_in 12288 = 3 x 4096) and
	// captionFactor divides that out. Nothing needs adding here for a new variant.
	//
	// FLUX.3 klein is the same shape: same 32-ch VAE (its ae is byte-identical
	// to FLUX.2's), an LLM encoder, txt_in 20480 = 8 x 2560 (Qwen3-VL-4B).
	case strings.Contains(n, "klein") || strings.Contains(n, "flux2") || strings.Contains(n, "flux-2") || strings.Contains(n, "flux3") || strings.Contains(n, "flux-3"):
		c.vae = req("vae", enc.Flux2Vae)
		c.llm = req("llm", llmDefault)
	// LongCat-Image / LongCat-Image-Edit ship their DiT in BFL (flux) tensor
	// layout, so every converter tags them general.architecture "flux" (and
	// stduhpf's quants carry no KVs at all: the double_blocks sniff in
	// readTensorScan lands them here too). They are not flux.1: clip_l/t5 are
	// gone in favour of a Qwen2.5-VL LLM encoder, while the VAE IS flux.1's ae.
	// Name-detect like chroma. The encoder and (for the edit variant) its vision
	// projector come from the scan, matched on the caption width the DiT states
	// (condHidden 3584 = Qwen2.5-VL-7B). --flow-shift is NOT wired: it is a
	// sampling knob with no structural tell, so LongCat-Edit still wants
	// `extraArgs: "--flow-shift 3.16"` on its override row (exp(1.15), its
	// scheduler's base_shift == max_shift, so unlike flux.1 it does not vary
	// with canvas size).
	case strings.Contains(n, "longcat"):
		c.vae = req("vae", enc.FluxVae)
		c.llm = req("llm", llmDefault)
	// MOSS-Image (arch moss_image) conditions on Qwen3.5-9B, a RECURRENT hybrid
	// (GatedDeltaNet). sd.cpp gives it its own conditioner, so it is the one
	// consumer of RoleRecurrentLlm; llmDefault never holds a recurrent file. It
	// also tokenizes outside the gguf: sd-server's moss-image spec makes a
	// Qwen3.5 tokenizer.json a required slot. VAE: the standard FLUX.2 ae.
	case a == "moss_image" || strings.Contains(n, "moss-image") || strings.Contains(n, "moss_image"):
		c.vae = req("vae", firstNonEmpty(enc.Flux2Vae, pool.Vae(VaeFamilyFlux2)))
		c.llm = req("llm", pool.RecurrentLlm(condHidden, "qwen3.5-9b", "qwen3_5-9b", "qwen3.5"))
		c.tokenizer = req("tokenizer", pool.Tokenizer("moss-image", "qwen3.5", "qwen3_5"))
	case a == "flux" || strings.HasPrefix(a, "flux"):
		c.vae = req("vae", enc.FluxVae)
		c.clipL = req("clip_l", enc.ClipL)
		c.t5 = req("t5xxl", enc.T5)
	// SD1/SD2/SDXL are NOT handled here: they load as -m full checkpoints
	// (isFullCheckpointArch), so imageCmdLines never calls resolveComponents for
	// them — sd.cpp can't version-detect a split SDXL (bare UNet + external CLIPs).
	case strings.HasPrefix(a, "lumina") || a == "z_image" || strings.Contains(n, "z-image"):
		c.vae = req("vae", enc.ZimageVae)
		c.llm = req("llm", llmDefault)
	// Qwen-Image lineage (Qwen-Image / Qwen-Image-Edit / Qwen-Rapid) and the
	// Wan-derived DiTs (Krea, ERNIE-Image) condition on an LLM and carry no
	// CLIP/T5 at all. Their VAEs split by arch: the wan/qwen_image 3D causal VAE
	// for the former, flux.2's 32-channel AE for ERNIE.
	//
	// The Wan-2.1 and Qwen-Image 20B VAEs are structurally IDENTICAL (same 194
	// tensors, every dimension equal), so nothing in either file distinguishes
	// them and the model name is the only signal available: hence the hints.
	// Wrong pick here is a colour-shifted decode, not a crash.
	//
	// Qwen-Image 2.1's RGBA VAE is in the same family but is 64-channel, and
	// THAT cross-wire is a bad decode rather than a tint, so it is excluded by
	// latent width before the hints ever run (see wan3dLatent).
	case a == "qwen_image" || a == "wan" || strings.Contains(n, "qwen-image") || strings.Contains(n, "qwen_image"):
		if a == "wan" && !strings.Contains(n, "wan") {
			// ERNIE-Image reports arch "wan" but is a flux.2-latent model.
			c.vae = req("vae", firstNonEmpty(enc.Flux2Vae, pool.Vae(VaeFamilyFlux2)))
		} else {
			c.vae = req("vae", pool.VaeOfWidth(VaeFamilyWan3D, wan3dLatent(a, condHidden), wan3dHints(n)...))
		}
		c.llm = req("llm", llmDefault)
	}
	// A projector with no encoder to attach it to is incoherent argv: sd-server
	// would take --llm_vision with no --llm. Only the arms above decide whether
	// this model has an LLM encoder at all.
	if c.llm == "" {
		c.llmVision = ""
	}
	// Explicit per-model override wins over the arch-wired pool default, and clears
	// the role from the missing list (an override can supply what the pool lacks).
	if ov != nil {
		set := func(role, ovPath string, dst *string) {
			if ovPath == "" {
				return
			}
			*dst = ovPath
			for i, m := range missing {
				if m == role {
					missing = append(missing[:i], missing[i+1:]...)
					break
				}
			}
		}
		set("vae", ov.VaePath, &c.vae)
		set("clip_l", ov.ClipLPath, &c.clipL)
		set("clip_g", ov.ClipGPath, &c.clipG)
		set("t5xxl", ov.T5Path, &c.t5)
		set("llm", ov.TextEncoderPath, &c.llm)
		set("audio_vae", ov.AudioVaePath, &c.audioVae)
		// An explicit projector path always wins; when the encoder itself was
		// overridden the auto-paired projector belonged to a DIFFERENT file, so
		// drop it unless the override supplies its own.
		if ov.TextEncoderPath != "" && ov.LlmVisionPath == "" {
			c.llmVision = projectorBeside(ov.TextEncoderPath, pool)
		}
		if ov.LlmVisionPath != "" {
			c.llmVision = ov.LlmVisionPath
		}
		if ov.LlmVision == "off" {
			c.llmVision = ""
		}
		if ov.LlmVision == "on" && c.llmVision == "" {
			c.llmVision = projectorBeside(c.llm, pool)
		}
	}
	return c, missing
}

// imageCmdLines builds the sd-server argv (exe first) for a diffusion gguf,
// applying the per-model Override knobs plus the arch-wired component pool. Shared
// by emitImageModel (YAML emit) and RenderSoloCmd (editor launch-parameters
// preview), so the box matches a save. Also returns the resolved VRAM budget and
// offload decision for the YAML comment, and any required-but-missing encoder roles.
func imageCmdLines(s Settings, row GgufRow, ov *Override, arch, name string, condHidden int64, vid videoInfo) (cmd ComposedCmd, budget, graph float64, offload bool, missing []string) {
	modelPath := strings.ReplaceAll(row.FullPath, "\\", "/")

	// Budget mirrors the LLM sizer: target minus headroom. A per-model
	// vramTargetGB override replaces it wholesale (manual escape hatch).
	budget = s.TargetVramGB - s.VramOverheadGB
	threads := s.Threads
	if ov != nil {
		if ov.VramTargetGB > 0 {
			budget = ov.VramTargetGB
		}
		if ov.Threads > 0 {
			threads = ov.Threads
		}
	}
	if budget < 1 {
		budget = 1
	}

	// Offload when resident weights + compute peak can't fit the budget. The
	// VAE/encoder are external (not in row.SizeGB); te=cpu covers the encoder,
	// --offload-to-cpu pages the diffusion weights. A per-model override pins it.
	// A video generation holds several frames of latent plus a 3D VAE decode
	// buffer, so its non-weight peak is several times an image's. Charging it
	// the image number would leave a model resident that cannot actually decode.
	//
	// The profile resolves HERE, above the placement decision, because a video
	// model's non-weight peak is priced off the resolution and frame count it
	// will actually launch with. The same def is what emits --width/--height/
	// --video-frames at the end of the argv, so a DefaultFrames override cannot
	// lengthen the clip without also re-sizing the placement for it.
	def := resolveGenDefaults(vid, arch, name, condHidden, ov)
	overhead := imageComputeOverheadGB
	if vid.is() {
		overhead = videoComputeOverhead(vid, name, def)
	}
	offload = row.SizeGB+overhead > budget
	if ov != nil {
		switch ov.OffloadToCpu {
		case "on":
			offload = true
		case "off":
			offload = false
		}
	}

	// Most diffusion GGUFs are standalone diffusion weights loaded via
	// --diffusion-model, with VAE/text-encoder supplied as separate component
	// files (--vae / --llm / --clip_l / --t5xxl). SD1/SD2/SDXL are the exception:
	// sd.cpp can't version-detect them split, so they must be an all-in-one
	// checkpoint loaded via -m (encoders + VAE baked in, none wired externally).
	//
	// Full-checkpoint archs bake their encoders/VAE — wire nothing external.
	// Split archs draw component files from the shared pool (per-model override
	// wins). Resolved BEFORE the argv literal because --max-vram is priced off
	// what these components weigh, not just off the diffusion gguf.
	fullCkpt := isFullCheckpointArch(arch)
	modelFlag := "--diffusion-model"
	if fullCkpt {
		modelFlag = "-m"
	}
	var comp imageComponents
	if !fullCkpt {
		comp, missing = resolveComponents(s.Encoders, ov, arch, name, encoderPoolFor(s.RootList()), condHidden, vid)
	}
	graph = graphBudget(budget, row, comp, ov, offload)
	// Per-model backend pick from the config editor (Override.Backend) or the
	// ★Default image entry; fall back to the legacy derived exe.
	sdExe := imageExe(s, ov)
	lines := []string{
		sdExe,
		fmt.Sprintf("%s %s", modelFlag, modelPath),
		"-l 127.0.0.1",
		"--listen-port ${PORT}",
		fmt.Sprintf("--max-vram %g", graph),
	}
	if p := imageArg(comp.vae); p != "" {
		lines = append(lines, "--vae "+p)
	}
	if p := imageArg(comp.clipL); p != "" {
		lines = append(lines, "--clip_l "+p)
	}
	if p := imageArg(comp.clipG); p != "" {
		lines = append(lines, "--clip_g "+p)
	}
	if p := imageArg(comp.t5); p != "" {
		lines = append(lines, "--t5xxl "+p)
	}
	if p := imageArg(comp.llm); p != "" {
		lines = append(lines, "--llm "+p)
	}
	if p := imageArg(comp.tokenizer); p != "" {
		lines = append(lines, "--tokenizer "+p)
	}
	// The soundtrack decoder of an audio-capable video model. Without it the DiT
	// still denoises an audio latent it then has no way to turn into samples.
	if p := imageArg(comp.audioVae); p != "" {
		lines = append(lines, "--audio-vae "+p)
	}
	// The vision tower of the text encoder, needed by edit pipelines that
	// condition on a reference image. Auto-paired to the chosen --llm (its
	// sibling mmproj), never hand-typed. Missing, most edit models do not error:
	// they report "vision disabled" and emit an image unrelated to the
	// reference, so a wrong-looking result is the only symptom. Qwen-Image 2.1
	// is the exception and refuses the prompt outright ("editing requires
	// Qwen3-VL vision weights"), which is the better behaviour of the two.
	if p := imageArg(comp.llmVision); p != "" {
		lines = append(lines, "--llm_vision "+p)
	}
	var ovLoraDir string
	if ov != nil {
		ovLoraDir = ov.LoraDir
	}
	if p := imageArg(resolveLoraDir(s, ovLoraDir, loraCategory(vid.is()), modelPath)); p != "" {
		lines = append(lines, "--lora-model-dir "+p)
	}
	// --diffusion-fa is a near-free VRAM saver, on unless turned off.
	if ov == nil || ov.DiffusionFa != "off" {
		lines = append(lines, "--diffusion-fa")
	}
	// --vae-tiling is load-bearing, not just a quality knob: it caps the VAE
	// decode VRAM spike. Decoding a full latent whole OOMs/hangs intermittently
	// on a tight (8GB) card, so keep it on by default. Quality is steps/cfg, not this.
	if ov == nil || ov.VaeTiling != "off" {
		lines = append(lines, "--vae-tiling")
	}
	// A video VAE needs the tile SIZED as well as switched on, and the default
	// is wrong for it by two orders of magnitude of memory.
	//
	// --vae-tile-size is in LATENT units and defaults to 32x32. On an image VAE
	// (8x spatial compression) that is a 256x256 pixel tile, which is the small
	// tile it reads as. LTX's video VAE compresses 32x spatially, so the SAME
	// default is a 1024x1024 pixel tile, and its decode graph asks for ~12.8GB.
	// Measured: a 24GB card with the DiT resident cannot serve that, and the
	// failure is not an OOM message but "ltx_video_vae segment 1/1 (graph)
	// failed during weight preparation" -> "generate_video returned no results",
	// i.e. tiling is nominally ON and the clip still never decodes.
	//
	// So express the tile as a FRACTION of the frame instead, which is
	// compression-ratio independent: --vae-relative-tile-size takes <1 as a
	// fraction, and 0.5x0.5 is four spatial tiles whatever the VAE's stride.
	// Halves rather than quarters because per-tile graph overhead dominates once
	// the tiles are small - measured at 1280x704x49, 0.25x0.25 decoded in 47.1s
	// across 42 tiles and 0.5x0.5 in 23.1s across 6.
	//
	// Image models are left on the default: 256x256 tiles are what the rest of
	// sd.cpp's VAE handling is tuned around, and nothing there is failing.
	if vid.is() && (ov == nil || ov.VaeTiling != "off") {
		lines = append(lines, "--vae-relative-tile-size 0.5x0.5")
	}
	// Video-only VRAM levers. --vae-tiling above chunks the decode spatially; a
	// clip's decode also grows along TIME with --video-frames and only
	// --temporal-tiling chunks that axis. --stream-layers streams the diffusion
	// weights against --max-vram rather than pinning them resident, which hands
	// that headroom to the sampler.
	//
	// Both are gated on vid.is(): --temporal-tiling applies to a video VAE decode
	// and nothing else, and an image model has no long-clip peak for
	// --stream-layers to relieve, so on the image path both would be noise in the
	// launch line at best.
	//
	// --temporal-tiling is gated a second time, on the FAMILY, because only some
	// video VAEs implement a tiled decode and the rest accept the flag and ignore
	// it (see vaeTemporalTiling). An explicit "on" still forces it through, so a
	// build that adds support for a family needs an override row rather than a
	// recompile.
	switch {
	case ov != nil && ov.TemporalTiling == "on":
		lines = append(lines, "--temporal-tiling")
	case vid.is() && vaeTemporalTiling(vid) && (ov == nil || ov.TemporalTiling != "off"):
		lines = append(lines, "--temporal-tiling")
	}
	// --stream-layers is additionally gated on OFFLOAD, and that is not caution,
	// it is the flag's actual precondition. sd.cpp: "--stream-layers has no
	// effect unless diffusion params backend is cpu; ignoring" - streaming the
	// weights in means they have to live somewhere to stream FROM. Only
	// --offload-to-cpu puts them there ("place the weights in RAM to save VRAM,
	// and automatically load them into VRAM when needed"), so emitting it beside
	// resident weights logged an ignore line and bought nothing, while reading in
	// the launch args like a VRAM lever that was already pulled. An explicit "on"
	// still forces it, as the escape hatch for a build that widens the rule.
	switch {
	case ov != nil && ov.StreamLayers == "on":
		lines = append(lines, "--stream-layers")
	case vid.is() && offload && (ov == nil || ov.StreamLayers != "off"):
		lines = append(lines, "--stream-layers")
	}
	lines = append(lines, fmt.Sprintf("-t %d", threads))
	// Park components on CPU via the sd-server --backend spec. te=cpu is the default
	// (the text encoder runs once per generation, cheapest to keep off the GPU, and
	// its VRAM is NOT in row.SizeGB — leaving it resident lets a "fits" diffusion
	// model thrash the whole graph through --max-vram). "off" keeps it on the GPU.
	// vae=cpu: the bf16 VAE decodes on the GPU by default, but some backends
	// whiten it, so "on" pins it to CPU as the safe (slower) fallback. An
	// offloaded IMAGE model also gets it, for the decode-spike reason given at
	// --offload-to-cpu below.
	//
	// It goes in this one --backend spec rather than the deprecated
	// --vae-on-cpu. sd.cpp folds the old flag into the same assignment string
	// anyway (prepare_backend_assignments prepends "vae=cpu"), so the two were
	// equivalent; the spec is the spelling the binary still supports.
	var beParts []string
	if ov == nil || ov.TeOnCpu != "off" {
		beParts = append(beParts, "te=cpu")
	}
	if (ov != nil && ov.VaeOnCpu == "on") || (offload && !vid.is()) {
		beParts = append(beParts, "vae=cpu")
	}
	if len(beParts) > 0 {
		lines = append(lines, "--backend "+strings.Join(beParts, ","))
	}
	// Distilled guidance for flux edit models. The /sdapi route only reads
	// cfg_scale (→ guidance.txt_cfg); it has NO per-request key for distilled
	// guidance, so it stays at whatever the server launched with. Bake BFL's
	// per-model default here: Kontext=2.5, Fill=30 (plain flux-dev keeps the
	// built-in 3.5; schnell/chroma are guidance-free → nothing emitted).
	if g, ok := fluxGuidance(name); ok {
		lines = append(lines, fmt.Sprintf("--guidance %g", g))
	}
	if offload {
		lines = append(lines, "--offload-to-cpu")
		// VAE decode is a single end-of-generation VRAM spike that --vae-tiling
		// only caps, not removes. On a tight card (offload=true, ~1.5GB VRAM
		// headroom) with a token-heavy model like Kontext (reference image ~2x
		// the sequence), that spike overcommits shared VRAM and hard-hangs
		// Windows. The VAE runs once per image, so parking it on CPU is nearly
		// free and removes the crash. That is the vae=cpu in --backend above;
		// te=cpu only moves the text encoders.
		//
		// "Nearly free" is an IMAGE claim and does not survive the move to video.
		// A 3D VAE decodes every frame of the clip rather than one picture, so on
		// CPU it stops being a rounding error and becomes a large share of the
		// whole render. The premise fails too: the spike was dangerous precisely
		// because offloading an IMAGE model left ~1.5GB of headroom, but a video
		// model that offloads hands its ENTIRE budget to the graph (graphBudget
		// returns the full budget when offload is set), so the decode has room on
		// the GPU it was being protected from. Override .VaeOnCpu = "on" is still
		// there for a box that wants it anyway.
	}
	// Generation defaults applied when a request omits them. An image model
	// starts from its arch profile, which is empty except where sd-server's
	// built-in steps/cfg are wrong for the model (see imageDefaultsFor); a video
	// model starts from its family profile, because two of those numbers are not
	// preferences:
	// --video-frames defaults to 1 (a single still), and H3 conditions at
	// cfg-scale 1.0 while sd-server's built-in default is 7.0. Per-model
	// overrides still win over both.
	//
	// def was resolved far above, next to the placement decision it also feeds.
	if def.steps > 0 {
		lines = append(lines, fmt.Sprintf("--steps %d", def.steps))
	}
	if def.cfg > 0 {
		lines = append(lines, fmt.Sprintf("--cfg-scale %g", def.cfg))
	}
	if def.sampler != "" {
		lines = append(lines, "--sampling-method "+def.sampler)
	}
	if def.flowShift > 0 {
		lines = append(lines, fmt.Sprintf("--flow-shift %g", def.flowShift))
	}
	if def.width > 0 {
		lines = append(lines, fmt.Sprintf("--width %d", def.width))
	}
	if def.height > 0 {
		lines = append(lines, fmt.Sprintf("--height %d", def.height))
	}
	if def.frames > 0 {
		lines = append(lines, fmt.Sprintf("--video-frames %d", def.frames))
	}
	if def.fps > 0 {
		lines = append(lines, fmt.Sprintf("--fps %d", def.fps))
	}
	// Custom launch arguments go through the SAME composer llama-server uses,
	// against sd-server's own flag table. The old code appended ov.ExtraArgs
	// verbatim here, which is the whole reported bug: a pinned --video-frames
	// landed NEXT TO the generated one instead of replacing it, and because the
	// editor's two-way box parsed what it recognised back into the structured
	// fields and left the rest in the text, every save re-emitted both copies
	// and grew the line again. One LTX sidecar reached five.
	//
	// ComposeCmd is one-way by construction: it drops each generated flag whose
	// knob the custom text owns, then appends the text once. A parse failure
	// returns the lines with the text appended anyway, which is exactly what
	// this code did unconditionally before, so a command the spawner cannot
	// split still fails with sd-server's own message rather than silently
	// losing the user's flags here.
	custom := ""
	if ov != nil {
		custom = ov.CustomArgsText()
	}
	cc, _ := SdFlags.ComposeCmd(lines, custom)
	return cc, budget, graph, offload, missing
}

// mergeImageVariant overlays an image variant onto its base override: the
// variant inherits every base field it leaves empty (component paths, placement)
// and overrides only what it sets. Unlisted is the variant's own (a variant is a
// distinct served id). Variants is cleared to avoid re-emitting.
func mergeImageVariant(base Override, v VariantSpec) Override {
	o := base
	o.Variants = nil
	o.Unlisted = v.Unlisted
	// Component paths resolve through the shared sentinel rule: empty inherits
	// the model-wide path, "none" drops the encoder for this preset only.
	mergeInheritImageStrings(&o, &v)
	if v.RefEdit != "" {
		o.RefEdit = v.RefEdit
	}
	if v.LlmVision != "" {
		o.LlmVision = v.LlmVision
	}
	if v.OffloadToCpu != "" {
		o.OffloadToCpu = v.OffloadToCpu
	}
	if v.TeOnCpu != "" {
		o.TeOnCpu = v.TeOnCpu
	}
	if v.VaeOnCpu != "" {
		o.VaeOnCpu = v.VaeOnCpu
	}
	if v.VaeTiling != "" {
		o.VaeTiling = v.VaeTiling
	}
	if v.TemporalTiling != "" {
		o.TemporalTiling = v.TemporalTiling
	}
	if v.StreamLayers != "" {
		o.StreamLayers = v.StreamLayers
	}
	if v.DiffusionFa != "" {
		o.DiffusionFa = v.DiffusionFa
	}
	if v.VramTargetGB > 0 {
		o.VramTargetGB = v.VramTargetGB
	}
	if v.Threads > 0 {
		o.Threads = v.Threads
	}
	if v.DefaultSteps > 0 {
		o.DefaultSteps = v.DefaultSteps
	}
	if v.DefaultCfg > 0 {
		o.DefaultCfg = v.DefaultCfg
	}
	if v.DefaultSampler != "" {
		o.DefaultSampler = v.DefaultSampler
	}
	if v.DefaultWidth > 0 {
		o.DefaultWidth = v.DefaultWidth
	}
	if v.DefaultHeight > 0 {
		o.DefaultHeight = v.DefaultHeight
	}
	if v.DefaultFrames > 0 {
		o.DefaultFrames = v.DefaultFrames
	}
	if v.DefaultFps > 0 {
		o.DefaultFps = v.DefaultFps
	}
	// Both launch-argument buckets inherit, and a variant that sets EITHER
	// replaces BOTH. They are two spellings of one setting (CustomArgsText
	// prefers the new one), so letting a variant override only the new field
	// while the base's legacy text stayed behind would leave the old text
	// applying to a variant that had plainly replaced it.
	if v.CustomArgs != "" || v.ExtraArgs != "" {
		o.CustomArgs = v.CustomArgs
		o.ExtraArgs = v.ExtraArgs
	}
	return o
}

// extraImageBudget resolves the --max-vram for an extra image model (its own
// VramTargetGB, else the settings target minus overhead; floored at 1).
func extraImageBudget(s Settings, m ExtraImageModel) float64 {
	budget := s.TargetVramGB - s.VramOverheadGB
	if m.VramTargetGB > 0 {
		budget = m.VramTargetGB
	}
	if budget < 1 {
		budget = 1
	}
	return budget
}

// imageExe picks the sd-server binary this image model launches: its own backend
// pin when it has one, else the class default the ★ picks, else the legacy slot.
// Only an auto model follows a later default switch. The class guard keeps a
// stray non-image id from emitting the wrong launcher.
func imageExe(s Settings, ov *Override) string {
	if rb := resolveBackend(s, ov, "image"); rb.Exe != "" && kindServesClass(rb.Kind, "image") {
		return rb.Exe
	}
	return s.SdServerExe
}

// extraImageCmdLines builds the sd-server argv for one hand-declared extra image
// model (safetensors DiT). Shared by config emit and the editor's cmd preview so
// both render identically — the gguf-scan RenderSoloCmd path can't serve these
// (a safetensors DiT has no gguf header to arch-detect from).
func extraImageCmdLines(s Settings, m ExtraImageModel) ComposedCmd {
	threads := s.Threads
	if m.Threads > 0 {
		threads = m.Threads
	}
	modelFlag := "-m"
	if strings.TrimSpace(m.ModelFlag) != "" {
		modelFlag = strings.TrimSpace(m.ModelFlag)
	}
	lines := []string{
		imageExe(s, &Override{Backend: m.Backend}),
		fmt.Sprintf("%s %s", modelFlag, imageArg(m.ModelPath)),
		"-l 127.0.0.1",
		"--listen-port ${PORT}",
		fmt.Sprintf("--max-vram %g", extraImageBudget(s, m)),
	}
	if p := imageArg(m.VaePath); p != "" {
		lines = append(lines, "--vae "+p)
	}
	if p := imageArg(m.ClipLPath); p != "" {
		lines = append(lines, "--clip_l "+p)
	}
	if p := imageArg(m.ClipGPath); p != "" {
		lines = append(lines, "--clip_g "+p)
	}
	if p := imageArg(m.T5Path); p != "" {
		lines = append(lines, "--t5xxl "+p)
	}
	if p := imageArg(m.LlmPath); p != "" {
		lines = append(lines, "--llm "+p)
	}
	// Hand-declared extras are scanned by nothing, so there is no videoInfo to
	// read a category off (the same blind spot --temporal-tiling is opted into
	// by hand below). They take the "image" entry; a video extra that needs the
	// other tree sets its own loraDir, which wins over both.
	if p := imageArg(resolveLoraDir(s, m.LoraDir, "image", m.ModelPath)); p != "" {
		lines = append(lines, "--lora-model-dir "+p)
	}
	if m.DiffusionFa != "off" {
		lines = append(lines, "--diffusion-fa")
	}
	if m.VaeTiling != "off" {
		lines = append(lines, "--vae-tiling")
	}
	// Opt-in, unlike the discovered path above: an extra model is hand-declared
	// and never scanned, so there is nothing here that knows it is a video model.
	if m.TemporalTiling == "on" {
		lines = append(lines, "--temporal-tiling")
	}
	if m.StreamLayers == "on" {
		lines = append(lines, "--stream-layers")
	}
	lines = append(lines, fmt.Sprintf("-t %d", threads))
	var beParts []string
	if m.TeOnCpu != "off" {
		beParts = append(beParts, "te=cpu")
	}
	// Offload carries vae=cpu, as on the discovered path. Unlike there it is not
	// skipped for video: nothing here knows this is a video model.
	if m.VaeOnCpu == "on" || m.OffloadToCpu == "on" {
		beParts = append(beParts, "vae=cpu")
	}
	if len(beParts) > 0 {
		lines = append(lines, "--backend "+strings.Join(beParts, ","))
	}
	if m.OffloadToCpu == "on" {
		lines = append(lines, "--offload-to-cpu")
	}
	if m.DefaultSteps > 0 {
		lines = append(lines, fmt.Sprintf("--steps %d", m.DefaultSteps))
	}
	if m.DefaultCfg > 0 {
		lines = append(lines, fmt.Sprintf("--cfg-scale %g", m.DefaultCfg))
	}
	if sm := strings.TrimSpace(m.DefaultSampler); sm != "" {
		lines = append(lines, "--sampling-method "+sm)
	}
	if m.DefaultWidth > 0 {
		lines = append(lines, fmt.Sprintf("--width %d", m.DefaultWidth))
	}
	if m.DefaultHeight > 0 {
		lines = append(lines, fmt.Sprintf("--height %d", m.DefaultHeight))
	}
	// Same composition as imageCmdLines above. An ExtraImageModel carries the
	// text on its own field rather than on an Override, so it is borrowed into
	// one for the composer: CustomArgsText is the only thing read, and it is
	// what resolves the new customArgs field against the legacy bucket.
	cc, _ := SdFlags.ComposeCmd(lines, (&Override{CustomArgs: m.CustomArgs, CustomArgsOff: m.CustomArgsOff, ExtraArgs: m.ExtraArgs}).CustomArgsText())
	return cc
}

// RenderExtraImageCmd renders the full sd-server command for one extra image
// model as a single line (the editor's cmd-preview endpoint uses this).
func RenderExtraImageCmd(s Settings, m ExtraImageModel) string {
	return extraImageCmdLines(s, m).Effective
}

// FindExtraImageModel returns the settings entry whose model path matches p
// (the editor resolves a model to its -m/--diffusion-model gguf path).
func FindExtraImageModel(s Settings, p string) (ExtraImageModel, bool) {
	for _, m := range s.ExtraImageModels {
		if config.PathEqual(m.ModelPath, p) {
			return m, true
		}
	}
	return ExtraImageModel{}, false
}

// ExtraImageAsOverride projects an ExtraImageModel's editable fields into an
// Override so the config editor can seed its image form from the hand-declared
// settings entry when no sidecar override exists yet (without this, opening an
// unedited extra model shows blank fields and the first save wipes the base).
func ExtraImageAsOverride(m ExtraImageModel) Override {
	return Override{
		Match:                    m.ModelPath,
		VaePath:                  m.VaePath,
		ClipLPath:                m.ClipLPath,
		ClipGPath:                m.ClipGPath,
		T5Path:                   m.T5Path,
		TextEncoderPath:          m.LlmPath,
		LoraDir:                  m.LoraDir,
		TeOnCpu:                  m.TeOnCpu,
		VaeOnCpu:                 m.VaeOnCpu,
		VaeTiling:                m.VaeTiling,
		TemporalTiling:           m.TemporalTiling,
		StreamLayers:             m.StreamLayers,
		DiffusionFa:              m.DiffusionFa,
		OffloadToCpu:             m.OffloadToCpu,
		DefaultSteps:             m.DefaultSteps,
		DefaultCfg:               m.DefaultCfg,
		DefaultSampler:           m.DefaultSampler,
		DefaultWidth:             m.DefaultWidth,
		DefaultHeight:            m.DefaultHeight,
		VramTargetGB:             m.VramTargetGB,
		Threads:                  m.Threads,
		CustomArgs:               m.CustomArgs,
		CustomArgsOff:            m.CustomArgsOff,
		ExtraArgs:                m.ExtraArgs,
		Unlisted:                 m.Unlisted,
		PromptEnhancer:           m.PromptEnhancer,
		PromptEnhancerEdit:       m.PromptEnhancerEdit,
		PromptEnhancerPrompt:     m.PromptEnhancerPrompt,
		PromptEnhancerEditPrompt: m.PromptEnhancerEditPrompt,
	}
}

// ApplyOverrideToExtraImage overlays a matched Override's image fields onto a
// hand-declared ExtraImageModel so UI config edits (the sidecar override) reach
// the safetensors models the gguf scan never sees. The Override is the UI's
// COMPLETE snapshot (seeded from ExtraImageAsOverride), so every UI-modelled
// field is taken authoritatively — including the "" tri-state defaults, so a
// toggle flipped back ON (DiffusionFa "") clears a base "off". Structural fields
// (Name/ModelPath/ModelFlag) always stay.
// ponytail: authoritative replace, not field-merge — correct because a UI save
// writes the full field set; a broad glob file override matching a safetensors
// model would flatten its toggles to defaults, but none is authored that way.
func ApplyOverrideToExtraImage(m ExtraImageModel, ov *Override) ExtraImageModel {
	if ov == nil {
		return m
	}
	m.VaePath = ov.VaePath
	m.ClipLPath = ov.ClipLPath
	m.ClipGPath = ov.ClipGPath
	m.T5Path = ov.T5Path
	m.LlmPath = ov.TextEncoderPath
	m.LoraDir = ov.LoraDir
	m.Backend = ov.Backend
	m.TeOnCpu = ov.TeOnCpu
	m.VaeOnCpu = ov.VaeOnCpu
	m.VaeTiling = ov.VaeTiling
	m.TemporalTiling = ov.TemporalTiling
	m.StreamLayers = ov.StreamLayers
	m.DiffusionFa = ov.DiffusionFa
	m.OffloadToCpu = ov.OffloadToCpu
	m.DefaultSteps = ov.DefaultSteps
	m.DefaultCfg = ov.DefaultCfg
	m.DefaultSampler = ov.DefaultSampler
	m.DefaultWidth = ov.DefaultWidth
	m.DefaultHeight = ov.DefaultHeight
	m.CustomArgs = ov.CustomArgs
	m.CustomArgsOff = ov.CustomArgsOff
	m.ExtraArgs = ov.ExtraArgs
	m.Unlisted = ov.Unlisted
	m.PromptEnhancer = ov.PromptEnhancer
	m.PromptEnhancerEdit = ov.PromptEnhancerEdit
	m.PromptEnhancerPrompt = ov.PromptEnhancerPrompt
	m.PromptEnhancerEditPrompt = ov.PromptEnhancerEditPrompt
	if ov.Threads > 0 {
		m.Threads = ov.Threads
	}
	if ov.VramTargetGB > 0 {
		m.VramTargetGB = ov.VramTargetGB
	}
	return m
}

// emitExtraImageModels writes an sd-server block for each Settings.ExtraImageModel
// (safetensors DiTs autogen's gguf scan can't see). Unlike emitImageModel these
// wire components verbatim from explicit paths — no arch detection, no VRAM
// planner. A matching override (sidecar UI edit or file rule) is overlaid so the
// config editor can tune these.
//
// A name already served (any discovered model's id OR any profile it emitted:
// ctx tiers, variants, the vision twin) keeps its owner: renaming the discovered
// model would flip which process answers an id the user already calls. The
// extra is skipped with an in-band reason rather than dropped silently, since it
// was declared on purpose and a quiet no-op reads as the feature not working.
func emitExtraImageModels(b *strings.Builder, s Settings, overrides []Override, seen map[string]bool, emitted *[]string) {
	for _, m := range s.ExtraImageModels {
		name := strings.TrimSpace(m.Name)
		if strings.TrimSpace(m.ModelPath) == "" || name == "" {
			fmt.Fprintf(b, "\n  # SKIPPED extra image model %q: name and modelPath are both required\n", name)
			continue
		}
		if seen[name] || slices.Contains(*emitted, name) {
			fmt.Fprintf(b, "\n  # SKIPPED extra image model %q: that name is already served by another model\n", name)
			continue
		}
		seen[name] = true

		ov := ResolveOverride(GgufRow{FullPath: m.ModelPath}, overrides)
		m = ApplyOverrideToExtraImage(m, ov)
		lines := extraImageCmdLines(s, m).Lines

		fmt.Fprintf(b, "\n  # extra image model (safetensors, sd-server, max-vram=%gGB) - hand-declared, no gguf scan\n", extraImageBudget(s, m))
		fmt.Fprintf(b, "  %q:\n", name)
		b.WriteString("    cmd: >\n")
		for _, line := range lines {
			fmt.Fprintf(b, "      %s\n", line)
		}
		fmt.Fprintf(b, "    ttl: %d\n", s.TtlSec)
		writeSingleDeviceEnv(b, s, imageExe(s, &Override{Backend: m.Backend}))
		writeEstVram(b, extraImageBudget(s, m))
		b.WriteString("    checkEndpoint: /\n")
		writeEnhancerBlock(b, s, name, m.PromptEnhancer, m.PromptEnhancerEdit, m.PromptEnhancerPrompt, m.PromptEnhancerEditPrompt)
		if m.Unlisted {
			b.WriteString("    unlisted: true\n")
		}
		b.WriteString("    capabilities:\n")
		if IsReferenceEditModel(name, ov) {
			b.WriteString("      in: [text, image]\n")
		} else {
			b.WriteString("      in: [text]\n")
		}
		b.WriteString("      out: [image]\n")
		*emitted = append(*emitted, name)
	}
}

// writeMissingComponents carries the roles resolveComponents could not fill
// into the model entry itself. The YAML comment above it is for someone
// reading config.yaml; this field is what the process layer logs at Load,
// which is where a user who downloaded only the DiT is actually looking.
func writeMissingComponents(b *strings.Builder, missing []string) {
	if len(missing) == 0 {
		return
	}
	fmt.Fprintf(b, "    missingComponents: [%s]\n", strings.Join(missing, ", "))
}

func emitImageModel(b *strings.Builder, s Settings, row GgufRow, ov *Override, name, arch string, bakedEnc bool, condHidden int64, emitted *[]string) {
	cmd, budget, graph, offload, missing := imageCmdLines(s, row, ov, arch, name, condHidden, videoInfo{})
	lines := cmd.Lines

	fmt.Fprintf(b, "\n  # arch=%s size=%gGB (image model, sd-server, budget=%gGB, max-vram=%gGB, offload=%t)\n", arch, row.SizeGB, budget, graph, offload)
	// SD/SDXL served as -m full checkpoints: if this gguf has no baked encoders it
	// is a bare UNet, which sd.cpp cannot load standalone (no split path for SDXL).
	if isFullCheckpointArch(arch) && !bakedEnc {
		fmt.Fprintf(b, "  # WARNING: %s looks UNet-only (no baked text encoder) - sd.cpp can't load SD/SDXL split; supply an all-in-one checkpoint\n", name)
	}
	if len(missing) > 0 {
		fmt.Fprintf(b, "  # WARNING: %s needs component(s) [%s] that were not found on disk - generation will fail until they are downloaded or declared\n", name, strings.Join(missing, ", "))
	}
	fmt.Fprintf(b, "  %q:\n", name)
	b.WriteString("    cmd: >\n")
	for _, line := range lines {
		fmt.Fprintf(b, "      %s\n", line)
	}
	fmt.Fprintf(b, "    ttl: %d\n", s.TtlSec)
	writeMissingComponents(b, missing)
	writeSingleDeviceEnv(b, s, imageExe(s, ov))
	// Admission estimate = the whole budget this model was sized against, NOT the
	// --max-vram it launches with: that one is only the graph-cut headroom left
	// once the resident weights are paid for, so charging the scheduler that
	// number would book a 14GB model as if it were the 7GB of slack around it.
	// True peak during sampling/VAE decode runs above it, which is why the
	// scheduler additionally refuses to spawn anything while a render is in
	// flight (see FIFO.imageRenderInFlight) rather than trusting this number
	// alone.
	writeEstVram(b, budget)
	// sd-server has no /health; the webui root returns 200 once loaded.
	b.WriteString("    checkEndpoint: /\n")
	// The enhancer is not a launch flag - sd-server has no such concept. It rides
	// the model entry so the playground can read it off /api/models and offer the
	// rewrite before the render, which is the only place a rewrite is reviewable.
	writeEnhancerBlock(b, s, row.ID, ovPromptEnhancer(ov), ovPromptEnhancerEdit(ov), ovEnhancerPrompt(ov), ovEnhancerEditPrompt(ov))
	if ov != nil && ov.Unlisted {
		b.WriteString("    unlisted: true\n")
	}
	b.WriteString("    capabilities:\n")
	// A reference-edit model declares an image INPUT, which is what tells the
	// playground to send the source as a reference (extra_images) instead of an
	// img2img base. That distinction is not cosmetic: the img2img route scales
	// the step count by the denoise strength, so an 8-step distill silently ran
	// 4 steps and produced artifacted output.
	if IsReferenceEditModel(name, ov) {
		b.WriteString("      in: [text, image]\n")
	} else {
		b.WriteString("      in: [text]\n")
	}
	b.WriteString("      out: [image]\n")
	writeDisplayName(b, s, name)
	*emitted = append(*emitted, name)
}

// wan3dHints orders the two indistinguishable 3D causal VAEs by what the model
// name says its lineage is. Wan/Krea take wan_2.1_vae, everything else in the
// Qwen-Image family takes qwen_image_vae.
// wan3dLatent is the VAE latent width a Wan-3D-family image DiT expects, or 0
// for "no opinion" (match any, the behaviour every other model here relies on).
//
// It cannot be read off the DiT. Qwen-Image's img_in is [64, hidden] for BOTH
// lines: the 20B patchifies a 16-channel latent 2x2 into 64, while 2.1 consumes
// a 64-channel latent whole. The caption projection is what separates them, and
// the file states it: the 20B line is 3584 wide (Qwen2.5-VL-7B) and pairs with
// the 16-channel VAE, 2.1 is 4096 (Qwen3-VL-8B) and pairs with the 64-channel
// RGBA one. Krea2 is arch qwen_image too and sits at 2560, i.e. 16-channel.
//
// Scoped to qwen_image on purpose: Wan VIDEO checkpoints share this family and
// do not share this rule (Wan 2.2 TI2V ships a 48-channel VAE), so they keep
// passing 0 and picking by hint.
func wan3dLatent(arch string, condHidden int64) int64 {
	if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(arch)), "qwen_image") {
		return 0
	}
	if condHidden == 4096 {
		return 64
	}
	return 16
}

// imageDefaultsFor is the image-side counterpart of videoDefaultsFor: the
// steps/cfg an image model launches with when sd-server's built-ins (20 steps,
// cfg 7.0) would render it wrong. Everything else gets the zero profile.
//
// Scoped to the qwen_image arch, the one family here where BOTH built-ins are
// wrong out of the box:
//
//   - A turbo checkpoint (Qwen-Image 2.1 Turbo, Krea2 Turbo) is distilled to 8
//     steps and guidance-free. 20 steps wastes 2.5x the time and cfg 7.0
//     scorches it.
//   - Qwen-Image 2.1 BASE (caption projection 4096 wide, see wan3dLatent) is
//     trained without guidance: diffusers' QwenImage21Pipeline defaults
//     true_cfg_scale=1.0 at 40 steps. This inverts the 20B line (cfg 4.0, 50
//     steps) despite the shared arch tag, so the 20B line is left alone.
//
// Turbo vs base is a NAME read for the same reason as LTX's distilled/dev pair
// (isDistilledName): the turbo is a fine-tune with the base's exact tensor
// table, so nothing in the file separates them. Override.DefaultSteps/
// DefaultCfg still win.
//
// Two more families need pins for the same reason:
//
//   - MOSS-Image (arch moss_image): the publisher's reference call is 50 steps
//     at guidance 3.0, and sd-server's own moss-image spec adds flow_shift 5.
//     Both built-ins are wrong (cfg 7 scorches it). Its reference call also
//     passes uncond_skip_layers=[22], sd.cpp's --skip-layers/--slg-scale; not
//     wired, since the scale has no published value.
//   - FLUX.3 klein: guidance-distilled, so cfg 1, and the publisher measures
//     its quants at 16 steps. Name-matched like its klein arm in
//     resolveComponents: it reports arch "flux".
func imageDefaultsFor(arch, name string, condHidden int64) genDefaults {
	a := strings.ToLower(strings.TrimSpace(arch))
	n := strings.ToLower(name)
	switch {
	case a == "moss_image" || strings.Contains(n, "moss-image") || strings.Contains(n, "moss_image"):
		return genDefaults{steps: 50, cfg: 3.0, flowShift: 5}
	case strings.Contains(n, "flux-3") || strings.Contains(n, "flux3"):
		return genDefaults{steps: 16, cfg: 1.0}
	}
	if !strings.HasPrefix(a, "qwen_image") {
		return genDefaults{}
	}
	switch {
	case isDistilledName(name):
		return genDefaults{steps: 8, cfg: 1.0}
	case condHidden == 4096:
		return genDefaults{steps: 40, cfg: 1.0}
	}
	return genDefaults{}
}

func wan3dHints(name string) []string {
	if strings.Contains(name, "krea") || strings.Contains(name, "wan") {
		return []string{"wan_2.1", "wan2.1", "wan"}
	}
	return []string{"qwen_image", "qwen-image"}
}

// firstNonEmpty returns the first non-blank of its arguments.
func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if strings.TrimSpace(v) != "" {
			return v
		}
	}
	return ""
}