Improve digital human template UI
This commit is contained in:
@@ -8833,6 +8833,25 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.digital-human-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.digital-human-section-head h2 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.digital-human-section-head p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.digital-human-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -8867,6 +8886,88 @@ textarea {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.digital-human-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.digital-human-template-card {
|
||||
min-height: 126px;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 74%, var(--color-bg-card));
|
||||
color: var(--color-text-primary);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color .18s ease, background .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.digital-human-template-card:hover,
|
||||
.digital-human-template-card.is-active {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.digital-human-template-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.digital-human-template-top strong {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.digital-human-template-top em {
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
padding: 3px 7px;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card));
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.digital-human-template-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.digital-human-template-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.digital-human-template-tags i {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 999px;
|
||||
padding: 3px 7px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.digital-human-template-select {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.digital-human-template-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -8906,6 +9007,26 @@ textarea {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.digital-human-upload-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.digital-human-audio-preview {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.digital-human-upload-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.digital-human-upload-card input,
|
||||
.digital-human-mapping-row input {
|
||||
width: 100%;
|
||||
@@ -8969,6 +9090,7 @@ textarea {
|
||||
.digital-human-job-head div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.digital-human-job-head em {
|
||||
@@ -8978,6 +9100,21 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-human-job-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.digital-human-job-meta span {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-bg) 80%, var(--color-bg-card));
|
||||
border: 1px solid var(--color-border-light);
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.digital-human-progress {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
@@ -9055,6 +9192,64 @@ textarea {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.digital-human-picker {
|
||||
width: min(880px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 40px));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.digital-human-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.digital-human-picker-item {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-height: 172px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digital-human-picker-item:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
|
||||
.digital-human-picker-item img,
|
||||
.digital-human-picker-item video,
|
||||
.digital-human-picker-audio {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 84%, var(--color-bg-card));
|
||||
}
|
||||
|
||||
.digital-human-picker-audio {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.digital-human-picker-item strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.digital-human-picker-item span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace-page .page-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary } from '../api.js';
|
||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
|
||||
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
||||
type UploadPurpose = 'avatar' | 'audio';
|
||||
type MappingKey = keyof typeof DEFAULT_MAPPING;
|
||||
|
||||
const DEFAULT_MAPPING = {
|
||||
prompt: { nodeId: '', field: 'prompt' },
|
||||
@@ -51,7 +52,7 @@ function graphMapping(workflow?: WorkflowSummary | null) {
|
||||
|| DEFAULT_MAPPING;
|
||||
}
|
||||
|
||||
function mappingValue(mapping: any, key: keyof typeof DEFAULT_MAPPING) {
|
||||
function mappingValue(mapping: any, key: MappingKey) {
|
||||
const value = mapping?.[key] || DEFAULT_MAPPING[key];
|
||||
if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
|
||||
return {
|
||||
@@ -64,6 +65,117 @@ function jobAssets(job: DigitalHumanJob) {
|
||||
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
||||
}
|
||||
|
||||
function templateHasMapping(workflow: WorkflowSummary) {
|
||||
const mapping = graphMapping(workflow);
|
||||
return ['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every((key) => {
|
||||
return Boolean(mappingValue(mapping, key as MappingKey).nodeId);
|
||||
});
|
||||
}
|
||||
|
||||
function workflowKindLabel(workflow: WorkflowSummary) {
|
||||
if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板';
|
||||
if (workflow.visibility === 'public') return '共享模板';
|
||||
return '私有工作流';
|
||||
}
|
||||
|
||||
function sourcePrompt(graph: any) {
|
||||
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graph || {};
|
||||
}
|
||||
|
||||
function collectPromptNodes(graph: any) {
|
||||
const prompt = sourcePrompt(graph);
|
||||
const nodes: Array<{ id: string; classType: string; inputs: Record<string, any>; title: string }> = [];
|
||||
|
||||
if (prompt && typeof prompt === 'object' && !Array.isArray(prompt)) {
|
||||
for (const [id, node] of Object.entries(prompt as Record<string, any>)) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const classType = String((node as any).class_type || (node as any).type || '');
|
||||
const inputs = ((node as any).inputs && typeof (node as any).inputs === 'object') ? (node as any).inputs : {};
|
||||
if (classType || Object.keys(inputs).length > 0) {
|
||||
nodes.push({
|
||||
id: String(id),
|
||||
classType,
|
||||
inputs,
|
||||
title: String((node as any)._meta?.title || (node as any).title || classType || id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphNodes = graph?.nodes;
|
||||
if (Array.isArray(graphNodes)) {
|
||||
for (const node of graphNodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const id = String(node.id || node.nodeId || '');
|
||||
if (!id || nodes.some((item) => item.id === id)) continue;
|
||||
const classType = String(node.type || node.class_type || '');
|
||||
nodes.push({
|
||||
id,
|
||||
classType,
|
||||
inputs: {},
|
||||
title: String(node.title || node.label || classType || id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function includesAny(value: string, words: string[]) {
|
||||
const text = value.toLowerCase();
|
||||
return words.some((word) => text.includes(word));
|
||||
}
|
||||
|
||||
function findNode(
|
||||
nodes: ReturnType<typeof collectPromptNodes>,
|
||||
words: string[],
|
||||
inputWords: string[] = [],
|
||||
) {
|
||||
return nodes.find((node) => {
|
||||
const haystack = `${node.classType} ${node.title}`;
|
||||
const inputNames = Object.keys(node.inputs || {}).join(' ');
|
||||
return includesAny(haystack, words) || (inputWords.length > 0 && includesAny(inputNames, inputWords));
|
||||
});
|
||||
}
|
||||
|
||||
function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
||||
if (!workflow) return DEFAULT_MAPPING;
|
||||
const nodes = collectPromptNodes(workflow.graph);
|
||||
const promptNode = findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate', 'wan', 'ltx'], ['prompt']);
|
||||
const scriptNode = findNode(nodes, ['script', 'text', 'tts', 'voice', 'audio'], ['text', 'script', 'caption']);
|
||||
const avatarNode = findNode(nodes, ['loadimage', 'image', 'video', 'avatar', 'portrait', 'person'], ['image', 'video', 'avatar']);
|
||||
const audioNode = findNode(nodes, ['audio', 'sound', 'voice', 'tts', 'load audio'], ['audio', 'voice']);
|
||||
const outputNode = findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix'])
|
||||
|| [...nodes].reverse().find((node) => includesAny(`${node.classType} ${node.title}`, ['save', 'preview', 'output']));
|
||||
|
||||
return {
|
||||
prompt: { nodeId: promptNode?.id || '', field: promptNode?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
||||
script: { nodeId: scriptNode?.id || promptNode?.id || '', field: scriptNode?.inputs?.script !== undefined ? 'script' : 'text' },
|
||||
avatar: { nodeId: avatarNode?.id || '', field: avatarNode?.inputs?.video !== undefined ? 'video' : 'image' },
|
||||
referenceAudio: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||
voiceReference: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||
output: { nodeId: outputNode?.id || '', field: outputNode?.inputs?.filename_prefix !== undefined ? 'filename_prefix' : 'video' },
|
||||
};
|
||||
}
|
||||
|
||||
function assetFileUrl(asset: WorkspaceAsset, audience: SessionAudience) {
|
||||
if (asset.remoteUrl) return asset.remoteUrl;
|
||||
if (!asset.hasLocalFile) return '';
|
||||
const prefix = audience === 'user' ? '/api/user' : '/api';
|
||||
return `${prefix}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`;
|
||||
}
|
||||
|
||||
function assetName(asset: WorkspaceAsset) {
|
||||
return asset.title || asset.fileName || asset.publicId;
|
||||
}
|
||||
|
||||
function formatJobTime(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export default function DigitalHuman() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
@@ -72,6 +184,7 @@ export default function DigitalHuman() {
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
@@ -84,10 +197,19 @@ export default function DigitalHuman() {
|
||||
const [subtitles, setSubtitles] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
||||
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||
const sortedWorkflows = useMemo(() => {
|
||||
return [...workflows].sort((a, b) => {
|
||||
const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
|
||||
+ (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
|
||||
return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
|
||||
});
|
||||
}, [workflows]);
|
||||
const mapped = useMemo(() => ({
|
||||
prompt: mappingValue(mapping, 'prompt'),
|
||||
script: mappingValue(mapping, 'script'),
|
||||
@@ -111,6 +233,12 @@ export default function DigitalHuman() {
|
||||
&& !!productScript.trim()
|
||||
&& missingMapping.length === 0
|
||||
&& !submitting;
|
||||
const selectedTemplateReady = selectedWorkflow ? templateHasMapping(selectedWorkflow) || missingMapping.length === 0 : false;
|
||||
const pickerAssets = workspaceAssets.filter((asset) => {
|
||||
if (assetPicker === 'audio') return asset.kind === 'audio';
|
||||
if (assetPicker === 'avatar') return asset.kind === 'image' || asset.kind === 'video';
|
||||
return false;
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [workflowPayload, jobPayload] = await Promise.all([
|
||||
@@ -175,6 +303,36 @@ export default function DigitalHuman() {
|
||||
}));
|
||||
};
|
||||
|
||||
const inferMapping = () => {
|
||||
setMapping(inferMappingFromWorkflow(selectedWorkflow));
|
||||
};
|
||||
|
||||
const openAssetPicker = async (purpose: UploadPurpose) => {
|
||||
setAssetPicker(purpose);
|
||||
setLoadingAssets(true);
|
||||
setError('');
|
||||
try {
|
||||
const kind = purpose === 'audio' ? 'audio' : undefined;
|
||||
const payload = await client.getWorkspaceAssets({ kind, pageSize: 60 });
|
||||
setWorkspaceAssets(Array.isArray(payload.items) ? payload.items : []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '工作空间素材加载失败');
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseAsset = (asset: WorkspaceAsset) => {
|
||||
const url = assetFileUrl(asset, audience);
|
||||
if (!url) {
|
||||
setError('这个素材没有可用文件地址');
|
||||
return;
|
||||
}
|
||||
if (assetPicker === 'avatar') setAvatarUrl(url);
|
||||
if (assetPicker === 'audio') setReferenceAudioUrl(url);
|
||||
setAssetPicker('');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
@@ -243,14 +401,46 @@ export default function DigitalHuman() {
|
||||
|
||||
<div className="digital-human-layout">
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<div>
|
||||
<h2>1. 选择工作流模板</h2>
|
||||
<label className="digital-human-field">
|
||||
<span>模板</span>
|
||||
<p>优先选择已配置数字人节点映射的模板;普通 Comfy 工作流也可以用自动猜测补齐。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>猜测映射</button>
|
||||
</div>
|
||||
<div className="digital-human-template-grid">
|
||||
{sortedWorkflows.slice(0, 6).map((workflow) => {
|
||||
const ready = templateHasMapping(workflow);
|
||||
const active = workflow.publicId === templateId;
|
||||
return (
|
||||
<button
|
||||
key={workflow.publicId}
|
||||
type="button"
|
||||
className={`digital-human-template-card${active ? ' is-active' : ''}`}
|
||||
onClick={() => setTemplateId(workflow.publicId)}
|
||||
>
|
||||
<span className="digital-human-template-top">
|
||||
<strong>{workflow.name}</strong>
|
||||
<em>{ready ? '可直接生成' : '需映射'}</em>
|
||||
</span>
|
||||
<span className="digital-human-template-desc">
|
||||
{workflow.description || 'Comfy 工作流模板,可注入人物素材、文案和参考音频。'}
|
||||
</span>
|
||||
<span className="digital-human-template-tags">
|
||||
<i>{workflowKindLabel(workflow)}</i>
|
||||
<i>{workflow.visibility}</i>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="digital-human-field digital-human-template-select">
|
||||
<span>全部模板</span>
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||
<option value="">请选择模板</option>
|
||||
{workflows.map((workflow) => (
|
||||
{sortedWorkflows.map((workflow) => (
|
||||
<option key={workflow.publicId} value={workflow.publicId}>
|
||||
{workflow.name} · {workflow.visibility}
|
||||
{workflow.name} · {workflowKindLabel(workflow)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -259,6 +449,7 @@ export default function DigitalHuman() {
|
||||
<div className="digital-human-template-meta">
|
||||
<strong>{selectedWorkflow.name}</strong>
|
||||
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
||||
<span>{selectedTemplateReady ? '节点映射已准备好。' : '节点映射不完整,可点击“猜测映射”后再微调。'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -267,27 +458,39 @@ export default function DigitalHuman() {
|
||||
<div className="digital-human-upload-card">
|
||||
<strong>人物图片 / 视频</strong>
|
||||
<span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
|
||||
{avatarUrl && (
|
||||
avatarUrl.match(/\.(mp4|webm|mov|m4v)(\?|$)/i)
|
||||
? <video className="digital-human-upload-preview" src={avatarUrl} muted controls preload="metadata" />
|
||||
: <img className="digital-human-upload-preview" src={avatarUrl} alt="" loading="lazy" />
|
||||
)}
|
||||
<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 = '';
|
||||
}} />
|
||||
<div className="digital-human-upload-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
|
||||
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('avatar')}>工作空间</button>
|
||||
</div>
|
||||
<input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="或粘贴图片/视频 URL" />
|
||||
</div>
|
||||
<div className="digital-human-upload-card">
|
||||
<strong>参考音频</strong>
|
||||
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
|
||||
{referenceAudioUrl && <audio className="digital-human-audio-preview" src={referenceAudioUrl} controls />}
|
||||
<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 = '';
|
||||
}} />
|
||||
<div className="digital-human-upload-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
|
||||
{uploading === 'audio' ? '上传中...' : '上传参考音频'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('audio')}>工作空间</button>
|
||||
</div>
|
||||
<input value={referenceAudioUrl} onChange={(event) => setReferenceAudioUrl(event.target.value)} placeholder="或粘贴音频 URL" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +536,13 @@ export default function DigitalHuman() {
|
||||
</section>
|
||||
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<div>
|
||||
<h2>节点映射</h2>
|
||||
<p>把页面输入写入对应 Comfy 节点。字段名可按模板节点输入口微调。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>重新猜测</button>
|
||||
</div>
|
||||
<div className="digital-human-mapping-grid">
|
||||
{([
|
||||
['prompt', '提示词节点'],
|
||||
@@ -376,10 +585,14 @@ export default function DigitalHuman() {
|
||||
<div className="digital-human-job-head">
|
||||
<div>
|
||||
<strong>{job.title || '数字人讲解任务'}</strong>
|
||||
<span>{job.message || statusLabel(job.status)}</span>
|
||||
<span>{job.message || statusLabel(job.status)}{formatJobTime(job.createdAt) ? ` · ${formatJobTime(job.createdAt)}` : ''}</span>
|
||||
</div>
|
||||
<em>{statusLabel(job.status)}</em>
|
||||
</div>
|
||||
<div className="digital-human-job-meta">
|
||||
<span>进度 {progress}%</span>
|
||||
{job.result?.prompt_id && <span>Prompt {job.result.prompt_id}</span>}
|
||||
</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 && (
|
||||
@@ -401,6 +614,45 @@ export default function DigitalHuman() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{assetPicker && (
|
||||
<div className="modal-overlay" onClick={() => setAssetPicker('')}>
|
||||
<div className="digital-human-picker modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h3>{assetPicker === 'audio' ? '选择参考音频' : '选择人物素材'}</h3>
|
||||
<p>从工作空间选取已有素材,只有最终产出仍会归档到工作空间。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAssetPicker('')}>关闭</button>
|
||||
</div>
|
||||
{loadingAssets ? (
|
||||
<div className="empty-state" style={{ minHeight: 180 }}>
|
||||
<div className="empty-state-title">正在加载素材</div>
|
||||
</div>
|
||||
) : pickerAssets.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 180 }}>
|
||||
<div className="empty-state-title">没有可选素材</div>
|
||||
<div className="empty-state-desc">可以先上传本地文件,或去工作空间确认素材类型。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="digital-human-picker-grid">
|
||||
{pickerAssets.map((asset) => {
|
||||
const preview = assetFileUrl(asset, audience);
|
||||
return (
|
||||
<button key={asset.publicId} type="button" className="digital-human-picker-item" onClick={() => chooseAsset(asset)}>
|
||||
{asset.kind === 'image' && preview ? <img src={preview} alt="" loading="lazy" /> : null}
|
||||
{asset.kind === 'video' && preview ? <video src={preview} muted preload="metadata" /> : null}
|
||||
{asset.kind === 'audio' ? <span className="digital-human-picker-audio">音频</span> : null}
|
||||
<strong>{assetName(asset)}</strong>
|
||||
<span>{asset.kind} · {asset.source || 'workspace'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user