import type { LoraStackItem } from './loras' /** An omitted field inherits the shared settings; an empty LoRA stack means none. */ export interface ImageIteration { prompt: string steps?: number cfg?: number seed?: number | 'same' | 'random' strength?: number loraStack?: LoraStackItem[] } export const MAX_IMAGE_ITERATIONS = 50 function optionalNumber(raw: unknown, label: string, min: number, max: number, integer = false) { if (raw == null || raw === '') return undefined const value = Number(raw) if (!Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) { throw new Error(`${label} must be ${integer ? 'a whole number ' : ''}between ${min} and ${max}.`) } return value } export function normalizeImageIterations(raw: unknown): ImageIteration[] { if (raw == null || raw === '') return [] if (typeof raw === 'string') { try { raw = JSON.parse(raw) } catch { throw new Error('Iterations must be a valid JSON list.') } } if (!Array.isArray(raw)) throw new Error('Iterations must be a list.') if (raw.length >= MAX_IMAGE_ITERATIONS) throw new Error(`Use at most ${MAX_IMAGE_ITERATIONS} images including the first.`) return raw.map((item, index) => { if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error(`Iteration ${index + 2} is invalid.`) const result: ImageIteration = { prompt: String(item.prompt ?? '').trim().slice(0, 8000) } const steps = optionalNumber(item.steps, 'Steps', 1, 50, true) const cfg = optionalNumber(item.cfg, 'CFG', 0, 20) const strength = optionalNumber(item.strength, 'Denoise', 0.15, 0.75) if (steps !== undefined) result.steps = steps if (cfg !== undefined) result.cfg = Math.round(cfg * 10) / 10 if (strength !== undefined) result.strength = Math.round(strength * 100) / 100 if (item.seed === 'same' || item.seed === 'random') result.seed = item.seed else { const seed = optionalNumber(item.seed, 'Seed', 0, 2_147_483_647, true) if (seed !== undefined) result.seed = seed } if (item.loraStack != null) { if (!Array.isArray(item.loraStack) || item.loraStack.length > 8) throw new Error('Use at most 8 LoRAs per iteration.') result.loraStack = item.loraStack.map((lora: Record) => { const name = String(lora?.name ?? '').trim() if (!name) throw new Error('Each iteration LoRA needs a name.') return { name, strengthModel: optionalNumber(lora.strengthModel, 'LoRA model strength', -2, 2) ?? 1, strengthClip: optionalNumber(lora.strengthClip, 'LoRA CLIP strength', -2, 2) ?? 1 } }) } return result }) } export function resolveImageIteration(base: { steps: number; cfg: number; seed: number; strength?: number }, pass?: ImageIteration, random = Math.random) { return { steps: pass?.steps ?? base.steps, cfg: pass?.cfg ?? base.cfg, strength: pass?.strength ?? base.strength, seed: !pass || pass.seed === 'same' ? base.seed : typeof pass.seed === 'number' ? pass.seed : Math.floor(random() * 2_147_483_647) } } /** Ordered Cartesian product: each CFG value is rendered at each requested step count. */ export function imageIterationSweep(stepText: string, cfgText: string) { const parse = (text: string) => text.split(/[,\s]+/).filter(Boolean).map(Number) const steps = parse(stepText) const cfgs = parse(cfgText) if (!steps.length || !cfgs.length) throw new Error('Enter at least one steps value and one CFG value.') if (steps.length * cfgs.length > MAX_IMAGE_ITERATIONS) throw new Error(`A sweep can contain at most ${MAX_IMAGE_ITERATIONS} images.`) const rows = cfgs.flatMap(cfg => steps.map(steps => ({ prompt: '', steps, cfg, seed: 'same' as const }))) // Validate the first row too; the normal parser reserves one slot for the shared first image. return rows.map(row => normalizeImageIterations([row])[0]) }