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
+135
-1
@@ -15,8 +15,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::comfy::{ApproveError, ComfyClient, ComfyError, HistoryEntry, Staged, SubmitError};
|
||||
use super::config::{EnhanceConfig, Loaded, PromptMode};
|
||||
use super::lora_catalog::{self, CatalogEntry};
|
||||
use super::path_map::PathMap;
|
||||
use super::template::{Template, random_seed};
|
||||
use super::template::{SlotPick, Template, random_seed};
|
||||
use crate::database::enhance_dao::{
|
||||
EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED,
|
||||
STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE,
|
||||
@@ -165,6 +166,27 @@ pub struct EnhanceService {
|
||||
idle: Mutex<IdleState>,
|
||||
/// Serialises approvals (rare; keeps "approve twice" simple).
|
||||
approve_lock: tokio::sync::Mutex<()>,
|
||||
/// ComfyUI's LoRA files and when they were read (#220).
|
||||
lora_names: Mutex<Option<(Instant, Vec<String>)>>,
|
||||
}
|
||||
|
||||
/// How long ComfyUI's LoRA list is reused; a new download shows up after it.
|
||||
const LORA_LIST_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// One `POST /enhance { slots }` entry: a catalog id and an optional scale.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
pub struct SlotRequest {
|
||||
pub lora: String,
|
||||
#[serde(default)]
|
||||
pub scale: Option<f64>,
|
||||
}
|
||||
|
||||
/// A resolved pick, as stored on the job and returned in its view.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlotChoice {
|
||||
pub lora: String,
|
||||
pub label: String,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl EnhanceService {
|
||||
@@ -233,9 +255,116 @@ impl EnhanceService {
|
||||
unreachable_since: Mutex::new(HashMap::new()),
|
||||
idle: Mutex::new(IdleState::default()),
|
||||
approve_lock: tokio::sync::Mutex::new(()),
|
||||
lora_names: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// ComfyUI's LoRA files, cached for [`LORA_LIST_TTL`]. `fresh` re-reads
|
||||
/// them (a submit naming an id the cache doesn't know). An unreachable
|
||||
/// ComfyUI keeps the last list.
|
||||
async fn lora_names(&self, fresh: bool) -> Vec<String> {
|
||||
let Some(client) = &self.client else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !fresh
|
||||
&& let Some((at, names)) = &*self.lora_names.lock().unwrap()
|
||||
&& at.elapsed() < LORA_LIST_TTL
|
||||
{
|
||||
return names.clone();
|
||||
}
|
||||
match client.lora_names().await {
|
||||
Ok(names) => {
|
||||
*self.lora_names.lock().unwrap() = Some((Instant::now(), names.clone()));
|
||||
names
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("enhance: couldn't list ComfyUI's LoRAs: {e}");
|
||||
self.lora_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|(_, n)| n.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The LoRAs a template's slots can take; empty without slots.
|
||||
pub async fn catalog(&self, template: &Template) -> Vec<CatalogEntry> {
|
||||
if template.slot_count() == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let names = self.lora_names(false).await;
|
||||
lora_catalog::build(
|
||||
&names,
|
||||
template.fixed_loras(),
|
||||
&template.cfg.catalog.exclude,
|
||||
)
|
||||
}
|
||||
|
||||
/// Turn `slots` into picks for the template's slots, checking the ids
|
||||
/// against the catalog (re-read once if one is unknown).
|
||||
async fn resolve_slots(
|
||||
&self,
|
||||
template: &Template,
|
||||
slots: &[SlotRequest],
|
||||
) -> Result<(Vec<SlotPick>, Vec<SlotChoice>), EnhanceError> {
|
||||
if slots.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
if slots.len() > template.slot_count() {
|
||||
return Err(EnhanceError::BadRequest(if template.slot_count() == 0 {
|
||||
"this template doesn't take picked LoRAs".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"this template takes at most {} picked LoRAs",
|
||||
template.slot_count()
|
||||
)
|
||||
}));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Some(dup) = slots.iter().find(|s| !seen.insert(s.lora.as_str())) {
|
||||
return Err(EnhanceError::BadRequest(format!(
|
||||
"LoRA {:?} is picked twice",
|
||||
dup.lora
|
||||
)));
|
||||
}
|
||||
let mut catalog = self.catalog(template).await;
|
||||
if slots
|
||||
.iter()
|
||||
.any(|s| !catalog.iter().any(|e| e.id == s.lora))
|
||||
{
|
||||
let names = self.lora_names(true).await;
|
||||
catalog = lora_catalog::build(
|
||||
&names,
|
||||
template.fixed_loras(),
|
||||
&template.cfg.catalog.exclude,
|
||||
);
|
||||
}
|
||||
let mut picks = Vec::new();
|
||||
let mut choices = Vec::new();
|
||||
for s in slots {
|
||||
let Some(entry) = catalog.iter().find(|e| e.id == s.lora) else {
|
||||
return Err(EnhanceError::BadRequest(format!(
|
||||
"no LoRA {:?} (it may have been removed from ComfyUI)",
|
||||
s.lora
|
||||
)));
|
||||
};
|
||||
let scale = template.cfg.catalog.resolve(s.scale);
|
||||
picks.push(SlotPick {
|
||||
high: entry.high.clone(),
|
||||
low: entry.low.clone(),
|
||||
scale,
|
||||
});
|
||||
choices.push(SlotChoice {
|
||||
lora: entry.id.clone(),
|
||||
label: entry.label.clone(),
|
||||
scale,
|
||||
});
|
||||
}
|
||||
Ok((picks, choices))
|
||||
}
|
||||
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.client.is_some()
|
||||
}
|
||||
@@ -293,6 +422,7 @@ impl EnhanceService {
|
||||
prompt: Option<&str>,
|
||||
reference: Option<&SourceFile>,
|
||||
loras: &std::collections::BTreeMap<String, super::config::LoraRequest>,
|
||||
slots: &[SlotRequest],
|
||||
) -> Result<EnhanceJobRow, EnhanceError> {
|
||||
let Some(client) = &self.client else {
|
||||
return Err(EnhanceError::Disabled);
|
||||
@@ -319,12 +449,14 @@ impl EnhanceService {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let (picks, choices) = self.resolve_slots(template, slots).await?;
|
||||
let filled = template
|
||||
.fill(
|
||||
&source,
|
||||
prompt,
|
||||
reference_path.as_deref(),
|
||||
loras,
|
||||
&picks,
|
||||
&mut random_seed,
|
||||
)
|
||||
.map_err(|e| EnhanceError::BadRequest(e.0))?;
|
||||
@@ -374,6 +506,8 @@ impl EnhanceService {
|
||||
run_ms: None,
|
||||
loras: (!filled.loras.is_empty())
|
||||
.then(|| serde_json::to_string(&filled.loras).unwrap_or_default()),
|
||||
lora_slots: (!choices.is_empty())
|
||||
.then(|| serde_json::to_string(&choices).unwrap_or_default()),
|
||||
};
|
||||
self.dao
|
||||
.lock()
|
||||
|
||||
Reference in new issue
Block a user