Load Comfy templates for MoneyPrinter
This commit is contained in:
@@ -3,6 +3,7 @@ use crate::{
|
|||||||
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
services::workspace::{self, WorkspaceAssetInput},
|
services::workspace::{self, WorkspaceAssetInput},
|
||||||
|
workflow::storage,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -492,19 +493,41 @@ fn comfy_mapping_from_body(body: &Value) -> Value {
|
|||||||
.unwrap_or_else(default_comfy_mapping)
|
.unwrap_or_else(default_comfy_mapping)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_prompt_from_body(body: &Value) -> AppResult<Value> {
|
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
||||||
body.get("comfyPrompt")
|
graph.get("prompt")
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
|
.unwrap_or_else(|| graph.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn workflow_prompt_from_body(
|
||||||
|
state: &AppState,
|
||||||
|
auth: &MoneyPrinterAuth,
|
||||||
|
body: &Value,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
if let Some(value) = body.get("comfyPrompt")
|
||||||
.or_else(|| body.get("comfy_prompt"))
|
.or_else(|| body.get("comfy_prompt"))
|
||||||
.or_else(|| body.get("promptWorkflow"))
|
.or_else(|| body.get("promptWorkflow"))
|
||||||
.or_else(|| body.get("prompt_workflow"))
|
.or_else(|| body.get("prompt_workflow"))
|
||||||
.or_else(|| body.get("workflowPrompt"))
|
.or_else(|| body.get("workflowPrompt"))
|
||||||
.or_else(|| body.get("workflow_prompt"))
|
.or_else(|| body.get("workflow_prompt"))
|
||||||
.cloned()
|
.cloned() {
|
||||||
.ok_or_else(|| {
|
return Ok(value);
|
||||||
AppError::BadRequest(
|
}
|
||||||
"Comfy API 模式需要传入 comfyPrompt/promptWorkflow 和节点映射".into(),
|
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
|
||||||
|
let workflow = storage::get_workflow(
|
||||||
|
&state.db,
|
||||||
|
&template_id,
|
||||||
|
auth.user_id,
|
||||||
|
auth.role == "admin",
|
||||||
)
|
)
|
||||||
})
|
.await?;
|
||||||
|
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
||||||
|
}
|
||||||
|
Err(AppError::BadRequest(
|
||||||
|
"Comfy API 模式需要选择工作流模板,或传入 comfyPrompt/promptWorkflow 和节点映射".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
||||||
@@ -687,6 +710,7 @@ async fn archive_comfy_output(
|
|||||||
|
|
||||||
async fn submit_comfy_video(
|
async fn submit_comfy_video(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
auth: &MoneyPrinterAuth,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
plan: &Value,
|
plan: &Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
@@ -695,7 +719,7 @@ async fn submit_comfy_video(
|
|||||||
"Comfy API 视频生成需要配置 comfyui.backend = cloud 或 external".into(),
|
"Comfy API 视频生成需要配置 comfyui.backend = cloud 或 external".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut prompt = workflow_prompt_from_body(body)?;
|
let mut prompt = workflow_prompt_from_body(state, auth, body).await?;
|
||||||
let mapping = comfy_mapping_from_body(body);
|
let mapping = comfy_mapping_from_body(body);
|
||||||
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?;
|
||||||
@@ -908,7 +932,7 @@ async fn create_job(
|
|||||||
let mut assets = Vec::new();
|
let mut assets = Vec::new();
|
||||||
let (submitted, final_video_response, video_url, prompt, model) = if video_backend == "comfy" {
|
let (submitted, final_video_response, video_url, prompt, model) = if video_backend == "comfy" {
|
||||||
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 Comfy API 工作流").await?;
|
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 Comfy API 工作流").await?;
|
||||||
let submitted = submit_comfy_video(&state_for_task, &body, &plan).await?;
|
let submitted = submit_comfy_video(&state_for_task, &auth_for_task, &body, &plan).await?;
|
||||||
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9334,6 +9334,25 @@ textarea {
|
|||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card));
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.money-printer-template-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-printer-template-meta strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-printer-template-meta span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.money-printer-section-head {
|
.money-printer-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api, userApi, type MoneyPrinterJob } from '../api.js';
|
import { api, userApi, type MoneyPrinterJob, type WorkflowSummary } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
|
|
||||||
const STYLE_PRESETS = [
|
const STYLE_PRESETS = [
|
||||||
@@ -41,6 +41,45 @@ function planValue(job: MoneyPrinterJob, key: string) {
|
|||||||
return job.result?.script?.plan?.[key] || '';
|
return job.result?.script?.plan?.[key] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowPromptFromGraph(graph: any) {
|
||||||
|
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graph || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mappingFromGraph(graph: any) {
|
||||||
|
return graph?.metadata?.moneyPrinter?.nodeMapping
|
||||||
|
|| graph?.moneyPrinter?.nodeMapping
|
||||||
|
|| graph?.metadata?.digitalHuman?.nodeMapping
|
||||||
|
|| graph?.digitalHuman?.nodeMapping
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMappingFromPrompt(prompt: any) {
|
||||||
|
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : [];
|
||||||
|
const find = (classWords: string[], inputWords: string[] = []) => {
|
||||||
|
return nodes.find(([, node]) => {
|
||||||
|
const classType = String(node?.class_type || node?.type || '').toLowerCase();
|
||||||
|
const inputs = Object.keys(node?.inputs || {}).join(' ').toLowerCase();
|
||||||
|
return classWords.some((word) => classType.includes(word)) || inputWords.some((word) => inputs.includes(word));
|
||||||
|
})?.[0] || '';
|
||||||
|
};
|
||||||
|
const promptNode = find(['text', 'prompt', 'wan', 'ltx', 'seedance', 'video'], ['prompt', 'positive', 'text']);
|
||||||
|
const negativeNode = find(['negative'], ['negative']);
|
||||||
|
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
|
||||||
|
return {
|
||||||
|
prompt: { nodeId: String(promptNode), field: prompt?.[String(promptNode)]?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
||||||
|
negativePrompt: { nodeId: String(negativeNode), field: 'text' },
|
||||||
|
ratio: { nodeId: String(videoNode), field: 'ratio' },
|
||||||
|
resolution: { nodeId: String(videoNode), field: 'resolution' },
|
||||||
|
duration: { nodeId: String(videoNode), field: 'duration' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateLabel(workflow: WorkflowSummary) {
|
||||||
|
if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板';
|
||||||
|
if (workflow.visibility === 'public') return '共享模板';
|
||||||
|
return '私有工作流';
|
||||||
|
}
|
||||||
|
|
||||||
export default function MoneyPrinter() {
|
export default function MoneyPrinter() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -58,6 +97,8 @@ export default function MoneyPrinter() {
|
|||||||
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
|
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
|
||||||
const [comfyPrompt, setComfyPrompt] = useState('');
|
const [comfyPrompt, setComfyPrompt] = useState('');
|
||||||
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
||||||
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||||
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
|
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -67,9 +108,26 @@ export default function MoneyPrinter() {
|
|||||||
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
|
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
const loadWorkflows = useCallback(async () => {
|
||||||
|
const payload = await client.getWorkflows({ include_public: true });
|
||||||
|
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||||
|
setWorkflows(items);
|
||||||
|
setTemplateId((current) => current || items.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId || '');
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadJobs().catch((err) => setError(err?.message || '短视频任务加载失败'));
|
Promise.all([loadJobs(), loadWorkflows()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
|
||||||
}, [loadJobs]);
|
}, [loadJobs, loadWorkflows]);
|
||||||
|
|
||||||
|
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedWorkflow || videoBackend !== 'comfy') return;
|
||||||
|
const prompt = workflowPromptFromGraph(selectedWorkflow.graph);
|
||||||
|
if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2));
|
||||||
|
const mapping = mappingFromGraph(selectedWorkflow.graph);
|
||||||
|
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
|
||||||
|
}, [selectedWorkflow?.publicId, videoBackend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobs.some((job) => !terminal(job.status))) return;
|
if (!jobs.some((job) => !terminal(job.status))) return;
|
||||||
@@ -99,6 +157,7 @@ export default function MoneyPrinter() {
|
|||||||
requirements,
|
requirements,
|
||||||
language: '中文',
|
language: '中文',
|
||||||
...(videoBackend === 'comfy' ? {
|
...(videoBackend === 'comfy' ? {
|
||||||
|
templateId: templateId || undefined,
|
||||||
comfyPrompt: comfyPrompt.trim() ? JSON.parse(comfyPrompt) : undefined,
|
comfyPrompt: comfyPrompt.trim() ? JSON.parse(comfyPrompt) : undefined,
|
||||||
comfyNodeMapping: comfyNodeMapping.trim() ? JSON.parse(comfyNodeMapping) : undefined,
|
comfyNodeMapping: comfyNodeMapping.trim() ? JSON.parse(comfyNodeMapping) : undefined,
|
||||||
} : {}),
|
} : {}),
|
||||||
@@ -135,6 +194,7 @@ export default function MoneyPrinter() {
|
|||||||
setRequirements(request.requirements || requirements);
|
setRequirements(request.requirements || requirements);
|
||||||
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
||||||
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
||||||
|
setTemplateId(request.templateId || templateId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,9 +286,26 @@ export default function MoneyPrinter() {
|
|||||||
<div className="money-printer-section-head">
|
<div className="money-printer-section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Comfy API 工作流</h2>
|
<h2>Comfy API 工作流</h2>
|
||||||
<p>粘贴 Comfy API prompt JSON,并填写要注入视频提示词等参数的节点映射。</p>
|
<p>选择保存过的工作流模板,会自动带入 Comfy API prompt 和节点映射。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="money-printer-field">
|
||||||
|
<span>工作流模板</span>
|
||||||
|
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||||
|
<option value="">请选择模板</option>
|
||||||
|
{workflows.map((workflow) => (
|
||||||
|
<option key={workflow.publicId} value={workflow.publicId}>
|
||||||
|
{workflow.name} · {templateLabel(workflow)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedWorkflow && (
|
||||||
|
<div className="money-printer-template-meta">
|
||||||
|
<strong>{selectedWorkflow.name}</strong>
|
||||||
|
<span>{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '这个模板没有内置节点映射,可在下方手动填写。'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>Comfy prompt JSON</span>
|
<span>Comfy prompt JSON</span>
|
||||||
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
|
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
|
||||||
|
|||||||
Reference in New Issue
Block a user