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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
import {
ASSET_API_PATHS,
postApiJson,
} from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
ASSET_API_PATHS.characterWorkflowCache;
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
ASSET_API_PATHS.characterVisualPublish;
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
export const CHARACTER_ANIMATION_GENERATE_API_PATH =
ASSET_API_PATHS.characterAnimationGenerate;
export const CHARACTER_ANIMATION_PUBLISH_API_PATH =
ASSET_API_PATHS.characterAnimationPublish;
export const CHARACTER_ANIMATION_JOB_API_PATH =
ASSET_API_PATHS.characterAnimationJobs;
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
ASSET_API_PATHS.characterAnimationImportVideo;
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
ASSET_API_PATHS.characterAnimationTemplates;
export type CharacterVisualSourceMode =
| 'text-to-image'
| 'image-to-image'
| 'upload';
export type CharacterAnimationStrategy =
| 'image-sequence'
| 'image-to-video'
| 'motion-transfer'
| 'reference-to-video';
export type CharacterMotionTransferModel =
| 'wan2.2-animate-move'
| 'wan2.2-animate-mix';
export type CharacterVisualDraft = {
id: string;
label: string;
imageSrc: string;
width: number;
height: number;
};
export type CharacterAssetWorkflowCache = {
characterId: string;
cacheScopeId?: string;
visualPromptText: string;
animationPromptText: string;
animationPromptTextByKey?: Record<string, string>;
visualDrafts: CharacterVisualDraft[];
selectedVisualDraftId: string;
selectedAnimation: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
updatedAt?: string;
};
export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;
size: string;
};
export type CharacterVisualPublishPayload = {
characterId: string;
sourceMode: CharacterVisualSourceMode;
promptText: string;
selectedPreviewSource: string;
previewSources: string[];
width: number;
height: number;
updateCharacterOverride?: boolean;
};
export type CharacterAnimationGenerationPayload = {
characterId: string;
strategy: CharacterAnimationStrategy;
animation: string;
promptText: string;
characterBriefText?: string;
actionTemplateId?: string;
visualSource: string;
referenceImageDataUrls: string[];
referenceVideoDataUrls: string[];
lastFrameImageDataUrl?: string;
frameCount: number;
fps: number;
durationSeconds: number;
loop: boolean;
useChromaKey: boolean;
resolution: string;
ratio: string;
imageSequenceModel: string;
videoModel: string;
referenceVideoModel: string;
motionTransferModel: CharacterMotionTransferModel;
};
export type CharacterAnimationDraftPayload = {
framesDataUrls: string[];
fps: number;
loop: boolean;
frameWidth: number;
frameHeight: number;
frameCount?: number;
applyChromaKey?: boolean;
sampleStartRatio?: number;
sampleEndRatio?: number;
previewVideoPath?: string;
};
export type CharacterAnimationTemplate = {
id: string;
label: string;
animation: string;
promptSuffix: string;
notes: string;
};
export type CharacterAssetJobStatus = {
taskId: string;
kind: 'visual' | 'animation';
status: 'queued' | 'running' | 'completed' | 'failed';
characterId: string;
animation?: string;
strategy?: CharacterAnimationStrategy;
model: string;
prompt: string;
createdAt: string;
updatedAt: string;
result?: Record<string, unknown>;
errorMessage?: string;
};
export async function generateCharacterVisualCandidates(
payload: CharacterVisualGenerationPayload,
) {
return postApiJson<{
ok: true;
taskId: string;
model: string;
prompt: string;
drafts: CharacterVisualDraft[];
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
}
export async function fetchCharacterWorkflowCache(
characterId: string,
cacheScopeId?: string,
) {
return fetchJson<{
ok: true;
cache: CharacterAssetWorkflowCache | null;
}>(
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}${
cacheScopeId
? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}`
: ''
}`,
'读取角色形象生成缓存失败',
);
}
export async function saveCharacterWorkflowCache(
payload: CharacterAssetWorkflowCache,
) {
return postApiJson<{
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
}>(
CHARACTER_WORKFLOW_CACHE_API_PATH,
payload,
'保存角色形象生成缓存失败',
);
}
export async function fetchCharacterVisualJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
'读取角色主形象任务状态失败',
);
}
export async function publishCharacterVisualAsset(
payload: CharacterVisualPublishPayload,
) {
return postApiJson<{
ok: true;
assetId: string;
portraitPath: string;
overrideMap: Record<string, unknown>;
saveMessage: string;
}>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败');
}
export async function generateCharacterAnimationDraft(
payload: CharacterAnimationGenerationPayload,
) {
return postApiJson<
| {
ok: true;
taskId: string;
strategy: 'image-sequence';
model: string;
prompt: string;
imageSources: string[];
}
| {
ok: true;
taskId: string;
strategy: 'image-to-video' | 'motion-transfer' | 'reference-to-video';
model: string;
prompt: string;
previewVideoPath: string;
}
>(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败');
}
export async function fetchCharacterAnimationJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_ANIMATION_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
'读取角色动作任务状态失败',
);
}
export async function fetchCharacterAnimationTemplates() {
return fetchJson<{
ok: true;
templates: CharacterAnimationTemplate[];
}>(CHARACTER_ANIMATION_TEMPLATES_API_PATH, '读取动作模板列表失败');
}
export async function importCharacterAnimationVideo(payload: {
characterId: string;
animation: string;
videoSource: string;
sourceLabel?: string;
}) {
return postApiJson<{
ok: true;
importedVideoPath: string;
draftId: string;
saveMessage: string;
}>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败');
}
export async function publishCharacterAnimationAssets(payload: {
characterId: string;
visualAssetId: string;
animations: Record<string, CharacterAnimationDraftPayload>;
updateCharacterOverride?: boolean;
}) {
return postApiJson<{
ok: true;
animationSetId: string;
overrideMap: Record<string, unknown>;
animationMap: Record<string, unknown>;
saveMessage: string;
}>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败');
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('uses model-generated role descriptions directly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to existing entity descriptions without assembling new rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -0,0 +1 @@
export * from '../../prompts/customWorldRolePromptDefaults';

View File

@@ -0,0 +1,75 @@
const PROJECT_PIXEL_STYLE_REFERENCE_SOURCES = [
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
'/character/Archer Hero/Original/Hero/idle/idle01.png',
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
'/character/Fighter 4/original/Hero/idle/idle01.png',
] as const;
function loadImageFromSource(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
image.src = source;
});
}
function drawContainedImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
options: {
x: number;
y: number;
width: number;
height: number;
},
) {
const fitScale = Math.min(
options.width / image.width,
options.height / image.height,
);
const drawWidth = image.width * fitScale;
const drawHeight = image.height * fitScale;
const drawX = options.x + (options.width - drawWidth) / 2;
const drawY = options.y + (options.height - drawHeight) / 2;
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
export async function buildProjectPixelStyleReferenceBoard(
sources = PROJECT_PIXEL_STYLE_REFERENCE_SOURCES,
) {
const images = await Promise.all(
sources.map((source) => loadImageFromSource(source)),
);
const cols = 3;
const rows = 2;
const cellSize = 320;
const padding = 24;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = cols * cellSize + padding * 2;
canvas.height = rows * cellSize + padding * 2;
context.fillStyle = '#f6f0dd';
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = false;
images.forEach((image, index) => {
const colIndex = index % cols;
const rowIndex = Math.floor(index / cols);
drawContainedImage(context, image, {
x: padding + colIndex * cellSize,
y: padding + rowIndex * cellSize,
width: cellSize,
height: cellSize,
});
});
return canvas.toDataURL('image/png');
}