379
server-node/src/prompts/characterAssetPrompts.ts
Normal file
379
server-node/src/prompts/characterAssetPrompts.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
buildVideoActionPrompt,
|
||||
getActionTemplateById,
|
||||
} from '../../../packages/shared/src/prompts/qwenSprite.js';
|
||||
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
export type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor =
|
||||
params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation
|
||||
? `环境中可埋入与当前目标相关的暗示:${params.motivation}。`
|
||||
: '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAnimationPromptText(value: string, maxLength: number) {
|
||||
return value
|
||||
.replace(/\s+/gu, ' ')
|
||||
.replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '')
|
||||
.replace(/死亡|死去|击杀/gu, '倒地结束')
|
||||
.replace(/受击|受伤/gu, '失衡')
|
||||
.replace(/砍杀|斩击/gu, '挥击')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildCompactAnimationCharacterBrief(value: string) {
|
||||
const normalized = sanitizeAnimationPromptText(value, 160);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[/|\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
export function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
|
||||
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return buildMasterPrompt(
|
||||
mergedBrief || '自定义世界角色,服装完整,姿态自然。',
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcVisualNegativePrompt() {
|
||||
return [
|
||||
'正面视角',
|
||||
'左朝向',
|
||||
'完全 90 度纯右视图',
|
||||
'镜头透视',
|
||||
'半身像',
|
||||
'脚被裁切',
|
||||
'头顶被裁切',
|
||||
'多角色',
|
||||
'复杂背景',
|
||||
'建筑场景',
|
||||
'漂浮物',
|
||||
'烟雾环境',
|
||||
'武器消失',
|
||||
'武器换手',
|
||||
'额外手臂',
|
||||
'额外腿',
|
||||
'服装变化',
|
||||
'脸部变化',
|
||||
'模糊',
|
||||
'运动模糊',
|
||||
'文字',
|
||||
'水印',
|
||||
'UI 元素',
|
||||
'软萌 Q版大头贴',
|
||||
'儿童绘本风',
|
||||
'厚涂插画感',
|
||||
'低对比柔边',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
export function buildImageSequencePrompt(
|
||||
animation: string,
|
||||
promptText: string,
|
||||
frameCount: number,
|
||||
useChromaKey: boolean,
|
||||
) {
|
||||
return [
|
||||
`同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`,
|
||||
'固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。',
|
||||
'帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。',
|
||||
useChromaKey
|
||||
? '纯绿色背景,无地面装饰,方便后期抠像。'
|
||||
: '背景尽量纯净,避免复杂场景。',
|
||||
promptText.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildNpcAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const loopRule = options.loop
|
||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||
: options.animation === 'die'
|
||||
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<
|
||||
typeof getActionTemplateById
|
||||
>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${options.animation} 动作角色`,
|
||||
}),
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText,
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildArkCharacterAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const normalizedAnimationName =
|
||||
options.animation.trim().replace(/\s+/gu, '_') || 'idle';
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const frameRule = options.loop
|
||||
? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。'
|
||||
: '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${normalizedAnimationName} action role`,
|
||||
}),
|
||||
`动作英文名:${normalizedAnimationName}。`,
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`,
|
||||
'角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText ? `动作细节:${actionDetailText}` : '',
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildFallbackModerationSafeAnimationPrompt(options: {
|
||||
animation: string;
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
|
||||
options.loop
|
||||
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
|
||||
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素。'
|
||||
: '背景简洁纯净。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user