This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
|
||||
import {
|
||||
type AnimationState,
|
||||
type Character,
|
||||
} from '../../types';
|
||||
import { CORE_ACTIONS } from './roleAssetStudioModel';
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type StatusBadgeProps = {
|
||||
tone: 'green' | 'amber' | 'zinc';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type TextAreaProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type CharacterAnimatorProps = {
|
||||
state: AnimationState;
|
||||
character: Character;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
imageClassName?: string;
|
||||
playbackRate?: number;
|
||||
};
|
||||
|
||||
export function RpgCreationRoleAnimationSection(props: {
|
||||
ActionButton: (props: ActionButtonProps) => ReactNode;
|
||||
CharacterAnimator: ComponentType<CharacterAnimatorProps>;
|
||||
Field: (props: FieldProps) => ReactNode;
|
||||
Section: (props: SectionProps) => ReactNode;
|
||||
StatusBadge: (props: StatusBadgeProps) => ReactNode;
|
||||
TextArea: (props: TextAreaProps) => ReactNode;
|
||||
animationPreviewFrameStyle: CSSProperties;
|
||||
animationPreviewPlaybackRate: number;
|
||||
animationPreviewViewportStyle: CSSProperties;
|
||||
animationPromptText: string;
|
||||
generatingAnimationMap: Partial<Record<AnimationState, boolean>>;
|
||||
hasGeneratedAnimation: (animation: AnimationState) => boolean;
|
||||
isSelectedAnimationGenerating: boolean;
|
||||
previewCharacter: Character | null;
|
||||
previewImageSrc: string;
|
||||
selectedAnimation: AnimationState;
|
||||
selectedAnimationStatus: string | null;
|
||||
shouldUseSelectedAnimationPreview: boolean;
|
||||
syncBusy: boolean;
|
||||
animationPointCost: number;
|
||||
workingRoleImageSrc?: string;
|
||||
workingRoleGeneratedVisualAssetId?: string;
|
||||
workingRoleName: string;
|
||||
onAnimationPromptChange: (value: string) => void;
|
||||
onGenerateAnimation: () => void;
|
||||
onPlaybackRateChange: (value: number) => void;
|
||||
onSelectAnimation: (animation: AnimationState) => void;
|
||||
}) {
|
||||
const {
|
||||
ActionButton,
|
||||
CharacterAnimator,
|
||||
Field,
|
||||
Section,
|
||||
StatusBadge,
|
||||
TextArea,
|
||||
animationPreviewFrameStyle,
|
||||
animationPreviewPlaybackRate,
|
||||
animationPreviewViewportStyle,
|
||||
animationPromptText,
|
||||
generatingAnimationMap,
|
||||
hasGeneratedAnimation,
|
||||
isSelectedAnimationGenerating,
|
||||
previewCharacter,
|
||||
previewImageSrc,
|
||||
selectedAnimation,
|
||||
selectedAnimationStatus,
|
||||
shouldUseSelectedAnimationPreview,
|
||||
syncBusy,
|
||||
animationPointCost,
|
||||
workingRoleGeneratedVisualAssetId,
|
||||
workingRoleImageSrc,
|
||||
workingRoleName,
|
||||
onAnimationPromptChange,
|
||||
onGenerateAnimation,
|
||||
onPlaybackRateChange,
|
||||
onSelectAnimation,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Section title="动作">
|
||||
<div className="space-y-4">
|
||||
<div className="platform-role-studio__preview rounded-3xl p-4">
|
||||
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
|
||||
{shouldUseSelectedAnimationPreview && previewCharacter ? (
|
||||
<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={workingRoleName}
|
||||
className="max-h-[28rem] w-full object-contain pixelated"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 text-sm text-zinc-500">暂无动作预览</div>
|
||||
)}
|
||||
</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) =>
|
||||
onPlaybackRateChange(Number.parseFloat(event.target.value) || 0.75)
|
||||
}
|
||||
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(item.animation);
|
||||
const isGenerating = generatingAnimationMap[item.animation] === true;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.animation}
|
||||
type="button"
|
||||
onClick={() => onSelectAnimation(item.animation)}
|
||||
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
|
||||
{isGenerating
|
||||
? '后台生成中'
|
||||
: isSelected
|
||||
? '当前预览'
|
||||
: '点击切换'}
|
||||
<span>{item.required ? '必需动作' : '可选动作'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
|
||||
>
|
||||
{isGenerating
|
||||
? '生成中'
|
||||
: isReady
|
||||
? '已生成'
|
||||
: item.required
|
||||
? '待生成'
|
||||
: (item.fallbackStatusLabel ?? '可选')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Field label="动作描述">
|
||||
<TextArea
|
||||
value={animationPromptText}
|
||||
onChange={onAnimationPromptChange}
|
||||
rows={5}
|
||||
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
|
||||
subLabel={`消耗${animationPointCost}叙世币`}
|
||||
onClick={onGenerateAnimation}
|
||||
disabled={
|
||||
isSelectedAnimationGenerating ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId ||
|
||||
syncBusy
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAnimationStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{selectedAnimationStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAnimationSection;
|
||||
@@ -0,0 +1,53 @@
|
||||
export function RpgCreationRoleAssetStudioFooter(props: {
|
||||
isSavingToRole: boolean;
|
||||
saveStatus: string | null;
|
||||
syncBusy: boolean;
|
||||
workingRoleGeneratedVisualAssetId?: string;
|
||||
workingRoleImageSrc?: string;
|
||||
onSaveToRole: () => void;
|
||||
}) {
|
||||
const {
|
||||
isSavingToRole,
|
||||
saveStatus,
|
||||
syncBusy,
|
||||
workingRoleGeneratedVisualAssetId,
|
||||
workingRoleImageSrc,
|
||||
onSaveToRole,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
|
||||
<div className="space-y-3">
|
||||
{saveStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{saveStatus}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveToRole}
|
||||
disabled={
|
||||
isSavingToRole ||
|
||||
syncBusy ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId
|
||||
}
|
||||
className={`rounded-full border border-emerald-400/30 bg-emerald-500/10 px-5 py-2 text-sm font-semibold text-emerald-100 transition-colors hover:bg-emerald-500/20 ${
|
||||
isSavingToRole ||
|
||||
syncBusy ||
|
||||
!workingRoleImageSrc ||
|
||||
!workingRoleGeneratedVisualAssetId
|
||||
? 'cursor-not-allowed opacity-45'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSavingToRole || syncBusy ? '保存中...' : '保存到当前角色'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAssetStudioFooter;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import {
|
||||
RpgCreationRoleAssetStudioModal as RpgCreationRoleAssetStudioModalImpl,
|
||||
} from './RpgCreationRoleAssetStudioModalImpl';
|
||||
|
||||
/**
|
||||
* 工作包 C 完成后,角色资产工坊 façade 已直接桥接 RPG 创作目录下的真实实现。
|
||||
* 旧入口仍保留兼容导出,后续视觉/动作工作流继续在该目录内部演进。
|
||||
*/
|
||||
export type RpgCreationRoleAssetStudioModalProps = ComponentProps<
|
||||
typeof RpgCreationRoleAssetStudioModalImpl
|
||||
>;
|
||||
|
||||
export function RpgCreationRoleAssetStudioModal(
|
||||
props: RpgCreationRoleAssetStudioModalProps,
|
||||
) {
|
||||
return <RpgCreationRoleAssetStudioModalImpl {...props} />;
|
||||
}
|
||||
|
||||
export default RpgCreationRoleAssetStudioModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { ImagePlus, RefreshCcw } from 'lucide-react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky' | 'green';
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type TextAreaProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function RpgCreationRoleVisualSection(props: {
|
||||
ActionButton: (props: ActionButtonProps) => ReactNode;
|
||||
Field: (props: FieldProps) => ReactNode;
|
||||
Section: (props: SectionProps) => ReactNode;
|
||||
TextArea: (props: TextAreaProps) => ReactNode;
|
||||
handleReferenceImageUpload: (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => Promise<void>;
|
||||
hasGeneratedVisualPreview: boolean;
|
||||
isApplyingVisual: boolean;
|
||||
isGeneratingVisuals: boolean;
|
||||
previewImageSrc: string;
|
||||
referenceImageDataUrls: string[];
|
||||
selectedTemplatePortrait?: string | null;
|
||||
selectedTemplateName?: string | null;
|
||||
syncBusy: boolean;
|
||||
visualPointCost: number;
|
||||
visualPromptText: string;
|
||||
visualStatus: string | null;
|
||||
workingRoleName: string;
|
||||
onClearReferenceImages: () => void;
|
||||
onGenerateVisuals: () => void;
|
||||
onVisualPromptChange: (value: string) => void;
|
||||
}) {
|
||||
const {
|
||||
ActionButton,
|
||||
Field,
|
||||
Section,
|
||||
TextArea,
|
||||
handleReferenceImageUpload,
|
||||
hasGeneratedVisualPreview,
|
||||
isApplyingVisual,
|
||||
isGeneratingVisuals,
|
||||
previewImageSrc,
|
||||
referenceImageDataUrls,
|
||||
selectedTemplateName,
|
||||
selectedTemplatePortrait,
|
||||
syncBusy,
|
||||
visualPointCost,
|
||||
visualPromptText,
|
||||
visualStatus,
|
||||
workingRoleName,
|
||||
onClearReferenceImages,
|
||||
onGenerateVisuals,
|
||||
onVisualPromptChange,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Section title="角色形象">
|
||||
<div className="space-y-4">
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={previewImageSrc}
|
||||
alt={workingRoleName}
|
||||
className="max-h-[28rem] w-full object-contain"
|
||||
/>
|
||||
) : selectedTemplatePortrait ? (
|
||||
<ResolvedAssetImage
|
||||
src={selectedTemplatePortrait}
|
||||
alt={selectedTemplateName ?? workingRoleName}
|
||||
className="max-h-[20rem] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 text-center text-sm text-zinc-500">
|
||||
暂无角色形象预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="形象描述">
|
||||
<TextArea
|
||||
value={visualPromptText}
|
||||
onChange={onVisualPromptChange}
|
||||
rows={6}
|
||||
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="参考图">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageUpload(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageDataUrls.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{referenceImageDataUrls.map((imageSrc, index) => (
|
||||
<div
|
||||
key={`${imageSrc}-${index}`}
|
||||
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`reference-${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
label="清空参考图"
|
||||
onClick={onClearReferenceImages}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
icon={
|
||||
hasGeneratedVisualPreview ? (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={
|
||||
isGeneratingVisuals
|
||||
? '生成中...'
|
||||
: hasGeneratedVisualPreview
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}叙世币`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visualStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{visualStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationRoleVisualSection;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AnimationState } from '../../types';
|
||||
|
||||
export type EditableCustomWorldRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CustomWorldAiActionConfig = {
|
||||
animation: AnimationState;
|
||||
label: string;
|
||||
templateId: string;
|
||||
fps: number;
|
||||
frameCount: number;
|
||||
durationSeconds: number;
|
||||
loop: boolean;
|
||||
required: boolean;
|
||||
fallbackStatusLabel?: string;
|
||||
};
|
||||
|
||||
export const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
{
|
||||
animation: AnimationState.RUN,
|
||||
label: '奔跑',
|
||||
templateId: 'run',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.ATTACK,
|
||||
label: '攻击',
|
||||
templateId: 'attack_slash',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.IDLE,
|
||||
label: '待机',
|
||||
templateId: 'idle',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认静止',
|
||||
},
|
||||
{
|
||||
animation: AnimationState.DIE,
|
||||
label: '死亡',
|
||||
templateId: 'die',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认倒地动画',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
} from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
/**
|
||||
* 工作包 C 第一轮先把发布相关 API 出口收口到独立 client。
|
||||
* 后续场景资产工坊复用时,可以直接沿用这里的发布边界。
|
||||
*/
|
||||
export const roleAssetStudioPublishClient = {
|
||||
publishCharacterAnimationAssets,
|
||||
publishCharacterVisualAsset,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { generateCharacterAnimationDraft } from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
import type { CharacterAnimationGenerationPayload } from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient';
|
||||
import type {
|
||||
CustomWorldAiActionConfig,
|
||||
EditableCustomWorldRole,
|
||||
} from './roleAssetStudioModel';
|
||||
|
||||
export function useRoleAnimationWorkflow() {
|
||||
const generateAnimationClipForRole = async (params: {
|
||||
actionConfig: CustomWorldAiActionConfig;
|
||||
animationPromptText: string;
|
||||
characterBriefText: string;
|
||||
role: EditableCustomWorldRole;
|
||||
}) => {
|
||||
const { actionConfig, animationPromptText, characterBriefText, role } =
|
||||
params;
|
||||
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
throw new Error('请先生成角色形象,再生成动作。');
|
||||
}
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: role.id,
|
||||
strategy: 'image-to-video',
|
||||
animation: actionConfig.animation,
|
||||
promptText: animationPromptText,
|
||||
characterBriefText,
|
||||
actionTemplateId: actionConfig.templateId,
|
||||
visualSource: role.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
lastFrameImageDataUrl: role.imageSrc,
|
||||
frameCount: actionConfig.frameCount,
|
||||
fps: actionConfig.fps,
|
||||
durationSeconds: actionConfig.durationSeconds,
|
||||
loop: actionConfig.loop,
|
||||
useChromaKey: true,
|
||||
resolution: '480p',
|
||||
ratio: '1:1',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
} satisfies CharacterAnimationGenerationPayload);
|
||||
|
||||
if (result.strategy !== 'image-to-video') {
|
||||
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
|
||||
}
|
||||
|
||||
return {
|
||||
fps: actionConfig.fps,
|
||||
loop: actionConfig.loop,
|
||||
frameWidth: 192,
|
||||
frameHeight: 256,
|
||||
frameCount: actionConfig.frameCount,
|
||||
applyChromaKey: true,
|
||||
sampleStartRatio: actionConfig.loop ? 0.12 : 0,
|
||||
sampleEndRatio: actionConfig.loop ? 0.94 : 1,
|
||||
previewVideoPath: result.previewVideoPath,
|
||||
};
|
||||
};
|
||||
|
||||
const publishAnimationClipForRole = async (params: {
|
||||
actionConfig: CustomWorldAiActionConfig;
|
||||
clip: Awaited<ReturnType<typeof generateAnimationClipForRole>>;
|
||||
role: EditableCustomWorldRole;
|
||||
}) => {
|
||||
const { actionConfig, clip, role } = params;
|
||||
|
||||
if (!role.generatedVisualAssetId) {
|
||||
throw new Error('缺少角色主图资产,无法发布动作。');
|
||||
}
|
||||
|
||||
return roleAssetStudioPublishClient.publishCharacterAnimationAssets({
|
||||
characterId: role.id,
|
||||
visualAssetId: role.generatedVisualAssetId,
|
||||
animations: {
|
||||
[actionConfig.animation]: {
|
||||
framesDataUrls: [],
|
||||
fps: clip.fps,
|
||||
loop: clip.loop,
|
||||
frameWidth: clip.frameWidth,
|
||||
frameHeight: clip.frameHeight,
|
||||
frameCount: clip.frameCount,
|
||||
applyChromaKey: clip.applyChromaKey,
|
||||
sampleStartRatio: clip.sampleStartRatio,
|
||||
sampleEndRatio: clip.sampleEndRatio,
|
||||
previewVideoPath: clip.previewVideoPath,
|
||||
},
|
||||
},
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
generateAnimationClipForRole,
|
||||
publishAnimationClipForRole,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { generateCharacterVisualCandidates } from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
import type { CharacterVisualDraft } from '../asset-studio/characterAssetWorkflowPersistence';
|
||||
|
||||
import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient';
|
||||
import type { EditableCustomWorldRole } from './roleAssetStudioModel';
|
||||
|
||||
export function useRoleVisualCandidateWorkflow() {
|
||||
const generateVisualCandidatesForRole = async (params: {
|
||||
promptText: string;
|
||||
referenceImageDataUrls: string[];
|
||||
role: EditableCustomWorldRole;
|
||||
sourceMode: 'text-to-image' | 'image-to-image';
|
||||
}) => {
|
||||
const {
|
||||
promptText,
|
||||
referenceImageDataUrls,
|
||||
role,
|
||||
sourceMode,
|
||||
} = params;
|
||||
|
||||
return generateCharacterVisualCandidates({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText,
|
||||
referenceImageDataUrls,
|
||||
candidateCount: 1,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1024',
|
||||
});
|
||||
};
|
||||
|
||||
const applyVisualDraftToRole = async (params: {
|
||||
draft: CharacterVisualDraft;
|
||||
promptText: string;
|
||||
role: EditableCustomWorldRole;
|
||||
sourceMode: 'text-to-image' | 'image-to-image';
|
||||
}) => {
|
||||
const { draft, promptText, role, sourceMode } = params;
|
||||
|
||||
return roleAssetStudioPublishClient.publishCharacterVisualAsset({
|
||||
characterId: role.id,
|
||||
sourceMode,
|
||||
promptText,
|
||||
selectedPreviewSource: draft.imageSrc,
|
||||
previewSources: [draft.imageSrc],
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
updateCharacterOverride: false,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
applyVisualDraftToRole,
|
||||
generateVisualCandidatesForRole,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user