1
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user