refactor(workflow_ui): 抽出 repair 子模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 22:05:30 +08:00
parent 35f63c46f1
commit f43daed3ee
2 changed files with 649 additions and 640 deletions
+5 -640
View File
@@ -152,6 +152,11 @@ use model_inject::{
native_model_folder_payload_or_fallback, workflow_model_folder_payload, native_model_folder_payload_or_fallback, workflow_model_folder_payload,
workflow_node_defs_with_models, workflow_node_defs_with_models,
}; };
mod repair;
use repair::{
inject_native_prompt_cloud_api_key, repair_dynamic_combo_nodes, repair_resize_image_mask_nodes,
repair_text_generate_ltx2_prompt_nodes, repair_wan2_api_nodes,
};
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
@@ -689,646 +694,6 @@ pub(super) fn prompt_error_response(message: impl Into<String>, details: impl In
.into_response() .into_response()
} }
fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) {
if !state.config.comfyui.uses_cloud_backend() {
return;
}
let Some(api_key) = workflow_cloud_api_key(&state.config.comfyui) else {
return;
};
let Value::Object(extra_data) = &mut payload.extra_data else {
payload.extra_data = json!({ "api_key_comfy_org": api_key });
return;
};
extra_data.insert(
"api_key_comfy_org".to_string(),
Value::String(api_key.to_string()),
);
}
pub(super) fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
let workflow = extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or(Value::Null);
let mut workflow_nodes = Vec::<Value>::new();
collect_workflow_nodes(&workflow, &mut workflow_nodes);
if workflow_nodes.is_empty() {
return;
}
let workflow_by_id = workflow_nodes
.iter()
.filter_map(|node| {
let id = node.get("id")?;
Some((id_to_prompt_key(id), node))
})
.collect::<HashMap<_, _>>();
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "ResizeImageMaskNode")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
continue;
}
let Some(workflow_node) = workflow_by_id
.get(node_id)
.or_else(|| prompt_subgraph_node_id(node_id).and_then(|id| workflow_by_id.get(id)))
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
let Some(values) = workflow_node
.get("widgets_values")
.and_then(Value::as_array)
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
if values
.first()
.and_then(Value::as_str)
.is_some_and(|value| value == "scale dimensions")
&& values.len() >= 4
{
inputs.insert(
"resize_type".to_string(),
Value::String("scale dimensions".to_string()),
);
inputs.insert("resize_type.width".to_string(), values[1].clone());
inputs.insert("resize_type.height".to_string(), values[2].clone());
inputs.insert("resize_type.crop".to_string(), values[3].clone());
if let Some(method) = values.get(4) {
inputs.insert("resize_type.method".to_string(), method.clone());
}
tracing::warn!(node_id = %node_id, "repaired ResizeImageMaskNode dynamic widget prompt inputs");
}
}
}
fn repair_resize_image_mask_node_inputs_without_workflow(
node_id: &str,
inputs: &mut Map<String, Value>,
) {
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
return;
}
inputs
.entry("resize_type".to_string())
.or_insert_with(|| Value::String("scale dimensions".to_string()));
inputs.insert(
"resize_type.crop".to_string(),
Value::String("center".to_string()),
);
tracing::warn!(
node_id = %node_id,
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
);
}
pub(super) fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "TextGenerateLTX2Prompt")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let repetition_penalty_invalid = inputs
.get("sampling_mode.repetition_penalty")
.and_then(Value::as_f64)
.is_some_and(|value| !(0.0..=5.0).contains(&value));
if !repetition_penalty_invalid {
continue;
}
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs
.entry("sampling_mode.seed".to_string())
.or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
tracing::warn!(
node_id = %node_id,
"repaired TextGenerateLTX2Prompt dynamic sampling prompt inputs"
);
}
}
#[derive(Debug, Clone)]
struct DynamicComboField {
name: String,
value_type: String,
options: Vec<String>,
default: Option<Value>,
min: Option<f64>,
max: Option<f64>,
}
pub(super) fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.is_empty() {
continue;
}
let dynamic_groups = dynamic_combo_groups_for_node(object_info, &class_type);
if dynamic_groups.is_empty() {
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let mut repaired_groups = Vec::<String>::new();
for (group_name, option_key, fields) in dynamic_groups {
if repair_dynamic_combo_group(inputs, &group_name, &option_key, &fields) {
repaired_groups.push(group_name);
}
}
if !repaired_groups.is_empty() {
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
groups = ?repaired_groups,
"repaired ComfyUI dynamic combo prompt inputs from object_info schema"
);
}
}
}
fn dynamic_combo_groups_for_node(
object_info: &Value,
class_type: &str,
) -> Vec<(String, String, Vec<DynamicComboField>)> {
let Some(node) = object_info.get(class_type) else {
return Vec::new();
};
let mut groups = Vec::new();
for section in ["required", "optional"] {
let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
else {
continue;
};
for (name, input) in inputs {
let Some(input_type) = input.get(0).and_then(Value::as_str) else {
continue;
};
if !input_type.starts_with("COMFY_DYNAMICCOMBO") {
continue;
}
let Some(option) = input
.get(1)
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.and_then(|options| options.first())
else {
continue;
};
let option_key = option
.get("key")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let fields = dynamic_combo_fields_from_option(option);
if !fields.is_empty() {
groups.push((name.clone(), option_key, fields));
}
}
}
groups
}
fn dynamic_combo_fields_from_option(option: &Value) -> Vec<DynamicComboField> {
let Some(required) = option
.pointer("/inputs/required")
.and_then(Value::as_object)
else {
return Vec::new();
};
required
.iter()
.filter_map(|(name, spec)| {
let value_type = spec.get(0).and_then(Value::as_str)?.to_string();
let config = spec.get(1).and_then(Value::as_object);
let options = config
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(DynamicComboField {
name: name.clone(),
value_type,
options,
default: config.and_then(|config| config.get("default")).cloned(),
min: config
.and_then(|config| config.get("min"))
.and_then(Value::as_f64),
max: config
.and_then(|config| config.get("max"))
.and_then(Value::as_f64),
})
})
.collect()
}
fn repair_dynamic_combo_group(
inputs: &mut Map<String, Value>,
group_name: &str,
option_key: &str,
fields: &[DynamicComboField],
) -> bool {
if fields.is_empty() {
return false;
}
let mut values = Vec::<Value>::new();
if let Some(value) = inputs.get(group_name).cloned() {
values.push(value);
}
for field in fields {
if let Some(value) = inputs.get(&format!("{group_name}.{}", field.name)).cloned() {
values.push(value);
}
}
if values.is_empty() {
return false;
}
let invalid = fields.iter().any(|field| {
let key = format!("{group_name}.{}", field.name);
inputs
.get(&key)
.is_some_and(|value| !dynamic_combo_value_matches_field(value, field))
});
if !invalid {
return false;
}
let mut used = vec![false; values.len()];
if !option_key.is_empty() {
inputs.insert(
group_name.to_string(),
Value::String(option_key.to_string()),
);
for (idx, value) in values.iter().enumerate() {
if value.as_str() == Some(option_key) {
used[idx] = true;
break;
}
}
}
for field in fields {
let key = format!("{group_name}.{}", field.name);
let selected_idx = values.iter().enumerate().find_map(|(idx, value)| {
(!used[idx] && dynamic_combo_value_matches_field(value, field)).then_some(idx)
});
let repaired = selected_idx
.map(|idx| {
used[idx] = true;
normalize_dynamic_combo_value(&values[idx], field)
})
.unwrap_or_else(|| default_dynamic_combo_value(field));
inputs.insert(key, repaired);
}
true
}
fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -> bool {
match field.value_type.as_str() {
"STRING" => value.as_str().is_some(),
"COMBO" => value
.as_str()
.is_some_and(|text| field.options.iter().any(|option| option == text)),
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
field.min.is_none_or(|min| (number as f64) >= min)
&& field.max.is_none_or(|max| (number as f64) <= max)
}),
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
}),
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
_ => true,
}
}
fn normalize_dynamic_combo_value(value: &Value, field: &DynamicComboField) -> Value {
match field.value_type.as_str() {
"INT" => dynamic_combo_value_as_i64(value)
.map(|number| json!(clamp_i64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"FLOAT" => dynamic_combo_value_as_f64(value)
.map(|number| json!(clamp_f64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"BOOLEAN" => dynamic_combo_value_as_bool(value)
.map(Value::Bool)
.unwrap_or_else(|| default_dynamic_combo_value(field)),
_ => value.clone(),
}
}
fn default_dynamic_combo_value(field: &DynamicComboField) -> Value {
if let Some(default) = &field.default {
return normalize_dynamic_combo_value(default, field);
}
match field.value_type.as_str() {
"COMBO" => field
.options
.first()
.cloned()
.map(Value::String)
.unwrap_or(Value::Null),
"STRING" => Value::String(String::new()),
"INT" => json!(field.min.unwrap_or(0.0).ceil() as i64),
"FLOAT" => json!(field.min.unwrap_or(0.0)),
"BOOLEAN" => Value::Bool(false),
_ => Value::Null,
}
}
fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
.or_else(|| value.as_f64().map(|number| number.round() as i64))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
}
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<f64>().ok())
})
}
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| {
value
.as_str()
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
})
})
}
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
let min = field
.min
.map(|value| value.ceil() as i64)
.unwrap_or(i64::MIN);
let max = field
.max
.map(|value| value.floor() as i64)
.unwrap_or(i64::MAX);
value.clamp(min, max)
}
fn clamp_f64_to_field(value: f64, field: &DynamicComboField) -> f64 {
let min = field.min.unwrap_or(f64::NEG_INFINITY);
let max = field.max.unwrap_or(f64::INFINITY);
value.clamp(min, max)
}
pub(super) fn repair_wan2_api_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let field_names = match class_type.as_str() {
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
"prompt",
"negative_prompt",
"resolution",
"ratio",
"duration",
][..],
_ => continue,
};
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if !wan2_model_inputs_look_shifted(inputs) {
continue;
}
repair_wan2_model_inputs(inputs, field_names);
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
"repaired Wan2 dynamic model prompt inputs"
);
}
}
fn wan2_model_inputs_look_shifted(inputs: &Map<String, Value>) -> bool {
inputs
.get("model.resolution")
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
|| inputs.get("model.ratio").is_some_and(|value| {
!matches!(
value.as_str(),
Some("16:9" | "9:16" | "1:1" | "4:3" | "3:4")
)
})
|| inputs
.get("model.duration")
.is_some_and(|value| value.as_i64().is_none())
}
fn repair_wan2_model_inputs(inputs: &mut Map<String, Value>, field_names: &[&str]) {
let mut values = field_names
.iter()
.filter_map(|name| inputs.remove(&format!("model.{name}")))
.collect::<Vec<_>>();
if let Some(model) = inputs.remove("model") {
values.insert(0, model);
}
let prompt = values
.iter()
.find_map(|value| {
value.as_str().and_then(|text| {
let trimmed = text.trim();
if trimmed.len() > 20 && !is_wan2_resolution(trimmed) && !is_wan2_ratio(trimmed) {
Some(Value::String(text.to_string()))
} else {
None
}
})
})
.unwrap_or_else(|| Value::String(String::new()));
let negative_prompt = values
.iter()
.filter_map(Value::as_str)
.find(|text| {
let trimmed = text.trim();
!trimmed.is_empty()
&& trimmed.len() <= 20
&& !is_wan2_model_key(trimmed)
&& !is_wan2_resolution(trimmed)
&& !is_wan2_ratio(trimmed)
})
.map(|text| Value::String(text.to_string()))
.unwrap_or_else(|| Value::String(String::new()));
let resolution = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_resolution(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("720P".to_string()));
let ratio = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_ratio(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("16:9".to_string()));
let duration = values
.iter()
.find_map(|value| {
value.as_i64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
})
.map(|value| value.clamp(2, 15))
.unwrap_or(5);
inputs.insert(
"model".to_string(),
infer_wan2_model_key(field_names, &values),
);
inputs.insert("model.prompt".to_string(), prompt);
inputs.insert("model.negative_prompt".to_string(), negative_prompt);
inputs.insert("model.resolution".to_string(), resolution);
if field_names.contains(&"ratio") {
inputs.insert("model.ratio".to_string(), ratio);
}
inputs.insert("model.duration".to_string(), json!(duration));
}
fn infer_wan2_model_key(field_names: &[&str], values: &[Value]) -> Value {
if let Some(model) = values
.iter()
.filter_map(Value::as_str)
.find(|value| is_wan2_model_key(value.trim()))
{
return Value::String(model.trim().to_string());
}
if field_names.contains(&"ratio") {
Value::String("wan2.7-t2v".to_string())
} else {
Value::String("wan2.7-i2v".to_string())
}
}
fn is_wan2_resolution(value: &str) -> bool {
matches!(value, "720P" | "1080P")
}
fn is_wan2_ratio(value: &str) -> bool {
matches!(value, "16:9" | "9:16" | "1:1" | "4:3" | "3:4")
}
fn is_wan2_model_key(value: &str) -> bool {
matches!(value, "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v")
}
fn collect_workflow_nodes(value: &Value, nodes: &mut Vec<Value>) {
if let Some(items) = value.get("nodes").and_then(Value::as_array) {
nodes.extend(items.iter().cloned());
}
match value {
Value::Array(items) => {
for item in items {
collect_workflow_nodes(item, nodes);
}
}
Value::Object(object) => {
for item in object.values() {
collect_workflow_nodes(item, nodes);
}
}
_ => {}
}
}
fn prompt_subgraph_node_id(node_id: &str) -> Option<&str> {
node_id
.rsplit_once(':')
.map(|(_, child_id)| child_id.trim())
.filter(|value| !value.is_empty())
}
fn id_to_prompt_key(id: &Value) -> String {
if let Some(value) = id.as_i64() {
return value.to_string();
}
id.as_str().unwrap_or_default().to_string()
}
async fn internal_logs_subscribe( async fn internal_logs_subscribe(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
+644
View File
@@ -0,0 +1,644 @@
use super::workflow_cloud_api_key;
use super::PromptPayload;
use crate::AppState;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
pub(super) fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) {
if !state.config.comfyui.uses_cloud_backend() {
return;
}
let Some(api_key) = workflow_cloud_api_key(&state.config.comfyui) else {
return;
};
let Value::Object(extra_data) = &mut payload.extra_data else {
payload.extra_data = json!({ "api_key_comfy_org": api_key });
return;
};
extra_data.insert(
"api_key_comfy_org".to_string(),
Value::String(api_key.to_string()),
);
}
pub(super) fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
let workflow = extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or(Value::Null);
let mut workflow_nodes = Vec::<Value>::new();
collect_workflow_nodes(&workflow, &mut workflow_nodes);
if workflow_nodes.is_empty() {
return;
}
let workflow_by_id = workflow_nodes
.iter()
.filter_map(|node| {
let id = node.get("id")?;
Some((id_to_prompt_key(id), node))
})
.collect::<HashMap<_, _>>();
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "ResizeImageMaskNode")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
continue;
}
let Some(workflow_node) = workflow_by_id
.get(node_id)
.or_else(|| prompt_subgraph_node_id(node_id).and_then(|id| workflow_by_id.get(id)))
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
let Some(values) = workflow_node
.get("widgets_values")
.and_then(Value::as_array)
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
if values
.first()
.and_then(Value::as_str)
.is_some_and(|value| value == "scale dimensions")
&& values.len() >= 4
{
inputs.insert(
"resize_type".to_string(),
Value::String("scale dimensions".to_string()),
);
inputs.insert("resize_type.width".to_string(), values[1].clone());
inputs.insert("resize_type.height".to_string(), values[2].clone());
inputs.insert("resize_type.crop".to_string(), values[3].clone());
if let Some(method) = values.get(4) {
inputs.insert("resize_type.method".to_string(), method.clone());
}
tracing::warn!(node_id = %node_id, "repaired ResizeImageMaskNode dynamic widget prompt inputs");
}
}
}
fn repair_resize_image_mask_node_inputs_without_workflow(
node_id: &str,
inputs: &mut Map<String, Value>,
) {
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
return;
}
inputs
.entry("resize_type".to_string())
.or_insert_with(|| Value::String("scale dimensions".to_string()));
inputs.insert(
"resize_type.crop".to_string(),
Value::String("center".to_string()),
);
tracing::warn!(
node_id = %node_id,
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
);
}
pub(super) fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "TextGenerateLTX2Prompt")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let repetition_penalty_invalid = inputs
.get("sampling_mode.repetition_penalty")
.and_then(Value::as_f64)
.is_some_and(|value| !(0.0..=5.0).contains(&value));
if !repetition_penalty_invalid {
continue;
}
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs
.entry("sampling_mode.seed".to_string())
.or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
tracing::warn!(
node_id = %node_id,
"repaired TextGenerateLTX2Prompt dynamic sampling prompt inputs"
);
}
}
#[derive(Debug, Clone)]
struct DynamicComboField {
name: String,
value_type: String,
options: Vec<String>,
default: Option<Value>,
min: Option<f64>,
max: Option<f64>,
}
pub(super) fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.is_empty() {
continue;
}
let dynamic_groups = dynamic_combo_groups_for_node(object_info, &class_type);
if dynamic_groups.is_empty() {
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let mut repaired_groups = Vec::<String>::new();
for (group_name, option_key, fields) in dynamic_groups {
if repair_dynamic_combo_group(inputs, &group_name, &option_key, &fields) {
repaired_groups.push(group_name);
}
}
if !repaired_groups.is_empty() {
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
groups = ?repaired_groups,
"repaired ComfyUI dynamic combo prompt inputs from object_info schema"
);
}
}
}
fn dynamic_combo_groups_for_node(
object_info: &Value,
class_type: &str,
) -> Vec<(String, String, Vec<DynamicComboField>)> {
let Some(node) = object_info.get(class_type) else {
return Vec::new();
};
let mut groups = Vec::new();
for section in ["required", "optional"] {
let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
else {
continue;
};
for (name, input) in inputs {
let Some(input_type) = input.get(0).and_then(Value::as_str) else {
continue;
};
if !input_type.starts_with("COMFY_DYNAMICCOMBO") {
continue;
}
let Some(option) = input
.get(1)
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.and_then(|options| options.first())
else {
continue;
};
let option_key = option
.get("key")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let fields = dynamic_combo_fields_from_option(option);
if !fields.is_empty() {
groups.push((name.clone(), option_key, fields));
}
}
}
groups
}
fn dynamic_combo_fields_from_option(option: &Value) -> Vec<DynamicComboField> {
let Some(required) = option
.pointer("/inputs/required")
.and_then(Value::as_object)
else {
return Vec::new();
};
required
.iter()
.filter_map(|(name, spec)| {
let value_type = spec.get(0).and_then(Value::as_str)?.to_string();
let config = spec.get(1).and_then(Value::as_object);
let options = config
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(DynamicComboField {
name: name.clone(),
value_type,
options,
default: config.and_then(|config| config.get("default")).cloned(),
min: config
.and_then(|config| config.get("min"))
.and_then(Value::as_f64),
max: config
.and_then(|config| config.get("max"))
.and_then(Value::as_f64),
})
})
.collect()
}
fn repair_dynamic_combo_group(
inputs: &mut Map<String, Value>,
group_name: &str,
option_key: &str,
fields: &[DynamicComboField],
) -> bool {
if fields.is_empty() {
return false;
}
let mut values = Vec::<Value>::new();
if let Some(value) = inputs.get(group_name).cloned() {
values.push(value);
}
for field in fields {
if let Some(value) = inputs.get(&format!("{group_name}.{}", field.name)).cloned() {
values.push(value);
}
}
if values.is_empty() {
return false;
}
let invalid = fields.iter().any(|field| {
let key = format!("{group_name}.{}", field.name);
inputs
.get(&key)
.is_some_and(|value| !dynamic_combo_value_matches_field(value, field))
});
if !invalid {
return false;
}
let mut used = vec![false; values.len()];
if !option_key.is_empty() {
inputs.insert(
group_name.to_string(),
Value::String(option_key.to_string()),
);
for (idx, value) in values.iter().enumerate() {
if value.as_str() == Some(option_key) {
used[idx] = true;
break;
}
}
}
for field in fields {
let key = format!("{group_name}.{}", field.name);
let selected_idx = values.iter().enumerate().find_map(|(idx, value)| {
(!used[idx] && dynamic_combo_value_matches_field(value, field)).then_some(idx)
});
let repaired = selected_idx
.map(|idx| {
used[idx] = true;
normalize_dynamic_combo_value(&values[idx], field)
})
.unwrap_or_else(|| default_dynamic_combo_value(field));
inputs.insert(key, repaired);
}
true
}
fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -> bool {
match field.value_type.as_str() {
"STRING" => value.as_str().is_some(),
"COMBO" => value
.as_str()
.is_some_and(|text| field.options.iter().any(|option| option == text)),
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
field.min.is_none_or(|min| (number as f64) >= min)
&& field.max.is_none_or(|max| (number as f64) <= max)
}),
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
}),
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
_ => true,
}
}
fn normalize_dynamic_combo_value(value: &Value, field: &DynamicComboField) -> Value {
match field.value_type.as_str() {
"INT" => dynamic_combo_value_as_i64(value)
.map(|number| json!(clamp_i64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"FLOAT" => dynamic_combo_value_as_f64(value)
.map(|number| json!(clamp_f64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"BOOLEAN" => dynamic_combo_value_as_bool(value)
.map(Value::Bool)
.unwrap_or_else(|| default_dynamic_combo_value(field)),
_ => value.clone(),
}
}
fn default_dynamic_combo_value(field: &DynamicComboField) -> Value {
if let Some(default) = &field.default {
return normalize_dynamic_combo_value(default, field);
}
match field.value_type.as_str() {
"COMBO" => field
.options
.first()
.cloned()
.map(Value::String)
.unwrap_or(Value::Null),
"STRING" => Value::String(String::new()),
"INT" => json!(field.min.unwrap_or(0.0).ceil() as i64),
"FLOAT" => json!(field.min.unwrap_or(0.0)),
"BOOLEAN" => Value::Bool(false),
_ => Value::Null,
}
}
fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
.or_else(|| value.as_f64().map(|number| number.round() as i64))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
}
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<f64>().ok())
})
}
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| {
value
.as_str()
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
})
})
}
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
let min = field
.min
.map(|value| value.ceil() as i64)
.unwrap_or(i64::MIN);
let max = field
.max
.map(|value| value.floor() as i64)
.unwrap_or(i64::MAX);
value.clamp(min, max)
}
fn clamp_f64_to_field(value: f64, field: &DynamicComboField) -> f64 {
let min = field.min.unwrap_or(f64::NEG_INFINITY);
let max = field.max.unwrap_or(f64::INFINITY);
value.clamp(min, max)
}
pub(super) fn repair_wan2_api_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let field_names = match class_type.as_str() {
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
"prompt",
"negative_prompt",
"resolution",
"ratio",
"duration",
][..],
_ => continue,
};
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if !wan2_model_inputs_look_shifted(inputs) {
continue;
}
repair_wan2_model_inputs(inputs, field_names);
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
"repaired Wan2 dynamic model prompt inputs"
);
}
}
fn wan2_model_inputs_look_shifted(inputs: &Map<String, Value>) -> bool {
inputs
.get("model.resolution")
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
|| inputs.get("model.ratio").is_some_and(|value| {
!matches!(
value.as_str(),
Some("16:9" | "9:16" | "1:1" | "4:3" | "3:4")
)
})
|| inputs
.get("model.duration")
.is_some_and(|value| value.as_i64().is_none())
}
fn repair_wan2_model_inputs(inputs: &mut Map<String, Value>, field_names: &[&str]) {
let mut values = field_names
.iter()
.filter_map(|name| inputs.remove(&format!("model.{name}")))
.collect::<Vec<_>>();
if let Some(model) = inputs.remove("model") {
values.insert(0, model);
}
let prompt = values
.iter()
.find_map(|value| {
value.as_str().and_then(|text| {
let trimmed = text.trim();
if trimmed.len() > 20 && !is_wan2_resolution(trimmed) && !is_wan2_ratio(trimmed) {
Some(Value::String(text.to_string()))
} else {
None
}
})
})
.unwrap_or_else(|| Value::String(String::new()));
let negative_prompt = values
.iter()
.filter_map(Value::as_str)
.find(|text| {
let trimmed = text.trim();
!trimmed.is_empty()
&& trimmed.len() <= 20
&& !is_wan2_model_key(trimmed)
&& !is_wan2_resolution(trimmed)
&& !is_wan2_ratio(trimmed)
})
.map(|text| Value::String(text.to_string()))
.unwrap_or_else(|| Value::String(String::new()));
let resolution = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_resolution(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("720P".to_string()));
let ratio = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_ratio(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("16:9".to_string()));
let duration = values
.iter()
.find_map(|value| {
value.as_i64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
})
.map(|value| value.clamp(2, 15))
.unwrap_or(5);
inputs.insert(
"model".to_string(),
infer_wan2_model_key(field_names, &values),
);
inputs.insert("model.prompt".to_string(), prompt);
inputs.insert("model.negative_prompt".to_string(), negative_prompt);
inputs.insert("model.resolution".to_string(), resolution);
if field_names.contains(&"ratio") {
inputs.insert("model.ratio".to_string(), ratio);
}
inputs.insert("model.duration".to_string(), json!(duration));
}
fn infer_wan2_model_key(field_names: &[&str], values: &[Value]) -> Value {
if let Some(model) = values
.iter()
.filter_map(Value::as_str)
.find(|value| is_wan2_model_key(value.trim()))
{
return Value::String(model.trim().to_string());
}
if field_names.contains(&"ratio") {
Value::String("wan2.7-t2v".to_string())
} else {
Value::String("wan2.7-i2v".to_string())
}
}
fn is_wan2_resolution(value: &str) -> bool {
matches!(value, "720P" | "1080P")
}
fn is_wan2_ratio(value: &str) -> bool {
matches!(value, "16:9" | "9:16" | "1:1" | "4:3" | "3:4")
}
fn is_wan2_model_key(value: &str) -> bool {
matches!(value, "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v")
}
fn collect_workflow_nodes(value: &Value, nodes: &mut Vec<Value>) {
if let Some(items) = value.get("nodes").and_then(Value::as_array) {
nodes.extend(items.iter().cloned());
}
match value {
Value::Array(items) => {
for item in items {
collect_workflow_nodes(item, nodes);
}
}
Value::Object(object) => {
for item in object.values() {
collect_workflow_nodes(item, nodes);
}
}
_ => {}
}
}
fn prompt_subgraph_node_id(node_id: &str) -> Option<&str> {
node_id
.rsplit_once(':')
.map(|(_, child_id)| child_id.trim())
.filter(|value| !value.is_empty())
}
fn id_to_prompt_key(id: &Value) -> String {
if let Some(value) = id.as_i64() {
return value.to_string();
}
id.as_str().unwrap_or_default().to_string()
}