feat(enhance): LoRA switches per template (#218)

A template can offer optional LoRAs the app turns on or off.

- enhance.toml: [[template.lora]] { id, label, default } per template.
  Each id drives every workflow node titled `imageapi:lora:<id>`, so
  Wan's high- and low-noise pair share one switch.
- POST /enhance takes `loras: {id: bool}`; a missing id uses its default,
  an id the template doesn't offer is a 400. Off sets strength_model
  (and strength_clip when present) to 0, so the graph is never rewired.
- The resolved switches are stored on the job (new enhance_jobs.loras
  JSON column) and returned in the job view, so a retry or adjust can
  repeat them; GET /enhance/templates lists each template's switches.
- A lora-titled node without a declaration keeps its exported strength,
  and a declaration without nodes isn't offered; both are startup
  warnings. A lora-titled node without a numeric strength_model fails
  the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-03 13:53:53 -05:00
1 parent e317bfee98
commit 31da21e061
12 files changed
+910 -33

No files matched your search

+67 -5
View File
@@ -100,6 +100,30 @@ pub struct TemplateConfig {
/// Approximate output size, for the client's large-original warning.
#[serde(default = "default_output_mp")]
pub output_megapixels: f64,
/// Optional LoRAs the app can switch on or off (#218), each matching the
/// workflow nodes titled `imageapi:lora:<id>`.
#[serde(rename = "lora", default)]
pub loras: Vec<LoraConfig>,
}
/// One LoRA switch (`[[template.lora]]`).
#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct LoraConfig {
/// Matches the nodes titled `imageapi:lora:<id>` (Wan's high and low
/// pair share one id); clients send it in `POST /enhance { loras }`.
pub id: String,
pub label: String,
/// On when the request doesn't say.
#[serde(default)]
pub default: bool,
}
/// Template names and LoRA ids: non-empty, letters, digits, `_` and `-`.
fn valid_id(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn default_join() -> String {
@@ -132,11 +156,7 @@ impl EnhanceConfig {
}
let mut seen = std::collections::HashSet::new();
for t in &self.templates {
let ok = !t.name.is_empty()
&& t.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !ok {
if !valid_id(&t.name) {
bail!(
"template name {:?} must be non-empty and use only letters, digits, '_' and '-'",
t.name
@@ -151,6 +171,22 @@ impl EnhanceConfig {
if t.output_megapixels.is_nan() || t.output_megapixels <= 0.0 {
bail!("template {:?}: output_megapixels must be > 0", t.name);
}
let mut ids = std::collections::HashSet::new();
for l in &t.loras {
if !valid_id(&l.id) {
bail!(
"template {:?}: lora id {:?} must be non-empty and use only letters, digits, '_' and '-'",
t.name,
l.id
);
}
if !ids.insert(l.id.as_str()) {
bail!("template {:?}: duplicate lora id {:?}", t.name, l.id);
}
if l.label.trim().is_empty() {
bail!("template {:?}: lora {:?} needs a label", t.name, l.id);
}
}
}
if let Some(d) = &self.default_template
&& !self.templates.iter().any(|t| &t.name == d)
@@ -252,6 +288,31 @@ mod tests {
assert!(parse("[[path_map]]\nfrom=''\nto='Z:\\\\'").is_err());
}
#[test]
fn lora_switches_parse_and_validate() {
let ok = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n\
[[template.lora]]\nid='style'\nlabel='Style'\n\
[[template.lora]]\nid='detail'\nlabel='Detail'\ndefault=true";
let cfg = parse(ok).unwrap();
let l = &cfg.templates[0].loras;
assert_eq!((l[0].id.as_str(), l[0].default), ("style", false));
assert_eq!((l[1].id.as_str(), l[1].default), ("detail", true));
let base = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n";
let dup = format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\n[[template.lora]]\nid='x'\nlabel='Y'"
);
assert!(parse(&dup).is_err());
assert!(parse(&format!("{base}[[template.lora]]\nid='a b'\nlabel='X'")).is_err());
assert!(parse(&format!("{base}[[template.lora]]\nid='x'\nlabel=' '")).is_err());
assert!(
parse(&format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\nstrength=1"
))
.is_err()
);
}
#[test]
fn template_defaults() {
let cfg = parse("[[template]]\nname='a'\nlabel='A'\nfile='a.json'").unwrap();
@@ -259,5 +320,6 @@ mod tests {
assert_eq!(t.prompt_mode, PromptMode::Append);
assert_eq!(t.prompt_join, " ");
assert_eq!(t.output_megapixels, 1.0);
assert!(t.loras.is_empty());
}
}