Integrate role asset studio into custom world agent flow
This commit is contained in:
@@ -4,13 +4,17 @@ import {
|
||||
ImagePlus,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type CustomWorldNpc,
|
||||
type CustomWorldPlayableNpc,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
@@ -29,7 +33,23 @@ import {
|
||||
publishCharacterVisualAsset,
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
|
||||
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;
|
||||
@@ -298,7 +318,7 @@ function buildRoleCharacterBrief(
|
||||
role.personality ? `角色性格:${role.personality}` : '',
|
||||
role.motivation ? `角色动机:${role.motivation}` : '',
|
||||
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
|
||||
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
|
||||
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
|
||||
templateLabel ? `参考模板:${templateLabel}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -319,13 +339,35 @@ 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;
|
||||
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 [sourceMode, setSourceMode] =
|
||||
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
|
||||
role.imageSrc ? 'image-to-image' : 'text-to-image',
|
||||
@@ -351,42 +393,66 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
|
||||
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkingRole(role);
|
||||
}, [role]);
|
||||
|
||||
const selectedTemplate =
|
||||
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
|
||||
roleKind === 'playable' && workingRole.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
(character) => character.id === workingRole.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
const characterBriefText = useMemo(
|
||||
() =>
|
||||
buildRoleCharacterBrief(
|
||||
role,
|
||||
workingRole,
|
||||
selectedTemplate
|
||||
? `${selectedTemplate.name} / ${selectedTemplate.title}`
|
||||
: undefined,
|
||||
),
|
||||
[role, selectedTemplate],
|
||||
[workingRole, selectedTemplate],
|
||||
);
|
||||
const effectiveReferenceImages =
|
||||
referenceImageDataUrls.length > 0
|
||||
? referenceImageDataUrls
|
||||
: role.imageSrc
|
||||
? [role.imageSrc]
|
||||
: workingRole.imageSrc
|
||||
? [workingRole.imageSrc]
|
||||
: [];
|
||||
const selectedVisualDraft =
|
||||
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
|
||||
const previewImageSrc =
|
||||
selectedVisualDraft?.imageSrc ??
|
||||
role.imageSrc ??
|
||||
workingRole.imageSrc ??
|
||||
selectedTemplate?.portrait ??
|
||||
'';
|
||||
const selectedActionConfig =
|
||||
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
||||
CORE_ACTIONS[0];
|
||||
const appliedActionCount = CORE_ACTIONS.filter(
|
||||
(item) => role.animationMap?.[item.animation]?.basePath,
|
||||
(item) =>
|
||||
Boolean(
|
||||
(workingRole.animationMap as Record<string, { basePath?: string }> | null)
|
||||
?.[item.animation]?.basePath,
|
||||
),
|
||||
).length;
|
||||
|
||||
const visualCandidateCount = priorityTier === 'supporting' ? 1 : 2;
|
||||
|
||||
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>,
|
||||
) => {
|
||||
@@ -406,6 +472,16 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
};
|
||||
|
||||
const handleGenerateVisuals = async () => {
|
||||
if (
|
||||
!confirmPointSpend({
|
||||
kindLabel: '主图候选生成',
|
||||
points: visualPointCost,
|
||||
description: '这次是主图候选抽卡,不是最终发布。',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVisuals(true);
|
||||
setVisualStatus(null);
|
||||
|
||||
@@ -418,12 +494,12 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
const result = await generateCharacterVisualCandidates({
|
||||
characterId: role.id,
|
||||
characterId: workingRole.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
characterBriefText,
|
||||
referenceImageDataUrls: effectiveReferenceImages,
|
||||
candidateCount: 3,
|
||||
candidateCount: visualCandidateCount,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1536',
|
||||
});
|
||||
@@ -450,7 +526,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
|
||||
try {
|
||||
const result = await publishCharacterVisualAsset({
|
||||
characterId: role.id,
|
||||
characterId: workingRole.id,
|
||||
sourceMode,
|
||||
promptText: visualPromptText,
|
||||
selectedPreviewSource: selectedVisualDraft.imageSrc,
|
||||
@@ -460,13 +536,25 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
imageSrc: result.portraitPath,
|
||||
const nextRole = mergeRole(workingRole, {
|
||||
imageSrc: result.portraitPath,
|
||||
generatedVisualAssetId: result.assetId,
|
||||
generatedAnimationSetId: undefined,
|
||||
animationMap: undefined,
|
||||
});
|
||||
setWorkingRole(nextRole);
|
||||
onApply?.(nextRole);
|
||||
onPublishSuccess?.(
|
||||
{
|
||||
roleId: workingRole.id,
|
||||
portraitPath: result.portraitPath,
|
||||
generatedVisualAssetId: result.assetId,
|
||||
generatedAnimationSetId: undefined,
|
||||
animationMap: undefined,
|
||||
}),
|
||||
generatedAnimationSetId: null,
|
||||
animationMap: null,
|
||||
},
|
||||
{
|
||||
closeAfterSync: false,
|
||||
},
|
||||
);
|
||||
setDraftAnimations({});
|
||||
setAnimationStatus(null);
|
||||
@@ -481,18 +569,18 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
};
|
||||
|
||||
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
|
||||
throw new Error('请先应用主图,再生成动作。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: role.id,
|
||||
characterId: workingRole.id,
|
||||
strategy: 'image-to-video',
|
||||
animation: config.animation,
|
||||
promptText: animationPromptText,
|
||||
characterBriefText,
|
||||
actionTemplateId: config.templateId,
|
||||
visualSource: role.imageSrc,
|
||||
visualSource: workingRole.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: config.frameCount,
|
||||
@@ -525,6 +613,16 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirmPointSpend({
|
||||
kindLabel: '动作草稿生成',
|
||||
points: animationPointCost,
|
||||
description: '这次是动作草稿试片,不是最终发布。',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
@@ -545,6 +643,16 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
};
|
||||
|
||||
const handleGenerateAllAnimations = async () => {
|
||||
if (
|
||||
!confirmPointSpend({
|
||||
kindLabel: '核心动作生成',
|
||||
points: animationPointCost,
|
||||
description: '这次会生成核心动作草稿,发布前仍可继续调整。',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAnimations(true);
|
||||
setAnimationStatus(null);
|
||||
|
||||
@@ -570,7 +678,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
};
|
||||
|
||||
const handleApplyAnimations = async () => {
|
||||
if (!role.generatedVisualAssetId) {
|
||||
if (!workingRole.generatedVisualAssetId) {
|
||||
setAnimationStatus('请先应用主图,再应用动作。');
|
||||
return;
|
||||
}
|
||||
@@ -601,22 +709,37 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
]),
|
||||
);
|
||||
const result = await publishCharacterAnimationAssets({
|
||||
characterId: role.id,
|
||||
visualAssetId: role.generatedVisualAssetId,
|
||||
characterId: workingRole.id,
|
||||
visualAssetId: workingRole.generatedVisualAssetId,
|
||||
animations: payload,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
|
||||
onApply(
|
||||
mergeRole(role, {
|
||||
const nextRole = mergeRole(workingRole, {
|
||||
generatedAnimationSetId: result.animationSetId,
|
||||
animationMap: {
|
||||
...(role.animationMap ?? {}),
|
||||
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
|
||||
...(result.animationMap as NonNullable<
|
||||
EditableCustomWorldRole['animationMap']
|
||||
>),
|
||||
},
|
||||
}),
|
||||
});
|
||||
setWorkingRole(nextRole);
|
||||
onApply?.(nextRole);
|
||||
onPublishSuccess?.(
|
||||
{
|
||||
roleId: workingRole.id,
|
||||
portraitPath: workingRole.imageSrc ?? previewImageSrc,
|
||||
generatedVisualAssetId: workingRole.generatedVisualAssetId ?? '',
|
||||
generatedAnimationSetId: result.animationSetId,
|
||||
animationMap: (nextRole.animationMap ?? null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
},
|
||||
{
|
||||
closeAfterSync: true,
|
||||
},
|
||||
);
|
||||
setAnimationStatus('核心动作已应用到当前角色。');
|
||||
} catch (error) {
|
||||
@@ -637,7 +760,8 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
isGeneratingVisuals ||
|
||||
isApplyingVisual ||
|
||||
isGeneratingAnimations ||
|
||||
isApplyingAnimations
|
||||
isApplyingAnimations ||
|
||||
syncBusy
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
|
||||
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<StatusBadge tone="amber">
|
||||
本轮预计 {visualPointCost} 积分
|
||||
</StatusBadge>
|
||||
<ActionButton
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
|
||||
onClick={() => void handleGenerateVisuals()}
|
||||
disabled={isGeneratingVisuals}
|
||||
disabled={isGeneratingVisuals || syncBusy}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingVisual ? '应用中...' : '应用主图'}
|
||||
onClick={() => void handleApplyVisual()}
|
||||
disabled={isApplyingVisual || !selectedVisualDraft}
|
||||
disabled={isApplyingVisual || !selectedVisualDraft || syncBusy}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
alt={workingRole.name}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
@@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
: `生成${selectedActionConfig?.label ?? '当前'}动作`
|
||||
}
|
||||
onClick={() => void handleGenerateSingleAnimation()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
disabled={
|
||||
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
@@ -820,16 +949,21 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
isGeneratingAnimations ? '生成中...' : '生成核心动作'
|
||||
}
|
||||
onClick={() => void handleGenerateAllAnimations()}
|
||||
disabled={isGeneratingAnimations || !role.imageSrc}
|
||||
disabled={
|
||||
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
|
||||
}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label={isApplyingAnimations ? '应用中...' : '应用动作'}
|
||||
onClick={() => void handleApplyAnimations()}
|
||||
disabled={isApplyingAnimations}
|
||||
disabled={isApplyingAnimations || syncBusy}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
本轮动作草稿预计消耗 {animationPointCost} 积分。
|
||||
</div>
|
||||
{animationStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{animationStatus}
|
||||
@@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<StatusBadge tone="amber">
|
||||
已应用动作 {appliedActionCount}/{CORE_ACTIONS.length}
|
||||
</StatusBadge>
|
||||
{role.generatedVisualAssetId ? (
|
||||
{workingRole.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">主图已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待应用主图</StatusBadge>
|
||||
@@ -853,7 +987,12 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
{CORE_ACTIONS.map((item) => {
|
||||
const hasDraft = Boolean(draftAnimations[item.animation]);
|
||||
const isApplied = Boolean(
|
||||
role.animationMap?.[item.animation]?.basePath,
|
||||
(
|
||||
workingRole.animationMap as Record<
|
||||
string,
|
||||
{ basePath?: string }
|
||||
> | null
|
||||
)?.[item.animation]?.basePath,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -896,17 +1035,22 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
state={selectedAnimation}
|
||||
character={{
|
||||
...selectedTemplate,
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
title: role.title,
|
||||
portrait: role.imageSrc || selectedTemplate.portrait,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId,
|
||||
id: workingRole.id,
|
||||
name: workingRole.name,
|
||||
title: workingRole.title,
|
||||
portrait:
|
||||
workingRole.imageSrc || selectedTemplate.portrait,
|
||||
generatedVisualAssetId:
|
||||
workingRole.generatedVisualAssetId ?? undefined,
|
||||
generatedAnimationSetId:
|
||||
role.generatedAnimationSetId,
|
||||
animationMap: role.animationMap
|
||||
workingRole.generatedAnimationSetId ?? undefined,
|
||||
animationMap: workingRole.animationMap
|
||||
? {
|
||||
...(selectedTemplate.animationMap ?? {}),
|
||||
...role.animationMap,
|
||||
...(workingRole.animationMap as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
}
|
||||
: selectedTemplate.animationMap,
|
||||
}}
|
||||
@@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
alt={workingRole.name}
|
||||
className="max-h-[16rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
@@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="space-y-5">
|
||||
<Section title="当前角色档案">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-lg font-semibold text-white">{role.name}</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{workingRole.name}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{role.title} / {role.role}
|
||||
{workingRole.title} / {workingRole.role}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
|
||||
{role.description ? <div>{role.description}</div> : null}
|
||||
{role.combatStyle ? <div>战斗风格:{role.combatStyle}</div> : null}
|
||||
{role.tags.length > 0 ? <div>标签:{role.tags.join('、')}</div> : null}
|
||||
{workingRole.description ? <div>{workingRole.description}</div> : null}
|
||||
{workingRole.combatStyle ? (
|
||||
<div>战斗风格:{workingRole.combatStyle}</div>
|
||||
) : null}
|
||||
{workingRole.tags && workingRole.tags.length > 0 ? (
|
||||
<div>标签:{workingRole.tags.join('、')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="自动提示词依据">
|
||||
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm text-zinc-200">主图状态</div>
|
||||
{role.generatedVisualAssetId ? (
|
||||
{workingRole.generatedVisualAssetId ? (
|
||||
<StatusBadge tone="green">已应用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="zinc">待生成</StatusBadge>
|
||||
@@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
|
||||
当前面板只保留主图和图生视频抽帧这条生产链,不提供视频预览、抽帧编辑、修帧和导出步骤。
|
||||
角色优先级:{priorityTier === 'hero'
|
||||
? '主角级'
|
||||
: priorityTier === 'featured'
|
||||
? '重点角色'
|
||||
: '支撑角色'}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user