1234 lines
37 KiB
TypeScript
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>
|
|
);
|
|
}
|