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,
|
||||
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> {
|
||||
Router::new()
|
||||
@@ -689,646 +694,6 @@ pub(super) fn prompt_error_response(message: impl Into<String>, details: impl In
|
||||
.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(
|
||||
State(state): State<AppState>,
|
||||
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