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

+135 -1
View File
@@ -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()