Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -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>