This commit is contained in:
1113
src/components/asset-studio/characterAssetWorkflowModel.ts
Normal file
1113
src/components/asset-studio/characterAssetWorkflowModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
271
src/components/asset-studio/characterAssetWorkflowPersistence.ts
Normal file
271
src/components/asset-studio/characterAssetWorkflowPersistence.ts
Normal 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, '发布角色基础动作失败');
|
||||
}
|
||||
@@ -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('提示词');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal file
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user