Files
Genarrative/src/components/CustomWorldRoleAssetStudioModal.tsx
高物 09d4c0c31b
Some checks failed
CI / verify (push) Has been cancelled
11
2026-04-16 21:47:20 +08:00

1234 lines
37 KiB
TypeScript

import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import {
type ChangeEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type Character,
} from '../types';
import {
buildAnimationClipFromVideoSource,
normalizeMasterVisualSourceToDataUrl,
readFileAsDataUrl,
} from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationGenerationPayload,
type CharacterAssetWorkflowCache,
type CharacterVisualDraft,
fetchCharacterWorkflowCache,
generateCharacterAnimationDraft,
generateCharacterPromptBundle,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
saveCharacterWorkflowCache,
} from './asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
import { CharacterAnimator } from './CharacterAnimator';
type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAiActionConfig = {
animation: AnimationState;
label: string;
templateId: string;
fps: number;
frameCount: number;
durationSeconds: number;
loop: boolean;
};
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
: (event) => {
if (event.target === event.currentTarget) {
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 PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
disableClose?: boolean;
children: React.ReactNode;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
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 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,
subLabel,
onClick,
disabled = false,
tone = 'default',
}: {
icon?: React.ReactNode;
label: string;
subLabel?: 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"
onPointerDown={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
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 ?? null}
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{subLabel ? (
<span className="text-[11px] font-medium opacity-70">
{subLabel}
</span>
) : null}
</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 && 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,
};
}
function hasGeneratedAnimation(
role: EditableCustomWorldRole,
animation: AnimationState,
) {
const entry = role.animationMap?.[animation] as
| { basePath?: string; spriteSheetPath?: string }
| undefined;
return Boolean(entry?.basePath || entry?.spriteSheetPath);
}
function buildAnimationPreviewCharacter(params: {
workingRole: EditableCustomWorldRole;
selectedTemplate: (typeof ROLE_TEMPLATE_CHARACTERS)[number] | null;
}): Character | null {
const { workingRole, selectedTemplate } = params;
const portrait = workingRole.imageSrc || selectedTemplate?.portrait;
if (!portrait) {
return null;
}
return {
id: workingRole.id,
name: workingRole.name,
title: workingRole.title,
description: workingRole.description ?? '',
backstory: workingRole.backstory ?? '',
avatar: portrait,
portrait,
assetFolder: selectedTemplate?.assetFolder ?? 'custom-world',
assetVariant: selectedTemplate?.assetVariant ?? 'generated',
generatedVisualAssetId: workingRole.generatedVisualAssetId,
generatedAnimationSetId: workingRole.generatedAnimationSetId,
animationMap: {
...(selectedTemplate?.animationMap ?? {}),
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
},
attributes: selectedTemplate?.attributes ?? {},
personality: workingRole.personality ?? '',
skills: selectedTemplate?.skills ?? [],
adventureOpenings: selectedTemplate?.adventureOpenings ?? {},
} as Character;
}
export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply?: (nextRole: EditableCustomWorldRole) => void;
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: {
closeAfterSync?: boolean;
},
) => void;
onClose: () => void;
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const baseRole = useMemo<EditableCustomWorldRole>(
() => ({
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.description,
backstory: role.backstory,
personality: role.personality,
motivation: role.motivation,
combatStyle: role.combatStyle,
tags: role.tags,
templateCharacterId: role.templateCharacterId,
imageSrc: role.imageSrc,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap,
}),
[
role.animationMap,
role.backstory,
role.combatStyle,
role.description,
role.generatedAnimationSetId,
role.generatedVisualAssetId,
role.id,
role.imageSrc,
role.motivation,
role.name,
role.personality,
role.role,
role.tags,
role.templateCharacterId,
role.title,
],
);
const initialPromptBundle = useMemo(
() => buildDefaultRolePromptBundle(baseRole),
[baseRole],
);
const [visualPromptText, setVisualPromptText] = useState(
initialPromptBundle.visualPromptText,
);
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(
initialPromptBundle.animationPromptText,
);
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [isSavingToRole, setIsSavingToRole] = useState(false);
const [isHydratingCache, setIsHydratingCache] = useState(true);
const selectedTemplate =
roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === workingRole.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
workingRole,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[workingRole, selectedTemplate],
);
const promptSeedKey = useMemo(
() =>
[
roleKind,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description ?? '',
workingRole.backstory ?? '',
workingRole.personality ?? '',
workingRole.motivation ?? '',
workingRole.combatStyle ?? '',
(workingRole.tags ?? []).join('|'),
selectedTemplate?.name ?? '',
selectedTemplate?.title ?? '',
].join('||'),
[
roleKind,
selectedTemplate?.name,
selectedTemplate?.title,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description,
workingRole.backstory,
workingRole.personality,
workingRole.motivation,
workingRole.combatStyle,
workingRole.tags,
],
);
const roleSnapshotKey = useMemo(
() =>
[
role.id,
role.name,
role.title,
role.role,
role.imageSrc ?? '',
role.generatedVisualAssetId ?? '',
role.generatedAnimationSetId ?? '',
].join('||'),
[
role.generatedAnimationSetId,
role.generatedVisualAssetId,
role.id,
role.imageSrc,
role.name,
role.role,
role.title,
],
);
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
workingRole.imageSrc ??
selectedTemplate?.portrait ??
'';
const hasGeneratedVisualPreview = Boolean(
selectedVisualDraft?.imageSrc || workingRole.imageSrc,
);
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const previewCharacter = useMemo(
() =>
buildAnimationPreviewCharacter({
workingRole,
selectedTemplate,
}),
[selectedTemplate, workingRole],
);
const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
useEffect(() => {
let cancelled = false;
setWorkingRole(baseRole);
setVisualPromptText(initialPromptBundle.visualPromptText);
setAnimationPromptText(initialPromptBundle.animationPromptText);
setReferenceImageDataUrls([]);
setVisualDrafts([]);
setSelectedVisualDraftId('');
setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE);
setVisualStatus(null);
setAnimationStatus(null);
setSaveStatus(null);
setIsHydratingCache(true);
void fetchCharacterWorkflowCache(baseRole.id)
.then((result) => {
if (cancelled || !result.cache) {
return;
}
const cache = result.cache;
const nextRole = mergeRole(baseRole, {
imageSrc: cache.imageSrc ?? baseRole.imageSrc,
generatedVisualAssetId:
cache.generatedVisualAssetId ?? baseRole.generatedVisualAssetId,
generatedAnimationSetId:
cache.generatedAnimationSetId ?? baseRole.generatedAnimationSetId,
animationMap:
(cache.animationMap as EditableCustomWorldRole['animationMap']) ??
baseRole.animationMap,
});
setWorkingRole(nextRole);
setVisualPromptText(
cache.visualPromptText || initialPromptBundle.visualPromptText,
);
setAnimationPromptText(
cache.animationPromptText || initialPromptBundle.animationPromptText,
);
setVisualDrafts(cache.visualDrafts ?? []);
setSelectedVisualDraftId(
cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '',
);
setSelectedAnimation(
CORE_ACTIONS.some((item) => item.animation === cache.selectedAnimation)
? (cache.selectedAnimation as AnimationState)
: (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE),
);
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) {
setIsHydratingCache(false);
}
});
return () => {
cancelled = true;
};
}, [
baseRole,
initialPromptBundle,
roleSnapshotKey,
]);
useEffect(() => {
if (!characterBriefText.trim()) {
return;
}
let cancelled = false;
void generateCharacterPromptBundle({
roleKind,
characterName: workingRole.name,
roleTitle: workingRole.title,
roleLabel: workingRole.role,
description: workingRole.description,
backstory: workingRole.backstory,
personality: workingRole.personality,
motivation: workingRole.motivation,
combatStyle: workingRole.combatStyle,
tags: workingRole.tags,
characterBriefText,
})
.then((result) => {
if (cancelled) {
return;
}
setVisualPromptText((current) =>
current.trim() && current !== initialPromptBundle.visualPromptText
? current
: result.visualPromptText,
);
setAnimationPromptText((current) =>
current.trim() && current !== initialPromptBundle.animationPromptText
? current
: result.animationPromptText,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [
characterBriefText,
initialPromptBundle.animationPromptText,
initialPromptBundle.visualPromptText,
promptSeedKey,
roleKind,
workingRole.backstory,
workingRole.combatStyle,
workingRole.description,
workingRole.motivation,
workingRole.name,
workingRole.personality,
workingRole.role,
workingRole.tags,
workingRole.title,
]);
useEffect(() => {
if (isHydratingCache) {
return;
}
const timer = window.setTimeout(() => {
const payload: CharacterAssetWorkflowCache = {
characterId: workingRole.id,
visualPromptText,
animationPromptText,
visualDrafts,
selectedVisualDraftId,
selectedAnimation,
imageSrc: workingRole.imageSrc,
generatedVisualAssetId: workingRole.generatedVisualAssetId,
generatedAnimationSetId: workingRole.generatedAnimationSetId,
animationMap: (workingRole.animationMap ?? null) as Record<
string,
unknown
> | null,
};
void saveCharacterWorkflowCache(payload).catch(() => undefined);
}, 350);
return () => {
window.clearTimeout(timer);
};
}, [
animationPromptText,
isHydratingCache,
selectedAnimation,
selectedVisualDraftId,
visualDrafts,
visualPromptText,
workingRole.animationMap,
workingRole.generatedAnimationSetId,
workingRole.generatedVisualAssetId,
workingRole.id,
workingRole.imageSrc,
]);
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
description: string;
}) => {
if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
return true;
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
);
};
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 applyVisualDraftToWorkflow = async (
draft: CharacterVisualDraft,
draftList = visualDrafts,
) => {
const normalizedVisual = await normalizeMasterVisualSourceToDataUrl(
draft.imageSrc,
{
applyChromaKey: true,
},
);
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
selectedPreviewSource: normalizedVisual.dataUrl,
previewSources: [normalizedVisual.dataUrl],
width: normalizedVisual.width,
height: normalizedVisual.height,
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
setSelectedVisualDraftId(draft.id);
setAnimationStatus(null);
setSaveStatus(null);
setVisualStatus('角色形象已更新,可继续生成动作。');
};
const handleGenerateVisuals = async () => {
if (
!confirmPointSpend({
kindLabel: '角色形象生成',
points: visualPointCost,
description: '这次是角色形象草稿生成,不是最终发布。',
})
) {
return;
}
setIsGeneratingVisuals(true);
setVisualStatus(null);
try {
const result = await generateCharacterVisualCandidates({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: referenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',
size: '1024*1024',
});
setVisualDrafts(result.drafts);
if (result.drafts[0]) {
await applyVisualDraftToWorkflow(result.drafts[0], result.drafts);
setVisualStatus('角色形象已生成,如不满意可直接重新生成。');
} else {
setSelectedVisualDraftId('');
setVisualStatus('这次没有生成可用角色形象。');
}
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '生成角色形象失败。',
);
} finally {
setIsGeneratingVisuals(false);
}
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先生成角色形象,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: workingRole.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 handleGenerateAnimation = async () => {
if (!selectedActionConfig) {
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
points: animationPointCost,
description: '这次是动作草稿试片,不是最终发布。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
const clip = await generateActionClip(selectedActionConfig);
const result = await publishCharacterAnimationAssets({
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId!,
animations: {
[selectedActionConfig.animation]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
previewVideoPath: clip.previewVideoPath,
},
},
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
});
setWorkingRole(nextRole);
setSaveStatus(null);
setAnimationStatus(`${selectedActionConfig.label} 动作已更新。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成角色动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleSaveToRole = async () => {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
setSaveStatus('请先生成角色形象后再保存。');
return;
}
setIsSavingToRole(true);
setSaveStatus(null);
try {
const nextRole = mergeRole(role, {
imageSrc: workingRole.imageSrc,
generatedVisualAssetId: workingRole.generatedVisualAssetId,
generatedAnimationSetId: workingRole.generatedAnimationSetId,
animationMap: workingRole.animationMap,
});
onApply?.(nextRole);
if (onPublishSuccess) {
onPublishSuccess(
{
roleId: role.id,
portraitPath: nextRole.imageSrc ?? previewImageSrc,
generatedVisualAssetId: nextRole.generatedVisualAssetId ?? '',
generatedAnimationSetId: nextRole.generatedAnimationSetId ?? null,
animationMap: (nextRole.animationMap ?? null) as Record<
string,
unknown
> | null,
},
{
closeAfterSync: true,
},
);
} else {
onClose();
}
} catch (error) {
setSaveStatus(error instanceof Error ? error.message : '保存角色形象失败。');
} finally {
setIsSavingToRole(false);
}
};
return (
<PortalModalShell
title="AI角色形象生成"
onClose={onClose}
disableClose={
isHydratingCache ||
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isSavingToRole ||
syncBusy
}
>
<div className="space-y-5">
<Section title="角色形象">
<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-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.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 text-zinc-500">
</div>
)}
</div>
</div>
<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>
{referenceImageDataUrls.length > 0 ? (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap gap-2">
{referenceImageDataUrls.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>
))}
</div>
<div>
<ActionButton
label="清空参考图"
onClick={() => setReferenceImageDataUrls([])}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
/>
</div>
</div>
) : null}
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={
hasGeneratedVisualPreview ? (
<RefreshCcw className="h-4 w-4" />
) : (
<ImagePlus className="h-4 w-4" />
)
}
label={
isGeneratingVisuals
? '生成中...'
: hasGeneratedVisualPreview
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}叙世币`}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"
/>
</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>
</Section>
<Section title="动作">
<div className="space-y-4">
<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="flex min-h-[16rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div className="h-[220px] w-[220px]">
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation);
return (
<button
key={item.animation}
type="button"
onClick={() => setSelectedAnimation(item.animation)}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isSelected
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{item.label}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{isSelected ? '当前预览' : '点击切换'}
</div>
</div>
<StatusBadge tone={isReady ? 'green' : 'zinc'}>
{isReady ? '已生成' : '待生成'}
</StatusBadge>
</div>
</button>
);
})}
</div>
<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 ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`}
onClick={() => void handleGenerateAnimation()}
disabled={
isGeneratingAnimations ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId ||
syncBusy
}
tone="sky"
/>
</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>
</Section>
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:mx-0 sm:rounded-3xl sm:border sm:px-4">
<div className="space-y-3">
{saveStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{saveStatus}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => void handleSaveToRole()}
disabled={
isSavingToRole ||
syncBusy ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId
}
className={`rounded-full border border-emerald-400/30 bg-emerald-500/10 px-5 py-2 text-sm font-semibold text-emerald-100 transition-colors hover:bg-emerald-500/20 ${
isSavingToRole ||
syncBusy ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId
? 'cursor-not-allowed opacity-45'
: ''
}`}
>
{isSavingToRole || syncBusy ? '保存中...' : '保存到当前角色'}
</button>
</div>
</div>
</div>
</div>
</PortalModalShell>
);
}