Normalize MoneyPrinter Comfy workflow templates

This commit is contained in:
boos
2026-06-02 17:11:15 +08:00
parent 6b24f160c3
commit a213e394d9
2 changed files with 268 additions and 15 deletions
+201 -14
View File
@@ -493,11 +493,197 @@ fn comfy_mapping_from_body(body: &Value) -> Value {
.unwrap_or_else(default_comfy_mapping) .unwrap_or_else(default_comfy_mapping)
} }
fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn graph_node_type(node: &Value) -> String {
node.get("type")
.or_else(|| node.get("class_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
node.get("params")
.and_then(Value::as_object)
.cloned()
.or_else(|| node.get("inputs").and_then(Value::as_object).cloned())
.unwrap_or_default()
}
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
match (class_type, field) {
("KSampler", "cfg") => value.as_f64().filter(|v| (0.0..=50.0).contains(v)).map_or(json!(8.0), |v| json!(v)),
("KSampler", "denoise") => value.as_f64().map(|v| if v > 1.0 { v / 100.0 } else { v }).filter(|v| (0.0..=1.0).contains(v)).map_or(json!(1.0), |v| json!(v)),
("KSampler", "sampler_name") => {
if value.is_string() { value } else { json!("euler") }
}
("KSampler", "scheduler") => {
if value.is_string() { value } else { json!("normal") }
}
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
}
("EmptyLatentImage", "batch_size") => {
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
}
_ => value,
}
}
fn split_endpoint(value: &str) -> Option<(&str, &str)> {
let (node_id, port) = value.split_once('.')?;
Some((node_id.trim(), port.trim()))
}
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
let nodes = graph.get("nodes")?.as_array()?;
let mut prompt = serde_json::Map::new();
for node in nodes {
let id = scalar_string(node.get("id"))?;
let class_type = graph_node_type(node);
if class_type.trim().is_empty() {
continue;
}
let mut inputs = serde_json::Map::new();
for (field, value) in graph_node_params(node) {
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
}
prompt.insert(id, json!({
"class_type": class_type,
"inputs": inputs,
}));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, from_port)) = split_endpoint(from) else { continue; };
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
inputs.insert(to_field.to_string(), json!([from_id, 0]));
if to_field == "model" && from_port == "MODEL" {
inputs.insert("model".to_string(), json!([from_id, 0]));
}
}
}
repair_common_comfy_prompt_links(&mut prompt);
Some(Value::Object(prompt))
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
.map(|(id, _)| id.clone())
}
fn infer_comfy_mapping(prompt: &Value) -> Value {
let Some(object) = prompt.as_object() else {
return default_comfy_mapping();
};
let text_nodes = clip_text_nodes(object);
let prompt_node = text_nodes
.first()
.cloned()
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
.or_else(|| first_node_by_class(object, "Wan2ImageToVideoApi"))
.unwrap_or_default();
let negative_node = text_nodes.get(1).cloned().unwrap_or_default();
let video_node = first_node_by_class(object, "Wan2ImageToVideoApi")
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
.or_else(|| first_node_by_class(object, "KSampler"))
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
.unwrap_or_default();
json!({
"prompt": {
"nodeId": prompt_node,
"field": "text"
},
"negativePrompt": {
"nodeId": negative_node,
"field": "text"
},
"ratio": {
"nodeId": video_node,
"field": "ratio"
},
"resolution": {
"nodeId": video_node,
"field": "resolution"
},
"duration": {
"nodeId": video_node,
"field": "duration"
}
})
}
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
prompt
.iter()
.filter(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode"))
.map(|(id, _)| id.clone())
.collect()
}
fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>) {
let checkpoint = first_node_by_class(prompt, "CheckpointLoaderSimple");
let latent = first_node_by_class(prompt, "EmptyLatentImage");
let ksampler = first_node_by_class(prompt, "KSampler");
let text_nodes = clip_text_nodes(prompt);
if let Some(ksampler_id) = ksampler {
if let Some(inputs) = prompt
.get_mut(&ksampler_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
if let Some(checkpoint_id) = checkpoint.as_deref() {
inputs.insert("model".to_string(), json!([checkpoint_id, 0]));
}
if let Some(latent_id) = latent.as_deref() {
inputs.insert("latent_image".to_string(), json!([latent_id, 0]));
}
if let Some(positive_id) = text_nodes.first() {
inputs.insert("positive".to_string(), json!([positive_id, 0]));
}
if let Some(negative_id) = text_nodes.get(1).or_else(|| text_nodes.first()) {
inputs.insert("negative".to_string(), json!([negative_id, 0]));
}
}
}
if let Some(vae_decode_id) = first_node_by_class(prompt, "VAEDecode") {
let ksampler_for_vae = first_node_by_class(prompt, "KSampler");
if let Some(inputs) = prompt
.get_mut(&vae_decode_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
if let Some(ksampler_id) = ksampler_for_vae {
inputs.insert("samples".to_string(), json!([ksampler_id, 0]));
}
if let Some(checkpoint_id) = checkpoint.as_deref() {
inputs.insert("vae".to_string(), json!([checkpoint_id, 2]));
}
}
}
}
fn workflow_prompt_from_graph(graph: &Value) -> Value { fn workflow_prompt_from_graph(graph: &Value) -> Value {
graph.get("prompt") graph.get("prompt")
.cloned() .cloned()
.or_else(|| graph.get("workflow").cloned()) .or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned()) .or_else(|| graph.get("comfyPrompt").cloned())
.or_else(|| graph_to_comfy_prompt(graph))
.unwrap_or_else(|| graph.clone()) .unwrap_or_else(|| graph.clone())
} }
@@ -720,7 +906,10 @@ async fn submit_comfy_video(
)); ));
} }
let mut prompt = workflow_prompt_from_body(state, auth, body).await?; let mut prompt = workflow_prompt_from_body(state, auth, body).await?;
let mapping = comfy_mapping_from_body(body); let mut mapping = comfy_mapping_from_body(body);
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
mapping = infer_comfy_mapping(&prompt);
}
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?; let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?; let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
Ok(json!({ Ok(json!({
@@ -957,19 +1146,17 @@ async fn create_job(
if !outputs.is_empty() { if !outputs.is_empty() {
update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?; update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?;
for output in &outputs { for output in &outputs {
if output_kind(output) == "video" { if let Some(asset) = archive_comfy_output(
if let Some(asset) = archive_comfy_output( &state_for_task,
&state_for_task, &auth_for_task,
&auth_for_task, output,
output, &prompt_id,
&prompt_id, &prompt,
&prompt, &title,
&title, )
) .await
.await {
{ assets.push(asset);
assets.push(asset);
}
} }
} }
final_history = history; final_history = history;
+67 -1
View File
@@ -42,7 +42,7 @@ function planValue(job: MoneyPrinterJob, key: string) {
} }
function workflowPromptFromGraph(graph: any) { function workflowPromptFromGraph(graph: any) {
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graph || null; return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graphToComfyPrompt(graph) || graph || null;
} }
function mappingFromGraph(graph: any) { function mappingFromGraph(graph: any) {
@@ -74,6 +74,72 @@ function inferMappingFromPrompt(prompt: any) {
}; };
} }
function normalizeParam(classType: string, field: string, value: any) {
if (classType === 'KSampler' && field === 'cfg') {
return typeof value === 'number' && value >= 0 && value <= 50 ? value : 8;
}
if (classType === 'KSampler' && field === 'denoise') {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric)) return 1;
return numeric > 1 ? Math.min(1, numeric / 100) : Math.max(0, numeric);
}
if (classType === 'KSampler' && field === 'sampler_name') return typeof value === 'string' ? value : 'euler';
if (classType === 'KSampler' && field === 'scheduler') return typeof value === 'string' ? value : 'normal';
if (classType === 'KSampler' && field === 'seed') return Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
if (classType === 'EmptyLatentImage' && ['width', 'height'].includes(field)) {
const numeric = Number(value);
return numeric >= 64 && numeric <= 4096 ? numeric : 1024;
}
if (classType === 'EmptyLatentImage' && field === 'batch_size') {
const numeric = Number(value);
return numeric >= 1 && numeric <= 16 ? numeric : 1;
}
return value;
}
function graphToComfyPrompt(graph: any) {
if (!Array.isArray(graph?.nodes)) return null;
const prompt: Record<string, any> = {};
for (const node of graph.nodes) {
const id = String(node?.id || '');
const classType = String(node?.type || node?.class_type || '');
if (!id || !classType) continue;
const params = node?.params && typeof node.params === 'object' ? node.params : {};
const inputs: Record<string, any> = {};
for (const [field, value] of Object.entries(params)) {
inputs[field] = normalizeParam(classType, field, value);
}
prompt[id] = { class_type: classType, inputs };
}
for (const edge of Array.isArray(graph?.edges) ? graph.edges : []) {
const from = String(edge?.from || '');
const to = String(edge?.to || '');
const [fromId] = from.split('.');
const [toId, toField] = to.split('.');
if (fromId && toId && toField && prompt[toId]?.inputs) {
prompt[toId].inputs[toField] = [fromId, 0];
}
}
const firstByType = (type: string) => Object.entries(prompt).find(([, node]) => node.class_type === type)?.[0] || '';
const allByType = (type: string) => Object.entries(prompt).filter(([, node]) => node.class_type === type).map(([id]) => id);
const checkpoint = firstByType('CheckpointLoaderSimple');
const latent = firstByType('EmptyLatentImage');
const ksampler = firstByType('KSampler');
const textNodes = allByType('CLIPTextEncode');
if (ksampler) {
if (checkpoint) prompt[ksampler].inputs.model = [checkpoint, 0];
if (latent) prompt[ksampler].inputs.latent_image = [latent, 0];
if (textNodes[0]) prompt[ksampler].inputs.positive = [textNodes[0], 0];
if (textNodes[1] || textNodes[0]) prompt[ksampler].inputs.negative = [textNodes[1] || textNodes[0], 0];
}
const vaeDecode = firstByType('VAEDecode');
if (vaeDecode) {
if (ksampler) prompt[vaeDecode].inputs.samples = [ksampler, 0];
if (checkpoint) prompt[vaeDecode].inputs.vae = [checkpoint, 2];
}
return prompt;
}
function templateLabel(workflow: WorkflowSummary) { function templateLabel(workflow: WorkflowSummary) {
if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板'; if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板';
if (workflow.visibility === 'public') return '共享模板'; if (workflow.visibility === 'public') return '共享模板';