refactor(workflow_ui): 抽出 repair 子模块
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user