feat(enhance): pick LoRAs from ComfyUI per job (#220)

Templates with `imageapi:lora-slot:<n>:high|low` nodes take LoRAs picked
per job from a catalog built live from ComfyUI.

- Catalog: ComfyUI's LoraLoaderModelOnly `lora_name` choices (cached
  60 s, re-read when a submit names an unknown id), paired by High/Low
  name tokens (StyleHigh/StyleLow, x-HIGH/x-LOW, x_high_noise/...). Files
  the workflow already loads outside the slots (lightx2v, switches) and
  `[template.catalog] exclude` substrings never show; ids derive from the
  names so they stay stable.
- POST /enhance `slots: [{lora, scale?}]` fills the slots in order: each
  pick sets the slot nodes' lora_name and scales their exported strength;
  unused slots run at 0, so the graph is never rewired. More picks than
  slots, duplicates or unknown ids are 400s.
- GET /enhance/templates gains `slots` and `catalog: {loras, strength,
  min, max}`; jobs store and return the picks (new enhance_jobs.lora_slots
  JSON column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-03 16:28:23 -05:00
1 parent 6fffca9989
commit e2cf18678f
15 files changed
+1454 -33

No files matched your search

+129 -1
View File
@@ -27,6 +27,7 @@ use crate::versions::xmp::test_png::{producer_xmp, write_png};
const RESTORE: &str = include_str!("../../tests/fixtures/enhance/restore_turbo.json");
const ANIMATE_LORAS: &str = include_str!("../../tests/fixtures/enhance/animate_loras.json");
const ANIMATE_SLOTS: &str = include_str!("../../tests/fixtures/enhance/animate_slots.json");
const EDIT: &str = include_str!("../../tests/fixtures/enhance/edit_turbo.json");
// ---- mock ComfyUI ---------------------------------------------------------
@@ -45,6 +46,9 @@ struct Mock {
free_calls: AtomicUsize,
reject_prompts: Mutex<Option<Value>>,
view_404: Mutex<bool>,
/// What `/object_info/LoraLoaderModelOnly` lists.
lora_names: Mutex<Vec<String>>,
lora_list_calls: AtomicUsize,
}
async fn start_mock() -> (Arc<Mock>, String) {
@@ -54,6 +58,16 @@ async fn start_mock() -> (Arc<Mock>, String) {
App::new()
.app_data(Data::from(m.clone()))
.route("/system_stats", web::get().to(|| async { HttpResponse::Ok().json(json!({})) }))
.route(
"/object_info/LoraLoaderModelOnly",
web::get().to(|m: Data<Mock>| async move {
m.lora_list_calls.fetch_add(1, Ordering::SeqCst);
let names = m.lora_names.lock().unwrap().clone();
HttpResponse::Ok().json(json!({"LoraLoaderModelOnly": {"input": {"required": {
"lora_name": [names], "strength_model": ["FLOAT", {}]
}}}}))
}),
)
.route(
"/prompt",
web::post().to(|m: Data<Mock>, body: web::Json<Value>| async move {
@@ -170,6 +184,7 @@ fn template(name: &str, mode: PromptMode, json: &str) -> Template {
prompt_join: " ".into(),
output_megapixels: 1.0,
loras: Vec::new(),
catalog: Default::default(),
},
serde_json::from_str(json).unwrap(),
)
@@ -222,6 +237,7 @@ async fn fx_with(url: Option<String>, tweak: impl FnOnce(&mut EnhanceConfig)) ->
template("restore", PromptMode::Append, RESTORE),
template("edit", PromptMode::Replace, EDIT),
animate_with_style(),
template("animate-picks", PromptMode::Append, ANIMATE_SLOTS),
],
dao,
)
@@ -350,7 +366,7 @@ async fn comfy_down_means_unavailable_and_a_clear_submit_error() {
assert_eq!(st, 200);
assert_eq!(body["available"], false);
assert_eq!(body["reason"], "ComfyUI isn't running");
assert_eq!(body["templates"].as_array().unwrap().len(), 3);
assert_eq!(body["templates"].as_array().unwrap().len(), 4);
let (st, body, _) = call(
&fx,
@@ -1010,6 +1026,7 @@ async fn unreachable_active_jobs_become_unavailable_after_the_grace_period() {
updated_at: now,
run_ms: None,
loras: None,
lora_slots: None,
};
SqliteEnhanceJobDao::from_shared(fx.conn.clone())
.insert(&dao_job)
@@ -1223,3 +1240,114 @@ async fn lora_switches_are_listed_sent_and_stored_on_the_job() {
.await;
assert!(plain.get("loras").is_none(), "{plain}");
}
#[actix_rt::test]
async fn picked_loras_are_listed_filled_into_slots_and_stored() {
let fx = fx().await;
fx.put(PHOTO, b"photo");
*fx.mock.lora_names.lock().unwrap() = [
"wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
"wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
"example_style_high.safetensors",
"example_style_low.safetensors",
"MotionHigh.safetensors",
"MotionLow.safetensors",
"Lonely_High.safetensors",
]
.map(String::from)
.to_vec();
// Only the slot template lists a catalog; the workflow's own LoRAs
// (lightx2v, the style switch) and unpaired files are left out.
let (_, body, _) = call(&fx, "GET", "/enhance/templates", None).await;
let templates = body["templates"].as_array().unwrap();
let picks = templates
.iter()
.find(|t| t["name"] == "animate-picks")
.unwrap();
assert_eq!(picks["slots"], 2);
assert_eq!(
picks["catalog"],
json!({"loras": [{"id": "motion", "label": "Motion"}], "strength": 1.0, "min": 0.0, "max": 1.5})
);
let restore = templates.iter().find(|t| t["name"] == "restore").unwrap();
assert_eq!(restore["slots"], 0);
assert!(restore.get("catalog").is_none());
let (st, job, _) = call(
&fx,
"POST",
"/enhance",
Some(json!({"path": PHOTO, "template": "animate-picks", "slots": [{"lora": "motion", "scale": 0.5}]})),
)
.await;
assert_eq!(st, 200, "{job}");
assert_eq!(
job["slots"],
json!([{"lora": "motion", "label": "Motion", "scale": 0.5}])
);
let (_, again, _) = call(
&fx,
"GET",
&format!("/enhance/jobs/{}", job["id"].as_str().unwrap()),
None,
)
.await;
assert_eq!(again["slots"], job["slots"]);
{
let sent = fx.mock.submitted.lock().unwrap();
let p = &sent[0]["prompt"];
assert_eq!(p["60"]["inputs"]["lora_name"], "MotionHigh.safetensors");
assert_eq!(p["61"]["inputs"]["lora_name"], "MotionLow.safetensors");
assert_eq!(p["60"]["inputs"]["strength_model"], json!(0.5));
assert_eq!(p["62"]["inputs"]["strength_model"], json!(0.0));
}
// No scale: the catalog's strength.
let (_, job, _) = call(
&fx,
"POST",
"/enhance",
Some(json!({"path": PHOTO, "template": "animate-picks", "slots": [{"lora": "motion"}]})),
)
.await;
assert_eq!(job["slots"][0]["scale"], json!(1.0));
// A LoRA downloaded since the last listing is found by re-reading it.
fx.mock.lora_names.lock().unwrap().extend([
"NewHigh.safetensors".to_string(),
"NewLow.safetensors".to_string(),
]);
let (st, job, _) = call(
&fx,
"POST",
"/enhance",
Some(json!({"path": PHOTO, "template": "animate-picks", "slots": [{"lora": "new"}, {"lora": "motion"}]})),
)
.await;
assert_eq!(st, 200, "{job}");
assert_eq!(job["slots"][0]["lora"], "new");
// Unknown, duplicate, too many, or on a template without slots: 400.
for (template, slots) in [
("animate-picks", json!([{"lora": "nope"}])),
(
"animate-picks",
json!([{"lora": "motion"}, {"lora": "motion"}]),
),
(
"animate-picks",
json!([{"lora": "motion"}, {"lora": "new"}, {"lora": "lonely"}]),
),
("restore", json!([{"lora": "motion"}])),
] {
let (st, err, _) = call(
&fx,
"POST",
"/enhance",
Some(json!({"path": PHOTO, "template": template, "slots": slots})),
)
.await;
assert_eq!(st, 400, "{template} {err}");
}
}