1193 lines
35 KiB
TypeScript
1193 lines
35 KiB
TypeScript
import {
|
|
type ChangeEvent,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import { ROLE_TEMPLATE_CHARACTERS } from '../../data/characterPresets';
|
|
import {
|
|
AnimationState,
|
|
type Character,
|
|
type CharacterAnimationConfig,
|
|
} from '../../types';
|
|
import { readFileAsDataUrl } from '../asset-studio/characterAssetWorkflowModel';
|
|
import {
|
|
type CharacterAssetWorkflowCache,
|
|
type CharacterVisualDraft,
|
|
fetchCharacterWorkflowCache,
|
|
saveCharacterWorkflowCache,
|
|
} from '../asset-studio/characterAssetWorkflowPersistence';
|
|
import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePromptDefaults';
|
|
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
|
|
import { useAuthUi } from '../auth/AuthUiContext';
|
|
import { CharacterAnimator } from '../CharacterAnimator';
|
|
import {
|
|
CORE_ACTIONS,
|
|
type CustomWorldAiActionConfig,
|
|
type EditableCustomWorldRole,
|
|
} from './roleAssetStudioModel';
|
|
import { RpgCreationRoleAnimationSection } from './RpgCreationRoleAnimationSection';
|
|
import { RpgCreationRoleAssetStudioFooter } from './RpgCreationRoleAssetStudioFooter';
|
|
import { RpgCreationRoleVisualSection } from './RpgCreationRoleVisualSection';
|
|
import { useRoleAnimationWorkflow } from './useRoleAnimationWorkflow';
|
|
import { useRoleVisualCandidateWorkflow } from './useRoleVisualCandidateWorkflow';
|
|
|
|
const DEFAULT_ANIMATION_PLAYBACK_RATE = 0.75;
|
|
const MIN_ANIMATION_PLAYBACK_RATE = 0.25;
|
|
const MAX_ANIMATION_PLAYBACK_RATE = 1.5;
|
|
|
|
function clampAnimationPlaybackRate(value: number) {
|
|
if (!Number.isFinite(value)) {
|
|
return DEFAULT_ANIMATION_PLAYBACK_RATE;
|
|
}
|
|
|
|
return Math.min(
|
|
MAX_ANIMATION_PLAYBACK_RATE,
|
|
Math.max(MIN_ANIMATION_PLAYBACK_RATE, value),
|
|
);
|
|
}
|
|
|
|
function buildDefaultAnimationPromptTextByKey(defaultText: string) {
|
|
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
|
|
(result, action) => ({
|
|
...result,
|
|
[action.animation]: defaultText,
|
|
}),
|
|
{},
|
|
);
|
|
}
|
|
|
|
function pickCachedAnimationPromptTextByKey(
|
|
cache: CharacterAssetWorkflowCache,
|
|
fallbackText: string,
|
|
preferFreshRoleText: boolean,
|
|
) {
|
|
const fromCache = cache.animationPromptTextByKey ?? {};
|
|
|
|
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
|
|
(result, action) => {
|
|
const cachedText = fromCache[action.animation]?.trim();
|
|
const legacyText = cache.animationPromptText?.trim();
|
|
return {
|
|
...result,
|
|
[action.animation]: preferFreshRoleText
|
|
? fallbackText
|
|
: cachedText && !isLegacyGeneratedActionDescription(cachedText)
|
|
? cachedText
|
|
: legacyText && !isLegacyGeneratedActionDescription(legacyText)
|
|
? legacyText
|
|
: fallbackText,
|
|
};
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
function roundAnimationFps(value: number) {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function ModalShell({
|
|
title,
|
|
subtitle,
|
|
onClose,
|
|
disableClose = false,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
onClose: () => void;
|
|
disableClose?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const authUi = useAuthUi();
|
|
const platformThemeClass =
|
|
authUi?.platformTheme === 'dark'
|
|
? 'platform-theme--dark'
|
|
: 'platform-theme--light';
|
|
|
|
return (
|
|
<div
|
|
className={`platform-overlay platform-theme ${platformThemeClass} fixed inset-0 z-[100] flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
|
onClick={
|
|
disableClose
|
|
? undefined
|
|
: (event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClose();
|
|
}
|
|
}
|
|
}
|
|
>
|
|
<div
|
|
className={`platform-modal-shell platform-role-studio platform-ui-shell platform-theme ${platformThemeClass} flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] 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 isLegacyGeneratedVisualDescription(value: string) {
|
|
const normalized = value.trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
'2D 横版 RPG',
|
|
'纯绿色绿幕',
|
|
'2 到 2.5 头身',
|
|
'深色粗轮廓',
|
|
'身体整体朝右',
|
|
'脚底完整可见',
|
|
].some((marker) => normalized.includes(marker));
|
|
}
|
|
|
|
function isLegacyGeneratedActionDescription(value: string) {
|
|
const normalized = value.trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
'动作气质参考:',
|
|
'发力起手明确',
|
|
'收招利落',
|
|
'动作表现偏向',
|
|
'起手克制',
|
|
].some((marker) => normalized.includes(marker));
|
|
}
|
|
|
|
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 getAnimationPreviewFrameStyle(
|
|
_config: CharacterAnimationConfig | null | undefined,
|
|
targetSize: number,
|
|
) {
|
|
return {
|
|
width: `${targetSize}px`,
|
|
height: `${targetSize}px`,
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
} satisfies CSSProperties;
|
|
}
|
|
|
|
function getAnimationPreviewViewportStyle(size: number) {
|
|
return {
|
|
width: `${size}px`,
|
|
height: `${size}px`,
|
|
maxWidth: '100%',
|
|
} satisfies CSSProperties;
|
|
}
|
|
|
|
function resolveAnimationPlaybackRate(
|
|
actionConfig: CustomWorldAiActionConfig | undefined,
|
|
animationConfig: CharacterAnimationConfig | null | undefined,
|
|
) {
|
|
if (!actionConfig) {
|
|
return DEFAULT_ANIMATION_PLAYBACK_RATE;
|
|
}
|
|
|
|
const configuredFps =
|
|
typeof animationConfig?.fps === 'number' &&
|
|
Number.isFinite(animationConfig.fps)
|
|
? animationConfig.fps
|
|
: null;
|
|
|
|
if (!configuredFps) {
|
|
return DEFAULT_ANIMATION_PLAYBACK_RATE;
|
|
}
|
|
|
|
return clampAnimationPlaybackRate(configuredFps / actionConfig.fps);
|
|
}
|
|
|
|
function applyPlaybackRateToAnimationMap(params: {
|
|
animationMap: Record<string, unknown> | undefined;
|
|
animation: AnimationState;
|
|
actionConfig: CustomWorldAiActionConfig;
|
|
playbackRate: number;
|
|
}) {
|
|
const { animationMap, animation, actionConfig, playbackRate } = params;
|
|
if (!animationMap) {
|
|
return animationMap;
|
|
}
|
|
|
|
const currentConfig = animationMap[animation];
|
|
if (!currentConfig || typeof currentConfig !== 'object') {
|
|
return animationMap;
|
|
}
|
|
|
|
const currentAnimationConfig = currentConfig as CharacterAnimationConfig;
|
|
const nextFps = roundAnimationFps(
|
|
Math.max(1, actionConfig.fps * clampAnimationPlaybackRate(playbackRate)),
|
|
);
|
|
const currentFps =
|
|
typeof currentAnimationConfig.fps === 'number' &&
|
|
Number.isFinite(currentAnimationConfig.fps)
|
|
? roundAnimationFps(currentAnimationConfig.fps)
|
|
: null;
|
|
|
|
if (currentFps === nextFps) {
|
|
return animationMap;
|
|
}
|
|
|
|
return {
|
|
...animationMap,
|
|
[animation]: {
|
|
...currentAnimationConfig,
|
|
fps: nextFps,
|
|
},
|
|
};
|
|
}
|
|
|
|
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 interface RpgCreationRoleAssetStudioModalProps {
|
|
role: EditableCustomWorldRole;
|
|
roleKind: 'playable' | 'story';
|
|
cacheScopeId?: string;
|
|
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;
|
|
}
|
|
|
|
export function RpgCreationRoleAssetStudioModal({
|
|
role,
|
|
roleKind,
|
|
cacheScopeId,
|
|
onApply,
|
|
onPublishSuccess,
|
|
onClose,
|
|
syncBusy = false,
|
|
visualPointCost = 20,
|
|
animationPointCost = 60,
|
|
}: RpgCreationRoleAssetStudioModalProps) {
|
|
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
|
|
const baseRole = useMemo<EditableCustomWorldRole>(
|
|
() => ({
|
|
id: role.id,
|
|
name: role.name,
|
|
title: role.title,
|
|
role: role.role,
|
|
visualDescription: role.visualDescription,
|
|
actionDescription: role.actionDescription,
|
|
sceneVisualDescription: role.sceneVisualDescription,
|
|
description: role.description,
|
|
backstory: role.backstory,
|
|
personality: role.personality,
|
|
motivation: role.motivation,
|
|
combatStyle: role.combatStyle,
|
|
tags: role.tags,
|
|
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.actionDescription,
|
|
role.motivation,
|
|
role.name,
|
|
role.personality,
|
|
role.role,
|
|
role.sceneVisualDescription,
|
|
role.tags,
|
|
role.title,
|
|
role.visualDescription,
|
|
],
|
|
);
|
|
const initialPromptBundle = useMemo(
|
|
() => buildDefaultRolePromptBundle(baseRole),
|
|
[baseRole],
|
|
);
|
|
const [visualPromptText, setVisualPromptText] = useState(
|
|
initialPromptBundle.visualPromptText,
|
|
);
|
|
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
|
|
string[]
|
|
>([]);
|
|
const [
|
|
projectStyleReferenceBoardSource,
|
|
setProjectStyleReferenceBoardSource,
|
|
] = useState('');
|
|
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 [animationPromptTextByKey, setAnimationPromptTextByKey] = useState<
|
|
Partial<Record<AnimationState, string>>
|
|
>(() =>
|
|
buildDefaultAnimationPromptTextByKey(
|
|
initialPromptBundle.animationPromptText,
|
|
),
|
|
);
|
|
const [animationStatusByKey, setAnimationStatusByKey] = useState<
|
|
Partial<Record<AnimationState, string | null>>
|
|
>({});
|
|
const [generatingAnimationMap, setGeneratingAnimationMap] = useState<
|
|
Partial<Record<AnimationState, boolean>>
|
|
>({});
|
|
const [animationPreviewPlaybackRate, setAnimationPreviewPlaybackRate] =
|
|
useState(DEFAULT_ANIMATION_PLAYBACK_RATE);
|
|
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
|
const [isSavingToRole, setIsSavingToRole] = useState(false);
|
|
const [isHydratingCache, setIsHydratingCache] = useState(true);
|
|
const { applyVisualDraftToRole, generateVisualCandidatesForRole } =
|
|
useRoleVisualCandidateWorkflow();
|
|
const { generateAnimationClipForRole, publishAnimationClipForRole } =
|
|
useRoleAnimationWorkflow();
|
|
|
|
const selectedTemplate =
|
|
roleKind === 'playable' ? (ROLE_TEMPLATE_CHARACTERS[0] ?? null) : null;
|
|
const characterBriefText = useMemo(
|
|
() =>
|
|
buildRoleCharacterBrief(
|
|
workingRole,
|
|
selectedTemplate
|
|
? `${selectedTemplate.name} / ${selectedTemplate.title}`
|
|
: undefined,
|
|
),
|
|
[workingRole, selectedTemplate],
|
|
);
|
|
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 =
|
|
workingRole.imageSrc ??
|
|
selectedVisualDraft?.imageSrc ??
|
|
selectedTemplate?.portrait ??
|
|
'';
|
|
const hasGeneratedVisualPreview = Boolean(
|
|
selectedVisualDraft?.imageSrc || workingRole.imageSrc,
|
|
);
|
|
const selectedActionConfig =
|
|
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
|
CORE_ACTIONS[0]!;
|
|
const animationPromptText =
|
|
animationPromptTextByKey[selectedAnimation] ??
|
|
initialPromptBundle.animationPromptText;
|
|
const previewCharacter = useMemo(
|
|
() =>
|
|
buildAnimationPreviewCharacter({
|
|
workingRole,
|
|
selectedTemplate,
|
|
}),
|
|
[selectedTemplate, workingRole],
|
|
);
|
|
const selectedAnimationConfig = previewCharacter?.animationMap?.[
|
|
selectedAnimation
|
|
] as CharacterAnimationConfig | undefined;
|
|
const selectedAnimationStatus =
|
|
animationStatusByKey[selectedAnimation] ?? null;
|
|
const isSelectedAnimationGenerating =
|
|
generatingAnimationMap[selectedAnimation] === true;
|
|
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
|
|
(value) => value === true,
|
|
);
|
|
const isSelectedAnimationGenerated = hasGeneratedAnimation(
|
|
workingRole,
|
|
selectedAnimation,
|
|
);
|
|
const shouldUseSelectedAnimationPreview =
|
|
Boolean(previewCharacter) &&
|
|
(isSelectedAnimationGenerated ||
|
|
selectedAnimation === AnimationState.IDLE ||
|
|
selectedAnimation === AnimationState.DIE);
|
|
const animationPreviewFrameStyle = useMemo(
|
|
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
|
|
[selectedAnimationConfig],
|
|
);
|
|
const animationPreviewViewportStyle = useMemo(
|
|
() => getAnimationPreviewViewportStyle(440),
|
|
[],
|
|
);
|
|
const effectiveVisualReferenceImageDataUrls = useMemo(() => {
|
|
if (!projectStyleReferenceBoardSource) {
|
|
return referenceImageDataUrls;
|
|
}
|
|
|
|
if (referenceImageDataUrls.length >= 4) {
|
|
return referenceImageDataUrls;
|
|
}
|
|
|
|
return [projectStyleReferenceBoardSource, ...referenceImageDataUrls].slice(
|
|
0,
|
|
4,
|
|
);
|
|
}, [projectStyleReferenceBoardSource, referenceImageDataUrls]);
|
|
const visualSourceMode =
|
|
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void buildProjectPixelStyleReferenceBoard()
|
|
.then((nextBoardSource) => {
|
|
if (!cancelled) {
|
|
setProjectStyleReferenceBoardSource(nextBoardSource);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setWorkingRole(baseRole);
|
|
setVisualPromptText(initialPromptBundle.visualPromptText);
|
|
setAnimationPromptTextByKey(
|
|
buildDefaultAnimationPromptTextByKey(
|
|
initialPromptBundle.animationPromptText,
|
|
),
|
|
);
|
|
setReferenceImageDataUrls([]);
|
|
setVisualDrafts([]);
|
|
setSelectedVisualDraftId('');
|
|
setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE);
|
|
setVisualStatus(null);
|
|
setAnimationStatusByKey({});
|
|
setGeneratingAnimationMap({});
|
|
setAnimationPreviewPlaybackRate(DEFAULT_ANIMATION_PLAYBACK_RATE);
|
|
setSaveStatus(null);
|
|
setIsHydratingCache(true);
|
|
|
|
void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId)
|
|
.then((result) => {
|
|
if (cancelled || !result.cache) {
|
|
return;
|
|
}
|
|
|
|
const cache = result.cache;
|
|
if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) {
|
|
return;
|
|
}
|
|
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(
|
|
!baseRole.visualDescription?.trim() &&
|
|
cache.visualPromptText &&
|
|
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
|
|
? cache.visualPromptText
|
|
: initialPromptBundle.visualPromptText,
|
|
);
|
|
setAnimationPromptTextByKey(
|
|
pickCachedAnimationPromptTextByKey(
|
|
cache,
|
|
initialPromptBundle.animationPromptText,
|
|
Boolean(baseRole.actionDescription?.trim()),
|
|
),
|
|
);
|
|
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, cacheScopeId, initialPromptBundle, roleSnapshotKey]);
|
|
|
|
useEffect(() => {
|
|
if (isHydratingCache) {
|
|
return;
|
|
}
|
|
|
|
const timer = window.setTimeout(() => {
|
|
const payload: CharacterAssetWorkflowCache = {
|
|
characterId: workingRole.id,
|
|
cacheScopeId,
|
|
visualPromptText,
|
|
animationPromptText,
|
|
animationPromptTextByKey,
|
|
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,
|
|
animationPromptTextByKey,
|
|
isHydratingCache,
|
|
selectedAnimation,
|
|
selectedVisualDraftId,
|
|
cacheScopeId,
|
|
visualDrafts,
|
|
visualPromptText,
|
|
workingRole.animationMap,
|
|
workingRole.generatedAnimationSetId,
|
|
workingRole.generatedVisualAssetId,
|
|
workingRole.id,
|
|
workingRole.imageSrc,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setAnimationPreviewPlaybackRate(
|
|
resolveAnimationPlaybackRate(
|
|
selectedActionConfig,
|
|
selectedAnimationConfig,
|
|
),
|
|
);
|
|
}, [selectedActionConfig, selectedAnimationConfig]);
|
|
|
|
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) => {
|
|
setIsApplyingVisual(true);
|
|
try {
|
|
const result = await applyVisualDraftToRole({
|
|
draft,
|
|
promptText: visualPromptText,
|
|
role: workingRole,
|
|
sourceMode: visualSourceMode,
|
|
});
|
|
|
|
const nextRole = mergeRole(workingRole, {
|
|
imageSrc: result.portraitPath,
|
|
generatedVisualAssetId: result.assetId,
|
|
generatedAnimationSetId: undefined,
|
|
animationMap: undefined,
|
|
});
|
|
setWorkingRole(nextRole);
|
|
setSelectedVisualDraftId(draft.id);
|
|
setAnimationStatusByKey({});
|
|
setGeneratingAnimationMap({});
|
|
setSaveStatus(null);
|
|
setVisualStatus('角色形象已更新,可继续生成动作。');
|
|
} finally {
|
|
setIsApplyingVisual(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateVisuals = async () => {
|
|
if (
|
|
!confirmPointSpend({
|
|
kindLabel: '角色形象生成',
|
|
points: visualPointCost,
|
|
description: '这次是角色形象草稿生成,不是最终发布。',
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setIsGeneratingVisuals(true);
|
|
setVisualStatus(null);
|
|
|
|
try {
|
|
const result = await generateVisualCandidatesForRole({
|
|
promptText: visualPromptText,
|
|
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
|
|
role: workingRole,
|
|
sourceMode: visualSourceMode,
|
|
});
|
|
setVisualDrafts(result.drafts);
|
|
if (result.drafts[0]) {
|
|
await applyVisualDraftToWorkflow(result.drafts[0]);
|
|
setVisualStatus('角色形象已生成,如不满意可直接重新生成。');
|
|
} else {
|
|
setSelectedVisualDraftId('');
|
|
setVisualStatus('这次没有生成可用角色形象。');
|
|
}
|
|
} catch (error) {
|
|
setVisualStatus(
|
|
error instanceof Error ? error.message : '生成角色形象失败。',
|
|
);
|
|
} finally {
|
|
setIsGeneratingVisuals(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateAnimation = async () => {
|
|
if (!selectedActionConfig) {
|
|
return;
|
|
}
|
|
|
|
const actionConfig = selectedActionConfig;
|
|
const requestedPlaybackRate = animationPreviewPlaybackRate;
|
|
if (generatingAnimationMap[actionConfig.animation]) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!confirmPointSpend({
|
|
kindLabel: '动作草稿生成',
|
|
points: animationPointCost,
|
|
description: '这次是动作草稿试片,不是最终发布。',
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setGeneratingAnimationMap((current) => ({
|
|
...current,
|
|
[actionConfig.animation]: true,
|
|
}));
|
|
setAnimationStatusByKey((current) => ({
|
|
...current,
|
|
[actionConfig.animation]: null,
|
|
}));
|
|
|
|
try {
|
|
const clip = await generateAnimationClipForRole({
|
|
actionConfig,
|
|
animationPromptText,
|
|
characterBriefText,
|
|
role: workingRole,
|
|
});
|
|
const result = await publishAnimationClipForRole({
|
|
actionConfig,
|
|
clip,
|
|
role: workingRole,
|
|
});
|
|
|
|
setWorkingRole((current) =>
|
|
mergeRole(current, {
|
|
generatedAnimationSetId: result.animationSetId,
|
|
animationMap: applyPlaybackRateToAnimationMap({
|
|
animationMap: {
|
|
...((current.animationMap ?? {}) as Record<string, unknown>),
|
|
...(result.animationMap as NonNullable<
|
|
EditableCustomWorldRole['animationMap']
|
|
>),
|
|
},
|
|
animation: actionConfig.animation,
|
|
actionConfig,
|
|
playbackRate: requestedPlaybackRate,
|
|
}),
|
|
}),
|
|
);
|
|
setSaveStatus(null);
|
|
setAnimationStatusByKey((current) => ({
|
|
...current,
|
|
[actionConfig.animation]: `${actionConfig.label} 动作已更新。`,
|
|
}));
|
|
} catch (error) {
|
|
setAnimationStatusByKey((current) => ({
|
|
...current,
|
|
[actionConfig.animation]:
|
|
error instanceof Error ? error.message : '生成角色动作失败。',
|
|
}));
|
|
} finally {
|
|
setGeneratingAnimationMap((current) => ({
|
|
...current,
|
|
[actionConfig.animation]: 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 ||
|
|
hasAnyGeneratingAnimations ||
|
|
isSavingToRole ||
|
|
syncBusy
|
|
}
|
|
>
|
|
<div className="space-y-5">
|
|
<RpgCreationRoleVisualSection
|
|
ActionButton={ActionButton}
|
|
Field={Field}
|
|
Section={Section}
|
|
TextArea={TextArea}
|
|
handleReferenceImageUpload={handleReferenceImageUpload}
|
|
hasGeneratedVisualPreview={hasGeneratedVisualPreview}
|
|
isApplyingVisual={isApplyingVisual}
|
|
isGeneratingVisuals={isGeneratingVisuals}
|
|
previewImageSrc={previewImageSrc}
|
|
referenceImageDataUrls={referenceImageDataUrls}
|
|
selectedTemplatePortrait={selectedTemplate?.portrait}
|
|
selectedTemplateName={selectedTemplate?.name}
|
|
syncBusy={syncBusy}
|
|
visualPointCost={visualPointCost}
|
|
visualPromptText={visualPromptText}
|
|
visualStatus={visualStatus}
|
|
workingRoleName={workingRole.name}
|
|
onClearReferenceImages={() => setReferenceImageDataUrls([])}
|
|
onGenerateVisuals={() => {
|
|
void handleGenerateVisuals();
|
|
}}
|
|
onVisualPromptChange={setVisualPromptText}
|
|
/>
|
|
|
|
<RpgCreationRoleAnimationSection
|
|
ActionButton={ActionButton}
|
|
CharacterAnimator={CharacterAnimator}
|
|
Field={Field}
|
|
Section={Section}
|
|
StatusBadge={StatusBadge}
|
|
TextArea={TextArea}
|
|
animationPreviewFrameStyle={animationPreviewFrameStyle}
|
|
animationPreviewPlaybackRate={animationPreviewPlaybackRate}
|
|
animationPreviewViewportStyle={animationPreviewViewportStyle}
|
|
animationPromptText={animationPromptText}
|
|
generatingAnimationMap={generatingAnimationMap}
|
|
hasGeneratedAnimation={(animation) =>
|
|
hasGeneratedAnimation(workingRole, animation)
|
|
}
|
|
isSelectedAnimationGenerating={isSelectedAnimationGenerating}
|
|
previewCharacter={previewCharacter}
|
|
previewImageSrc={previewImageSrc}
|
|
selectedAnimation={selectedAnimation}
|
|
selectedAnimationStatus={selectedAnimationStatus}
|
|
shouldUseSelectedAnimationPreview={shouldUseSelectedAnimationPreview}
|
|
syncBusy={syncBusy}
|
|
animationPointCost={animationPointCost}
|
|
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
|
|
workingRoleImageSrc={workingRole.imageSrc}
|
|
workingRoleName={workingRole.name}
|
|
onAnimationPromptChange={(value) => {
|
|
setAnimationPromptTextByKey((current) => ({
|
|
...current,
|
|
[selectedAnimation]: value,
|
|
}));
|
|
}}
|
|
onGenerateAnimation={() => {
|
|
void handleGenerateAnimation();
|
|
}}
|
|
onPlaybackRateChange={(value) => {
|
|
const nextPlaybackRate = clampAnimationPlaybackRate(value);
|
|
setAnimationPreviewPlaybackRate(nextPlaybackRate);
|
|
setSaveStatus(null);
|
|
setWorkingRole((current) => {
|
|
const nextAnimationMap = applyPlaybackRateToAnimationMap({
|
|
animationMap: current.animationMap as
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
animation: selectedAnimation,
|
|
actionConfig: selectedActionConfig,
|
|
playbackRate: nextPlaybackRate,
|
|
});
|
|
|
|
return nextAnimationMap === current.animationMap
|
|
? current
|
|
: mergeRole(current, {
|
|
animationMap: nextAnimationMap,
|
|
});
|
|
});
|
|
}}
|
|
onSelectAnimation={setSelectedAnimation}
|
|
/>
|
|
|
|
<RpgCreationRoleAssetStudioFooter
|
|
isSavingToRole={isSavingToRole}
|
|
saveStatus={saveStatus}
|
|
syncBusy={syncBusy}
|
|
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
|
|
workingRoleImageSrc={workingRole.imageSrc}
|
|
onSaveToRole={() => {
|
|
void handleSaveToRole();
|
|
}}
|
|
/>
|
|
</div>
|
|
</PortalModalShell>
|
|
);
|
|
}
|