Improve digital human template UI
This commit is contained in:
@@ -8833,6 +8833,25 @@ textarea {
|
|||||||
line-height: 1.5;
|
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 {
|
.digital-human-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -8867,6 +8886,88 @@ textarea {
|
|||||||
margin-top: 20px;
|
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 {
|
.digital-human-template-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -8906,6 +9007,26 @@ textarea {
|
|||||||
font-size: 13px;
|
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-upload-card input,
|
||||||
.digital-human-mapping-row input {
|
.digital-human-mapping-row input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -8969,6 +9090,7 @@ textarea {
|
|||||||
.digital-human-job-head div {
|
.digital-human-job-head div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.digital-human-job-head em {
|
.digital-human-job-head em {
|
||||||
@@ -8978,6 +9100,21 @@ textarea {
|
|||||||
white-space: nowrap;
|
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 {
|
.digital-human-progress {
|
||||||
height: 7px;
|
height: 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -9055,6 +9192,64 @@ textarea {
|
|||||||
font-size: 12px;
|
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 {
|
.workspace-page .page-header {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-15
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { 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';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
|
|
||||||
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
||||||
type UploadPurpose = 'avatar' | 'audio';
|
type UploadPurpose = 'avatar' | 'audio';
|
||||||
|
type MappingKey = keyof typeof DEFAULT_MAPPING;
|
||||||
|
|
||||||
const DEFAULT_MAPPING = {
|
const DEFAULT_MAPPING = {
|
||||||
prompt: { nodeId: '', field: 'prompt' },
|
prompt: { nodeId: '', field: 'prompt' },
|
||||||
@@ -51,7 +52,7 @@ function graphMapping(workflow?: WorkflowSummary | null) {
|
|||||||
|| DEFAULT_MAPPING;
|
|| DEFAULT_MAPPING;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mappingValue(mapping: any, key: keyof typeof DEFAULT_MAPPING) {
|
function mappingValue(mapping: any, key: MappingKey) {
|
||||||
const value = mapping?.[key] || DEFAULT_MAPPING[key];
|
const value = mapping?.[key] || DEFAULT_MAPPING[key];
|
||||||
if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
|
if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +65,117 @@ function jobAssets(job: DigitalHumanJob) {
|
|||||||
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
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() {
|
export default function DigitalHuman() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -72,6 +184,7 @@ export default function DigitalHuman() {
|
|||||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||||
|
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
||||||
const [avatarUrl, setAvatarUrl] = useState('');
|
const [avatarUrl, setAvatarUrl] = useState('');
|
||||||
@@ -84,10 +197,19 @@ export default function DigitalHuman() {
|
|||||||
const [subtitles, setSubtitles] = useState(true);
|
const [subtitles, setSubtitles] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
||||||
|
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
|
||||||
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
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(() => ({
|
const mapped = useMemo(() => ({
|
||||||
prompt: mappingValue(mapping, 'prompt'),
|
prompt: mappingValue(mapping, 'prompt'),
|
||||||
script: mappingValue(mapping, 'script'),
|
script: mappingValue(mapping, 'script'),
|
||||||
@@ -111,6 +233,12 @@ export default function DigitalHuman() {
|
|||||||
&& !!productScript.trim()
|
&& !!productScript.trim()
|
||||||
&& missingMapping.length === 0
|
&& missingMapping.length === 0
|
||||||
&& !submitting;
|
&& !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 loadData = useCallback(async () => {
|
||||||
const [workflowPayload, jobPayload] = await Promise.all([
|
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 () => {
|
const submit = async () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -243,14 +401,46 @@ export default function DigitalHuman() {
|
|||||||
|
|
||||||
<div className="digital-human-layout">
|
<div className="digital-human-layout">
|
||||||
<section className="digital-human-panel">
|
<section className="digital-human-panel">
|
||||||
<h2>1. 选择工作流模板</h2>
|
<div className="digital-human-section-head">
|
||||||
<label className="digital-human-field">
|
<div>
|
||||||
<span>模板</span>
|
<h2>1. 选择工作流模板</h2>
|
||||||
|
<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)}>
|
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||||
<option value="">请选择模板</option>
|
<option value="">请选择模板</option>
|
||||||
{workflows.map((workflow) => (
|
{sortedWorkflows.map((workflow) => (
|
||||||
<option key={workflow.publicId} value={workflow.publicId}>
|
<option key={workflow.publicId} value={workflow.publicId}>
|
||||||
{workflow.name} · {workflow.visibility}
|
{workflow.name} · {workflowKindLabel(workflow)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -259,6 +449,7 @@ export default function DigitalHuman() {
|
|||||||
<div className="digital-human-template-meta">
|
<div className="digital-human-template-meta">
|
||||||
<strong>{selectedWorkflow.name}</strong>
|
<strong>{selectedWorkflow.name}</strong>
|
||||||
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
||||||
|
<span>{selectedTemplateReady ? '节点映射已准备好。' : '节点映射不完整,可点击“猜测映射”后再微调。'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -267,27 +458,39 @@ export default function DigitalHuman() {
|
|||||||
<div className="digital-human-upload-card">
|
<div className="digital-human-upload-card">
|
||||||
<strong>人物图片 / 视频</strong>
|
<strong>人物图片 / 视频</strong>
|
||||||
<span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
|
<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) => {
|
<input ref={avatarInputRef} type="file" hidden accept="image/*,video/*" onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) void uploadFile(file, 'avatar');
|
if (file) void uploadFile(file, 'avatar');
|
||||||
event.currentTarget.value = '';
|
event.currentTarget.value = '';
|
||||||
}} />
|
}} />
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
|
<div className="digital-human-upload-actions">
|
||||||
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
|
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
|
||||||
</button>
|
{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" />
|
<input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="或粘贴图片/视频 URL" />
|
||||||
</div>
|
</div>
|
||||||
<div className="digital-human-upload-card">
|
<div className="digital-human-upload-card">
|
||||||
<strong>参考音频</strong>
|
<strong>参考音频</strong>
|
||||||
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
|
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
|
||||||
|
{referenceAudioUrl && <audio className="digital-human-audio-preview" src={referenceAudioUrl} controls />}
|
||||||
<input ref={audioInputRef} type="file" hidden accept="audio/*" onChange={(event) => {
|
<input ref={audioInputRef} type="file" hidden accept="audio/*" onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) void uploadFile(file, 'audio');
|
if (file) void uploadFile(file, 'audio');
|
||||||
event.currentTarget.value = '';
|
event.currentTarget.value = '';
|
||||||
}} />
|
}} />
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
|
<div className="digital-human-upload-actions">
|
||||||
{uploading === 'audio' ? '上传中...' : '上传参考音频'}
|
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
|
||||||
</button>
|
{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" />
|
<input value={referenceAudioUrl} onChange={(event) => setReferenceAudioUrl(event.target.value)} placeholder="或粘贴音频 URL" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +536,13 @@ export default function DigitalHuman() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="digital-human-panel">
|
<section className="digital-human-panel">
|
||||||
<h2>节点映射</h2>
|
<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">
|
<div className="digital-human-mapping-grid">
|
||||||
{([
|
{([
|
||||||
['prompt', '提示词节点'],
|
['prompt', '提示词节点'],
|
||||||
@@ -376,10 +585,14 @@ export default function DigitalHuman() {
|
|||||||
<div className="digital-human-job-head">
|
<div className="digital-human-job-head">
|
||||||
<div>
|
<div>
|
||||||
<strong>{job.title || '数字人讲解任务'}</strong>
|
<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>
|
</div>
|
||||||
<em>{statusLabel(job.status)}</em>
|
<em>{statusLabel(job.status)}</em>
|
||||||
</div>
|
</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>
|
<div className="digital-human-progress"><span style={{ width: `${progress}%` }} /></div>
|
||||||
{job.error && <div className="digital-human-job-error">{job.error}</div>}
|
{job.error && <div className="digital-human-job-error">{job.error}</div>}
|
||||||
{jobAssets(job).length > 0 && (
|
{jobAssets(job).length > 0 && (
|
||||||
@@ -401,6 +614,45 @@ export default function DigitalHuman() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user