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:
1 parent
e317bfee98
commit
31da21e061
12 files changed
+910
-33
No files matched your search
+67
-5
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user