diff --git a/CLAUDE.md b/CLAUDE.md index eaf17bf..480c9bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -460,7 +460,7 @@ auth and stays LAN-only, so ImageApi relays previews too. `unreachable_grace_secs`, `default_template`, `warn_above_megapixels`, `[[path_map]]` (`from`, `to`), `[[template]]` (`name`, `label`, `description`, `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 here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the 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:reference` (optional), `imageapi:lora:` (optional LoRA switches, #218: 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 - 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 + low pair; the resolved scale multiplies each node's exported `strength_model` / + `strength_clip` (1.0 = as exported, 0 = off, a pair keeps its ratio), never rewired; a + 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 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 @@ -518,9 +519,10 @@ GET /enhance/templates "prompt_mode": "append"|"replace", "prompt_required", "accepts_reference","default","output_megapixels", "warn_above_megapixels", "output": "image"|"video", - "loras": [{"id","label","default"}]}]} -POST /enhance {"path","template","prompt"?,"reference_path"?,"loras"?: {id: bool},"library"?} -> job - (a missing switch uses its default; an id the template doesn't offer = 400) + "loras": [{"id","label","default","strength","min","max"}]}]} +POST /enhance {"path","template","prompt"?,"reference_path"?,"loras"?: {id: bool|scale},"library"?} -> job + (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?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden 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) 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", "run_ms": , "pending": [{"n","preview": "/enhance/jobs/{id}/preview/{n}","approved"}], diff --git a/enhance.example.toml b/enhance.example.toml index 5d7a787..b52e0c4 100644 --- a/enhance.example.toml +++ b/enhance.example.toml @@ -64,11 +64,14 @@ prompt_mode = "replace" # label = "Restore (Dev, slow)" # file = "/media/nas/image_fibo2_restore_imageapi_API.json" -# A video template with optional LoRAs the app can switch on or off (#218). -# Each [[template.lora]] matches the workflow's LoRA loader nodes titled -# "imageapi:lora:" (Wan's high- and low-noise pair share one id). Off sets -# their strength_model (and strength_clip) to 0; the graph isn't rewired. -# Nodes titled that way with no entry here keep their exported strength. +# A video template with optional LoRAs the app can switch on or off and scale +# (#218). Each [[template.lora]] matches the workflow's LoRA loader nodes titled +# "imageapi:lora:" (Wan's high- and low-noise pair share one id). The +# request's scale multiplies each node's exported strength_model (and +# 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]] name = "animate" label = "Animate" @@ -81,3 +84,6 @@ output_megapixels = 0.92 id = "style" # node title "imageapi:lora:style" label = "Style" 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) diff --git a/src/enhance/config.rs b/src/enhance/config.rs index 0265c99..f958fb7 100644 --- a/src/enhance/config.rs +++ b/src/enhance/config.rs @@ -117,6 +117,48 @@ pub struct LoraConfig { /// On when the request doesn't say. #[serde(default)] 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) -> 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 `-`. @@ -186,6 +228,22 @@ impl EnhanceConfig { if l.label.trim().is_empty() { 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 @@ -293,12 +351,26 @@ mod tests { 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 base = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n"; 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)); + 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!( "{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='X'\nstrength=1" + "{base}[[template.lora]]\nid='x'\nlabel='X'\nweight=1" )) .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 = + 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] fn template_defaults() { let cfg = parse("[[template]]\nname='a'\nlabel='A'\nfile='a.json'").unwrap(); diff --git a/src/enhance/handlers.rs b/src/enhance/handlers.rs index 591f142..55633c0 100644 --- a/src/enhance/handlers.rs +++ b/src/enhance/handlers.rs @@ -91,10 +91,10 @@ pub struct JobView { prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] reference_path: Option, - /// The LoRA switches this job ran with (#218); absent when the template - /// had none. + /// The scale each LoRA switch ran at (#218; 0 = off); absent when the + /// template had none. #[serde(skip_serializing_if = "Option::is_none")] - loras: Option>, + loras: Option>, created_at: i64, updated_at: i64, /// Milliseconds ComfyUI ran the job; null until it has finished. @@ -291,9 +291,10 @@ pub struct SubmitBody { pub prompt: Option, #[serde(default)] pub reference_path: Option, - /// 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)] - pub loras: std::collections::BTreeMap, + pub loras: std::collections::BTreeMap, #[serde(default)] pub library: Option, } diff --git a/src/enhance/integration_tests.rs b/src/enhance/integration_tests.rs index 74f8f5f..f5670ba 100644 --- a/src/enhance/integration_tests.rs +++ b/src/enhance/integration_tests.rs @@ -183,6 +183,9 @@ fn animate_with_style() -> Template { id: "style".into(), label: "Style".into(), default: false, + strength: 0.8, + min: 0.0, + max: 1.5, }]; 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(); assert_eq!( 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(); 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( &fx, "POST", @@ -1141,7 +1144,7 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() { ) .await; assert_eq!(st, 200, "{job}"); - assert_eq!(job["loras"], json!({"style": true})); + assert_eq!(job["loras"], json!({"style": 0.8})); let (_, again, _) = call( &fx, "GET", @@ -1149,7 +1152,7 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() { None, ) .await; - assert_eq!(again["loras"], json!({"style": true})); + assert_eq!(again["loras"], json!({"style": 0.8})); // Omitted: the default (off) zeroes both nodes. let (st, off, _) = call( @@ -1160,12 +1163,27 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() { ) .await; 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(); assert_eq!( 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!( sent[1]["prompt"]["50"]["inputs"]["strength_model"], diff --git a/src/enhance/service.rs b/src/enhance/service.rs index a69d358..a189d89 100644 --- a/src/enhance/service.rs +++ b/src/enhance/service.rs @@ -292,7 +292,7 @@ impl EnhanceService { template_name: &str, prompt: Option<&str>, reference: Option<&SourceFile>, - loras: &std::collections::BTreeMap, + loras: &std::collections::BTreeMap, ) -> Result { let Some(client) = &self.client else { return Err(EnhanceError::Disabled); diff --git a/src/enhance/template.rs b/src/enhance/template.rs index 6fc1e7a..acf1549 100644 --- a/src/enhance/template.rs +++ b/src/enhance/template.rs @@ -10,7 +10,7 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; 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_SAVE: &str = "imageapi:save"; @@ -19,8 +19,8 @@ pub const TITLE_REFERENCE: &str = "imageapi:reference"; /// `imageapi:lora:`: a LoRA loader the app can switch off (#218). pub const TITLE_LORA_PREFIX: &str = "imageapi:lora:"; -/// LoRA loader inputs a switched-off LoRA sets to 0. `strength_model` is -/// required (LoraLoaderModelOnly); `strength_clip` is zeroed when present +/// LoRA loader inputs a switch scales (0 = off). `strength_model` is +/// required (LoraLoaderModelOnly); `strength_clip` is scaled when present /// (LoraLoader). const LORA_STRENGTH_INPUTS: &[&str] = &["strength_model", "strength_clip"]; @@ -70,8 +70,9 @@ pub struct Template { #[derive(Debug, Clone)] struct LoraSwitch { cfg: LoraConfig, - /// Every node titled `imageapi:lora:`. - nodes: Vec, + /// Every node titled `imageapi:lora:`, with its exported strengths + /// (`(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 @@ -91,9 +92,9 @@ impl std::fmt::Display for FillError { pub struct Filled { pub prompt: Value, pub dropped_reference: bool, - /// Every switch's resolved state (request, else its default); stored on - /// the job so a retry repeats it. Empty for templates without switches. - pub loras: BTreeMap, + /// Every switch's resolved scale (0 = off; request, else its default); + /// stored on the job so a retry repeats it. Empty without switches. + pub loras: BTreeMap, } impl Template { @@ -192,8 +193,21 @@ impl Template { let mut loras = Vec::new(); for l in &cfg.loras { match lora_nodes.remove(l.id.as_str()) { - Some(mut nodes) => { - nodes.sort(); + Some(mut ids) => { + 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 { cfg: l.clone(), nodes, @@ -265,14 +279,15 @@ impl Template { /// - Every `seed` / `noise_seed` / `*.seed` input gets a fresh value /// (ComfyUI's "randomize after generate" only exists in its page), or a /// retry would return the cached result. - /// - `loras` turns switches on or off (missing = the switch's default); - /// an off LoRA's strengths are set to 0, so the graph is never rewired. + /// - `loras` sets each switch (missing = its default; see + /// [`LoraConfig::resolve`]): the resolved scale multiplies the node's + /// exported strengths, 0 being off, so the graph is never rewired. pub fn fill( &self, source: &str, prompt: Option<&str>, reference: Option<&str>, - loras: &BTreeMap, + loras: &BTreeMap, next_seed: &mut dyn FnMut() -> u64, ) -> Result { if let Some(unknown) = loras @@ -338,18 +353,11 @@ impl Template { let mut resolved = BTreeMap::new(); for switch in &self.loras { - let on = loras - .get(&switch.cfg.id) - .copied() - .unwrap_or(switch.cfg.default); - resolved.insert(switch.cfg.id.clone(), on); - 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)); - } - } + let scale = switch.cfg.resolve(loras.get(&switch.cfg.id).copied()); + resolved.insert(switch.cfg.id.clone(), scale); + for (id, exported) in &switch.nodes { + for (input, value) in exported { + set_input(&mut wf, id, input, json!(value * scale)); } } } @@ -433,6 +441,9 @@ mod tests { id: id.into(), label: id.into(), 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()) } - fn switches(pairs: &[(&str, bool)]) -> BTreeMap { + fn reqs(pairs: &[(&str, LoraRequest)]) -> BTreeMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() } + fn scales(pairs: &[(&str, f64)]) -> BTreeMap { + pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() + } + + fn none() -> BTreeMap { + BTreeMap::new() + } + fn cfg(name: &str, mode: PromptMode) -> TemplateConfig { TemplateConfig { name: name.into(), @@ -547,7 +566,7 @@ mod tests { fn fill_sets_path_forces_approval_and_randomises_every_seed() { let t = load(RESTORE, PromptMode::Append).unwrap(); 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(); assert_eq!(input(&f.prompt, "37", "path"), "Z:\\p\\a.jpg"); assert_eq!(input(&f.prompt, "36", "require_approval"), true); @@ -560,8 +579,8 @@ mod tests { assert_ne!(a, 774744764437710); // Two fills with a real RNG differ. let mut rng = random_seed; - let x = t.fill("p", None, None, &BTreeMap::new(), &mut rng).unwrap(); - let y = 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, &none(), &mut rng).unwrap(); 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. let t = load(RESTORE, PromptMode::Append).unwrap(); for _ in 0..200 { - let f = t - .fill("p", None, None, &BTreeMap::new(), &mut random_seed) - .unwrap(); + let f = t.fill("p", None, None, &none(), &mut random_seed).unwrap(); 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() { let off = RESTORE.replace("\"require_approval\": true", "\"require_approval\": false"); let t = load(&off, PromptMode::Append).unwrap(); - let f = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .unwrap(); + let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap(); assert_eq!(input(&f.prompt, "36", "require_approval"), true); } @@ -626,12 +641,10 @@ mod tests { "Increase the clarity\n\nAdditional instructions: warmer" ); let f = t - .fill("p", Some(" "), None, &BTreeMap::new(), &mut seeds()) + .fill("p", Some(" "), None, &none(), &mut seeds()) .unwrap(); assert_eq!(input(&f.prompt, "13", "value"), base); - let f = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .unwrap(); + let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap(); assert_eq!(input(&f.prompt, "13", "value"), base); } @@ -654,12 +667,10 @@ mod tests { #[test] fn replace_mode_requires_and_uses_the_instruction() { let t = load(EDIT, PromptMode::Replace).unwrap(); - let err = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .unwrap_err(); + let err = t.fill("p", None, None, &none(), &mut seeds()).unwrap_err(); assert!(err.0.contains("instruction")); assert!( - t.fill("p", Some(" "), None, &BTreeMap::new(), &mut seeds()) + t.fill("p", Some(" "), None, &none(), &mut seeds()) .is_err() ); let f = t @@ -681,19 +692,13 @@ mod tests { // 14 links to 13; drop that link so the graph stays plausible. v["14"]["inputs"].as_object_mut().unwrap().remove("prompt"); let t = Template::from_value(cfg("t", PromptMode::Append), v).unwrap(); - assert!( - t.fill("p", Some("x"), None, &BTreeMap::new(), &mut seeds()) - .is_err() - ); - assert!( - t.fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .is_ok() - ); + assert!(t.fill("p", Some("x"), None, &none(), &mut seeds()).is_err()); + assert!(t.fill("p", None, None, &none(), &mut seeds()).is_ok()); let restore = load(RESTORE, PromptMode::Append).unwrap(); assert!( restore - .fill("p", None, Some("r"), &BTreeMap::new(), &mut seeds()) + .fill("p", None, Some("r"), &none(), &mut seeds()) .is_err() ); } @@ -719,7 +724,7 @@ mod tests { fn no_reference_removes_the_node_and_every_link_to_it() { let t = load(EDIT, PromptMode::Replace).unwrap(); let f = t - .fill("src", Some("x"), None, &BTreeMap::new(), &mut seeds()) + .fill("src", Some("x"), None, &none(), &mut seeds()) .unwrap(); assert!(f.dropped_reference); let wf = f.prompt.as_object().unwrap(); @@ -746,10 +751,8 @@ mod tests { assert!(t.warnings.is_empty(), "{:?}", t.warnings); // Default off: both the high and the low node go to 0. - let off = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .unwrap(); - assert_eq!(off.loras, switches(&[("style", false)])); + let off = t.fill("p", None, None, &none(), &mut seeds()).unwrap(); + assert_eq!(off.loras, scales(&[("style", 0.0)])); for id in ["50", "51"] { 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, "12", "strength_model"), &json!(1.0)); - // On: the exported strength stays. + // On: the configured strength (1.0 = as exported). let on = t - .fill("p", None, None, &switches(&[("style", true)]), &mut seeds()) + .fill( + "p", + None, + None, + &reqs(&[("style", LoraRequest::On(true))]), + &mut seeds(), + ) .unwrap(); - assert_eq!(on.loras, switches(&[("style", true)])); + assert_eq!(on.loras, scales(&[("style", 1.0)])); for id in ["50", "51"] { assert_eq!(input(&on.prompt, id, "strength_model"), &json!(1.0)); } } #[test] - fn lora_default_on_and_unknown_ids_refused() { - let t = load_with_loras(ANIMATE_LORAS, vec![lora("style", true)]).unwrap(); + fn a_scale_multiplies_each_nodes_exported_strength() { + // 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 - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) + .fill( + "p", + None, + None, + &reqs(&[("style", LoraRequest::Scale(0.5))]), + &mut seeds(), + ) .unwrap(); - assert_eq!(f.loras, switches(&[("style", true)])); - assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0)); + assert_eq!(f.loras, scales(&[("style", 0.5)])); + 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 - .fill("p", None, None, &switches(&[("nope", true)]), &mut seeds()) + .fill( + "p", + None, + None, + &reqs(&[("nope", LoraRequest::On(true))]), + &mut seeds(), + ) .unwrap_err(); assert!(err.0.contains("nope"), "{err}"); } #[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(); v["50"]["class_type"] = json!("LoraLoader"); 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)]; - c - }, - v, - ) - .unwrap(); - let f = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) + let mut c = cfg("animate", PromptMode::Append); + c.loras = vec![lora("style", false)]; + let t = Template::from_value(c, v).unwrap(); + let off = t.fill("p", None, None, &none(), &mut seeds()).unwrap(); + assert_eq!(input(&off.prompt, "50", "strength_clip"), &json!(0.0)); + let half = t + .fill( + "p", + None, + None, + &reqs(&[("style", LoraRequest::Scale(0.5))]), + &mut seeds(), + ) .unwrap(); - assert_eq!(input(&f.prompt, "50", "strength_clip"), &json!(0.0)); + assert_eq!(input(&half.prompt, "50", "strength_clip"), &json!(0.4)); } #[test] @@ -812,9 +874,7 @@ mod tests { "{:?}", t.warnings ); - let f = t - .fill("p", None, None, &BTreeMap::new(), &mut seeds()) - .unwrap(); + let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap(); assert!(f.loras.is_empty()); assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0)); assert!( @@ -822,7 +882,7 @@ mod tests { "p", None, None, - &switches(&[("style", false)]), + &reqs(&[("style", LoraRequest::On(false))]), &mut seeds() ) .is_err()