Simplify custom world result editing controls
This commit is contained in:
987
src/components/CustomWorldRoleAssetStudioModal.tsx
Normal file
987
src/components/CustomWorldRoleAssetStudioModal.tsx
Normal file
@@ -0,0 +1,987 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Film,
|
||||
ImagePlus,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type CustomWorldNpc,
|
||||
type CustomWorldPlayableNpc,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
buildAnimationClipFromVideoSource,
|
||||
type DraftAnimationClip,
|
||||
readFileAsDataUrl,
|
||||
} from './preset-editor/characterAssetStudioModel';
|
||||
import {
|
||||
type CharacterAnimationDraftPayload,
|
||||
type CharacterAnimationGenerationPayload,
|
||||
type CharacterVisualDraft,
|
||||
type CharacterVisualSourceMode,
|
||||
generateCharacterAnimationDraft,
|
||||
generateCharacterVisualCandidates,
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
} from './preset-editor/characterAssetStudioPersistence';
|
||||
|
||||
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
|
||||
|
||||
type CustomWorldAiActionConfig = {
|
||||
animation: AnimationState;
|
||||
label: string;
|
||||
templateId: string;
|
||||
fps: number;
|
||||
frameCount: number;
|
||||
durationSeconds: number;
|
||||
loop: boolean;
|
||||
};
|
||||
|
||||
const VISUAL_SOURCE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
}> = [
|
||||
{ label: '纯文生主图', value: 'text-to-image' },
|
||||
{ label: '参考图生主图', value: 'image-to-image' },
|
||||
];
|
||||
|
||||
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
{
|
||||
animation: AnimationState.IDLE,
|
||||
label: '待机',
|
||||
templateId: 'idle',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.RUN,
|
||||
label: '奔跑',
|
||||
templateId: 'run',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.ATTACK,
|
||||
label: '攻击',
|
||||
templateId: 'attack_slash',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 3,
|
||||
loop: false,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.HURT,
|
||||
label: '受击',
|
||||
templateId: 'hurt',
|
||||
fps: 10,
|
||||
frameCount: 6,
|
||||
durationSeconds: 3,
|
||||
loop: false,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.DIE,
|
||||
label: '死亡',
|
||||
templateId: 'die',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
},
|
||||
];
|
||||
|
||||
function ModalShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
disableClose = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
disableClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-300 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: 'green' | 'amber' | 'zinc';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const toneClassName = {
|
||||
green: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100',
|
||||
amber: 'border-amber-400/30 bg-amber-500/10 text-amber-100',
|
||||
zinc: 'border-white/10 bg-black/20 text-zinc-300',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] ${toneClassName}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'green'
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRoleCharacterBrief(
|
||||
role: EditableCustomWorldRole,
|
||||
templateLabel?: string,
|
||||
) {
|
||||
return [
|
||||
`角色名称:${role.name}`,
|
||||
`角色头衔:${role.title}`,
|
||||
`世界身份:${role.role}`,
|
||||
role.description ? `角色描述:${role.description}` : '',
|
||||
role.backstory ? `角色背景:${role.backstory}` : '',
|
||||
role.personality ? `角色性格:${role.personality}` : '',
|
||||
role.motivation ? `角色动机:${role.motivation}` : '',
|
||||
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
|
||||
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
|
||||
templateLabel ? `参考模板:${templateLabel}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function mergeRole<T extends EditableCustomWorldRole>(
|
||||
role: T,
|
||||
patch: Partial<T>,
|
||||
) {
|
||||
return {
|
||||
...role,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldRoleAssetStudioModal({
|
||||
role,
|
||||
roleKind,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
role: EditableCustomWorldRole;
|
||||
roleKind: 'playable' | 'story';
|
||||
onApply: (nextRole: EditableCustomWorldRole) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sourceMode, setSourceMode] =
|
||||
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
|
||||
role.imageSrc ? 'image-to-image' : 'text-to-image',
|
||||
);
|
||||
const [visualPromptText, setVisualPromptText] = useState('');
|
||||
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
|
||||
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
|
||||
const [visualStatus, setVisualStatus] = useState<string | null>(null);
|
||||
const [isGeneratingVisuals, setIsGeneratingVisuals] = useState(false);
|
||||
const [isApplyingVisual, setIsApplyingVisual] = useState(false);
|
||||
|
||||
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
|
||||
CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE,
|
||||
);
|
||||
const [animationPromptText, setAnimationPromptText] = useState('');
|
||||
const [draftAnimations, setDraftAnimations] = useState<
|
||||
Partial<Record<AnimationState, DraftAnimationClip>>
|
||||
>({});
|
||||
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
|
||||
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
|
||||
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
|
||||
|
||||
const selectedTemplate =
|
||||
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
|
||||
? PRESET_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
const characterBriefText = useMemo(
|
||||
() =>
|
||||
buildRoleCharacterBrief(
|
||||
role,
|
||||
selectedTemplate
|
||||
? `${selectedTemplate.name} / ${selectedTemplate.title}`
|
||||
: undefined,
|
||||
),
|
||||
[role, selectedTemplate],
|
||||
);
|
||||
const effectiveReferenceImages =
|
||||
referenceImageDataUrls.length > 0
|
||||
? referenceImageDataUrls
|
||||
: role.imageSrc
|
||||
? [role.imageSrc]
|
||||
: [];
|
||||
const selectedVisualDraft =
|
||||
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
|
||||
const previewImageSrc =
|
||||
selectedVisualDraft?.imageSrc ??
|
||||
role.imageSrc ??
|
||||
selectedTemplate?.portrait ??
|
||||
'';
|
||||
const selectedActionConfig =
|
||||
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
||||
CORE_ACTIONS[0];
|
||||
const appliedActionCount = CORE_ACTIONS.filter(
|
||||
(item) => role.animationMap?.[item.animation]?.basePath,
|
||||
).length;
|
||||
|
||||
const handleReferenceImageUpload = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const fileList = event.target.files;
|
||||
if (!fileList || fileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedDataUrls = await Promise.all(
|
||||
Array.from(fileList)
|
||||
.slice(0, 4)
|
||||
.map((file) => readFileAsDataUrl(file)),
|
||||
);
|
||||
setReferenceImageDataUrls(uploadedDataUrls);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleGenerateVisuals = async () => {
|
||||
setIsGeneratingVisuals(true);
|
||||
setVisualStatus(null);
|
||||
|
||||
try {
|
||||
if (
|
||||
sourceMode === 'image-to-image' &&
|
||||
effectiveReferenceImages.length === 0
|
||||
) {
|
||||
throw new Error('参考图生主图至少需要一张参考图。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterVisualCandidates({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
characterBriefText,
|
||||
referenceImageDataUrls: effectiveReferenceImages,
|
||||
candidateCount: 3,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1536',
|
||||
});
|
||||
setVisualDrafts(result.drafts);
|
||||
setSelectedVisualDraftId(result.drafts[0]?.id ?? '');
|
||||
setVisualStatus(`已生成 ${result.drafts.length} 个主图候选。`);
|
||||
} catch (error) {
|
||||
setVisualStatus(
|
||||
error instanceof Error ? error.message : '生成角色主图失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingVisuals(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyVisual = async () => {
|
||||
if (!selectedVisualDraft) {
|
||||
setVisualStatus('请先选择一个主图候选。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplyingVisual(true);
|
||||
setVisualStatus(null);
|
||||
|
||||
try {
|
||||
const result = await publishCharacterVisualAsset({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
selectedPreviewSource: selectedVisualDraft.imageSrc,
|
||||
previewSources: visualDrafts.map((draft) => draft.imageSrc),
|
||||
width: selectedVisualDraft.width,
|
||||
height: selectedVisualDraft.height,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
imageSrc: result.portraitPath,
|
||||
generatedVisualAssetId: result.assetId,
|
||||
generatedAnimationSetId: undefined,
|
||||
animationMap: undefined,
|
||||
}),
|
||||
);
|
||||
setDraftAnimations({});
|
||||
setAnimationStatus(null);
|
||||
setVisualStatus('主图已应用到当前角色,可继续生成核心动作。');
|
||||
} catch (error) {
|
||||
setVisualStatus(
|
||||
error instanceof Error ? error.message : '应用角色主图失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsApplyingVisual(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
throw new Error('请先应用主图,再生成动作。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: role.id,
|
||||
strategy: 'image-to-video',
|
||||
animation: config.animation,
|
||||
promptText: animationPromptText,
|
||||
characterBriefText,
|
||||
actionTemplateId: config.templateId,
|
||||
visualSource: role.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: config.frameCount,
|
||||
fps: config.fps,
|
||||
durationSeconds: config.durationSeconds,
|
||||
loop: config.loop,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
} satisfies CharacterAnimationGenerationPayload);
|
||||
|
||||
if (result.strategy !== 'image-to-video') {
|
||||
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
|
||||
}
|
||||
|
||||
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
|
||||
animation: config.animation,
|
||||
fps: config.fps,
|
||||
loop: config.loop,
|
||||
frameCount: config.frameCount,
|
||||
applyChromaKey: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateSingleAnimation = async () => {
|
||||
if (!selectedActionConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const clip = await generateActionClip(selectedActionConfig);
|
||||
setDraftAnimations((current) => ({
|
||||
...current,
|
||||
[selectedActionConfig.animation]: clip,
|
||||
}));
|
||||
setAnimationStatus(`${selectedActionConfig.label} 动作草稿已生成。`);
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '生成角色动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAllAnimations = async () => {
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const nextDrafts: Partial<Record<AnimationState, DraftAnimationClip>> = {
|
||||
...draftAnimations,
|
||||
};
|
||||
|
||||
for (const config of CORE_ACTIONS) {
|
||||
setAnimationStatus(`正在生成 ${config.label} 动作...`);
|
||||
nextDrafts[config.animation] = await generateActionClip(config);
|
||||
}
|
||||
|
||||
setDraftAnimations(nextDrafts);
|
||||
setAnimationStatus('核心动作草稿已生成,可直接应用到当前角色。');
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '批量生成核心动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyAnimations = async () => {
|
||||
if (!role.generatedVisualAssetId) {
|
||||
setAnimationStatus('请先应用主图,再应用动作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const animationEntries = Object.entries(draftAnimations).filter(
|
||||
(entry): entry is [AnimationState, DraftAnimationClip] => Boolean(entry[1]),
|
||||
);
|
||||
|
||||
if (animationEntries.length === 0) {
|
||||
setAnimationStatus('请先生成至少一个核心动作草稿。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplyingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
try {
|
||||
const payload = Object.fromEntries(
|
||||
animationEntries.map(([animation, clip]) => [
|
||||
animation,
|
||||
{
|
||||
framesDataUrls: clip.frames,
|
||||
fps: clip.fps,
|
||||
loop: clip.loop,
|
||||
frameWidth: clip.frameWidth,
|
||||
frameHeight: clip.frameHeight,
|
||||
} satisfies CharacterAnimationDraftPayload,
|
||||
]),
|
||||
);
|
||||
const result = await publishCharacterAnimationAssets({
|
||||
characterId: role.id,
|
||||
visualAssetId: role.generatedVisualAssetId,
|
||||
animations: payload,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
generatedAnimationSetId: result.animationSetId,
|
||||
animationMap: {
|
||||
...(role.animationMap ?? {}),
|
||||
...(result.animationMap as NonNullable<
|
||||
EditableCustomWorldRole['animationMap']
|
||||
>),
|
||||
},
|
||||
}),
|
||||
);
|
||||
setAnimationStatus('核心动作已应用到当前角色。');
|
||||
} catch (error) {
|
||||
setAnimationStatus(
|
||||
error instanceof Error ? error.message : '应用角色动作失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsApplyingAnimations(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="AI 角色资产"
|
||||
subtitle="先应用主图,再走图生视频抽帧生成核心动作。"
|
||||
onClose={onClose}
|
||||
disableClose={
|
||||
isGeneratingVisuals ||
|
||||
isApplyingVisual ||
|
||||
isGeneratingAnimations ||
|
||||
isApplyingAnimations
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
|
||||
<div className="space-y-5">
|
||||
<Section title="阶段 A · 主图">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="主图方式">
|
||||
<SelectInput
|
||||
value={sourceMode}
|
||||
onChange={(value) =>
|
||||
setSourceMode(
|
||||
value as Exclude<CharacterVisualSourceMode, 'upload'>,
|
||||
)
|
||||
}
|
||||
options={VISUAL_SOURCE_OPTIONS}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="形象补充要求">
|
||||
<TextArea
|
||||
value={visualPromptText}
|
||||
onChange={setVisualPromptText}
|
||||
rows={6}
|
||||
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="参考图">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={handleReferenceImageUpload}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{effectiveReferenceImages.map((imageSrc, index) => (
|
||||
<div
|
||||
key={`${imageSrc}-${index}`}
|
||||
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`reference-${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{effectiveReferenceImages.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 px-4 py-3 text-xs text-zinc-500">
|
||||
未上传时会只使用当前角色档案生成主图。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
|
||||
onClick={() => void handleGenerateVisuals()}
|
||||
disabled={isGeneratingVisuals}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingVisual ? '应用中...' : '应用主图'}
|
||||
onClick={() => void handleApplyVisual()}
|
||||
disabled={isApplyingVisual || !selectedVisualDraft}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
{visualStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{visualStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
|
||||
<div className="flex min-h-[20rem] items-center justify-center p-4">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<img
|
||||
src={selectedTemplate.portrait}
|
||||
alt={selectedTemplate.name}
|
||||
className="max-h-[20rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 text-center text-sm leading-7 text-zinc-500">
|
||||
先生成或选择主图候选。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{visualDrafts.map((draft) => {
|
||||
const isSelected = draft.id === selectedVisualDraftId;
|
||||
return (
|
||||
<button
|
||||
key={draft.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedVisualDraftId(draft.id)}
|
||||
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-emerald-400/40 bg-emerald-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[3/4] overflow-hidden bg-black/30 p-2">
|
||||
<img
|
||||
src={draft.imageSrc}
|
||||
alt={draft.label}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
||||
<div className="text-sm text-white">{draft.label}</div>
|
||||
{isSelected ? (
|
||||
<StatusBadge tone="green">当前选择</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visualDrafts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-black/20 px-4 py-8 text-sm text-zinc-500 sm:col-span-2">
|
||||
主图候选会显示在这里。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="阶段 B · 核心动作">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="动作槽位">
|
||||
<SelectInput
|
||||
value={selectedAnimation}
|
||||
onChange={(value) =>
|
||||
setSelectedAnimation(value as AnimationState)
|
||||
}
|
||||
options={CORE_ACTIONS.map((item) => ({
|
||||
value: item.animation,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动作补充要求">
|
||||
<TextArea
|
||||
value={animationPromptText}
|
||||
onChange={setAnimationPromptText}
|
||||
rows={5}
|
||||
placeholder="例如:剑客攻击更干脆,收招更稳;奔跑时衣摆不要飘得过大。"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
label={
|
||||
isGeneratingAnimations
|
||||
? '生成中...'
|
||||
: `生成${selectedActionConfig?.label ?? '当前'}动作`
|
||||
}
|
||||
onClick={() => void handleGenerateSingleAnimation()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
label={
|
||||
isGeneratingAnimations ? '生成中...' : '生成核心动作'
|
||||
}
|
||||
onClick={() => void handleGenerateAllAnimations()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingAnimations ? '应用中...' : '应用动作'}
|
||||
onClick={() => void handleApplyAnimations()}
|
||||
disabled={isApplyingAnimations}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
{animationStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{animationStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge tone="amber">
|
||||
已应用动作 {appliedActionCount}/{CORE_ACTIONS.length}
|
||||
</StatusBadge>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">主图已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待应用主图</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{CORE_ACTIONS.map((item) => {
|
||||
const hasDraft = Boolean(draftAnimations[item.animation]);
|
||||
const isApplied = Boolean(
|
||||
role.animationMap?.[item.animation]?.basePath,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.animation}
|
||||
className={`rounded-2xl border px-4 py-4 ${
|
||||
item.animation === selectedAnimation
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/10 bg-black/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{item.label}
|
||||
</div>
|
||||
{hasDraft ? (
|
||||
<StatusBadge tone="green">草稿已生成</StatusBadge>
|
||||
) : isApplied ? (
|
||||
<StatusBadge tone="amber">已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-500">
|
||||
图生视频抽帧
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||||
当前角色预览
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
{roleKind === 'playable' && selectedTemplate ? (
|
||||
<div className="h-[220px] w-[220px]">
|
||||
<CharacterAnimator
|
||||
state={selectedAnimation}
|
||||
character={{
|
||||
...selectedTemplate,
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
title: role.title,
|
||||
portrait: role.imageSrc || selectedTemplate.portrait,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId,
|
||||
generatedAnimationSetId:
|
||||
role.generatedAnimationSetId,
|
||||
animationMap: role.animationMap
|
||||
? {
|
||||
...(selectedTemplate.animationMap ?? {}),
|
||||
...role.animationMap,
|
||||
}
|
||||
: selectedTemplate.animationMap,
|
||||
}}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="max-h-[16rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 text-sm text-zinc-500">
|
||||
应用主图后,这里会显示当前角色。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Section title="当前角色档案">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-lg font-semibold text-white">{role.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{role.title} / {role.role}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
|
||||
{role.description ? <div>{role.description}</div> : null}
|
||||
{role.combatStyle ? <div>战斗风格:{role.combatStyle}</div> : null}
|
||||
{role.tags.length > 0 ? <div>标签:{role.tags.join('、')}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="自动提示词依据">
|
||||
<TextArea
|
||||
value={characterBriefText}
|
||||
onChange={() => {}}
|
||||
rows={14}
|
||||
placeholder=""
|
||||
readOnly
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="当前进度">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm text-zinc-200">主图状态</div>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm text-zinc-200">核心动作状态</div>
|
||||
{appliedActionCount > 0 ? (
|
||||
<StatusBadge tone="amber">
|
||||
已应用 {appliedActionCount}/{CORE_ACTIONS.length}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
|
||||
当前面板只保留主图和图生视频抽帧这条生产链,不提供视频预览、抽帧编辑、修帧和导出步骤。
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user