This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -3,6 +3,7 @@ import {
RefreshCcw,
} from 'lucide-react';
import {
type CSSProperties,
type ChangeEvent,
type ReactNode,
useEffect,
@@ -14,11 +15,11 @@ import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CharacterAnimationConfig,
type Character,
} from '../types';
import {
buildAnimationClipFromVideoSource,
normalizeMasterVisualSourceToDataUrl,
readFileAsDataUrl,
} from './asset-studio/characterAssetWorkflowModel';
import {
@@ -27,7 +28,6 @@ import {
type CharacterVisualDraft,
fetchCharacterWorkflowCache,
generateCharacterAnimationDraft,
generateCharacterPromptBundle,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
@@ -41,6 +41,9 @@ type EditableCustomWorldRole = {
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
@@ -112,6 +115,25 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
},
];
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 roundAnimationFps(value: number) {
return Math.round(value * 100) / 100;
}
function ModalShell({
title,
subtitle,
@@ -357,6 +379,86 @@ function hasGeneratedAnimation(
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;
@@ -399,7 +501,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
@@ -420,7 +521,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const baseRole = useMemo<EditableCustomWorldRole>(
@@ -429,6 +529,9 @@ export function CustomWorldRoleAssetStudioModal({
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,
@@ -450,13 +553,16 @@ export function CustomWorldRoleAssetStudioModal({
role.generatedVisualAssetId,
role.id,
role.imageSrc,
role.actionDescription,
role.motivation,
role.name,
role.personality,
role.role,
role.sceneVisualDescription,
role.tags,
role.templateCharacterId,
role.title,
role.visualDescription,
],
);
const initialPromptBundle = useMemo(
@@ -481,8 +587,14 @@ export function CustomWorldRoleAssetStudioModal({
const [animationPromptText, setAnimationPromptText] = useState(
initialPromptBundle.animationPromptText,
);
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
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);
@@ -503,37 +615,6 @@ export function CustomWorldRoleAssetStudioModal({
),
[workingRole, selectedTemplate],
);
const promptSeedKey = useMemo(
() =>
[
roleKind,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description ?? '',
workingRole.backstory ?? '',
workingRole.personality ?? '',
workingRole.motivation ?? '',
workingRole.combatStyle ?? '',
(workingRole.tags ?? []).join('|'),
selectedTemplate?.name ?? '',
selectedTemplate?.title ?? '',
].join('||'),
[
roleKind,
selectedTemplate?.name,
selectedTemplate?.title,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description,
workingRole.backstory,
workingRole.personality,
workingRole.motivation,
workingRole.combatStyle,
workingRole.tags,
],
);
const roleSnapshotKey = useMemo(
() =>
[
@@ -558,8 +639,8 @@ export function CustomWorldRoleAssetStudioModal({
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
workingRole.imageSrc ??
selectedVisualDraft?.imageSrc ??
selectedTemplate?.portrait ??
'';
const hasGeneratedVisualPreview = Boolean(
@@ -576,6 +657,23 @@ export function CustomWorldRoleAssetStudioModal({
}),
[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 animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig],
);
const animationPreviewViewportStyle = useMemo(
() => getAnimationPreviewViewportStyle(440),
[],
);
const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
@@ -589,7 +687,9 @@ export function CustomWorldRoleAssetStudioModal({
setSelectedVisualDraftId('');
setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE);
setVisualStatus(null);
setAnimationStatus(null);
setAnimationStatusByKey({});
setGeneratingAnimationMap({});
setAnimationPreviewPlaybackRate(DEFAULT_ANIMATION_PLAYBACK_RATE);
setSaveStatus(null);
setIsHydratingCache(true);
@@ -643,64 +743,6 @@ export function CustomWorldRoleAssetStudioModal({
roleSnapshotKey,
]);
useEffect(() => {
if (!characterBriefText.trim()) {
return;
}
let cancelled = false;
void generateCharacterPromptBundle({
roleKind,
characterName: workingRole.name,
roleTitle: workingRole.title,
roleLabel: workingRole.role,
description: workingRole.description,
backstory: workingRole.backstory,
personality: workingRole.personality,
motivation: workingRole.motivation,
combatStyle: workingRole.combatStyle,
tags: workingRole.tags,
characterBriefText,
})
.then((result) => {
if (cancelled) {
return;
}
setVisualPromptText((current) =>
current.trim() && current !== initialPromptBundle.visualPromptText
? current
: result.visualPromptText,
);
setAnimationPromptText((current) =>
current.trim() && current !== initialPromptBundle.animationPromptText
? current
: result.animationPromptText,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [
characterBriefText,
initialPromptBundle.animationPromptText,
initialPromptBundle.visualPromptText,
promptSeedKey,
roleKind,
workingRole.backstory,
workingRole.combatStyle,
workingRole.description,
workingRole.motivation,
workingRole.name,
workingRole.personality,
workingRole.role,
workingRole.tags,
workingRole.title,
]);
useEffect(() => {
if (isHydratingCache) {
return;
@@ -742,6 +784,15 @@ export function CustomWorldRoleAssetStudioModal({
workingRole.imageSrc,
]);
useEffect(() => {
setAnimationPreviewPlaybackRate(
resolveAnimationPlaybackRate(
selectedActionConfig,
selectedAnimationConfig,
),
);
}, [selectedActionConfig, selectedAnimationConfig]);
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
@@ -774,38 +825,35 @@ export function CustomWorldRoleAssetStudioModal({
event.target.value = '';
};
const applyVisualDraftToWorkflow = async (
draft: CharacterVisualDraft,
draftList = visualDrafts,
) => {
const normalizedVisual = await normalizeMasterVisualSourceToDataUrl(
draft.imageSrc,
{
applyChromaKey: true,
},
);
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
selectedPreviewSource: normalizedVisual.dataUrl,
previewSources: [normalizedVisual.dataUrl],
width: normalizedVisual.width,
height: normalizedVisual.height,
updateCharacterOverride: false,
});
const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
setIsApplyingVisual(true);
try {
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
selectedPreviewSource: draft.imageSrc,
previewSources: [draft.imageSrc],
width: draft.width,
height: draft.height,
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
setSelectedVisualDraftId(draft.id);
setAnimationStatus(null);
setSaveStatus(null);
setVisualStatus('角色形象已更新,可继续生成动作。');
const 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 () => {
@@ -835,7 +883,7 @@ export function CustomWorldRoleAssetStudioModal({
});
setVisualDrafts(result.drafts);
if (result.drafts[0]) {
await applyVisualDraftToWorkflow(result.drafts[0], result.drafts);
await applyVisualDraftToWorkflow(result.drafts[0]);
setVisualStatus('角色形象已生成,如不满意可直接重新生成。');
} else {
setSelectedVisualDraftId('');
@@ -855,6 +903,8 @@ export function CustomWorldRoleAssetStudioModal({
throw new Error('请先生成角色形象,再生成动作。');
}
const isLoopAction = config.loop;
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
strategy: 'image-to-video',
@@ -865,14 +915,15 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: '720P',
resolution: isLoopAction ? '720P' : '480P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
@@ -887,6 +938,8 @@ export function CustomWorldRoleAssetStudioModal({
loop: config.loop,
frameCount: config.frameCount,
applyChromaKey: true,
sampleStartRatio: config.loop ? 0.12 : 0,
sampleEndRatio: config.loop ? 0.94 : 1,
});
};
@@ -895,6 +948,12 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
const actionConfig = selectedActionConfig;
const requestedPlaybackRate = animationPreviewPlaybackRate;
if (generatingAnimationMap[actionConfig.animation]) {
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
@@ -905,16 +964,22 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
setGeneratingAnimationMap((current) => ({
...current,
[actionConfig.animation]: true,
}));
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]: null,
}));
try {
const clip = await generateActionClip(selectedActionConfig);
const clip = await generateActionClip(actionConfig);
const result = await publishCharacterAnimationAssets({
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId!,
animations: {
[selectedActionConfig.animation]: {
[actionConfig.animation]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
@@ -926,24 +991,38 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
});
setWorkingRole(nextRole);
setSaveStatus(null);
setAnimationStatus(`${selectedActionConfig.label} 动作已更新。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成角色动作失败。',
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 {
setIsGeneratingAnimations(false);
setGeneratingAnimationMap((current) => ({
...current,
[actionConfig.animation]: false,
}));
}
};
@@ -999,7 +1078,7 @@ export function CustomWorldRoleAssetStudioModal({
isHydratingCache ||
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
hasAnyGeneratingAnimations ||
isSavingToRole ||
syncBusy
}
@@ -1109,20 +1188,25 @@ export function CustomWorldRoleAssetStudioModal({
<Section title="动作">
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="flex min-h-[16rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div className="h-[220px] w-[220px]">
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
>
<div style={animationPreviewFrameStyle}>
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
className="max-h-[28rem] w-full object-contain"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
@@ -1130,10 +1214,53 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</div>
<Field label="预览速度">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input
type="range"
min="0.25"
max="1.5"
step="0.05"
value={animationPreviewPlaybackRate}
onChange={(event) => {
const nextPlaybackRate = clampAnimationPlaybackRate(
Number.parseFloat(event.target.value) ||
DEFAULT_ANIMATION_PLAYBACK_RATE,
);
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,
});
});
}}
className="w-full accent-sky-400"
/>
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
<span>0.25x</span>
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
<span>1.50x</span>
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation);
const isGenerating = generatingAnimationMap[item.animation] === true;
return (
<button
key={item.animation}
@@ -1151,11 +1278,17 @@ export function CustomWorldRoleAssetStudioModal({
{item.label}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{isSelected ? '当前预览' : '点击切换'}
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
</div>
</div>
<StatusBadge tone={isReady ? 'green' : 'zinc'}>
{isReady ? '已生成' : '待生成'}
<StatusBadge
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
>
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
</StatusBadge>
</div>
</button>
@@ -1175,11 +1308,11 @@ export function CustomWorldRoleAssetStudioModal({
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isGeneratingAnimations ? '生成中...' : '生成动作'}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`}
onClick={() => void handleGenerateAnimation()}
disabled={
isGeneratingAnimations ||
isSelectedAnimationGenerating ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId ||
syncBusy
@@ -1188,9 +1321,9 @@ export function CustomWorldRoleAssetStudioModal({
/>
</div>
{animationStatus ? (
{selectedAnimationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
{selectedAnimationStatus}
</div>
) : null}
</div>