Files
Genarrative/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
2026-04-26 17:34:52 +08:00

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>
);
}