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:
1 parent
6fffca9989
commit
e2cf18678f
15 files changed
+1454
-33
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user