Add Comfy digital human jobs

This commit is contained in:
boos
2026-06-02 11:07:31 +08:00
parent 7b34153235
commit fa5a2419d8
4 changed files with 1411 additions and 248 deletions
+874 -11
View File
@@ -1,26 +1,51 @@
use crate::{ use crate::{
auth::jwt::validate_jwt, auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey, db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
error::{AppError, AppResult}, error::{AppError, AppResult},
services::workspace::{self, WorkspaceAssetInput},
workflow::storage,
AppState, AppState,
}; };
use axum::{ use axum::{
extract::State, body::Bytes,
http::{header, HeaderMap}, extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Method},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::{collections::HashMap, time::Duration};
const TASK_TYPE_DIGITAL_HUMAN: &str = "digital-human-generation";
const DIGITAL_HUMAN_TIMEOUT_SECS: u64 = 900;
const DIGITAL_HUMAN_POLL_INTERVAL_SECS: u64 = 5;
const DIGITAL_HUMAN_MAX_POLLS: usize = 180;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DigitalHumanAuth { struct DigitalHumanAuth {
user_id: Option<i64>,
role: String,
outbound_key: String, outbound_key: String,
} }
#[derive(Debug, Deserialize)]
struct JobListQuery {
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/digital-human/status", get(status)) .route("/api/digital-human/status", get(status))
.route("/api/digital-human/generate", post(generate)) .route("/api/digital-human/generate", post(generate))
.route(
"/api/digital-human/jobs",
get(list_jobs).post(create_job),
)
.route("/api/digital-human/jobs/clear", post(clear_jobs))
.route("/api/digital-human/jobs/{id}", get(get_job))
.route("/api/digital-human/jobs/{id}/cancel", post(cancel_job))
.route("/api/digital-human/upload", post(upload_asset))
} }
fn local_v1_base(state: &AppState) -> String { fn local_v1_base(state: &AppState) -> String {
@@ -32,6 +57,15 @@ fn local_v1_base(state: &AppState) -> String {
format!("http://{}:{}/v1", host, state.config.server.port) format!("http://{}:{}/v1", host, state.config.server.port)
} }
fn public_base_url(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}", host, state.config.server.port)
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers headers
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
@@ -72,6 +106,8 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Ok(DigitalHumanAuth { return Ok(DigitalHumanAuth {
user_id: None,
role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.config.auth.proxy_token.clone(),
}); });
} }
@@ -88,9 +124,13 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?; .map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
match user_downstream_key(state, user_id).await { match user_downstream_key(state, user_id).await {
Ok(key) => Ok(DigitalHumanAuth { Ok(key) => Ok(DigitalHumanAuth {
user_id: Some(user_id),
role: claims.role,
outbound_key: key.key, outbound_key: key.key,
}), }),
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth { Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
user_id: Some(user_id),
role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.config.auth.proxy_token.clone(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
@@ -101,17 +141,22 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
let _ = resolve_auth(&headers, &state).await?; let _ = resolve_auth(&headers, &state).await?;
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"mode": if state.config.comfyui.uses_runninghub_backend() { "runninghub-ai-app" } else { "boosapi-video-route" }, "mode": if state.config.comfyui.uses_native_backend() { "comfy-native" } else { "boosapi-video-route" },
"runninghub": { "runninghub": {
"configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()), "configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
"endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run" "endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run"
}, },
"comfy": {
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"configured": state.config.comfyui.uses_native_backend(),
"baseUrl": state.config.comfyui.target_base_url()
},
"capabilities": [ "capabilities": [
"text_to_avatar_video", "product_explainer_video",
"image_to_avatar_video", "image_or_video_avatar",
"audio_driven_avatar", "audio_lip_sync",
"multi_person_prompt", "voice_reference",
"segmented_long_video" "workspace_archive"
] ]
}))) })))
} }
@@ -295,7 +340,7 @@ async fn generate_runninghub(state: &AppState, body: Value) -> AppResult<Json<Va
let api_key = runninghub_api_key(state, &body)?; let api_key = runninghub_api_key(state, &body)?;
let request_body = runninghub_request_body(&body, &api_key)?; let request_body = runninghub_request_body(&body, &api_key)?;
let response = reqwest::Client::builder() let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) .timeout(Duration::from_secs(300))
.build() .build()
.unwrap_or_else(|_| reqwest::Client::new()) .unwrap_or_else(|_| reqwest::Client::new())
.post("https://www.runninghub.cn/task/openapi/ai-app/run") .post("https://www.runninghub.cn/task/openapi/ai-app/run")
@@ -363,7 +408,7 @@ async fn generate(
object.insert("task_type".to_string(), json!("digital_human")); object.insert("task_type".to_string(), json!("digital_human"));
} }
let response = reqwest::Client::builder() let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) .timeout(Duration::from_secs(300))
.build() .build()
.unwrap_or_else(|_| reqwest::Client::new()) .unwrap_or_else(|_| reqwest::Client::new())
.post(format!("{}/videos", local_v1_base(&state))) .post(format!("{}/videos", local_v1_base(&state)))
@@ -387,3 +432,821 @@ async fn generate(
"response": value "response": value
}))) })))
} }
fn scalar_string(value: Option<&Value>) -> Option<String> {
match value? {
Value::String(value) => Some(value.trim().to_string()).filter(|value| !value.is_empty()),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn require_string(body: &Value, keys: &[&str], message: &str) -> AppResult<String> {
for key in keys {
if let Some(value) = scalar_string(body.get(*key)) {
return Ok(value);
}
}
Err(AppError::BadRequest(message.into()))
}
fn optional_string(body: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| scalar_string(body.get(*key)))
}
fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().find_map(|key| mapping.get(*key))
}
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
} else {
scalar_string(Some(value))
}
})
}
fn mapping_field(mapping: &Value, keys: &[&str], default_field: &str) -> String {
mapping_value(mapping, keys)
.and_then(|value| {
if value.is_object() {
scalar_string(value.get("field").or_else(|| value.get("fieldName")))
} else {
None
}
})
.unwrap_or_else(|| default_field.to_string())
}
fn default_mapping() -> Value {
json!({
"prompt": { "nodeId": "", "field": "prompt" },
"script": { "nodeId": "", "field": "text" },
"avatar": { "nodeId": "", "field": "image" },
"referenceAudio": { "nodeId": "", "field": "audio" },
"voiceReference": { "nodeId": "", "field": "audio" },
"output": { "nodeId": "", "field": "video" }
})
}
fn workflow_mapping_from_body(body: &Value, workflow: &Value) -> Value {
body.get("nodeMapping")
.or_else(|| body.get("node_mapping"))
.cloned()
.or_else(|| workflow.pointer("/metadata/digitalHuman/nodeMapping").cloned())
.or_else(|| workflow.pointer("/digitalHuman/nodeMapping").cloned())
.unwrap_or_else(default_mapping)
}
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("node mapping contains empty nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!(
"workflow prompt missing node {node_id}"
)));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!(
"workflow node {node_id} has no inputs"
)));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn workflow_prompt_from_graph(graph: &Value) -> Value {
graph.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
.unwrap_or_else(|| graph.clone())
}
fn inject_digital_human_inputs(
prompt: &mut Value,
mapping: &Value,
request: &Value,
) -> AppResult<String> {
let avatar_url = require_string(
request,
&["avatarUrl", "avatar_url", "imageUrl", "image_url", "videoUrl", "video_url"],
"人物图片或视频素材是必填项",
)?;
let product_script = require_string(
request,
&["script", "productScript", "product_script"],
"商品文案是必填项",
)?;
let reference_audio_url = require_string(
request,
&["referenceAudioUrl", "reference_audio_url", "audioUrl", "audio_url"],
"参考音频是必填项",
)?;
let audio_role = optional_string(request, &["audioRole", "audio_role"])
.unwrap_or_else(|| "both".to_string());
if !matches!(audio_role.as_str(), "lip_sync" | "voice_reference" | "both") {
return Err(AppError::BadRequest(
"audioRole must be lip_sync, voice_reference or both".into(),
));
}
let prompt_text = optional_string(request, &["prompt"]).unwrap_or_else(|| {
format!("真人数字人主播讲解商品,口播自然,有带货节奏。\n商品文案:{product_script}")
});
let prompt_node = mapping_node_id(mapping, &["prompt", "promptNode", "prompt_node"])
.ok_or_else(|| AppError::BadRequest("缺少提示词节点映射".into()))?;
let script_node = mapping_node_id(mapping, &["script", "scriptNode", "script_node"])
.ok_or_else(|| AppError::BadRequest("缺少文案节点映射".into()))?;
let avatar_node = mapping_node_id(mapping, &["avatar", "image", "video", "avatarNode"])
.ok_or_else(|| AppError::BadRequest("缺少人物素材节点映射".into()))?;
let audio_node = mapping_node_id(
mapping,
&["referenceAudio", "audio", "lipSyncAudio", "audioNode"],
)
.ok_or_else(|| AppError::BadRequest("缺少参考音频节点映射".into()))?;
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "promptNode", "prompt_node"], "prompt"),
json!(prompt_text),
)?;
apply_node_input(
prompt,
&script_node,
&mapping_field(mapping, &["script", "scriptNode", "script_node"], "text"),
json!(product_script),
)?;
apply_node_input(
prompt,
&avatar_node,
&mapping_field(mapping, &["avatar", "image", "video", "avatarNode"], "image"),
json!(avatar_url),
)?;
apply_node_input(
prompt,
&audio_node,
&mapping_field(
mapping,
&["referenceAudio", "audio", "lipSyncAudio", "audioNode"],
"audio",
),
json!(reference_audio_url),
)?;
if matches!(audio_role.as_str(), "voice_reference" | "both") {
if let Some(voice_node) =
mapping_node_id(mapping, &["voiceReference", "voice", "voiceReferenceNode"])
{
apply_node_input(
prompt,
&voice_node,
&mapping_field(
mapping,
&["voiceReference", "voice", "voiceReferenceNode"],
"audio",
),
json!(reference_audio_url),
)?;
}
}
for (keys, request_keys, default_field) in [
(
&["ratio", "ratioNode"][..],
&["ratio", "aspectRatio", "aspect_ratio"][..],
"ratio",
),
(&["resolution", "resolutionNode"][..], &["resolution"][..], "resolution"),
(&["subtitles", "subtitleNode"][..], &["subtitles", "subtitle"][..], "enabled"),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| request.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
}
}
}
Ok(product_script)
}
fn native_base(state: &AppState) -> String {
state.config.comfyui.target_base_url()
}
fn native_path(state: &AppState, path: &str) -> String {
if state.config.comfyui.uses_cloud_backend() {
format!(
"/api{}",
path.strip_prefix('/')
.map(|value| format!("/{value}"))
.unwrap_or_else(|| path.to_string())
)
} else {
path.to_string()
}
}
fn native_url(state: &AppState, path: &str) -> String {
format!("{}{}", native_base(state), native_path(state, path))
}
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(DIGITAL_HUMAN_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let reqwest_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
let mut request = client.request(reqwest_method, native_url(state, path));
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
request
}
async fn native_json(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let mut request = native_request(state, method, path);
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(value)
}
async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
let body = json!({
"client_id": format!("boosapi-digital-human-{}", uuid::Uuid::new_v4().simple()),
"prompt": prompt,
});
let value = native_json(state, Method::POST, "/prompt", Some(body)).await?;
let prompt_id = scalar_string(
value
.get("prompt_id")
.or_else(|| value.get("promptId"))
.or_else(|| value.pointer("/data/prompt_id"))
.or_else(|| value.pointer("/data/promptId")),
)
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {value}")))?;
Ok((prompt_id, value))
}
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await
}
fn output_objects(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
fn walk(value: &Value, out: &mut Vec<Value>) {
match value {
Value::Object(object) => {
for key in ["videos", "gifs", "images", "audio"] {
if let Some(items) = object.get(key).and_then(Value::as_array) {
out.extend(items.iter().filter(|item| item.is_object()).cloned());
}
}
for value in object.values() {
walk(value, out);
}
}
Value::Array(items) => {
for item in items {
walk(item, out);
}
}
_ => {}
}
}
walk(value, &mut out);
out
}
fn output_kind(item: &Value) -> &'static str {
let filename = scalar_string(item.get("filename")).unwrap_or_default();
let lower = filename.to_ascii_lowercase();
if lower.ends_with(".mp4") || lower.ends_with(".webm") || lower.ends_with(".mov") {
"video"
} else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".m4a") {
"audio"
} else {
"image"
}
}
fn output_query(item: &Value) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(filename) = scalar_string(item.get("filename")) {
params.insert("filename".to_string(), filename);
}
if let Some(file_type) = scalar_string(item.get("type")) {
params.insert("type".to_string(), file_type);
} else {
params.insert("type".to_string(), "output".to_string());
}
if let Some(subfolder) = scalar_string(item.get("subfolder")) {
params.insert("subfolder".to_string(), subfolder);
}
params
}
fn encode(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn query_string(params: &HashMap<String, String>) -> String {
if params.is_empty() {
return String::new();
}
let mut parts = params
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>();
parts.sort();
format!("?{}", parts.join("&"))
}
async fn archive_output(
state: &AppState,
auth: &DigitalHumanAuth,
item: &Value,
prompt_id: &str,
script: &str,
) -> Option<Value> {
let params = output_query(item);
let filename = params.get("filename")?.clone();
let kind = output_kind(item).to_string();
let path = if kind == "video" { "/viewvideo" } else { "/view" };
let response = native_request(state, Method::GET, &format!("{path}{}", query_string(&params)))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response.bytes().await.ok()?;
let input = WorkspaceAssetInput {
user_id: auth.user_id,
source: "digital-human".to_string(),
kind: kind.clone(),
title: Some(filename.chars().take(80).collect()),
prompt: Some(script.chars().take(1000).collect()),
model: Some("comfy-native".to_string()),
remote_url: None,
metadata: json!({
"digitalHuman": {
"promptId": prompt_id,
"filename": filename,
"type": params.get("type"),
"subfolder": params.get("subfolder"),
"raw": item,
}
}),
};
match workspace::record_bytes_asset(state, input, &bytes, &content_type).await {
Ok(asset) => Some(json!({
"publicId": asset.public_id,
"kind": kind,
"filename": filename,
"status": asset.status,
})),
Err(err) => {
tracing::warn!(%err, "failed to archive digital human output");
None
}
}
}
fn job_visible_to_auth(task: &BackgroundTask, auth: &DigitalHumanAuth) -> bool {
if auth.role == "admin" && auth.user_id.is_none() {
return true;
}
let Some(raw) = task.result_json.as_deref() else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(raw) else {
return false;
};
value.get("userId").and_then(Value::as_i64) == auth.user_id
}
fn task_to_json(task: BackgroundTask) -> Value {
let result = task
.result_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.unwrap_or_else(|| json!({}));
json!({
"id": task.id,
"type": task.task_type,
"title": task.title,
"status": task.status,
"message": task.message,
"error": task.error,
"progress": task.progress.unwrap_or(0),
"progressTotal": task.progress_total.unwrap_or(5),
"result": result,
"createdAt": task.created_at,
"updatedAt": task.updated_at,
"startedAt": task.started_at,
"finishedAt": task.finished_at,
})
}
fn terminal_status(status: &str) -> bool {
matches!(status, "succeeded" | "failed" | "cancelled")
}
async fn load_job(state: &AppState, id: &str, auth: &DigitalHumanAuth) -> AppResult<Value> {
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(id)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("Digital human job not found".into()))?;
if !job_visible_to_auth(&task, auth) {
return Err(AppError::NotFound("Digital human job not found".into()));
}
Ok(task_to_json(task))
}
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET progress = ?, message = ?, updated_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(progress)
.bind(message)
.bind(id)
.execute(&*state.db)
.await?;
Ok(())
}
async fn is_cancelled(state: &AppState, id: &str) -> AppResult<bool> {
let status: Option<String> =
sqlx::query_scalar("SELECT status FROM background_tasks WHERE id = ? LIMIT 1")
.bind(id)
.fetch_optional(&*state.db)
.await?;
Ok(status.as_deref() == Some("cancelled"))
}
async fn create_job(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
if !state.config.comfyui.uses_native_backend() || state.config.comfyui.uses_runninghub_backend()
{
return Err(AppError::BadRequest(
"数字人商品讲解需要 comfyui.backend = cloud 或 external".into(),
));
}
let template_id = require_string(&body, &["templateId", "template_id"], "请选择工作流模板")?;
let workflow = storage::get_workflow(
&state.db,
&template_id,
auth.user_id,
auth.role == "admin",
)
.await?;
let mapping = workflow_mapping_from_body(&body, &workflow.graph);
let mut prompt = workflow_prompt_from_graph(&workflow.graph);
let script = inject_digital_human_inputs(&mut prompt, &mapping, &body)?;
let task_id = format!("dh_{}", uuid::Uuid::new_v4().simple());
let title = script.chars().take(30).collect::<String>();
let initial_result = json!({
"userId": auth.user_id,
"role": auth.role,
"templateId": template_id,
"templateName": workflow.name,
"audioRole": optional_string(&body, &["audioRole", "audio_role"]).unwrap_or_else(|| "both".to_string()),
"request": body,
"mapping": mapping,
});
sqlx::query(
"INSERT INTO background_tasks
(id, task_type, title, status, message, dedupe_key, result_json, progress, progress_total, started_at)
VALUES (?, ?, ?, 'running', '任务已创建,准备提交 Comfy', ?, ?, 1, 5, datetime('now'))",
)
.bind(&task_id)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.bind(if title.trim().is_empty() { "数字人讲解任务" } else { &title })
.bind(format!("digital-human:{}", auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())))
.bind(initial_result.to_string())
.execute(&*state.db)
.await?;
let state_for_task = state.clone();
let task_id_for_spawn = task_id.clone();
let auth_for_task = auth.clone();
tokio::spawn(async move {
let result = async {
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在提交 Comfy 工作流")
.await?;
let (prompt_id, submit_response) = submit_prompt(&state_for_task, prompt).await?;
let mid_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"templateId": template_id,
"templateName": workflow.name,
"promptId": prompt_id,
"submitResponse": submit_response,
"request": body,
"mapping": mapping,
});
sqlx::query(
"UPDATE background_tasks SET result_json = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(mid_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
for _ in 0..DIGITAL_HUMAN_MAX_POLLS {
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
return Ok(());
}
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
.await?;
let history = prompt_history(&state_for_task, &prompt_id).await?;
let outputs = output_objects(&history);
if !outputs.is_empty() {
update_progress(
&state_for_task,
&task_id_for_spawn,
4,
"生成完成,正在归档产出",
)
.await?;
let mut assets = Vec::new();
for output in &outputs {
if let Some(asset) = archive_output(
&state_for_task,
&auth_for_task,
output,
&prompt_id,
&script,
)
.await
{
assets.push(asset);
}
}
let final_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"templateId": template_id,
"templateName": workflow.name,
"promptId": prompt_id,
"request": body,
"mapping": mapping,
"history": history,
"outputs": outputs,
"assets": assets,
});
sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = '数字人讲解视频生成完成', result_json = ?, progress = 5,
updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(final_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
return Ok(());
}
tokio::time::sleep(Duration::from_secs(DIGITAL_HUMAN_POLL_INTERVAL_SECS)).await;
}
Err(AppError::GatewayTimeout("Comfy 数字人任务等待超时".into()))
}
.await;
if let Err(err) = result {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', message = '数字人任务失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(err.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await;
}
});
let job = load_job(&state, &task_id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn get_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let job = load_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn list_jobs(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<JobListQuery>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT ?",
)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.bind(limit * 3)
.fetch_all(&*state.db)
.await?;
let jobs = tasks
.into_iter()
.filter(|task| job_visible_to_auth(task, &auth))
.take(limit as usize)
.map(task_to_json)
.collect::<Vec<_>>();
Ok(Json(json!({ "success": true, "jobs": jobs })))
}
async fn cancel_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(&id)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("Digital human job not found".into()))?;
if !job_visible_to_auth(&task, &auth) {
return Err(AppError::NotFound("Digital human job not found".into()));
}
if terminal_status(&task.status) {
return Ok(Json(json!({ "success": true, "job": task_to_json(task) })));
}
sqlx::query(
"UPDATE background_tasks
SET status = 'cancelled', message = '已取消', updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND task_type = ? AND status IN ('pending', 'running')",
)
.bind(&id)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.execute(&*state.db)
.await?;
let job = load_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE task_type = ? ORDER BY created_at DESC",
)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.fetch_all(&*state.db)
.await?;
let mut cleared = 0_i64;
for task in tasks {
if !job_visible_to_auth(&task, &auth) || !terminal_status(&task.status) {
continue;
}
let affected = sqlx::query("DELETE FROM background_tasks WHERE id = ? AND task_type = ?")
.bind(&task.id)
.bind(TASK_TYPE_DIGITAL_HUMAN)
.execute(&*state.db)
.await?
.rows_affected();
cleared += i64::try_from(affected).unwrap_or(0);
}
Ok(Json(json!({ "success": true, "cleared": cleared })))
}
fn upload_kind_from_mime(mime: &str) -> &'static str {
if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"image"
}
}
async fn upload_asset(
State(state): State<AppState>,
headers: HeaderMap,
mut multipart: Multipart,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let mut file_name = "digital-human-upload.bin".to_string();
let mut mime_type = "application/octet-stream".to_string();
let mut bytes: Option<Bytes> = None;
let mut purpose = "avatar".to_string();
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
if name == "file" {
file_name = field.file_name().unwrap_or(&file_name).to_string();
mime_type = field
.content_type()
.map(str::to_string)
.unwrap_or_else(|| mime_type.clone());
bytes = Some(
field
.bytes()
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?,
);
} else if name == "purpose" {
purpose = field.text().await.unwrap_or_else(|_| purpose.clone());
}
}
let Some(bytes) = bytes else {
return Err(AppError::BadRequest("file is required".into()));
};
let kind = upload_kind_from_mime(&mime_type);
let input = WorkspaceAssetInput {
user_id: auth.user_id,
source: "digital-human".to_string(),
kind: kind.to_string(),
title: Some(file_name.clone()),
prompt: None,
model: None,
remote_url: None,
metadata: json!({ "digitalHumanUpload": { "purpose": purpose, "fileName": file_name } }),
};
let asset = workspace::record_bytes_asset(&state, input, &bytes, &mime_type).await?;
let file_url = format!(
"{}/api/{}/workspace/assets/{}/file",
public_base_url(&state),
if auth.user_id.is_some() { "user" } else { "" }.trim_matches('/'),
asset.public_id
)
.replace("/api//", "/api/");
Ok(Json(json!({
"success": true,
"asset": {
"publicId": asset.public_id,
"kind": kind,
"status": asset.status,
"fileUrl": file_url,
}
})))
}
+54
View File
@@ -1158,6 +1158,32 @@ export type WorkflowSavePayload = {
isTemplate?: boolean; isTemplate?: boolean;
}; };
export type DigitalHumanJob = {
id: string;
type: string;
title: string;
status: string;
message?: string | null;
error?: string | null;
progress: number;
progressTotal: number;
result?: any;
createdAt?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
};
export type DigitalHumanJobResponse = {
success: boolean;
job: DigitalHumanJob;
};
export type DigitalHumanJobsResponse = {
success: boolean;
jobs: DigitalHumanJob[];
};
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -2041,6 +2067,20 @@ export const api = {
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`), request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`), request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
createDigitalHumanJob: (data: any) =>
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getDigitalHumanJobs: (params?: { limit?: number }) =>
request<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
getDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
}; };
export const userApi = { export const userApi = {
register: (data: { register: (data: {
@@ -2116,4 +2156,18 @@ export const userApi = {
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`), userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`), userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
createDigitalHumanJob: (data: any) =>
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getDigitalHumanJobs: (params?: { limit?: number }) =>
userRequest<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
getDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
}; };
+160 -1
View File
@@ -8858,6 +8858,160 @@ textarea {
resize: vertical; resize: vertical;
} }
.digital-human-panel h2 {
margin: 0 0 12px;
font-size: 14px;
}
.digital-human-panel h2:not(:first-child) {
margin-top: 20px;
}
.digital-human-template-meta {
display: grid;
gap: 4px;
margin-bottom: 14px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border));
}
.digital-human-template-meta span,
.digital-human-upload-card span,
.digital-human-job-head span {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
}
.digital-human-upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.digital-human-upload-card {
display: grid;
gap: 9px;
min-width: 0;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 72%, var(--color-bg-card));
padding: 12px;
}
.digital-human-upload-card strong {
font-size: 13px;
}
.digital-human-upload-card input,
.digital-human-mapping-row input {
width: 100%;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 12px;
}
.digital-human-checkbox {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 13px;
}
.digital-human-mapping-grid,
.digital-human-job-list {
display: grid;
gap: 10px;
}
.digital-human-mapping-row {
display: grid;
grid-template-columns: minmax(86px, 0.8fr) minmax(92px, 1fr) minmax(92px, 1fr);
gap: 8px;
align-items: center;
}
.digital-human-mapping-row span {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.digital-human-job-actions {
display: flex;
justify-content: flex-end;
margin: -4px 0 10px;
}
.digital-human-job-card {
display: grid;
gap: 10px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 66%, var(--color-bg-card));
padding: 12px;
}
.digital-human-job-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.digital-human-job-head div {
display: grid;
gap: 3px;
}
.digital-human-job-head em {
font-style: normal;
color: var(--color-text-muted);
font-size: 12px;
white-space: nowrap;
}
.digital-human-progress {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 58%, transparent);
}
.digital-human-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width .25s ease;
}
.digital-human-job-error {
color: var(--color-danger);
font-size: 12px;
line-height: 1.45;
}
.digital-human-assets,
.digital-human-job-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.digital-human-assets a {
color: var(--color-primary);
font-size: 12px;
text-decoration: none;
}
.digital-human-toggle-row { .digital-human-toggle-row {
display: flex; display: flex;
gap: 14px; gap: 14px;
@@ -9366,7 +9520,12 @@ textarea {
} }
.digital-human-mode-grid, .digital-human-mode-grid,
.digital-human-form-grid { .digital-human-form-grid,
.digital-human-upload-grid {
grid-template-columns: 1fr;
}
.digital-human-mapping-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+323 -236
View File
@@ -1,124 +1,198 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js'; import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary } from '../api.js';
import type { SessionAudience } from '../authSession.js'; import type { SessionAudience } from '../authSession.js';
type Mode = 'audio' | 'image' | 'text'; type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
type Provider = 'boosapi' | 'runninghub'; type UploadPurpose = 'avatar' | 'audio';
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [ const DEFAULT_MAPPING = {
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' }, prompt: { nodeId: '', field: 'prompt' },
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' }, script: { nodeId: '', field: 'text' },
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' }, avatar: { nodeId: '', field: 'image' },
referenceAudio: { nodeId: '', field: 'audio' },
voiceReference: { nodeId: '', field: 'audio' },
output: { nodeId: '', field: 'video' },
};
const AUDIO_ROLES: Array<{ value: AudioRole; label: string; description: string }> = [
{ value: 'both', label: '口型 + 音色参考', description: '同一参考音频同时注入口型和音色节点。' },
{ value: 'lip_sync', label: '只驱动口型', description: '人物按参考音频开口,适合已有成品配音。' },
{ value: 'voice_reference', label: '只做音色参考', description: '模板内 TTS 节点按文案生成声音。' },
]; ];
const MODEL_PRESETS = [ function terminal(status: string) {
'longcat-video-avatar', return ['succeeded', 'failed', 'cancelled'].includes(status);
'doubao-seedance-1-0-lite-i2v-250428',
'doubao-seedance-1-0-lite-t2v-250428',
'wan2-1-14b-i2v-250225',
];
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
async function parseError(response: Response) {
const text = await response.text().catch(() => '');
if (!text) return `HTTP ${response.status}`;
try {
const value = JSON.parse(text);
return value?.error?.message || value?.message || text;
} catch {
return text;
}
} }
function extractTaskId(value: any): string { function statusLabel(status: string) {
return value?.response?.id if (status === 'succeeded') return '完成';
|| value?.response?.task_id if (status === 'failed') return '失败';
|| value?.response?.data?.id if (status === 'cancelled') return '已取消';
|| value?.response?.data?.task_id if (status === 'running') return '生成中';
|| value?.id return '排队中';
|| value?.task_id
|| '';
} }
function copyText(value: string) { function parseError(response: Response) {
if (!value) return; return response.text().then((text) => {
void navigator.clipboard?.writeText(value); if (!text) return `HTTP ${response.status}`;
try {
const value = JSON.parse(text);
return value?.error?.message || value?.message || text;
} catch {
return text;
}
});
}
function graphMapping(workflow?: WorkflowSummary | null) {
return workflow?.graph?.metadata?.digitalHuman?.nodeMapping
|| workflow?.graph?.digitalHuman?.nodeMapping
|| DEFAULT_MAPPING;
}
function mappingValue(mapping: any, key: keyof typeof DEFAULT_MAPPING) {
const value = mapping?.[key] || DEFAULT_MAPPING[key];
if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
return {
nodeId: String(value?.nodeId || value?.node_id || ''),
field: String(value?.field || value?.fieldName || DEFAULT_MAPPING[key].field),
};
}
function jobAssets(job: DigitalHumanJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : [];
} }
export default function DigitalHuman() { export default function DigitalHuman() {
const location = useLocation(); const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin'; const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [provider, setProvider] = useState<Provider>('runninghub'); const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
const [mode, setMode] = useState<Mode>('audio'); const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [model, setModel] = useState(MODEL_PRESETS[0]); const audioInputRef = useRef<HTMLInputElement | null>(null);
const [runninghubWebappId, setRunninghubWebappId] = useState(''); const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [runninghubApiKey, setRunninghubApiKey] = useState(''); const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
const [runninghubInstanceType, setRunninghubInstanceType] = useState('default'); const [templateId, setTemplateId] = useState('');
const [promptNodeId, setPromptNodeId] = useState('122'); const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
const [scriptNodeId, setScriptNodeId] = useState(''); const [avatarUrl, setAvatarUrl] = useState('');
const [imageNodeId, setImageNodeId] = useState(''); const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
const [audioNodeId, setAudioNodeId] = useState(''); const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。'); const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。'); const [audioRole, setAudioRole] = useState<AudioRole>('both');
const [imageUrl, setImageUrl] = useState('');
const [audioUrl, setAudioUrl] = useState('');
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
const [resolution, setResolution] = useState('720p');
const [ratio, setRatio] = useState('9:16'); const [ratio, setRatio] = useState('9:16');
const [duration, setDuration] = useState(8); const [resolution, setResolution] = useState('1080p');
const [refImageIndex, setRefImageIndex] = useState(0); const [subtitles, setSubtitles] = useState(true);
const [audioCfg, setAudioCfg] = useState(4.5);
const [segments, setSegments] = useState(false);
const [multiPerson, setMultiPerson] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [result, setResult] = useState<any>(null);
const bodyPreview = useMemo(() => ({ const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
provider, const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
...(provider === 'runninghub' ? { const mapped = useMemo(() => ({
use_runninghub: true, prompt: mappingValue(mapping, 'prompt'),
webappId: runninghubWebappId.trim(), script: mappingValue(mapping, 'script'),
runninghub_api_key: runninghubApiKey.trim() || undefined, avatar: mappingValue(mapping, 'avatar'),
instanceType: runninghubInstanceType, referenceAudio: mappingValue(mapping, 'referenceAudio'),
prompt_node_id: promptNodeId.trim() || undefined, voiceReference: mappingValue(mapping, 'voiceReference'),
script_node_id: scriptNodeId.trim() || undefined, output: mappingValue(mapping, 'output'),
image_node_id: imageNodeId.trim() || undefined, }), [mapping]);
audio_node_id: audioNodeId.trim() || undefined,
script: script.trim(),
} : {}),
model: model.trim(),
prompt: [
prompt.trim(),
script.trim() ? `台词:${script.trim()}` : '',
`风格:${voiceStyle}`,
].filter(Boolean).join('\n'),
mode,
ratio,
resolution,
duration,
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
audio_cfg: audioCfg,
ref_img_index: refImageIndex,
segmented: segments,
multi_person: multiPerson,
}), [audioCfg, audioNodeId, audioUrl, duration, imageNodeId, imageUrl, mode, model, multiPerson, prompt, promptNodeId, provider, ratio, refImageIndex, resolution, runninghubApiKey, runninghubInstanceType, runninghubWebappId, script, scriptNodeId, segments, voiceStyle]);
const submit = async () => { const missingMapping = [
setSubmitting(true); ['提示词节点', mapped.prompt.nodeId],
['文案节点', mapped.script.nodeId],
['人物素材节点', mapped.avatar.nodeId],
['参考音频节点', mapped.referenceAudio.nodeId],
['输出节点', mapped.output.nodeId],
].filter(([, value]) => !value).map(([label]) => label);
const canSubmit = !!templateId
&& !!avatarUrl.trim()
&& !!referenceAudioUrl.trim()
&& !!productScript.trim()
&& missingMapping.length === 0
&& !submitting;
const loadData = useCallback(async () => {
const [workflowPayload, jobPayload] = await Promise.all([
client.getWorkflows({ include_public: true }),
client.getDigitalHumanJobs({ limit: 30 }),
]);
const nextWorkflows = Array.isArray(workflowPayload.items) ? workflowPayload.items : [];
setWorkflows(nextWorkflows);
setJobs(Array.isArray(jobPayload.jobs) ? jobPayload.jobs : []);
setTemplateId((current) => current || nextWorkflows.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId || nextWorkflows[0]?.publicId || '');
}, [client]);
useEffect(() => {
loadData().catch((err) => setError(err?.message || '数字人数据加载失败'));
}, [loadData]);
useEffect(() => {
setMapping(graphMapping(selectedWorkflow));
}, [selectedWorkflow?.publicId]);
useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => {
client.getDigitalHumanJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || []))
.catch(() => {});
}, 4000);
return () => window.clearInterval(timer);
}, [client, jobs]);
const uploadFile = async (file: File, purpose: UploadPurpose) => {
setUploading(purpose);
setError(''); setError('');
setResult(null);
try { try {
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', { const form = new FormData();
form.append('file', file);
form.append('purpose', purpose);
const response = await fetchSessionResponse(audience, '/api/digital-human/upload', {
method: 'POST', method: 'POST',
body: JSON.stringify(bodyPreview), body: form,
timeoutMs: 300_000, timeoutMs: 180_000,
}); });
if (!response.ok) throw new Error(await parseError(response)); if (!response.ok) throw new Error(await parseError(response));
setResult(await response.json()); const payload = await response.json();
const fileUrl = payload?.asset?.fileUrl || '';
if (purpose === 'avatar') setAvatarUrl(fileUrl);
if (purpose === 'audio') setReferenceAudioUrl(fileUrl);
} catch (err: any) {
setError(err?.message || '素材上传失败');
} finally {
setUploading('');
}
};
const updateMapping = (key: keyof typeof DEFAULT_MAPPING, field: 'nodeId' | 'field', value: string) => {
setMapping((prev: any) => ({
...prev,
[key]: {
...mappingValue(prev, key),
[field]: value,
},
}));
};
const submit = async () => {
if (!canSubmit) return;
setSubmitting(true);
setError('');
try {
const payload = await client.createDigitalHumanJob({
templateId,
nodeMapping: mapping,
avatarUrl: avatarUrl.trim(),
referenceAudioUrl: referenceAudioUrl.trim(),
productScript: productScript.trim(),
prompt: prompt.trim(),
audioRole,
ratio,
resolution,
subtitles,
});
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
} catch (err: any) { } catch (err: any) {
setError(err?.message || '数字人任务创建失败'); setError(err?.message || '数字人任务创建失败');
} finally { } finally {
@@ -126,18 +200,41 @@ export default function DigitalHuman() {
} }
}; };
const taskId = extractTaskId(result); const cancel = async (id: string) => {
const payload = await client.cancelDigitalHumanJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
};
const clear = async () => {
await client.clearDigitalHumanJobs();
await loadData();
};
const retry = (job: DigitalHumanJob) => {
const request = job.result?.request || {};
setTemplateId(request.templateId || job.result?.templateId || templateId);
setMapping(request.nodeMapping || job.result?.mapping || mapping);
setAvatarUrl(request.avatarUrl || '');
setReferenceAudioUrl(request.referenceAudioUrl || '');
setProductScript(request.productScript || '');
setPrompt(request.prompt || prompt);
setAudioRole(request.audioRole || 'both');
setRatio(request.ratio || '9:16');
setResolution(request.resolution || '1080p');
setSubtitles(request.subtitles !== false);
};
return ( return (
<div className="digital-human-page animate-fade-in"> <div className="digital-human-page animate-fade-in">
<div className="page-header"> <div className="page-header">
<div> <div>
<h1 className="page-title">数字人</h1> <h1 className="page-title">数字人</h1>
<p className="page-subtitle">人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p> <p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}> <button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
{submitting ? '创建中...' : '创建数字人任务'} <button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? '提交中...' : '生成讲解视频'}
</button> </button>
</div> </div>
</div> </div>
@@ -146,87 +243,71 @@ export default function DigitalHuman() {
<div className="digital-human-layout"> <div className="digital-human-layout">
<section className="digital-human-panel"> <section className="digital-human-panel">
<div className="digital-human-mode-grid"> <h2>1. 选择工作流模板</h2>
<button <label className="digital-human-field">
type="button" <span>模板</span>
onClick={() => setProvider('runninghub')} <select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
className={`digital-human-mode ${provider === 'runninghub' ? 'is-active' : ''}`} <option value="">请选择模板</option>
> {workflows.map((workflow) => (
<strong>RunningHub API</strong> <option key={workflow.publicId} value={workflow.publicId}>
<span>调用 RunningHub AI 应用,适合数字人模板、口型和视频工作流。</span> {workflow.name} · {workflow.visibility}
</button> </option>
<button ))}
type="button" </select>
onClick={() => setProvider('boosapi')} </label>
className={`digital-human-mode ${provider === 'boosapi' ? 'is-active' : ''}`} {selectedWorkflow && (
> <div className="digital-human-template-meta">
<strong>BoosAPI 路由</strong> <strong>{selectedWorkflow.name}</strong>
<span>走本地 /v1/videos 中转,由模型路由选择视频上游。</span> <span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
</button>
</div>
{provider === 'runninghub' && (
<div className="digital-human-form-grid">
<label className="digital-human-field">
<span>RunningHub WebApp ID</span>
<input value={runninghubWebappId} onChange={(event) => setRunninghubWebappId(event.target.value)} placeholder="例如 1877265245566922753" />
</label>
<label className="digital-human-field">
<span>API Key(可选)</span>
<input value={runninghubApiKey} onChange={(event) => setRunninghubApiKey(event.target.value)} placeholder="留空使用 config.toml 的 comfyui.api_key" />
</label>
<label className="digital-human-field">
<span>算力规格</span>
<select value={runninghubInstanceType} onChange={(event) => setRunninghubInstanceType(event.target.value)}>
<option value="default">default 24G</option>
<option value="plus">plus 48G</option>
</select>
</label>
<label className="digital-human-field">
<span>提示词节点 ID</span>
<input value={promptNodeId} onChange={(event) => setPromptNodeId(event.target.value)} placeholder="如 122" />
</label>
<label className="digital-human-field">
<span>台词节点 ID</span>
<input value={scriptNodeId} onChange={(event) => setScriptNodeId(event.target.value)} placeholder="模板需要独立台词节点时填写" />
</label>
<label className="digital-human-field">
<span>人物图节点 ID</span>
<input value={imageNodeId} onChange={(event) => setImageNodeId(event.target.value)} placeholder="模板需要图片输入时填写" />
</label>
<label className="digital-human-field">
<span>音频节点 ID</span>
<input value={audioNodeId} onChange={(event) => setAudioNodeId(event.target.value)} placeholder="模板需要音频输入时填写" />
</label>
</div> </div>
)} )}
<div className="digital-human-mode-grid"> <h2>2. 上传素材</h2>
{MODE_OPTIONS.map((item) => ( <div className="digital-human-upload-grid">
<button <div className="digital-human-upload-card">
key={item.value} <strong>人物图片 / 视频</strong>
type="button" <span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
onClick={() => setMode(item.value)} <input ref={avatarInputRef} type="file" hidden accept="image/*,video/*" onChange={(event) => {
className={`digital-human-mode ${mode === item.value ? 'is-active' : ''}`} const file = event.target.files?.[0];
> if (file) void uploadFile(file, 'avatar');
<strong>{item.label}</strong> event.currentTarget.value = '';
<span>{item.description}</span> }} />
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
</button> </button>
))} <input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="或粘贴图片/视频 URL" />
</div>
<div className="digital-human-upload-card">
<strong>参考音频</strong>
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
<input ref={audioInputRef} type="file" hidden accept="audio/*" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFile(file, 'audio');
event.currentTarget.value = '';
}} />
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
{uploading === 'audio' ? '上传中...' : '上传参考音频'}
</button>
<input value={referenceAudioUrl} onChange={(event) => setReferenceAudioUrl(event.target.value)} placeholder="或粘贴音频 URL" />
</div>
</div> </div>
<h2>3. 商品文案</h2>
<label className="digital-human-field">
<span>讲解文案</span>
<textarea value={productScript} onChange={(event) => setProductScript(event.target.value)} rows={6} />
</label>
<label className="digital-human-field">
<span>画面提示</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={3} />
</label>
<div className="digital-human-form-grid"> <div className="digital-human-form-grid">
<label className="digital-human-field"> <label className="digital-human-field">
<span>模型</span> <span>参考音频用途</span>
<input <select value={audioRole} onChange={(event) => setAudioRole(event.target.value as AudioRole)}>
value={provider === 'runninghub' ? runninghubWebappId : model} {AUDIO_ROLES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
onChange={(event) => provider === 'runninghub' ? setRunninghubWebappId(event.target.value) : setModel(event.target.value)} </select>
list={provider === 'runninghub' ? undefined : 'digital-human-models'}
placeholder={provider === 'runninghub' ? 'RunningHub WebApp ID' : undefined}
/>
<datalist id="digital-human-models">
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
</datalist>
</label> </label>
<label className="digital-human-field"> <label className="digital-human-field">
<span>画幅</span> <span>画幅</span>
@@ -244,75 +325,81 @@ export default function DigitalHuman() {
<option value="480p">480p</option> <option value="480p">480p</option>
</select> </select>
</label> </label>
<label className="digital-human-field"> <label className="digital-human-checkbox">
<span>时长(秒)</span> <input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} /> <span>生成字幕</span>
</label> </label>
</div> </div>
<label className="digital-human-field">
<span>人物/场景描述</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} />
</label>
<label className="digital-human-field">
<span>台词/脚本</span>
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} />
</label>
<div className="digital-human-form-grid">
{mode !== 'text' && (
<label className="digital-human-field">
<span>人物图 URL</span>
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
</label>
)}
{mode === 'audio' && (
<label className="digital-human-field">
<span>音频 URL</span>
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." />
</label>
)}
<label className="digital-human-field">
<span>口播风格</span>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)}>
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="digital-human-field">
<span>ref_img_index</span>
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} />
</label>
<label className="digital-human-field">
<span>audio CFG</span>
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} />
</label>
</div>
<div className="digital-human-toggle-row">
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
</div>
</section> </section>
<aside className="digital-human-panel digital-human-side"> <section className="digital-human-panel">
<h2>任务请求</h2> <h2>节点映射</h2>
<pre>{JSON.stringify(bodyPreview, null, 2)}</pre> <div className="digital-human-mapping-grid">
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button> {([
['prompt', '提示词节点'],
<h2>返回结果</h2> ['script', '文案节点'],
{result ? ( ['avatar', '人物素材节点'],
<> ['referenceAudio', '参考音频节点'],
{taskId && <div className="digital-human-task-id">任务 ID:{taskId}</div>} ['voiceReference', '音色参考节点(可选)'],
<pre>{JSON.stringify(result.response || result, null, 2)}</pre> ['output', '输出视频节点'],
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button> ] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
</> const item = mapped[key];
) : ( return (
<div className="empty-state" style={{ minHeight: 160 }}> <div key={key} className="digital-human-mapping-row">
<div className="empty-state-title">暂无任务</div> <span>{label}</span>
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div> <input value={item.nodeId} onChange={(event) => updateMapping(key, 'nodeId', event.target.value)} placeholder="Node ID" />
<input value={item.field} onChange={(event) => updateMapping(key, 'field', event.target.value)} placeholder="字段名" />
</div>
);
})}
</div>
{missingMapping.length > 0 && (
<div className="alert alert-warning" style={{ marginTop: 12 }}>
缺少映射:{missingMapping.join('、')}
</div> </div>
)} )}
</aside>
<h2>任务队列</h2>
<div className="digital-human-job-actions">
<button type="button" className="btn btn-ghost" onClick={clear}>清空已完成</button>
</div>
<div className="digital-human-job-list">
{jobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 180 }}>
<div className="empty-state-title">暂无任务</div>
<div className="empty-state-desc">提交后这里会显示 Comfy 执行进度和工作空间产出。</div>
</div>
) : jobs.map((job) => {
const progress = Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
return (
<article key={job.id} className="digital-human-job-card">
<div className="digital-human-job-head">
<div>
<strong>{job.title || '数字人讲解任务'}</strong>
<span>{job.message || statusLabel(job.status)}</span>
</div>
<em>{statusLabel(job.status)}</em>
</div>
<div className="digital-human-progress"><span style={{ width: `${progress}%` }} /></div>
{job.error && <div className="digital-human-job-error">{job.error}</div>}
{jobAssets(job).length > 0 && (
<div className="digital-human-assets">
{jobAssets(job).map((asset: any) => (
<a key={asset.publicId} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}>
{asset.kind === 'video' ? '视频' : asset.kind === 'audio' ? '音频' : '封面'} · {asset.publicId}
</a>
))}
</div>
)}
<div className="digital-human-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => cancel(job.id)}>取消</button>}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>重试</button>}
</div>
</article>
);
})}
</div>
</section>
</div> </div>
</div> </div>
); );