feat(enhance): LoRA strength scale alongside the on/off switch (#218)

So the app builds against the final shape: each [[template.lora]] gains
`strength` (the scale when on, default 1.0) and `min` / `max` (the
slider's range, default 0.0-1.5).

- POST /enhance `loras` takes true / false or a scale: true = strength,
  false or <= 0 = off, a scale is clamped to min..max.
- The scale multiplies each node's exported strength_model /
  strength_clip, so 1.0 = as exported and a high/low pair keeps its
  ratio.
- Jobs and the job view store the resolved scale per id (0 = off);
  GET /enhance/templates lists strength, min and max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-03 14:01:04 -05:00
1 parent 31da21e061
commit cc96873f86
7 files changed
+298 -110

No files matched your search

+10 -8
View File
@@ -460,7 +460,7 @@ auth and stays LAN-only, so ImageApi relays previews too.
`unreachable_grace_secs`, `default_template`, `warn_above_megapixels`, `unreachable_grace_secs`, `default_template`, `warn_above_megapixels`,
`[[path_map]]` (`from`, `to`), `[[template]]` (`name`, `label`, `description`, `[[path_map]]` (`from`, `to`), `[[template]]` (`name`, `label`, `description`,
`file`, `prompt_mode`, `prompt_join`, `output_megapixels`, `[[template.lora]]` `file`, `prompt_mode`, `prompt_join`, `output_megapixels`, `[[template.lora]]`
(`id`, `label`, `default`)). (`id`, `label`, `default`, `strength`, `min`, `max`)).
- **Templates** are API-format workflow exports, read-only (never edit them from - **Templates** are API-format workflow exports, read-only (never edit them from
here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the
photo path), `imageapi:save` (required, forced to `require_approval = true`; a photo path), `imageapi:save` (required, forced to `require_approval = true`; a
@@ -469,9 +469,10 @@ auth and stays LAN-only, so ImageApi relays previews too.
`imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`), `imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`),
`imageapi:reference` (optional), `imageapi:lora:<id>` (optional LoRA switches, #218: `imageapi:reference` (optional), `imageapi:lora:<id>` (optional LoRA switches, #218:
a declared `[[template.lora]]` id drives every node with that title, e.g. Wan's high and a declared `[[template.lora]]` id drives every node with that title, e.g. Wan's high and
low pair; off = `strength_model`/`strength_clip` set to 0, never rewired; a node with no low pair; the resolved scale multiplies each node's exported `strength_model` /
declaration keeps its strength, a declaration with no node isn't offered; both log a `strength_clip` (1.0 = as exported, 0 = off, a pair keeps its ratio), never rewired; a
startup warning). `prompt_mode = "append"`: the user's text is joined node with no declaration keeps its strength, a declaration with no node isn't offered;
both log a startup warning). `prompt_mode = "append"`: the user's text is joined
after the template's own prompt with `prompt_join` (default one space); empty text after the template's own prompt with `prompt_join` (default one space); empty text
leaves the prompt as is. `"replace"`: the text *is* the prompt and is required (empty leaves the prompt as is. `"replace"`: the text *is* the prompt and is required (empty
= 400); it needs a prompt node. With no `reference_path` the reference node **and = 400); it needs a prompt node. With no `reference_path` the reference node **and
@@ -518,9 +519,10 @@ GET /enhance/templates
"prompt_mode": "append"|"replace", "prompt_required", "prompt_mode": "append"|"replace", "prompt_required",
"accepts_reference","default","output_megapixels", "accepts_reference","default","output_megapixels",
"warn_above_megapixels", "output": "image"|"video", "warn_above_megapixels", "output": "image"|"video",
"loras": [{"id","label","default"}]}]} "loras": [{"id","label","default","strength","min","max"}]}]}
POST /enhance {"path","template","prompt"?,"reference_path"?,"loras"?: {id: bool},"library"?} -> job POST /enhance {"path","template","prompt"?,"reference_path"?,"loras"?: {id: bool|scale},"library"?} -> job
(a missing switch uses its default; an id the template doesn't offer = 400) (true = the switch's strength, false or scale <= 0 = off, a scale is clamped to min..max;
a missing switch uses its default; an id the template doesn't offer = 400)
GET /enhance/jobs/{id} -> job GET /enhance/jobs/{id} -> job
GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden
GET /enhance/jobs[?status=a,b] -> {"jobs": [...]} no path = the jobs manager: every photo's GET /enhance/jobs[?status=a,b] -> {"jobs": [...]} no path = the jobs manager: every photo's
@@ -537,7 +539,7 @@ POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` (or `cli
POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched) POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched)
job = {"id","status","queue_position"?,"error"?,"path","library_id","template", job = {"id","status","queue_position"?,"error"?,"path","library_id","template",
"prompt"?,"reference_path"?,"loras"?: {id: bool} (resolved; absent without switches), "prompt"?,"reference_path"?,"loras"?: {id: scale} (resolved, 0 = off; absent without switches),
"created_at","updated_at", "created_at","updated_at",
"run_ms": <ms ComfyUI ran it, or null until finished / when ComfyUI gave no timings>, "run_ms": <ms ComfyUI ran it, or null until finished / when ComfyUI gave no timings>,
"pending": [{"n","preview": "/enhance/jobs/{id}/preview/{n}","approved"}], "pending": [{"n","preview": "/enhance/jobs/{id}/preview/{n}","approved"}],
+11 -5
View File
@@ -64,11 +64,14 @@ prompt_mode = "replace"
# label = "Restore (Dev, slow)" # label = "Restore (Dev, slow)"
# file = "/media/nas/image_fibo2_restore_imageapi_API.json" # file = "/media/nas/image_fibo2_restore_imageapi_API.json"
# A video template with optional LoRAs the app can switch on or off (#218). # A video template with optional LoRAs the app can switch on or off and scale
# Each [[template.lora]] matches the workflow's LoRA loader nodes titled # (#218). Each [[template.lora]] matches the workflow's LoRA loader nodes titled
# "imageapi:lora:<id>" (Wan's high- and low-noise pair share one id). Off sets # "imageapi:lora:<id>" (Wan's high- and low-noise pair share one id). The
# their strength_model (and strength_clip) to 0; the graph isn't rewired. # request's scale multiplies each node's exported strength_model (and
# Nodes titled that way with no entry here keep their exported strength. # strength_clip), so 1.0 = as exported, 0 = off and a pair keeps its ratio; the
# graph isn't rewired. Any number of switches can be on at once (LoRA patches
# add up, so their order in the graph doesn't matter). Nodes titled that way
# with no entry here keep their exported strength.
[[template]] [[template]]
name = "animate" name = "animate"
label = "Animate" label = "Animate"
@@ -81,3 +84,6 @@ output_megapixels = 0.92
id = "style" # node title "imageapi:lora:style" id = "style" # node title "imageapi:lora:style"
label = "Style" label = "Style"
default = false # default: false; used when the request omits it default = false # default: false; used when the request omits it
strength = 1.0 # default: 1.0; the scale when on without a number
min = 0.0 # default: 0.0; slider range while on (a requested
max = 1.5 # default: 1.5; scale is clamped; <= 0 is off)
+103 -2
View File
@@ -117,6 +117,48 @@ pub struct LoraConfig {
/// On when the request doesn't say. /// On when the request doesn't say.
#[serde(default)] #[serde(default)]
pub default: bool, pub default: bool,
/// Scale applied when on and the request gives no number: a factor on the
/// strengths exported in the workflow (1.0 = as exported), so a high/low
/// pair keeps its ratio.
#[serde(default = "default_lora_strength")]
pub strength: f64,
/// Slider bounds; a requested scale is clamped to them.
#[serde(default)]
pub min: f64,
#[serde(default = "default_lora_max")]
pub max: f64,
}
impl LoraConfig {
/// The scale a request resolves to: `true` = `strength`, `false` or a
/// number <= 0 = off (0), another number clamped to `min..=max` (the
/// slider's range while on), absent = the default's.
pub fn resolve(&self, request: Option<LoraRequest>) -> f64 {
match request {
None if self.default => self.strength,
None => 0.0,
Some(LoraRequest::On(true)) => self.strength,
Some(LoraRequest::On(false)) => 0.0,
Some(LoraRequest::Scale(s)) if s <= 0.0 => 0.0,
Some(LoraRequest::Scale(s)) => s.clamp(self.min, self.max),
}
}
}
/// One switch in `POST /enhance { loras }`: on/off, or a scale for the slider.
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum LoraRequest {
On(bool),
Scale(f64),
}
fn default_lora_strength() -> f64 {
1.0
}
fn default_lora_max() -> f64 {
1.5
} }
/// Template names and LoRA ids: non-empty, letters, digits, `_` and `-`. /// Template names and LoRA ids: non-empty, letters, digits, `_` and `-`.
@@ -186,6 +228,22 @@ impl EnhanceConfig {
if l.label.trim().is_empty() { if l.label.trim().is_empty() {
bail!("template {:?}: lora {:?} needs a label", t.name, l.id); bail!("template {:?}: lora {:?} needs a label", t.name, l.id);
} }
let finite = [l.strength, l.min, l.max].iter().all(|v| v.is_finite());
if !finite
|| l.min < 0.0
|| l.min >= l.max
|| !(l.min..=l.max).contains(&l.strength)
{
bail!(
"template {:?}: lora {:?} needs 0 <= min < max and strength within them \
(got min {}, strength {}, max {})",
t.name,
l.id,
l.min,
l.strength,
l.max
);
}
} }
} }
if let Some(d) = &self.default_template if let Some(d) = &self.default_template
@@ -293,12 +351,26 @@ mod tests {
let ok = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n\ let ok = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n\
[[template.lora]]\nid='style'\nlabel='Style'\n\ [[template.lora]]\nid='style'\nlabel='Style'\n\
[[template.lora]]\nid='detail'\nlabel='Detail'\ndefault=true"; [[template.lora]]\nid='detail'\nlabel='Detail'\ndefault=true";
let base = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n";
let cfg = parse(ok).unwrap(); let cfg = parse(ok).unwrap();
let l = &cfg.templates[0].loras; let l = &cfg.templates[0].loras;
assert_eq!((l[0].id.as_str(), l[0].default), ("style", false)); assert_eq!((l[0].id.as_str(), l[0].default), ("style", false));
assert_eq!((l[1].id.as_str(), l[1].default), ("detail", true)); assert_eq!((l[1].id.as_str(), l[1].default), ("detail", true));
assert_eq!((l[0].strength, l[0].min, l[0].max), (1.0, 0.0, 1.5));
let ranged =
format!("{base}[[template.lora]]\nid='x'\nlabel='X'\nstrength=0.8\nmin=0.2\nmax=2.0");
let r = &parse(&ranged).unwrap().templates[0].loras[0];
assert_eq!((r.strength, r.min, r.max), (0.8, 0.2, 2.0));
for bad in ["strength=2.0", "min=1.5", "min=-0.5", "max=0.5"] {
assert!(
parse(&format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\n{bad}"
))
.is_err(),
"{bad}"
);
}
let base = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n";
let dup = format!( let dup = format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\n[[template.lora]]\nid='x'\nlabel='Y'" "{base}[[template.lora]]\nid='x'\nlabel='X'\n[[template.lora]]\nid='x'\nlabel='Y'"
); );
@@ -307,12 +379,41 @@ mod tests {
assert!(parse(&format!("{base}[[template.lora]]\nid='x'\nlabel=' '")).is_err()); assert!(parse(&format!("{base}[[template.lora]]\nid='x'\nlabel=' '")).is_err());
assert!( assert!(
parse(&format!( parse(&format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\nstrength=1" "{base}[[template.lora]]\nid='x'\nlabel='X'\nweight=1"
)) ))
.is_err() .is_err()
); );
} }
#[test]
fn lora_requests_resolve_to_a_scale() {
let l = LoraConfig {
id: "x".into(),
label: "X".into(),
default: false,
strength: 0.8,
min: 0.2,
max: 1.2,
};
assert_eq!(l.resolve(None), 0.0);
assert_eq!(l.resolve(Some(LoraRequest::On(true))), 0.8);
assert_eq!(l.resolve(Some(LoraRequest::On(false))), 0.0);
assert_eq!(l.resolve(Some(LoraRequest::Scale(1.0))), 1.0);
// Clamped to the slider's range; 0 (or less) still means off.
assert_eq!(l.resolve(Some(LoraRequest::Scale(5.0))), 1.2);
assert_eq!(l.resolve(Some(LoraRequest::Scale(0.05))), 0.2);
assert_eq!(l.resolve(Some(LoraRequest::Scale(0.0))), 0.0);
assert_eq!(l.resolve(Some(LoraRequest::Scale(-1.0))), 0.0);
let on = LoraConfig { default: true, ..l };
assert_eq!(on.resolve(None), 0.8);
let parsed: std::collections::BTreeMap<String, LoraRequest> =
serde_json::from_str(r#"{"a": true, "b": false, "c": 0.5, "d": 1}"#).unwrap();
assert_eq!(parsed["a"], LoraRequest::On(true));
assert_eq!(parsed["c"], LoraRequest::Scale(0.5));
assert_eq!(parsed["d"], LoraRequest::Scale(1.0));
}
#[test] #[test]
fn template_defaults() { fn template_defaults() {
let cfg = parse("[[template]]\nname='a'\nlabel='A'\nfile='a.json'").unwrap(); let cfg = parse("[[template]]\nname='a'\nlabel='A'\nfile='a.json'").unwrap();
+6 -5
View File
@@ -91,10 +91,10 @@ pub struct JobView {
prompt: Option<String>, prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
reference_path: Option<String>, reference_path: Option<String>,
/// The LoRA switches this job ran with (#218); absent when the template /// The scale each LoRA switch ran at (#218; 0 = off); absent when the
/// had none. /// template had none.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
loras: Option<std::collections::BTreeMap<String, bool>>, loras: Option<std::collections::BTreeMap<String, f64>>,
created_at: i64, created_at: i64,
updated_at: i64, updated_at: i64,
/// Milliseconds ComfyUI ran the job; null until it has finished. /// Milliseconds ComfyUI ran the job; null until it has finished.
@@ -291,9 +291,10 @@ pub struct SubmitBody {
pub prompt: Option<String>, pub prompt: Option<String>,
#[serde(default)] #[serde(default)]
pub reference_path: Option<String>, pub reference_path: Option<String>,
/// LoRA switches by id (#218); a missing id uses its default. /// LoRA switches by id (#218): `true` / `false` or a scale (clamped to
/// the switch's range; <= 0 is off). A missing id uses its default.
#[serde(default)] #[serde(default)]
pub loras: std::collections::BTreeMap<String, bool>, pub loras: std::collections::BTreeMap<String, super::config::LoraRequest>,
#[serde(default)] #[serde(default)]
pub library: Option<String>, pub library: Option<String>,
} }
+24 -6
View File
@@ -183,6 +183,9 @@ fn animate_with_style() -> Template {
id: "style".into(), id: "style".into(),
label: "Style".into(), label: "Style".into(),
default: false, default: false,
strength: 0.8,
min: 0.0,
max: 1.5,
}]; }];
Template::from_value(cfg, serde_json::from_str(ANIMATE_LORAS).unwrap()).unwrap() Template::from_value(cfg, serde_json::from_str(ANIMATE_LORAS).unwrap()).unwrap()
} }
@@ -1127,12 +1130,12 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
let animate = templates.iter().find(|t| t["name"] == "animate").unwrap(); let animate = templates.iter().find(|t| t["name"] == "animate").unwrap();
assert_eq!( assert_eq!(
animate["loras"], animate["loras"],
json!([{"id": "style", "label": "Style", "default": false}]) json!([{"id": "style", "label": "Style", "default": false, "strength": 0.8, "min": 0.0, "max": 1.5}])
); );
let restore = templates.iter().find(|t| t["name"] == "restore").unwrap(); let restore = templates.iter().find(|t| t["name"] == "restore").unwrap();
assert_eq!(restore["loras"], json!([])); assert_eq!(restore["loras"], json!([]));
// On: the pair keeps its strength, and the job remembers the choice. // On: the configured strength, and the job remembers it.
let (st, job, _) = call( let (st, job, _) = call(
&fx, &fx,
"POST", "POST",
@@ -1141,7 +1144,7 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
) )
.await; .await;
assert_eq!(st, 200, "{job}"); assert_eq!(st, 200, "{job}");
assert_eq!(job["loras"], json!({"style": true})); assert_eq!(job["loras"], json!({"style": 0.8}));
let (_, again, _) = call( let (_, again, _) = call(
&fx, &fx,
"GET", "GET",
@@ -1149,7 +1152,7 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
None, None,
) )
.await; .await;
assert_eq!(again["loras"], json!({"style": true})); assert_eq!(again["loras"], json!({"style": 0.8}));
// Omitted: the default (off) zeroes both nodes. // Omitted: the default (off) zeroes both nodes.
let (st, off, _) = call( let (st, off, _) = call(
@@ -1160,12 +1163,27 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
) )
.await; .await;
assert_eq!(st, 200, "{off}"); assert_eq!(st, 200, "{off}");
assert_eq!(off["loras"], json!({"style": false})); assert_eq!(off["loras"], json!({"style": 0.0}));
// A slider value scales the exported strength.
let (st, half, _) = call(
&fx,
"POST",
"/enhance",
Some(json!({"path": PHOTO, "template": "animate", "loras": {"style": 0.5}})),
)
.await;
assert_eq!(st, 200, "{half}");
assert_eq!(half["loras"], json!({"style": 0.5}));
{ {
let sent = fx.mock.submitted.lock().unwrap(); let sent = fx.mock.submitted.lock().unwrap();
assert_eq!( assert_eq!(
sent[0]["prompt"]["50"]["inputs"]["strength_model"], sent[0]["prompt"]["50"]["inputs"]["strength_model"],
json!(1.0) json!(0.8)
);
assert_eq!(
sent[2]["prompt"]["51"]["inputs"]["strength_model"],
json!(0.5)
); );
assert_eq!( assert_eq!(
sent[1]["prompt"]["50"]["inputs"]["strength_model"], sent[1]["prompt"]["50"]["inputs"]["strength_model"],
+1 -1
View File
@@ -292,7 +292,7 @@ impl EnhanceService {
template_name: &str, template_name: &str,
prompt: Option<&str>, prompt: Option<&str>,
reference: Option<&SourceFile>, reference: Option<&SourceFile>,
loras: &std::collections::BTreeMap<String, bool>, loras: &std::collections::BTreeMap<String, super::config::LoraRequest>,
) -> Result<EnhanceJobRow, EnhanceError> { ) -> Result<EnhanceJobRow, EnhanceError> {
let Some(client) = &self.client else { let Some(client) = &self.client else {
return Err(EnhanceError::Disabled); return Err(EnhanceError::Disabled);
+143 -83
View File
@@ -10,7 +10,7 @@ use std::path::Path;
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use super::config::{LoraConfig, PromptMode, TemplateConfig}; use super::config::{LoraConfig, LoraRequest, PromptMode, TemplateConfig};
pub const TITLE_SOURCE: &str = "imageapi:source"; pub const TITLE_SOURCE: &str = "imageapi:source";
pub const TITLE_SAVE: &str = "imageapi:save"; pub const TITLE_SAVE: &str = "imageapi:save";
@@ -19,8 +19,8 @@ pub const TITLE_REFERENCE: &str = "imageapi:reference";
/// `imageapi:lora:<id>`: a LoRA loader the app can switch off (#218). /// `imageapi:lora:<id>`: a LoRA loader the app can switch off (#218).
pub const TITLE_LORA_PREFIX: &str = "imageapi:lora:"; pub const TITLE_LORA_PREFIX: &str = "imageapi:lora:";
/// LoRA loader inputs a switched-off LoRA sets to 0. `strength_model` is /// LoRA loader inputs a switch scales (0 = off). `strength_model` is
/// required (LoraLoaderModelOnly); `strength_clip` is zeroed when present /// required (LoraLoaderModelOnly); `strength_clip` is scaled when present
/// (LoraLoader). /// (LoraLoader).
const LORA_STRENGTH_INPUTS: &[&str] = &["strength_model", "strength_clip"]; const LORA_STRENGTH_INPUTS: &[&str] = &["strength_model", "strength_clip"];
@@ -70,8 +70,9 @@ pub struct Template {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct LoraSwitch { struct LoraSwitch {
cfg: LoraConfig, cfg: LoraConfig,
/// Every node titled `imageapi:lora:<id>`. /// Every node titled `imageapi:lora:<id>`, with its exported strengths
nodes: Vec<String>, /// (`(input, value)`), which the switch's scale multiplies.
nodes: Vec<(String, Vec<(&'static str, f64)>)>,
} }
/// Why a request can't be turned into a ComfyUI prompt. The message is for /// Why a request can't be turned into a ComfyUI prompt. The message is for
@@ -91,9 +92,9 @@ impl std::fmt::Display for FillError {
pub struct Filled { pub struct Filled {
pub prompt: Value, pub prompt: Value,
pub dropped_reference: bool, pub dropped_reference: bool,
/// Every switch's resolved state (request, else its default); stored on /// Every switch's resolved scale (0 = off; request, else its default);
/// the job so a retry repeats it. Empty for templates without switches. /// stored on the job so a retry repeats it. Empty without switches.
pub loras: BTreeMap<String, bool>, pub loras: BTreeMap<String, f64>,
} }
impl Template { impl Template {
@@ -192,8 +193,21 @@ impl Template {
let mut loras = Vec::new(); let mut loras = Vec::new();
for l in &cfg.loras { for l in &cfg.loras {
match lora_nodes.remove(l.id.as_str()) { match lora_nodes.remove(l.id.as_str()) {
Some(mut nodes) => { Some(mut ids) => {
nodes.sort(); ids.sort();
let nodes = ids
.into_iter()
.map(|id| {
let inputs = inputs_of(&workflow, &id);
let exported = LORA_STRENGTH_INPUTS
.iter()
.filter_map(|k| {
inputs.get(*k).and_then(Value::as_f64).map(|v| (*k, v))
})
.collect();
(id, exported)
})
.collect();
loras.push(LoraSwitch { loras.push(LoraSwitch {
cfg: l.clone(), cfg: l.clone(),
nodes, nodes,
@@ -265,14 +279,15 @@ impl Template {
/// - Every `seed` / `noise_seed` / `*.seed` input gets a fresh value /// - Every `seed` / `noise_seed` / `*.seed` input gets a fresh value
/// (ComfyUI's "randomize after generate" only exists in its page), or a /// (ComfyUI's "randomize after generate" only exists in its page), or a
/// retry would return the cached result. /// retry would return the cached result.
/// - `loras` turns switches on or off (missing = the switch's default); /// - `loras` sets each switch (missing = its default; see
/// an off LoRA's strengths are set to 0, so the graph is never rewired. /// [`LoraConfig::resolve`]): the resolved scale multiplies the node's
/// exported strengths, 0 being off, so the graph is never rewired.
pub fn fill( pub fn fill(
&self, &self,
source: &str, source: &str,
prompt: Option<&str>, prompt: Option<&str>,
reference: Option<&str>, reference: Option<&str>,
loras: &BTreeMap<String, bool>, loras: &BTreeMap<String, LoraRequest>,
next_seed: &mut dyn FnMut() -> u64, next_seed: &mut dyn FnMut() -> u64,
) -> Result<Filled, FillError> { ) -> Result<Filled, FillError> {
if let Some(unknown) = loras if let Some(unknown) = loras
@@ -338,18 +353,11 @@ impl Template {
let mut resolved = BTreeMap::new(); let mut resolved = BTreeMap::new();
for switch in &self.loras { for switch in &self.loras {
let on = loras let scale = switch.cfg.resolve(loras.get(&switch.cfg.id).copied());
.get(&switch.cfg.id) resolved.insert(switch.cfg.id.clone(), scale);
.copied() for (id, exported) in &switch.nodes {
.unwrap_or(switch.cfg.default); for (input, value) in exported {
resolved.insert(switch.cfg.id.clone(), on); set_input(&mut wf, id, input, json!(value * scale));
if !on {
for id in &switch.nodes {
for input in LORA_STRENGTH_INPUTS {
if inputs_of(&wf, id).get(*input).is_some_and(Value::is_number) {
set_input(&mut wf, id, input, json!(0.0));
}
}
} }
} }
} }
@@ -433,6 +441,9 @@ mod tests {
id: id.into(), id: id.into(),
label: id.into(), label: id.into(),
default, default,
strength: 1.0,
min: 0.0,
max: 1.5,
} }
} }
@@ -442,10 +453,18 @@ mod tests {
Template::from_value(c, serde_json::from_str(json).unwrap()) Template::from_value(c, serde_json::from_str(json).unwrap())
} }
fn switches(pairs: &[(&str, bool)]) -> BTreeMap<String, bool> { fn reqs(pairs: &[(&str, LoraRequest)]) -> BTreeMap<String, LoraRequest> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
} }
fn scales(pairs: &[(&str, f64)]) -> BTreeMap<String, f64> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
fn none() -> BTreeMap<String, LoraRequest> {
BTreeMap::new()
}
fn cfg(name: &str, mode: PromptMode) -> TemplateConfig { fn cfg(name: &str, mode: PromptMode) -> TemplateConfig {
TemplateConfig { TemplateConfig {
name: name.into(), name: name.into(),
@@ -547,7 +566,7 @@ mod tests {
fn fill_sets_path_forces_approval_and_randomises_every_seed() { fn fill_sets_path_forces_approval_and_randomises_every_seed() {
let t = load(RESTORE, PromptMode::Append).unwrap(); let t = load(RESTORE, PromptMode::Append).unwrap();
let f = t let f = t
.fill("Z:\\p\\a.jpg", None, None, &BTreeMap::new(), &mut seeds()) .fill("Z:\\p\\a.jpg", None, None, &none(), &mut seeds())
.unwrap(); .unwrap();
assert_eq!(input(&f.prompt, "37", "path"), "Z:\\p\\a.jpg"); assert_eq!(input(&f.prompt, "37", "path"), "Z:\\p\\a.jpg");
assert_eq!(input(&f.prompt, "36", "require_approval"), true); assert_eq!(input(&f.prompt, "36", "require_approval"), true);
@@ -560,8 +579,8 @@ mod tests {
assert_ne!(a, 774744764437710); assert_ne!(a, 774744764437710);
// Two fills with a real RNG differ. // Two fills with a real RNG differ.
let mut rng = random_seed; let mut rng = random_seed;
let x = t.fill("p", None, None, &BTreeMap::new(), &mut rng).unwrap(); let x = t.fill("p", None, None, &none(), &mut rng).unwrap();
let y = t.fill("p", None, None, &BTreeMap::new(), &mut rng).unwrap(); let y = t.fill("p", None, None, &none(), &mut rng).unwrap();
assert_ne!(input(&x.prompt, "5", "seed"), input(&y.prompt, "5", "seed")); assert_ne!(input(&x.prompt, "5", "seed"), input(&y.prompt, "5", "seed"));
} }
@@ -587,9 +606,7 @@ mod tests {
// SeedVR2VideoUpscaler rejects seeds above 2^32 - 1 at validation. // SeedVR2VideoUpscaler rejects seeds above 2^32 - 1 at validation.
let t = load(RESTORE, PromptMode::Append).unwrap(); let t = load(RESTORE, PromptMode::Append).unwrap();
for _ in 0..200 { for _ in 0..200 {
let f = t let f = t.fill("p", None, None, &none(), &mut random_seed).unwrap();
.fill("p", None, None, &BTreeMap::new(), &mut random_seed)
.unwrap();
assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX)); assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX));
} }
} }
@@ -598,9 +615,7 @@ mod tests {
fn force_approval_even_if_the_export_had_it_off() { fn force_approval_even_if_the_export_had_it_off() {
let off = RESTORE.replace("\"require_approval\": true", "\"require_approval\": false"); let off = RESTORE.replace("\"require_approval\": true", "\"require_approval\": false");
let t = load(&off, PromptMode::Append).unwrap(); let t = load(&off, PromptMode::Append).unwrap();
let f = t let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(input(&f.prompt, "36", "require_approval"), true); assert_eq!(input(&f.prompt, "36", "require_approval"), true);
} }
@@ -626,12 +641,10 @@ mod tests {
"Increase the clarity\n\nAdditional instructions: warmer" "Increase the clarity\n\nAdditional instructions: warmer"
); );
let f = t let f = t
.fill("p", Some(" "), None, &BTreeMap::new(), &mut seeds()) .fill("p", Some(" "), None, &none(), &mut seeds())
.unwrap(); .unwrap();
assert_eq!(input(&f.prompt, "13", "value"), base); assert_eq!(input(&f.prompt, "13", "value"), base);
let f = t let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(input(&f.prompt, "13", "value"), base); assert_eq!(input(&f.prompt, "13", "value"), base);
} }
@@ -654,12 +667,10 @@ mod tests {
#[test] #[test]
fn replace_mode_requires_and_uses_the_instruction() { fn replace_mode_requires_and_uses_the_instruction() {
let t = load(EDIT, PromptMode::Replace).unwrap(); let t = load(EDIT, PromptMode::Replace).unwrap();
let err = t let err = t.fill("p", None, None, &none(), &mut seeds()).unwrap_err();
.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.unwrap_err();
assert!(err.0.contains("instruction")); assert!(err.0.contains("instruction"));
assert!( assert!(
t.fill("p", Some(" "), None, &BTreeMap::new(), &mut seeds()) t.fill("p", Some(" "), None, &none(), &mut seeds())
.is_err() .is_err()
); );
let f = t let f = t
@@ -681,19 +692,13 @@ mod tests {
// 14 links to 13; drop that link so the graph stays plausible. // 14 links to 13; drop that link so the graph stays plausible.
v["14"]["inputs"].as_object_mut().unwrap().remove("prompt"); v["14"]["inputs"].as_object_mut().unwrap().remove("prompt");
let t = Template::from_value(cfg("t", PromptMode::Append), v).unwrap(); let t = Template::from_value(cfg("t", PromptMode::Append), v).unwrap();
assert!( assert!(t.fill("p", Some("x"), None, &none(), &mut seeds()).is_err());
t.fill("p", Some("x"), None, &BTreeMap::new(), &mut seeds()) assert!(t.fill("p", None, None, &none(), &mut seeds()).is_ok());
.is_err()
);
assert!(
t.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.is_ok()
);
let restore = load(RESTORE, PromptMode::Append).unwrap(); let restore = load(RESTORE, PromptMode::Append).unwrap();
assert!( assert!(
restore restore
.fill("p", None, Some("r"), &BTreeMap::new(), &mut seeds()) .fill("p", None, Some("r"), &none(), &mut seeds())
.is_err() .is_err()
); );
} }
@@ -719,7 +724,7 @@ mod tests {
fn no_reference_removes_the_node_and_every_link_to_it() { fn no_reference_removes_the_node_and_every_link_to_it() {
let t = load(EDIT, PromptMode::Replace).unwrap(); let t = load(EDIT, PromptMode::Replace).unwrap();
let f = t let f = t
.fill("src", Some("x"), None, &BTreeMap::new(), &mut seeds()) .fill("src", Some("x"), None, &none(), &mut seeds())
.unwrap(); .unwrap();
assert!(f.dropped_reference); assert!(f.dropped_reference);
let wf = f.prompt.as_object().unwrap(); let wf = f.prompt.as_object().unwrap();
@@ -746,10 +751,8 @@ mod tests {
assert!(t.warnings.is_empty(), "{:?}", t.warnings); assert!(t.warnings.is_empty(), "{:?}", t.warnings);
// Default off: both the high and the low node go to 0. // Default off: both the high and the low node go to 0.
let off = t let off = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
.fill("p", None, None, &BTreeMap::new(), &mut seeds()) assert_eq!(off.loras, scales(&[("style", 0.0)]));
.unwrap();
assert_eq!(off.loras, switches(&[("style", false)]));
for id in ["50", "51"] { for id in ["50", "51"] {
assert_eq!(input(&off.prompt, id, "strength_model"), &json!(0.0)); assert_eq!(input(&off.prompt, id, "strength_model"), &json!(0.0));
} }
@@ -757,49 +760,108 @@ mod tests {
assert_eq!(input(&off.prompt, "14", "model"), &json!(["50", 0])); assert_eq!(input(&off.prompt, "14", "model"), &json!(["50", 0]));
assert_eq!(input(&off.prompt, "12", "strength_model"), &json!(1.0)); assert_eq!(input(&off.prompt, "12", "strength_model"), &json!(1.0));
// On: the exported strength stays. // On: the configured strength (1.0 = as exported).
let on = t let on = t
.fill("p", None, None, &switches(&[("style", true)]), &mut seeds()) .fill(
"p",
None,
None,
&reqs(&[("style", LoraRequest::On(true))]),
&mut seeds(),
)
.unwrap(); .unwrap();
assert_eq!(on.loras, switches(&[("style", true)])); assert_eq!(on.loras, scales(&[("style", 1.0)]));
for id in ["50", "51"] { for id in ["50", "51"] {
assert_eq!(input(&on.prompt, id, "strength_model"), &json!(1.0)); assert_eq!(input(&on.prompt, id, "strength_model"), &json!(1.0));
} }
} }
#[test] #[test]
fn lora_default_on_and_unknown_ids_refused() { fn a_scale_multiplies_each_nodes_exported_strength() {
let t = load_with_loras(ANIMATE_LORAS, vec![lora("style", true)]).unwrap(); // A pair exported at different strengths keeps its ratio.
let mut v: Value = serde_json::from_str(ANIMATE_LORAS).unwrap();
v["51"]["inputs"]["strength_model"] = json!(0.8);
let mut c = cfg("animate", PromptMode::Append);
c.loras = vec![lora("style", false)];
let t = Template::from_value(c, v).unwrap();
let f = t let f = t
.fill("p", None, None, &BTreeMap::new(), &mut seeds()) .fill(
"p",
None,
None,
&reqs(&[("style", LoraRequest::Scale(0.5))]),
&mut seeds(),
)
.unwrap(); .unwrap();
assert_eq!(f.loras, switches(&[("style", true)])); assert_eq!(f.loras, scales(&[("style", 0.5)]));
assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0)); assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(0.5));
assert_eq!(input(&f.prompt, "51", "strength_model"), &json!(0.4));
// Clamped to the switch's max; 0 is off.
let f = t
.fill(
"p",
None,
None,
&reqs(&[("style", LoraRequest::Scale(9.0))]),
&mut seeds(),
)
.unwrap();
assert_eq!(f.loras, scales(&[("style", 1.5)]));
let f = t
.fill(
"p",
None,
None,
&reqs(&[("style", LoraRequest::Scale(0.0))]),
&mut seeds(),
)
.unwrap();
assert_eq!(input(&f.prompt, "51", "strength_model"), &json!(0.0));
}
#[test]
fn lora_default_on_and_unknown_ids_refused() {
let mut l = lora("style", true);
l.strength = 0.7;
let t = load_with_loras(ANIMATE_LORAS, vec![l]).unwrap();
let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
assert_eq!(f.loras, scales(&[("style", 0.7)]));
assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(0.7));
let err = t let err = t
.fill("p", None, None, &switches(&[("nope", true)]), &mut seeds()) .fill(
"p",
None,
None,
&reqs(&[("nope", LoraRequest::On(true))]),
&mut seeds(),
)
.unwrap_err(); .unwrap_err();
assert!(err.0.contains("nope"), "{err}"); assert!(err.0.contains("nope"), "{err}");
} }
#[test] #[test]
fn lora_clip_strength_is_zeroed_too() { fn lora_clip_strength_is_scaled_too() {
let mut v: Value = serde_json::from_str(ANIMATE_LORAS).unwrap(); let mut v: Value = serde_json::from_str(ANIMATE_LORAS).unwrap();
v["50"]["class_type"] = json!("LoraLoader"); v["50"]["class_type"] = json!("LoraLoader");
v["50"]["inputs"]["strength_clip"] = json!(0.8); v["50"]["inputs"]["strength_clip"] = json!(0.8);
let t = Template::from_value( let mut c = cfg("animate", PromptMode::Append);
{ c.loras = vec![lora("style", false)];
let mut c = cfg("animate", PromptMode::Append); let t = Template::from_value(c, v).unwrap();
c.loras = vec![lora("style", false)]; let off = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
c assert_eq!(input(&off.prompt, "50", "strength_clip"), &json!(0.0));
}, let half = t
v, .fill(
) "p",
.unwrap(); None,
let f = t None,
.fill("p", None, None, &BTreeMap::new(), &mut seeds()) &reqs(&[("style", LoraRequest::Scale(0.5))]),
&mut seeds(),
)
.unwrap(); .unwrap();
assert_eq!(input(&f.prompt, "50", "strength_clip"), &json!(0.0)); assert_eq!(input(&half.prompt, "50", "strength_clip"), &json!(0.4));
} }
#[test] #[test]
@@ -812,9 +874,7 @@ mod tests {
"{:?}", "{:?}",
t.warnings t.warnings
); );
let f = t let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.unwrap();
assert!(f.loras.is_empty()); assert!(f.loras.is_empty());
assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0)); assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0));
assert!( assert!(
@@ -822,7 +882,7 @@ mod tests {
"p", "p",
None, None,
None, None,
&switches(&[("style", false)]), &reqs(&[("style", LoraRequest::On(false))]),
&mut seeds() &mut seeds()
) )
.is_err() .is_err()