Files
aigen/utils/imageIterations.ts
T

80 lines
3.8 KiB
TypeScript

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<string, unknown>) => {
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])
}