init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '默认倒地动画',
},
];

View File

@@ -0,0 +1,13 @@
import {
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from '../asset-studio/characterAssetWorkflowPersistence';
/**
* 工作包 C 第一轮先把发布相关 API 出口收口到独立 client。
* 后续场景资产工坊复用时,可以直接沿用这里的发布边界。
*/
export const roleAssetStudioPublishClient = {
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
};

View File

@@ -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,
};
}

View File

@@ -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,
};
}