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

Merged
cameron merged 2 commits from feat/enhance-lora-switches into master 2026-11-03 19:08:04 +00:00
12 changed files with 910 additions and 33 deletions
Showing only changes of commit 31da21e061 - Show all commits

No files matched your search

+13 -5
View File
@@ -459,14 +459,19 @@ 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`)).
- **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; 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
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 +517,10 @@ 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"}]}]}
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)
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 +537,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: bool} (resolved; 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"}]}
+18
View File
@@ -63,3 +63,21 @@ 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 (#218).
# Each [[template.lora]] matches the workflow's LoRA loader nodes titled
# "imageapi:lora:<id>" (Wan's high- and low-noise pair share one id). Off sets
# 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.
[[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
@@ -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;
+8 -2
View File
@@ -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,
} }
} }
+1
View File
@@ -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>,
} }
} }
+67 -5
View File
@@ -100,6 +100,30 @@ 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,
}
/// 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 +156,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 +171,22 @@ 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);
}
}
} }
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 +288,31 @@ 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 cfg = parse(ok).unwrap();
let l = &cfg.templates[0].loras;
assert_eq!((l[0].id.as_str(), l[0].default), ("style", false));
assert_eq!((l[1].id.as_str(), l[1].default), ("detail", true));
let base = "[[template]]\nname='a'\nlabel='A'\nfile='a.json'\n";
let dup = format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\n[[template.lora]]\nid='x'\nlabel='Y'"
);
assert!(parse(&dup).is_err());
assert!(parse(&format!("{base}[[template.lora]]\nid='a b'\nlabel='X'")).is_err());
assert!(parse(&format!("{base}[[template.lora]]\nid='x'\nlabel=' '")).is_err());
assert!(
parse(&format!(
"{base}[[template.lora]]\nid='x'\nlabel='X'\nstrength=1"
))
.is_err()
);
}
#[test] #[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 +320,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());
} }
} }
+15
View File
@@ -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 LoRA switches this job ran with (#218); absent when the template
/// had none.
#[serde(skip_serializing_if = "Option::is_none")]
loras: Option<std::collections::BTreeMap<String, bool>>,
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,9 @@ pub struct SubmitBody {
pub prompt: Option<String>, pub prompt: Option<String>,
#[serde(default)] #[serde(default)]
pub reference_path: Option<String>, pub reference_path: Option<String>,
/// LoRA switches by id (#218); a missing id uses its default.
#[serde(default)]
pub loras: std::collections::BTreeMap<String, bool>,
#[serde(default)] #[serde(default)]
pub library: Option<String>, pub library: Option<String>,
} }
@@ -325,6 +339,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
{ {
+105 -1
View File
@@ -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,24 @@ 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,
}];
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 +218,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 +347,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 +1006,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 +1116,92 @@ 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}])
);
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.
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": true}));
let (_, again, _) = call(
&fx,
"GET",
&format!("/enhance/jobs/{}", job["id"].as_str().unwrap()),
None,
)
.await;
assert_eq!(again["loras"], json!({"style": true}));
// 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": false}));
{
let sent = fx.mock.submitted.lock().unwrap();
assert_eq!(
sent[0]["prompt"]["50"]["inputs"]["strength_model"],
json!(1.0)
);
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
View File
@@ -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, bool>,
) -> 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()
+322 -19
View File
@@ -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, 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 switched-off LoRA sets to 0. `strength_model` is
/// required (LoraLoaderModelOnly); `strength_clip` is zeroed 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,21 @@ 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>`.
nodes: Vec<String>,
}
/// 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 +91,9 @@ impl std::fmt::Display for FillError {
pub struct Filled { pub struct Filled {
pub prompt: Value, pub prompt: Value,
pub dropped_reference: bool, pub dropped_reference: bool,
/// Every switch's resolved state (request, else its default); stored on
/// the job so a retry repeats it. Empty for templates without switches.
pub loras: BTreeMap<String, bool>,
} }
impl Template { impl Template {
@@ -150,6 +170,49 @@ 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 nodes) => {
nodes.sort();
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 +233,7 @@ impl Template {
save_id, save_id,
prompt, prompt,
reference_id, reference_id,
loras,
output, output,
warnings, warnings,
}) })
@@ -187,6 +251,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 +265,24 @@ impl Template {
/// - Every `seed` / `noise_seed` / `*.seed` input gets a fresh value /// - Every `seed` / `noise_seed` / `*.seed` input gets a fresh value
/// (ComfyUI's "randomize after generate" only exists in its page), or a /// (ComfyUI's "randomize after generate" only exists in its page), or a
/// retry would return the cached result. /// retry would return the cached result.
/// - `loras` turns switches on or off (missing = the switch's default);
/// an off LoRA's strengths are set to 0, so the graph is never rewired.
pub fn fill( pub fn fill(
&self, &self,
source: &str, source: &str,
prompt: Option<&str>, prompt: Option<&str>,
reference: Option<&str>, reference: Option<&str>,
loras: &BTreeMap<String, bool>,
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 +336,24 @@ 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));
}
}
}
}
}
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 +367,7 @@ impl Template {
Ok(Filled { Ok(Filled {
prompt: Value::Object(wf), prompt: Value::Object(wf),
dropped_reference, dropped_reference,
loras: resolved,
}) })
} }
} }
@@ -324,6 +424,27 @@ 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,
}
}
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 switches(pairs: &[(&str, bool)]) -> BTreeMap<String, bool> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
fn cfg(name: &str, mode: PromptMode) -> TemplateConfig { fn cfg(name: &str, mode: PromptMode) -> TemplateConfig {
TemplateConfig { TemplateConfig {
@@ -334,6 +455,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 +523,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 +546,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, &BTreeMap::new(), &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 +560,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, &BTreeMap::new(), &mut rng).unwrap();
let y = t.fill("p", None, None, &mut rng).unwrap(); let y = t.fill("p", None, None, &BTreeMap::new(), &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 +587,9 @@ 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, &BTreeMap::new(), &mut random_seed)
.unwrap();
assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX)); assert!(input(&f.prompt, "5", "seed").as_u64().unwrap() <= u64::from(u32::MAX));
} }
} }
@@ -466,7 +598,9 @@ 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, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(input(&f.prompt, "36", "require_approval"), true); assert_eq!(input(&f.prompt, "36", "require_approval"), true);
} }
@@ -478,14 +612,26 @@ 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, &BTreeMap::new(), &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, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(input(&f.prompt, "13", "value"), base); assert_eq!(input(&f.prompt, "13", "value"), base);
} }
@@ -493,7 +639,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 +654,22 @@ 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, &BTreeMap::new(), &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, &BTreeMap::new(), &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 +681,34 @@ 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!(
assert!(t.fill("p", None, None, &mut seeds()).is_ok()); t.fill("p", Some("x"), None, &BTreeMap::new(), &mut seeds())
.is_err()
);
assert!(
t.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.is_ok()
);
let restore = load(RESTORE, PromptMode::Append).unwrap(); let restore = load(RESTORE, PromptMode::Append).unwrap();
assert!(restore.fill("p", None, Some("r"), &mut seeds()).is_err()); assert!(
restore
.fill("p", None, Some("r"), &BTreeMap::new(), &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 +718,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, &BTreeMap::new(), &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 +735,126 @@ 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, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(off.loras, switches(&[("style", false)]));
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 exported strength stays.
let on = t
.fill("p", None, None, &switches(&[("style", true)]), &mut seeds())
.unwrap();
assert_eq!(on.loras, switches(&[("style", true)]));
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();
let f = t
.fill("p", None, None, &BTreeMap::new(), &mut seeds())
.unwrap();
assert_eq!(f.loras, switches(&[("style", true)]));
assert_eq!(input(&f.prompt, "50", "strength_model"), &json!(1.0));
let err = t
.fill("p", None, None, &switches(&[("nope", true)]), &mut seeds())
.unwrap_err();
assert!(err.0.contains("nope"), "{err}");
}
#[test]
fn lora_clip_strength_is_zeroed_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())
.unwrap();
assert_eq!(input(&f.prompt, "50", "strength_clip"), &json!(0.0));
}
#[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, &BTreeMap::new(), &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,
&switches(&[("style", 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
View File
@@ -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"
}
}
}