Merge pull request 'feat(enhance): LoRA switches per template (#218)' (#219) from feat/enhance-lora-switches into master
Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
commit
5e1519fa80
12 files changed
+1098
-33
No files matched your search
@@ -459,14 +459,20 @@ auth and stays LAN-only, so ImageApi relays previews too.
|
|||||||
Keys: `url`, `timeout_secs`, `probe_ttl_secs`, `poll_secs`, `idle_free_secs`,
|
Keys: `url`, `timeout_secs`, `probe_ttl_secs`, `poll_secs`, `idle_free_secs`,
|
||||||
`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`).
|
`file`, `prompt_mode`, `prompt_join`, `output_megapixels`, `[[template.lora]]`
|
||||||
|
(`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
|
||||||
`ImageApiSaveVideoVersion` there makes the template's `output` `video`, an MP4 clip,
|
`ImageApiSaveVideoVersion` there makes the template's `output` `video`, an MP4 clip,
|
||||||
else `image`),
|
else `image`),
|
||||||
`imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`),
|
`imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`),
|
||||||
`imageapi:reference` (optional). `prompt_mode = "append"`: the user's text is joined
|
`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
|
||||||
|
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
|
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
|
||||||
@@ -512,8 +518,11 @@ GET /enhance/templates
|
|||||||
"templates": [{"name","label","description","accepts_prompt",
|
"templates": [{"name","label","description","accepts_prompt",
|
||||||
"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",
|
||||||
POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job
|
"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/{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
|
||||||
@@ -530,7 +539,8 @@ 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"?,"created_at","updated_at",
|
"prompt"?,"reference_path"?,"loras"?: {id: scale} (resolved, 0 = off; absent without switches),
|
||||||
|
"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"}],
|
||||||
"saved": [{"n","path","library_id"}]}
|
"saved": [{"n","path","library_id"}]}
|
||||||
|
|||||||
@@ -63,3 +63,27 @@ prompt_mode = "replace"
|
|||||||
# name = "restore-dev"
|
# name = "restore-dev"
|
||||||
# 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 and scale
|
||||||
|
# (#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). 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"
|
||||||
|
description = "A short clip from the photo."
|
||||||
|
file = "/media/nas/image_wan22_animate_imageapi_API.json"
|
||||||
|
prompt_join = ", "
|
||||||
|
output_megapixels = 0.92
|
||||||
|
|
||||||
|
[[template.lora]]
|
||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE enhance_jobs DROP COLUMN loras;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- The job's resolved LoRA switches (#218): JSON {id: bool}; NULL = the template had none.
|
||||||
|
ALTER TABLE enhance_jobs ADD COLUMN loras TEXT;
|
||||||
@@ -62,11 +62,15 @@ pub struct EnhanceJobRow {
|
|||||||
/// Milliseconds ComfyUI actually ran the job; set once it finishes.
|
/// Milliseconds ComfyUI actually ran the job; set once it finishes.
|
||||||
#[diesel(sql_type = Nullable<BigInt>)]
|
#[diesel(sql_type = Nullable<BigInt>)]
|
||||||
pub run_ms: Option<i64>,
|
pub run_ms: Option<i64>,
|
||||||
|
/// The resolved LoRA switches (#218), JSON `{id: bool}`; `None` when the
|
||||||
|
/// template had none. Owned by `crate::enhance` like `pending`.
|
||||||
|
#[diesel(sql_type = Nullable<Text>)]
|
||||||
|
pub loras: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS: &str = "id, original_hash, library_id, rel_path, template, prompt, reference_hash, \
|
const COLUMNS: &str = "id, original_hash, library_id, rel_path, template, prompt, reference_hash, \
|
||||||
reference_path, comfy_prompt_id, status, error, queue_position, pending, saved, \
|
reference_path, comfy_prompt_id, status, error, queue_position, pending, saved, \
|
||||||
discarded, created_at, updated_at, run_ms";
|
discarded, created_at, updated_at, run_ms, loras";
|
||||||
|
|
||||||
/// One distinct prompt used across jobs, newest use first.
|
/// One distinct prompt used across jobs, newest use first.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)]
|
#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)]
|
||||||
@@ -171,7 +175,7 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
|||||||
trace_db_call("insert", "enhance_job_insert", |_span| {
|
trace_db_call("insert", "enhance_job_insert", |_span| {
|
||||||
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
let mut conn = self.connection.lock().expect("Unable to get EnhanceJobDao");
|
||||||
sql_query(format!(
|
sql_query(format!(
|
||||||
"INSERT INTO enhance_jobs ({COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
"INSERT INTO enhance_jobs ({COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||||
))
|
))
|
||||||
.bind::<Text, _>(&job.id)
|
.bind::<Text, _>(&job.id)
|
||||||
.bind::<Text, _>(&job.original_hash)
|
.bind::<Text, _>(&job.original_hash)
|
||||||
@@ -191,6 +195,7 @@ impl EnhanceJobDao for SqliteEnhanceJobDao {
|
|||||||
.bind::<BigInt, _>(job.created_at)
|
.bind::<BigInt, _>(job.created_at)
|
||||||
.bind::<BigInt, _>(job.updated_at)
|
.bind::<BigInt, _>(job.updated_at)
|
||||||
.bind::<Nullable<BigInt>, _>(job.run_ms)
|
.bind::<Nullable<BigInt>, _>(job.run_ms)
|
||||||
|
.bind::<Nullable<Text>, _>(&job.loras)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||||
@@ -370,6 +375,7 @@ mod tests {
|
|||||||
created_at: created,
|
created_at: created,
|
||||||
updated_at: created,
|
updated_at: created,
|
||||||
run_ms: None,
|
run_ms: None,
|
||||||
|
loras: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ diesel::table! {
|
|||||||
created_at -> BigInt,
|
created_at -> BigInt,
|
||||||
updated_at -> BigInt,
|
updated_at -> BigInt,
|
||||||
run_ms -> Nullable<BigInt>,
|
run_ms -> Nullable<BigInt>,
|
||||||
|
loras -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+168
-5
@@ -100,6 +100,72 @@ pub struct TemplateConfig {
|
|||||||
/// Approximate output size, for the client's large-original warning.
|
/// Approximate output size, for the client's large-original warning.
|
||||||
#[serde(default = "default_output_mp")]
|
#[serde(default = "default_output_mp")]
|
||||||
pub output_megapixels: f64,
|
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,
|
||||||
|
/// 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 `-`.
|
||||||
|
fn valid_id(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_join() -> String {
|
fn default_join() -> String {
|
||||||
@@ -132,11 +198,7 @@ impl EnhanceConfig {
|
|||||||
}
|
}
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for t in &self.templates {
|
for t in &self.templates {
|
||||||
let ok = !t.name.is_empty()
|
if !valid_id(&t.name) {
|
||||||
&& t.name
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
|
|
||||||
if !ok {
|
|
||||||
bail!(
|
bail!(
|
||||||
"template name {:?} must be non-empty and use only letters, digits, '_' and '-'",
|
"template name {:?} must be non-empty and use only letters, digits, '_' and '-'",
|
||||||
t.name
|
t.name
|
||||||
@@ -151,6 +213,38 @@ impl EnhanceConfig {
|
|||||||
if t.output_megapixels.is_nan() || t.output_megapixels <= 0.0 {
|
if t.output_megapixels.is_nan() || t.output_megapixels <= 0.0 {
|
||||||
bail!("template {:?}: output_megapixels must be > 0", t.name);
|
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);
|
||||||
|
}
|
||||||
|
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
|
||||||
&& !self.templates.iter().any(|t| &t.name == d)
|
&& !self.templates.iter().any(|t| &t.name == d)
|
||||||
@@ -252,6 +346,74 @@ mod tests {
|
|||||||
assert!(parse("[[path_map]]\nfrom=''\nto='Z:\\\\'").is_err());
|
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 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 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'\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<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();
|
||||||
@@ -259,5 +421,6 @@ mod tests {
|
|||||||
assert_eq!(t.prompt_mode, PromptMode::Append);
|
assert_eq!(t.prompt_mode, PromptMode::Append);
|
||||||
assert_eq!(t.prompt_join, " ");
|
assert_eq!(t.prompt_join, " ");
|
||||||
assert_eq!(t.output_megapixels, 1.0);
|
assert_eq!(t.output_megapixels, 1.0);
|
||||||
|
assert!(t.loras.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +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 scale each LoRA switch ran at (#218; 0 = off); absent when the
|
||||||
|
/// template had none.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
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.
|
||||||
@@ -126,6 +130,10 @@ pub fn job_view(job: &EnhanceJobRow) -> JobView {
|
|||||||
template: job.template.clone(),
|
template: job.template.clone(),
|
||||||
prompt: job.prompt.clone(),
|
prompt: job.prompt.clone(),
|
||||||
reference_path: job.reference_path.clone(),
|
reference_path: job.reference_path.clone(),
|
||||||
|
loras: job
|
||||||
|
.loras
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|j| serde_json::from_str(j).ok()),
|
||||||
created_at: job.created_at,
|
created_at: job.created_at,
|
||||||
updated_at: job.updated_at,
|
updated_at: job.updated_at,
|
||||||
run_ms: job.run_ms,
|
run_ms: job.run_ms,
|
||||||
@@ -223,6 +231,8 @@ struct TemplateView {
|
|||||||
warn_above_megapixels: f64,
|
warn_above_megapixels: f64,
|
||||||
/// `image` (a PNG version) or `video` (an MP4 clip, e.g. Animate).
|
/// `image` (a PNG version) or `video` (an MP4 clip, e.g. Animate).
|
||||||
output: super::template::OutputKind,
|
output: super::template::OutputKind,
|
||||||
|
/// Optional LoRAs the client can switch on or off (#218).
|
||||||
|
loras: Vec<super::config::LoraConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/enhance/templates")]
|
#[get("/enhance/templates")]
|
||||||
@@ -251,6 +261,7 @@ pub async fn get_templates(_: Claims, svc: web::Data<EnhanceService>) -> impl Re
|
|||||||
output_megapixels: t.cfg.output_megapixels,
|
output_megapixels: t.cfg.output_megapixels,
|
||||||
warn_above_megapixels: svc.config.warn_above_megapixels,
|
warn_above_megapixels: svc.config.warn_above_megapixels,
|
||||||
output: t.output,
|
output: t.output,
|
||||||
|
loras: t.lora_switches().cloned().collect(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
@@ -280,6 +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): `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<String, super::config::LoraRequest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub library: Option<String>,
|
pub library: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -325,6 +340,7 @@ pub async fn submit(
|
|||||||
&body.template,
|
&body.template,
|
||||||
body.prompt.as_deref(),
|
body.prompt.as_deref(),
|
||||||
reference.as_ref(),
|
reference.as_ref(),
|
||||||
|
&body.loras,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::state::AppState;
|
|||||||
use crate::versions::xmp::test_png::{producer_xmp, write_png};
|
use crate::versions::xmp::test_png::{producer_xmp, write_png};
|
||||||
|
|
||||||
const RESTORE: &str = include_str!("../../tests/fixtures/enhance/restore_turbo.json");
|
const RESTORE: &str = include_str!("../../tests/fixtures/enhance/restore_turbo.json");
|
||||||
|
const ANIMATE_LORAS: &str = include_str!("../../tests/fixtures/enhance/animate_loras.json");
|
||||||
const EDIT: &str = include_str!("../../tests/fixtures/enhance/edit_turbo.json");
|
const EDIT: &str = include_str!("../../tests/fixtures/enhance/edit_turbo.json");
|
||||||
|
|
||||||
// ---- mock ComfyUI ---------------------------------------------------------
|
// ---- mock ComfyUI ---------------------------------------------------------
|
||||||
@@ -168,12 +169,27 @@ fn template(name: &str, mode: PromptMode, json: &str) -> Template {
|
|||||||
prompt_mode: mode,
|
prompt_mode: mode,
|
||||||
prompt_join: " ".into(),
|
prompt_join: " ".into(),
|
||||||
output_megapixels: 1.0,
|
output_megapixels: 1.0,
|
||||||
|
loras: Vec::new(),
|
||||||
},
|
},
|
||||||
serde_json::from_str(json).unwrap(),
|
serde_json::from_str(json).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Animate fixture with its `imageapi:lora:style` pair as a switch.
|
||||||
|
fn animate_with_style() -> Template {
|
||||||
|
let mut cfg = template("animate", PromptMode::Append, ANIMATE_LORAS).cfg;
|
||||||
|
cfg.loras = vec![crate::enhance::config::LoraConfig {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
async fn fx_with(url: Option<String>, tweak: impl FnOnce(&mut EnhanceConfig)) -> Fx {
|
async fn fx_with(url: Option<String>, tweak: impl FnOnce(&mut EnhanceConfig)) -> Fx {
|
||||||
let (mock, mock_url) = start_mock().await;
|
let (mock, mock_url) = start_mock().await;
|
||||||
let mut state = AppState::test_state();
|
let mut state = AppState::test_state();
|
||||||
@@ -205,6 +221,7 @@ async fn fx_with(url: Option<String>, tweak: impl FnOnce(&mut EnhanceConfig)) ->
|
|||||||
vec![
|
vec![
|
||||||
template("restore", PromptMode::Append, RESTORE),
|
template("restore", PromptMode::Append, RESTORE),
|
||||||
template("edit", PromptMode::Replace, EDIT),
|
template("edit", PromptMode::Replace, EDIT),
|
||||||
|
animate_with_style(),
|
||||||
],
|
],
|
||||||
dao,
|
dao,
|
||||||
)
|
)
|
||||||
@@ -333,7 +350,7 @@ async fn comfy_down_means_unavailable_and_a_clear_submit_error() {
|
|||||||
assert_eq!(st, 200);
|
assert_eq!(st, 200);
|
||||||
assert_eq!(body["available"], false);
|
assert_eq!(body["available"], false);
|
||||||
assert_eq!(body["reason"], "ComfyUI isn't running");
|
assert_eq!(body["reason"], "ComfyUI isn't running");
|
||||||
assert_eq!(body["templates"].as_array().unwrap().len(), 2);
|
assert_eq!(body["templates"].as_array().unwrap().len(), 3);
|
||||||
|
|
||||||
let (st, body, _) = call(
|
let (st, body, _) = call(
|
||||||
&fx,
|
&fx,
|
||||||
@@ -992,6 +1009,7 @@ async fn unreachable_active_jobs_become_unavailable_after_the_grace_period() {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
run_ms: None,
|
run_ms: None,
|
||||||
|
loras: None,
|
||||||
};
|
};
|
||||||
SqliteEnhanceJobDao::from_shared(fx.conn.clone())
|
SqliteEnhanceJobDao::from_shared(fx.conn.clone())
|
||||||
.insert(&dao_job)
|
.insert(&dao_job)
|
||||||
@@ -1101,3 +1119,107 @@ async fn recent_prompts_answers_when_enhance_is_off() {
|
|||||||
let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await;
|
let (st, body, _) = call(&fx, "GET", "/enhance/prompts", None).await;
|
||||||
assert_eq!((st, body), (200, json!({"prompts": []})));
|
assert_eq!((st, body), (200, json!({"prompts": []})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
|
||||||
|
let fx = fx().await;
|
||||||
|
fx.put(PHOTO, b"photo");
|
||||||
|
|
||||||
|
let (_, body, _) = call(&fx, "GET", "/enhance/templates", None).await;
|
||||||
|
let templates = body["templates"].as_array().unwrap();
|
||||||
|
let animate = templates.iter().find(|t| t["name"] == "animate").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
animate["loras"],
|
||||||
|
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 configured strength, and the job remembers it.
|
||||||
|
let (st, job, _) = call(
|
||||||
|
&fx,
|
||||||
|
"POST",
|
||||||
|
"/enhance",
|
||||||
|
Some(json!({"path": PHOTO, "template": "animate", "loras": {"style": true}})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, 200, "{job}");
|
||||||
|
assert_eq!(job["loras"], json!({"style": 0.8}));
|
||||||
|
let (_, again, _) = call(
|
||||||
|
&fx,
|
||||||
|
"GET",
|
||||||
|
&format!("/enhance/jobs/{}", job["id"].as_str().unwrap()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(again["loras"], json!({"style": 0.8}));
|
||||||
|
|
||||||
|
// Omitted: the default (off) zeroes both nodes.
|
||||||
|
let (st, off, _) = call(
|
||||||
|
&fx,
|
||||||
|
"POST",
|
||||||
|
"/enhance",
|
||||||
|
Some(json!({"path": PHOTO, "template": "animate"})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, 200, "{off}");
|
||||||
|
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!(0.8)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sent[2]["prompt"]["51"]["inputs"]["strength_model"],
|
||||||
|
json!(0.5)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sent[1]["prompt"]["50"]["inputs"]["strength_model"],
|
||||||
|
json!(0.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sent[1]["prompt"]["51"]["inputs"]["strength_model"],
|
||||||
|
json!(0.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown switch, or one on a template without switches: 400.
|
||||||
|
let (st, err, _) = call(
|
||||||
|
&fx,
|
||||||
|
"POST",
|
||||||
|
"/enhance",
|
||||||
|
Some(json!({"path": PHOTO, "template": "animate", "loras": {"nope": true}})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, 400, "{err}");
|
||||||
|
let (st, _, _) = call(
|
||||||
|
&fx,
|
||||||
|
"POST",
|
||||||
|
"/enhance",
|
||||||
|
Some(json!({"path": PHOTO, "template": "restore", "loras": {"style": true}})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, 400);
|
||||||
|
|
||||||
|
// A template without switches stores none.
|
||||||
|
let (_, plain, _) = call(
|
||||||
|
&fx,
|
||||||
|
"POST",
|
||||||
|
"/enhance",
|
||||||
|
Some(json!({"path": PHOTO, "template": "restore"})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(plain.get("loras").is_none(), "{plain}");
|
||||||
|
}
|
||||||
+10
-1
@@ -292,6 +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, 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);
|
||||||
@@ -319,7 +320,13 @@ impl EnhanceService {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let filled = template
|
let filled = template
|
||||||
.fill(&source, prompt, reference_path.as_deref(), &mut random_seed)
|
.fill(
|
||||||
|
&source,
|
||||||
|
prompt,
|
||||||
|
reference_path.as_deref(),
|
||||||
|
loras,
|
||||||
|
&mut random_seed,
|
||||||
|
)
|
||||||
.map_err(|e| EnhanceError::BadRequest(e.0))?;
|
.map_err(|e| EnhanceError::BadRequest(e.0))?;
|
||||||
|
|
||||||
if !self.probe_now().await {
|
if !self.probe_now().await {
|
||||||
@@ -365,6 +372,8 @@ impl EnhanceService {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
run_ms: None,
|
run_ms: None,
|
||||||
|
loras: (!filled.loras.is_empty())
|
||||||
|
.then(|| serde_json::to_string(&filled.loras).unwrap_or_default()),
|
||||||
};
|
};
|
||||||
self.dao
|
self.dao
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
+382
-19
@@ -4,17 +4,25 @@
|
|||||||
//! Nodes are found by `_meta.title`, never by id, so re-exporting after
|
//! Nodes are found by `_meta.title`, never by id, so re-exporting after
|
||||||
//! editing the graph keeps working.
|
//! editing the graph keeps working.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
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::{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";
|
||||||
pub const TITLE_PROMPT: &str = "imageapi:prompt";
|
pub const TITLE_PROMPT: &str = "imageapi:prompt";
|
||||||
pub const TITLE_REFERENCE: &str = "imageapi:reference";
|
pub const TITLE_REFERENCE: &str = "imageapi:reference";
|
||||||
|
/// `imageapi:lora:<id>`: a LoRA loader the app can switch off (#218).
|
||||||
|
pub const TITLE_LORA_PREFIX: &str = "imageapi:lora:";
|
||||||
|
|
||||||
|
/// 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"];
|
||||||
|
|
||||||
/// Prompt-node inputs, in the order they are tried.
|
/// Prompt-node inputs, in the order they are tried.
|
||||||
const PROMPT_INPUTS: &[&str] = &["text", "prompt", "value"];
|
const PROMPT_INPUTS: &[&str] = &["text", "prompt", "value"];
|
||||||
@@ -51,12 +59,22 @@ pub struct Template {
|
|||||||
/// `(node id, input name)` of the prompt text.
|
/// `(node id, input name)` of the prompt text.
|
||||||
prompt: Option<(String, &'static str)>,
|
prompt: Option<(String, &'static str)>,
|
||||||
reference_id: Option<String>,
|
reference_id: Option<String>,
|
||||||
|
/// Declared LoRA switches that have nodes, in `enhance.toml` order.
|
||||||
|
loras: Vec<LoraSwitch>,
|
||||||
/// Image or video, from the save node's class.
|
/// Image or video, from the save node's class.
|
||||||
pub output: OutputKind,
|
pub output: OutputKind,
|
||||||
/// Startup warnings (stray output nodes).
|
/// Startup warnings (stray output nodes).
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LoraSwitch {
|
||||||
|
cfg: LoraConfig,
|
||||||
|
/// Every node titled `imageapi:lora:<id>`, 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
|
/// Why a request can't be turned into a ComfyUI prompt. The message is for
|
||||||
/// the caller, so it names the problem in the user's terms.
|
/// the caller, so it names the problem in the user's terms.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -74,6 +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 scale (0 = off; request, else its default);
|
||||||
|
/// stored on the job so a retry repeats it. Empty without switches.
|
||||||
|
pub loras: BTreeMap<String, f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Template {
|
impl Template {
|
||||||
@@ -150,6 +171,62 @@ impl Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
// LoRA switches: declared ids collect their nodes; a declared id with
|
||||||
|
// none is skipped (not offered), an undeclared node keeps its strength.
|
||||||
|
let mut lora_nodes: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||||
|
for (id, node) in &workflow {
|
||||||
|
if let Some(lora) = title_of(node).and_then(|t| t.strip_prefix(TITLE_LORA_PREFIX)) {
|
||||||
|
if !inputs_of(&workflow, id)
|
||||||
|
.get("strength_model")
|
||||||
|
.is_some_and(Value::is_number)
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"node {id} titled {:?} has no numeric strength_model input \
|
||||||
|
(use a LoraLoaderModelOnly or LoraLoader)",
|
||||||
|
format!("{TITLE_LORA_PREFIX}{lora}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lora_nodes.entry(lora).or_default().push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut loras = Vec::new();
|
||||||
|
for l in &cfg.loras {
|
||||||
|
match lora_nodes.remove(l.id.as_str()) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => warnings.push(format!(
|
||||||
|
"lora {:?} has no node titled {:?}, so it isn't offered",
|
||||||
|
l.id,
|
||||||
|
format!("{TITLE_LORA_PREFIX}{}", l.id)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for lora in lora_nodes.keys() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"node titled {:?} has no [[template.lora]] entry, so it stays at its exported strength",
|
||||||
|
format!("{TITLE_LORA_PREFIX}{lora}")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
for (id, node) in &workflow {
|
for (id, node) in &workflow {
|
||||||
let class = node["class_type"].as_str().unwrap_or_default();
|
let class = node["class_type"].as_str().unwrap_or_default();
|
||||||
if STRAY_OUTPUT_CLASSES.contains(&class) {
|
if STRAY_OUTPUT_CLASSES.contains(&class) {
|
||||||
@@ -170,6 +247,7 @@ impl Template {
|
|||||||
save_id,
|
save_id,
|
||||||
prompt,
|
prompt,
|
||||||
reference_id,
|
reference_id,
|
||||||
|
loras,
|
||||||
output,
|
output,
|
||||||
warnings,
|
warnings,
|
||||||
})
|
})
|
||||||
@@ -187,6 +265,12 @@ impl Template {
|
|||||||
self.cfg.prompt_mode == PromptMode::Replace
|
self.cfg.prompt_mode == PromptMode::Replace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The LoRA switches clients can offer (declared and present in the
|
||||||
|
/// workflow), in `enhance.toml` order.
|
||||||
|
pub fn lora_switches(&self) -> impl Iterator<Item = &LoraConfig> {
|
||||||
|
self.loras.iter().map(|l| &l.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fill the workflow for one photo.
|
/// Fill the workflow for one photo.
|
||||||
///
|
///
|
||||||
/// - `source` / `reference` are paths as ComfyUI sees them.
|
/// - `source` / `reference` are paths as ComfyUI sees them.
|
||||||
@@ -195,13 +279,25 @@ 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` 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(
|
pub fn fill(
|
||||||
&self,
|
&self,
|
||||||
source: &str,
|
source: &str,
|
||||||
prompt: Option<&str>,
|
prompt: Option<&str>,
|
||||||
reference: Option<&str>,
|
reference: Option<&str>,
|
||||||
|
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
|
||||||
|
.keys()
|
||||||
|
.find(|id| !self.loras.iter().any(|l| &l.cfg.id == *id))
|
||||||
|
{
|
||||||
|
return Err(FillError(format!(
|
||||||
|
"this template has no LoRA switch {unknown:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let user_text = prompt.map(str::trim).filter(|t| !t.is_empty());
|
let user_text = prompt.map(str::trim).filter(|t| !t.is_empty());
|
||||||
match (&self.prompt, user_text) {
|
match (&self.prompt, user_text) {
|
||||||
(None, Some(_)) => {
|
(None, Some(_)) => {
|
||||||
@@ -255,6 +351,17 @@ impl Template {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut resolved = BTreeMap::new();
|
||||||
|
for switch in &self.loras {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for node in wf.values_mut() {
|
for node in wf.values_mut() {
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -268,6 +375,7 @@ impl Template {
|
|||||||
Ok(Filled {
|
Ok(Filled {
|
||||||
prompt: Value::Object(wf),
|
prompt: Value::Object(wf),
|
||||||
dropped_reference,
|
dropped_reference,
|
||||||
|
loras: resolved,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +432,38 @@ mod tests {
|
|||||||
pub const RESTORE: &str = include_str!("../../tests/fixtures/enhance/restore_turbo.json");
|
pub const RESTORE: &str = include_str!("../../tests/fixtures/enhance/restore_turbo.json");
|
||||||
pub const EDIT: &str = include_str!("../../tests/fixtures/enhance/edit_turbo.json");
|
pub const EDIT: &str = include_str!("../../tests/fixtures/enhance/edit_turbo.json");
|
||||||
pub const DEV: &str = include_str!("../../tests/fixtures/enhance/restore_dev.json");
|
pub const DEV: &str = include_str!("../../tests/fixtures/enhance/restore_dev.json");
|
||||||
|
/// The Animate template plus a Style pair titled `imageapi:lora:style`
|
||||||
|
/// (nodes 50 high, 51 low).
|
||||||
|
pub const ANIMATE_LORAS: &str = include_str!("../../tests/fixtures/enhance/animate_loras.json");
|
||||||
|
|
||||||
|
fn lora(id: &str, default: bool) -> LoraConfig {
|
||||||
|
LoraConfig {
|
||||||
|
id: id.into(),
|
||||||
|
label: id.into(),
|
||||||
|
default,
|
||||||
|
strength: 1.0,
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_with_loras(json: &str, loras: Vec<LoraConfig>) -> Result<Template> {
|
||||||
|
let mut c = cfg("animate", PromptMode::Append);
|
||||||
|
c.loras = loras;
|
||||||
|
Template::from_value(c, serde_json::from_str(json).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reqs(pairs: &[(&str, LoraRequest)]) -> BTreeMap<String, LoraRequest> {
|
||||||
|
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 {
|
||||||
@@ -334,6 +474,7 @@ mod tests {
|
|||||||
prompt_mode: mode,
|
prompt_mode: mode,
|
||||||
prompt_join: " ".into(),
|
prompt_join: " ".into(),
|
||||||
output_megapixels: 1.0,
|
output_megapixels: 1.0,
|
||||||
|
loras: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +542,13 @@ mod tests {
|
|||||||
v["13"]["inputs"] = json!({"prompt": "base"});
|
v["13"]["inputs"] = json!({"prompt": "base"});
|
||||||
let t = Template::from_value(cfg("t", PromptMode::Append), v).unwrap();
|
let t = Template::from_value(cfg("t", PromptMode::Append), v).unwrap();
|
||||||
let f = t
|
let f = t
|
||||||
.fill("Z:\\a.jpg", Some("more"), None, &mut seeds())
|
.fill(
|
||||||
|
"Z:\\a.jpg",
|
||||||
|
Some("more"),
|
||||||
|
None,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(input(&f.prompt, "13", "prompt"), "base more");
|
assert_eq!(input(&f.prompt, "13", "prompt"), "base more");
|
||||||
}
|
}
|
||||||
@@ -418,7 +565,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
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.fill("Z:\\p\\a.jpg", None, None, &mut seeds()).unwrap();
|
let f = t
|
||||||
|
.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, "37", "path"), "Z:\\p\\a.jpg");
|
||||||
assert_eq!(input(&f.prompt, "36", "require_approval"), true);
|
assert_eq!(input(&f.prompt, "36", "require_approval"), true);
|
||||||
// KSampler.seed and TextGenerate's sampling_mode.seed, both fresh.
|
// KSampler.seed and TextGenerate's sampling_mode.seed, both fresh.
|
||||||
@@ -430,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, &mut rng).unwrap();
|
let x = t.fill("p", None, None, &none(), &mut rng).unwrap();
|
||||||
let y = t.fill("p", 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"));
|
assert_ne!(input(&x.prompt, "5", "seed"), input(&y.prompt, "5", "seed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +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.fill("p", None, None, &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));
|
assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,7 +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.fill("p", None, None, &mut seeds()).unwrap();
|
let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
|
||||||
assert_eq!(input(&f.prompt, "36", "require_approval"), true);
|
assert_eq!(input(&f.prompt, "36", "require_approval"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,14 +627,24 @@ mod tests {
|
|||||||
let mut v: Value = serde_json::from_str(RESTORE).unwrap();
|
let mut v: Value = serde_json::from_str(RESTORE).unwrap();
|
||||||
v["13"]["inputs"]["value"] = json!(base);
|
v["13"]["inputs"]["value"] = json!(base);
|
||||||
let t = Template::from_value(c, v).unwrap();
|
let t = Template::from_value(c, v).unwrap();
|
||||||
let f = t.fill("p", Some(" warmer "), None, &mut seeds()).unwrap();
|
let f = t
|
||||||
|
.fill(
|
||||||
|
"p",
|
||||||
|
Some(" warmer "),
|
||||||
|
None,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
input(&f.prompt, "13", "value"),
|
input(&f.prompt, "13", "value"),
|
||||||
"Increase the clarity\n\nAdditional instructions: warmer"
|
"Increase the clarity\n\nAdditional instructions: warmer"
|
||||||
);
|
);
|
||||||
let f = t.fill("p", Some(" "), None, &mut seeds()).unwrap();
|
let f = t
|
||||||
|
.fill("p", Some(" "), None, &none(), &mut seeds())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(input(&f.prompt, "13", "value"), base);
|
assert_eq!(input(&f.prompt, "13", "value"), base);
|
||||||
let f = t.fill("p", None, None, &mut seeds()).unwrap();
|
let f = t.fill("p", None, None, &none(), &mut seeds()).unwrap();
|
||||||
assert_eq!(input(&f.prompt, "13", "value"), base);
|
assert_eq!(input(&f.prompt, "13", "value"), base);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +652,13 @@ mod tests {
|
|||||||
fn default_join_is_a_single_space_sentence() {
|
fn default_join_is_a_single_space_sentence() {
|
||||||
let t = load(RESTORE, PromptMode::Append).unwrap();
|
let t = load(RESTORE, PromptMode::Append).unwrap();
|
||||||
let f = t
|
let f = t
|
||||||
.fill("p", Some("Make it warmer."), None, &mut seeds())
|
.fill(
|
||||||
|
"p",
|
||||||
|
Some("Make it warmer."),
|
||||||
|
None,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let text = input(&f.prompt, "13", "value").as_str().unwrap();
|
let text = input(&f.prompt, "13", "value").as_str().unwrap();
|
||||||
assert!(text.ends_with("Remove any phone interface overlays. Make it warmer."));
|
assert!(text.ends_with("Remove any phone interface overlays. Make it warmer."));
|
||||||
@@ -502,11 +667,20 @@ 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.fill("p", None, None, &mut seeds()).unwrap_err();
|
let err = t.fill("p", None, None, &none(), &mut seeds()).unwrap_err();
|
||||||
assert!(err.0.contains("instruction"));
|
assert!(err.0.contains("instruction"));
|
||||||
assert!(t.fill("p", Some(" "), None, &mut seeds()).is_err());
|
assert!(
|
||||||
|
t.fill("p", Some(" "), None, &none(), &mut seeds())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
let f = t
|
let f = t
|
||||||
.fill("p", Some("make it black and white"), None, &mut seeds())
|
.fill(
|
||||||
|
"p",
|
||||||
|
Some("make it black and white"),
|
||||||
|
None,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(input(&f.prompt, "13", "value"), "make it black and white");
|
assert_eq!(input(&f.prompt, "13", "value"), "make it black and white");
|
||||||
}
|
}
|
||||||
@@ -518,18 +692,28 @@ 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!(t.fill("p", Some("x"), None, &mut seeds()).is_err());
|
assert!(t.fill("p", Some("x"), None, &none(), &mut seeds()).is_err());
|
||||||
assert!(t.fill("p", None, None, &mut seeds()).is_ok());
|
assert!(t.fill("p", None, None, &none(), &mut seeds()).is_ok());
|
||||||
|
|
||||||
let restore = load(RESTORE, PromptMode::Append).unwrap();
|
let restore = load(RESTORE, PromptMode::Append).unwrap();
|
||||||
assert!(restore.fill("p", None, Some("r"), &mut seeds()).is_err());
|
assert!(
|
||||||
|
restore
|
||||||
|
.fill("p", None, Some("r"), &none(), &mut seeds())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_given_sets_its_path_and_keeps_links() {
|
fn reference_given_sets_its_path_and_keeps_links() {
|
||||||
let t = load(EDIT, PromptMode::Replace).unwrap();
|
let t = load(EDIT, PromptMode::Replace).unwrap();
|
||||||
let f = t
|
let f = t
|
||||||
.fill("src", Some("x"), Some("Z:\\ref.jpg"), &mut seeds())
|
.fill(
|
||||||
|
"src",
|
||||||
|
Some("x"),
|
||||||
|
Some("Z:\\ref.jpg"),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!f.dropped_reference);
|
assert!(!f.dropped_reference);
|
||||||
assert_eq!(input(&f.prompt, "38", "path"), "Z:\\ref.jpg");
|
assert_eq!(input(&f.prompt, "38", "path"), "Z:\\ref.jpg");
|
||||||
@@ -539,7 +723,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
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.fill("src", Some("x"), None, &mut seeds()).unwrap();
|
let f = t
|
||||||
|
.fill("src", Some("x"), None, &none(), &mut seeds())
|
||||||
|
.unwrap();
|
||||||
assert!(f.dropped_reference);
|
assert!(f.dropped_reference);
|
||||||
let wf = f.prompt.as_object().unwrap();
|
let wf = f.prompt.as_object().unwrap();
|
||||||
assert!(!wf.contains_key("38"));
|
assert!(!wf.contains_key("38"));
|
||||||
@@ -554,4 +740,181 @@ mod tests {
|
|||||||
// Links to other nodes survive.
|
// Links to other nodes survive.
|
||||||
assert_eq!(input(&f.prompt, "36", "source"), &json!(["37", 1]));
|
assert_eq!(input(&f.prompt, "36", "source"), &json!(["37", 1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lora_switch_drives_every_node_with_its_id() {
|
||||||
|
let t = load_with_loras(ANIMATE_LORAS, vec![lora("style", false)]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
t.lora_switches().map(|l| l.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
["style"]
|
||||||
|
);
|
||||||
|
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, &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));
|
||||||
|
}
|
||||||
|
// The graph isn't rewired, and the fixed lightx2v LoRAs are untouched.
|
||||||
|
assert_eq!(input(&off.prompt, "14", "model"), &json!(["50", 0]));
|
||||||
|
assert_eq!(input(&off.prompt, "12", "strength_model"), &json!(1.0));
|
||||||
|
|
||||||
|
// On: the configured strength (1.0 = as exported).
|
||||||
|
let on = t
|
||||||
|
.fill(
|
||||||
|
"p",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&reqs(&[("style", LoraRequest::On(true))]),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
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 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,
|
||||||
|
&reqs(&[("style", LoraRequest::Scale(0.5))]),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
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,
|
||||||
|
&reqs(&[("nope", LoraRequest::On(true))]),
|
||||||
|
&mut seeds(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.0.contains("nope"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 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(&half.prompt, "50", "strength_clip"), &json!(0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undeclared_or_missing_lora_switches_are_warnings() {
|
||||||
|
// Nodes but no [[template.lora]]: fixed at their strength, not offered.
|
||||||
|
let t = load_with_loras(ANIMATE_LORAS, Vec::new()).unwrap();
|
||||||
|
assert_eq!(t.lora_switches().count(), 0);
|
||||||
|
assert!(
|
||||||
|
t.warnings.iter().any(|w| w.contains("imageapi:lora:style")),
|
||||||
|
"{:?}",
|
||||||
|
t.warnings
|
||||||
|
);
|
||||||
|
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!(
|
||||||
|
t.fill(
|
||||||
|
"p",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&reqs(&[("style", LoraRequest::On(false))]),
|
||||||
|
&mut seeds()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Declared but no node: skipped with a warning, the rest load.
|
||||||
|
let t = load_with_loras(
|
||||||
|
ANIMATE_LORAS,
|
||||||
|
vec![lora("style", false), lora("detail", true)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
t.lora_switches().map(|l| l.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
["style"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
t.warnings.iter().any(|w| w.contains("\"detail\"")),
|
||||||
|
"{:?}",
|
||||||
|
t.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lora_node_without_strength_model_is_rejected() {
|
||||||
|
let bad = ANIMATE_LORAS.replace(
|
||||||
|
"\"strength_model\": 1.0,\n \"model\": [\n \"12\"",
|
||||||
|
"\"weight\": 1.0,\n \"model\": [\n \"12\"",
|
||||||
|
);
|
||||||
|
assert_ne!(bad, ANIMATE_LORAS, "fixture layout changed");
|
||||||
|
let err = load_with_loras(&bad, vec![lora("style", false)])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("strength_model"), "{err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"class_type": "ImageApiLoadImageFromPath",
|
||||||
|
"_meta": {
|
||||||
|
"title": "imageapi:source"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"class_type": "ImageApiTargetSize",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Target Size (~0.92 MP, keeps aspect)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"image": [
|
||||||
|
"1",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_megapixels": 0.92,
|
||||||
|
"min_megapixels": 0.92,
|
||||||
|
"multiple_of": 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Wan 2.2 i2v high noise"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
|
||||||
|
"weight_dtype": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Wan 2.2 i2v low noise"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
|
||||||
|
"weight_dtype": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"_meta": {
|
||||||
|
"title": "lightx2v 4-step (high)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"10",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
|
||||||
|
"strength_model": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"_meta": {
|
||||||
|
"title": "lightx2v 4-step (low)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"11",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
|
||||||
|
"strength_model": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"class_type": "ModelSamplingSD3",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Shift (high)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"50",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"shift": 5.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"class_type": "ModelSamplingSD3",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Shift (low)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"51",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"shift": 5.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"40": {
|
||||||
|
"class_type": "PathchSageAttentionKJ",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Sage attention (high)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"14",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sage_attention": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"41": {
|
||||||
|
"class_type": "PathchSageAttentionKJ",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Sage attention (low)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"15",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"sage_attention": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load CLIP"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
|
||||||
|
"type": "wan",
|
||||||
|
"device": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "imageapi:prompt"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"clip": [
|
||||||
|
"16",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"text": "natural, lifelike motion with a slow, gentle camera movement, realistic, cinematic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"18": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Negative"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"clip": [
|
||||||
|
"16",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"text": "static, frozen, blurry, low quality, distorted, warped faces, morphing, flicker, watermark, text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load VAE"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "wan_2.1_vae.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"class_type": "WanImageToVideo",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Wan Image To Video (81 frames)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"positive": [
|
||||||
|
"17",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"18",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"19",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"start_image": [
|
||||||
|
"1",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"width": [
|
||||||
|
"2",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"height": [
|
||||||
|
"2",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"length": 81,
|
||||||
|
"batch_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"class_type": "KSamplerAdvanced",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Sampler (high, steps 0-2)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"40",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"21",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"21",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"21",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"add_noise": "enable",
|
||||||
|
"noise_seed": 1,
|
||||||
|
"steps": 4,
|
||||||
|
"cfg": 1.0,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"start_at_step": 0,
|
||||||
|
"end_at_step": 2,
|
||||||
|
"return_with_leftover_noise": "enable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"23": {
|
||||||
|
"class_type": "KSamplerAdvanced",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Sampler (low, steps 2-4)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"41",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"21",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"21",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"22",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"add_noise": "disable",
|
||||||
|
"noise_seed": 0,
|
||||||
|
"steps": 4,
|
||||||
|
"cfg": 1.0,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"start_at_step": 2,
|
||||||
|
"end_at_step": 10000,
|
||||||
|
"return_with_leftover_noise": "disable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"24": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE Decode"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"23",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"19",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"25": {
|
||||||
|
"class_type": "CreateVideo",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Create Video (16 fps)"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"images": [
|
||||||
|
"24",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fps": 16.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"class_type": "ImageApiSaveVideoVersion",
|
||||||
|
"_meta": {
|
||||||
|
"title": "imageapi:save"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"video": [
|
||||||
|
"25",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"1",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"kind": "animated",
|
||||||
|
"subfolder": "aiedits",
|
||||||
|
"require_approval": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"50": {
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": "example_style_high.safetensors",
|
||||||
|
"strength_model": 1.0,
|
||||||
|
"model": [
|
||||||
|
"12",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"_meta": {
|
||||||
|
"title": "imageapi:lora:style"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"51": {
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": "example_style_low.safetensors",
|
||||||
|
"strength_model": 1.0,
|
||||||
|
"model": [
|
||||||
|
"13",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LoraLoaderModelOnly",
|
||||||
|
"_meta": {
|
||||||
|
"title": "imageapi:lora:style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user