Add Comfy digital human jobs
This commit is contained in:
+874
-11
@@ -1,26 +1,51 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
|
||||
error::{AppError, AppResult},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
workflow::storage,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap},
|
||||
body::Bytes,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Method},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
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)]
|
||||
struct DigitalHumanAuth {
|
||||
user_id: Option<i64>,
|
||||
role: String,
|
||||
outbound_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JobListQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/digital-human/status", get(status))
|
||||
.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 {
|
||||
@@ -32,6 +57,15 @@ fn local_v1_base(state: &AppState) -> String {
|
||||
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> {
|
||||
headers
|
||||
.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 {
|
||||
return Ok(DigitalHumanAuth {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
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()))?;
|
||||
match user_downstream_key(state, user_id).await {
|
||||
Ok(key) => Ok(DigitalHumanAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: key.key,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
}),
|
||||
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?;
|
||||
Ok(Json(json!({
|
||||
"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": {
|
||||
"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"
|
||||
},
|
||||
"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": [
|
||||
"text_to_avatar_video",
|
||||
"image_to_avatar_video",
|
||||
"audio_driven_avatar",
|
||||
"multi_person_prompt",
|
||||
"segmented_long_video"
|
||||
"product_explainer_video",
|
||||
"image_or_video_avatar",
|
||||
"audio_lip_sync",
|
||||
"voice_reference",
|
||||
"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 request_body = runninghub_request_body(&body, &api_key)?;
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
.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"));
|
||||
}
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
.post(format!("{}/videos", local_v1_base(&state)))
|
||||
@@ -387,3 +432,821 @@ async fn generate(
|
||||
"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(¶ms)))
|
||||
.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,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1158,6 +1158,32 @@ export type WorkflowSavePayload = {
|
||||
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 = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -2041,6 +2067,20 @@ export const api = {
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
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 = {
|
||||
register: (data: {
|
||||
@@ -2116,4 +2156,18 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
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
@@ -8858,6 +8858,160 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
@@ -9366,7 +9520,12 @@ textarea {
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
+323
-236
@@ -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 { fetchSessionResponse } from '../api.js';
|
||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
|
||||
type Mode = 'audio' | 'image' | 'text';
|
||||
type Provider = 'BossAI' | 'runninghub';
|
||||
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
||||
type UploadPurpose = 'avatar' | 'audio';
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
|
||||
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
||||
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
||||
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
||||
const DEFAULT_MAPPING = {
|
||||
prompt: { nodeId: '', field: 'prompt' },
|
||||
script: { nodeId: '', field: 'text' },
|
||||
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 = [
|
||||
'longcat-video-avatar',
|
||||
'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 terminal(status: string) {
|
||||
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function extractTaskId(value: any): string {
|
||||
return value?.response?.id
|
||||
|| value?.response?.task_id
|
||||
|| value?.response?.data?.id
|
||||
|| value?.response?.data?.task_id
|
||||
|| value?.id
|
||||
|| value?.task_id
|
||||
|| '';
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'succeeded') return '完成';
|
||||
if (status === 'failed') return '失败';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
if (status === 'running') return '生成中';
|
||||
return '排队中';
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (!value) return;
|
||||
void navigator.clipboard?.writeText(value);
|
||||
function parseError(response: Response) {
|
||||
return response.text().then((text) => {
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const [provider, setProvider] = useState<Provider>('runninghub');
|
||||
const [mode, setMode] = useState<Mode>('audio');
|
||||
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
||||
const [runninghubWebappId, setRunninghubWebappId] = useState('');
|
||||
const [runninghubApiKey, setRunninghubApiKey] = useState('');
|
||||
const [runninghubInstanceType, setRunninghubInstanceType] = useState('default');
|
||||
const [promptNodeId, setPromptNodeId] = useState('122');
|
||||
const [scriptNodeId, setScriptNodeId] = useState('');
|
||||
const [imageNodeId, setImageNodeId] = useState('');
|
||||
const [audioNodeId, setAudioNodeId] = useState('');
|
||||
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BossAI 的模型路由和工作流能力。');
|
||||
const [script, setScript] = useState('大家好,欢迎使用 BossAI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
||||
const [resolution, setResolution] = useState('720p');
|
||||
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
|
||||
const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
|
||||
const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
|
||||
const [audioRole, setAudioRole] = useState<AudioRole>('both');
|
||||
const [ratio, setRatio] = useState('9:16');
|
||||
const [duration, setDuration] = useState(8);
|
||||
const [refImageIndex, setRefImageIndex] = useState(0);
|
||||
const [audioCfg, setAudioCfg] = useState(4.5);
|
||||
const [segments, setSegments] = useState(false);
|
||||
const [multiPerson, setMultiPerson] = useState(false);
|
||||
const [resolution, setResolution] = useState('1080p');
|
||||
const [subtitles, setSubtitles] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const bodyPreview = useMemo(() => ({
|
||||
provider,
|
||||
...(provider === 'runninghub' ? {
|
||||
use_runninghub: true,
|
||||
webappId: runninghubWebappId.trim(),
|
||||
runninghub_api_key: runninghubApiKey.trim() || undefined,
|
||||
instanceType: runninghubInstanceType,
|
||||
prompt_node_id: promptNodeId.trim() || undefined,
|
||||
script_node_id: scriptNodeId.trim() || undefined,
|
||||
image_node_id: imageNodeId.trim() || undefined,
|
||||
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 client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||
const mapped = useMemo(() => ({
|
||||
prompt: mappingValue(mapping, 'prompt'),
|
||||
script: mappingValue(mapping, 'script'),
|
||||
avatar: mappingValue(mapping, 'avatar'),
|
||||
referenceAudio: mappingValue(mapping, 'referenceAudio'),
|
||||
voiceReference: mappingValue(mapping, 'voiceReference'),
|
||||
output: mappingValue(mapping, 'output'),
|
||||
}), [mapping]);
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
const missingMapping = [
|
||||
['提示词节点', 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('');
|
||||
setResult(null);
|
||||
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',
|
||||
body: JSON.stringify(bodyPreview),
|
||||
timeoutMs: 300_000,
|
||||
body: form,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
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) {
|
||||
setError(err?.message || '数字人任务创建失败');
|
||||
} 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 (
|
||||
<div className="digital-human-page animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数字人</h1>
|
||||
<p className="page-subtitle">人物图、音频、台词、分段和多人数字人编排,底层走 BossAI 视频模型路由。</p>
|
||||
<p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建数字人任务'}
|
||||
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
||||
{submitting ? '提交中...' : '生成讲解视频'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,87 +243,71 @@ export default function DigitalHuman() {
|
||||
|
||||
<div className="digital-human-layout">
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-mode-grid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('runninghub')}
|
||||
className={`digital-human-mode ${provider === 'runninghub' ? 'is-active' : ''}`}
|
||||
>
|
||||
<strong>RunningHub API</strong>
|
||||
<span>调用 RunningHub AI 应用,适合数字人模板、口型和视频工作流。</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('BossAI')}
|
||||
className={`digital-human-mode ${provider === 'BossAI' ? 'is-active' : ''}`}
|
||||
>
|
||||
<strong>BossAI 路由</strong>
|
||||
<span>走本地 /v1/videos 中转,由模型路由选择视频上游。</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>
|
||||
<h2>1. 选择工作流模板</h2>
|
||||
<label className="digital-human-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} · {workflow.visibility}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedWorkflow && (
|
||||
<div className="digital-human-template-meta">
|
||||
<strong>{selectedWorkflow.name}</strong>
|
||||
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="digital-human-mode-grid">
|
||||
{MODE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setMode(item.value)}
|
||||
className={`digital-human-mode ${mode === item.value ? 'is-active' : ''}`}
|
||||
>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
<h2>2. 上传素材</h2>
|
||||
<div className="digital-human-upload-grid">
|
||||
<div className="digital-human-upload-card">
|
||||
<strong>人物图片 / 视频</strong>
|
||||
<span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
|
||||
<input ref={avatarInputRef} type="file" hidden accept="image/*,video/*" onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadFile(file, 'avatar');
|
||||
event.currentTarget.value = '';
|
||||
}} />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
|
||||
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<label className="digital-human-field">
|
||||
<span>模型</span>
|
||||
<input
|
||||
value={provider === 'runninghub' ? runninghubWebappId : model}
|
||||
onChange={(event) => provider === 'runninghub' ? setRunninghubWebappId(event.target.value) : setModel(event.target.value)}
|
||||
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>
|
||||
<span>参考音频用途</span>
|
||||
<select value={audioRole} onChange={(event) => setAudioRole(event.target.value as AudioRole)}>
|
||||
{AUDIO_ROLES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>画幅</span>
|
||||
@@ -244,75 +325,81 @@ export default function DigitalHuman() {
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="digital-human-field">
|
||||
<span>时长(秒)</span>
|
||||
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} />
|
||||
<label className="digital-human-checkbox">
|
||||
<input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
|
||||
<span>生成字幕</span>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<aside className="digital-human-panel digital-human-side">
|
||||
<h2>任务请求</h2>
|
||||
<pre>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
||||
|
||||
<h2>返回结果</h2>
|
||||
{result ? (
|
||||
<>
|
||||
{taskId && <div className="digital-human-task-id">任务 ID:{taskId}</div>}
|
||||
<pre>{JSON.stringify(result.response || result, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||
<div className="empty-state-title">暂无任务</div>
|
||||
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
||||
<section className="digital-human-panel">
|
||||
<h2>节点映射</h2>
|
||||
<div className="digital-human-mapping-grid">
|
||||
{([
|
||||
['prompt', '提示词节点'],
|
||||
['script', '文案节点'],
|
||||
['avatar', '人物素材节点'],
|
||||
['referenceAudio', '参考音频节点'],
|
||||
['voiceReference', '音色参考节点(可选)'],
|
||||
['output', '输出视频节点'],
|
||||
] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
|
||||
const item = mapped[key];
|
||||
return (
|
||||
<div key={key} className="digital-human-mapping-row">
|
||||
<span>{label}</span>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user