Add Comfy digital human jobs
This commit is contained in:
@@ -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 = 'boosapi' | '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('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
||||
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||
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">人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</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('boosapi')}
|
||||
className={`digital-human-mode ${provider === 'boosapi' ? 'is-active' : ''}`}
|
||||
>
|
||||
<strong>BoosAPI 路由</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