Merge branch 'master' into stdb
This commit is contained in:
478
packages/shared/src/assets/chromaKey.ts
Normal file
478
packages/shared/src/assets/chromaKey.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray;
|
||||
|
||||
const SOFT_EDGE_ALPHA_THRESHOLD = 224;
|
||||
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96;
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function lerp(from: number, to: number, t: number) {
|
||||
return from + (to - from) * clamp01(t);
|
||||
}
|
||||
|
||||
function computeGreenBackgroundScore(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
alpha: number,
|
||||
) {
|
||||
if (alpha === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const greenLead = green - Math.max(red, blue);
|
||||
if (green < 52 || greenLead <= 8) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const greenRatio = green / Math.max(1, red + blue);
|
||||
if (greenRatio <= 0.52) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp01(
|
||||
((green - 52) / 168) * 0.22 +
|
||||
((greenLead - 8) / 96) * 0.53 +
|
||||
((greenRatio - 0.52) / 0.82) * 0.25,
|
||||
);
|
||||
}
|
||||
|
||||
function computeWhiteBackgroundScore(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
alpha: number,
|
||||
) {
|
||||
if (alpha === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const maxChannel = Math.max(red, green, blue);
|
||||
const minChannel = Math.min(red, green, blue);
|
||||
const average = (red + green + blue) / 3;
|
||||
if (average < 188 || minChannel < 168) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spread = maxChannel - minChannel;
|
||||
const neutrality = 1 - clamp01((spread - 6) / 34);
|
||||
const brightness = clamp01((average - 188) / 55);
|
||||
const floor = clamp01((minChannel - 168) / 60);
|
||||
return clamp01(neutrality * (brightness * 0.85 + floor * 0.15));
|
||||
}
|
||||
|
||||
function collectForegroundNeighborColor(
|
||||
pixels: MutableRgbaBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
backgroundMask: Uint8Array,
|
||||
backgroundHints: Float32Array,
|
||||
) {
|
||||
let totalWeight = 0;
|
||||
let totalRed = 0;
|
||||
let totalGreen = 0;
|
||||
let totalBlue = 0;
|
||||
|
||||
for (let offsetY = -2; offsetY <= 2; offsetY += 1) {
|
||||
for (let offsetX = -2; offsetX <= 2; offsetX += 1) {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPixelIndex = nextY * width + nextX;
|
||||
if (backgroundMask[nextPixelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextOffset = nextPixelIndex * 4;
|
||||
const nextAlpha = pixels[nextOffset + 3] ?? 0;
|
||||
if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(offsetX) + Math.abs(offsetY);
|
||||
const weight =
|
||||
(nextAlpha / 255) *
|
||||
(distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7);
|
||||
|
||||
totalWeight += weight;
|
||||
totalRed += (pixels[nextOffset] ?? 0) * weight;
|
||||
totalGreen += (pixels[nextOffset + 1] ?? 0) * weight;
|
||||
totalBlue += (pixels[nextOffset + 2] ?? 0) * weight;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalWeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
red: Math.round(totalRed / totalWeight),
|
||||
green: Math.round(totalGreen / totalWeight),
|
||||
blue: Math.round(totalBlue / totalWeight),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeBackgroundFromRgba(
|
||||
pixels: MutableRgbaBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const pixelCount = width * height;
|
||||
if (pixelCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const backgroundMask = new Uint8Array(pixelCount);
|
||||
const greenScores = new Float32Array(pixelCount);
|
||||
const whiteScores = new Float32Array(pixelCount);
|
||||
const backgroundHints = new Float32Array(pixelCount);
|
||||
const queue: number[] = [];
|
||||
let queueIndex = 0;
|
||||
let changed = false;
|
||||
|
||||
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
||||
const offset = pixelIndex * 4;
|
||||
const red = pixels[offset] ?? 0;
|
||||
const green = pixels[offset + 1] ?? 0;
|
||||
const blue = pixels[offset + 2] ?? 0;
|
||||
const alpha = pixels[offset + 3] ?? 0;
|
||||
const greenScore = computeGreenBackgroundScore(red, green, blue, alpha);
|
||||
const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha);
|
||||
const transparencyHint = clamp01((56 - alpha) / 56) * 0.75;
|
||||
|
||||
greenScores[pixelIndex] = greenScore;
|
||||
whiteScores[pixelIndex] = whiteScore;
|
||||
backgroundHints[pixelIndex] = Math.max(
|
||||
greenScore,
|
||||
whiteScore,
|
||||
transparencyHint,
|
||||
);
|
||||
}
|
||||
|
||||
const trySeedBackground = (pixelIndex: number) => {
|
||||
if (backgroundMask[pixelIndex]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = pixelIndex * 4;
|
||||
const alpha = pixels[offset + 3] ?? 0;
|
||||
const strongCandidate =
|
||||
alpha < 40 ||
|
||||
(greenScores[pixelIndex] ?? 0) > 0.12 ||
|
||||
(whiteScores[pixelIndex] ?? 0) > 0.32;
|
||||
|
||||
if (!strongCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundMask[pixelIndex] = 1;
|
||||
queue.push(pixelIndex);
|
||||
};
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
trySeedBackground(x);
|
||||
trySeedBackground((height - 1) * width + x);
|
||||
}
|
||||
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
trySeedBackground(y * width);
|
||||
trySeedBackground(y * width + width - 1);
|
||||
}
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const pixelIndex = queue[queueIndex]!;
|
||||
queueIndex += 1;
|
||||
|
||||
const x = pixelIndex % width;
|
||||
const y = Math.floor(pixelIndex / width);
|
||||
const neighborIndexes = [
|
||||
x > 0 ? pixelIndex - 1 : -1,
|
||||
x + 1 < width ? pixelIndex + 1 : -1,
|
||||
y > 0 ? pixelIndex - width : -1,
|
||||
y + 1 < height ? pixelIndex + width : -1,
|
||||
];
|
||||
|
||||
for (const nextPixelIndex of neighborIndexes) {
|
||||
if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextOffset = nextPixelIndex * 4;
|
||||
const nextAlpha = pixels[nextOffset + 3] ?? 0;
|
||||
const nextGreenScore = greenScores[nextPixelIndex] ?? 0;
|
||||
const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0;
|
||||
const nextHint = backgroundHints[nextPixelIndex] ?? 0;
|
||||
const reachableSoftEdge =
|
||||
nextHint > 0.08 &&
|
||||
nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD &&
|
||||
(nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180);
|
||||
|
||||
if (
|
||||
nextAlpha < 40 ||
|
||||
nextGreenScore > 0.12 ||
|
||||
nextWhiteScore > 0.32 ||
|
||||
reachableSoftEdge
|
||||
) {
|
||||
backgroundMask[nextPixelIndex] = 1;
|
||||
queue.push(nextPixelIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < 2; iteration += 1) {
|
||||
const expandedMask = new Uint8Array(backgroundMask);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const pixelIndex = y * width + x;
|
||||
if (expandedMask[pixelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alpha = pixels[pixelIndex * 4 + 3] ?? 0;
|
||||
const hint = backgroundHints[pixelIndex] ?? 0;
|
||||
if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let adjacentBackgroundCount = 0;
|
||||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (backgroundMask[nextY * width + nextX]) {
|
||||
adjacentBackgroundCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
adjacentBackgroundCount >= 2 ||
|
||||
(adjacentBackgroundCount >= 1 && hint > 0.18)
|
||||
) {
|
||||
expandedMask[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backgroundMask.set(expandedMask);
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const pixelIndex = y * width + x;
|
||||
if (!backgroundMask[pixelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = pixelIndex * 4;
|
||||
const alpha = pixels[offset + 3] ?? 0;
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matteScore = Math.max(
|
||||
backgroundHints[pixelIndex] ?? 0,
|
||||
greenScores[pixelIndex] ?? 0,
|
||||
whiteScores[pixelIndex] ?? 0,
|
||||
);
|
||||
|
||||
let foregroundSupport = 0;
|
||||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPixelIndex = nextY * width + nextX;
|
||||
if (backgroundMask[nextPixelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0;
|
||||
if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
|
||||
foregroundSupport += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextAlpha = alpha;
|
||||
if (matteScore > 0.9 || foregroundSupport === 0) {
|
||||
nextAlpha = 0;
|
||||
} else if (matteScore > 0.72 && foregroundSupport <= 1) {
|
||||
nextAlpha = Math.min(alpha, Math.round(alpha * 0.08));
|
||||
} else {
|
||||
nextAlpha = Math.min(
|
||||
alpha,
|
||||
Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)),
|
||||
);
|
||||
}
|
||||
|
||||
if (foregroundSupport >= 3 && matteScore < 0.55) {
|
||||
nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22));
|
||||
}
|
||||
|
||||
if (nextAlpha < 10) {
|
||||
nextAlpha = 0;
|
||||
}
|
||||
|
||||
if (nextAlpha !== alpha) {
|
||||
pixels[offset + 3] = nextAlpha;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const pixelIndex = y * width + x;
|
||||
const offset = pixelIndex * 4;
|
||||
const alpha = pixels[offset + 3] ?? 0;
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let touchesTransparentEdge = false;
|
||||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
|
||||
touchesTransparentEdge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPixelIndex = nextY * width + nextX;
|
||||
if (
|
||||
backgroundMask[nextPixelIndex] ||
|
||||
(pixels[nextPixelIndex * 4 + 3] ?? 0) < 16
|
||||
) {
|
||||
touchesTransparentEdge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!touchesTransparentEdge) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const greenScore = greenScores[pixelIndex] ?? 0;
|
||||
const whiteScore = whiteScores[pixelIndex] ?? 0;
|
||||
const contamination = Math.max(
|
||||
greenScore,
|
||||
whiteScore,
|
||||
backgroundMask[pixelIndex] ? 0.35 : 0,
|
||||
alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0,
|
||||
);
|
||||
|
||||
if (contamination < 0.06) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let red = pixels[offset] ?? 0;
|
||||
let green = pixels[offset + 1] ?? 0;
|
||||
let blue = pixels[offset + 2] ?? 0;
|
||||
const sample = collectForegroundNeighborColor(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
backgroundMask,
|
||||
backgroundHints,
|
||||
);
|
||||
const blend = clamp01(
|
||||
Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0),
|
||||
);
|
||||
|
||||
if (sample) {
|
||||
red = Math.round(lerp(red, sample.red, blend));
|
||||
green = Math.round(lerp(green, sample.green, blend));
|
||||
blue = Math.round(lerp(blue, sample.blue, blend));
|
||||
|
||||
if (greenScore > 0.04) {
|
||||
green = Math.min(green, sample.green + 18);
|
||||
}
|
||||
|
||||
if (whiteScore > 0.1) {
|
||||
red = Math.min(red, sample.red + 26);
|
||||
green = Math.min(green, sample.green + 26);
|
||||
blue = Math.min(blue, sample.blue + 26);
|
||||
}
|
||||
} else {
|
||||
if (greenScore > 0.04) {
|
||||
green = Math.max(
|
||||
Math.max(red, blue),
|
||||
Math.round(green - (green - Math.max(red, blue)) * 0.78),
|
||||
);
|
||||
}
|
||||
|
||||
if (whiteScore > 0.12) {
|
||||
const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
|
||||
if (spread < 20) {
|
||||
const tonedValue = Math.round(((red + green + blue) / 3) * 0.88);
|
||||
red = Math.min(red, tonedValue);
|
||||
green = Math.min(green, tonedValue);
|
||||
blue = Math.min(blue, tonedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextAlpha = alpha;
|
||||
const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28);
|
||||
if (edgeFade > 0.08) {
|
||||
nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade)));
|
||||
if (nextAlpha < 10) {
|
||||
nextAlpha = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
red !== (pixels[offset] ?? 0) ||
|
||||
green !== (pixels[offset + 1] ?? 0) ||
|
||||
blue !== (pixels[offset + 2] ?? 0) ||
|
||||
nextAlpha !== alpha
|
||||
) {
|
||||
pixels[offset] = red;
|
||||
pixels[offset + 1] = green;
|
||||
pixels[offset + 2] = blue;
|
||||
pixels[offset + 3] = nextAlpha;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
@@ -1,147 +1 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'像素风画风,整体是像素游戏角色设计方向,深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
return (
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||
`风格要求:Q版大头身动作角色,清爽可爱,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`,
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`,
|
||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||
`风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
|
||||
`角色设定:${options.characterBrief.trim()}`,
|
||||
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
|
||||
].join(' ');
|
||||
}
|
||||
export * from '../prompts/qwenSprite.js';
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface CustomWorldWorkSummary {
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
coverImageSrc?: string | null;
|
||||
coverRenderMode?: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs?: string[];
|
||||
updatedAt: string;
|
||||
publishedAt?: string | null;
|
||||
stage?: string | null;
|
||||
@@ -195,6 +197,11 @@ export interface CustomWorldFoundationDraftCharacter {
|
||||
relationToPlayer: string;
|
||||
threadIds: string[];
|
||||
summary: string;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionPreviewConfig?: Record<string, unknown> | null;
|
||||
}>;
|
||||
imageSrc?: string | null;
|
||||
generatedVisualAssetId?: string | null;
|
||||
generatedAnimationSetId?: string | null;
|
||||
@@ -210,6 +217,7 @@ export interface CustomWorldFoundationDraftLandmark {
|
||||
importance: string;
|
||||
secret?: string;
|
||||
dangerLevel?: string;
|
||||
imageSrc?: string | null;
|
||||
characterIds: string[];
|
||||
threadIds: string[];
|
||||
summary: string;
|
||||
@@ -244,9 +252,48 @@ export interface CustomWorldFoundationDraftCamp {
|
||||
description: string;
|
||||
mood: string;
|
||||
dangerLevel?: string;
|
||||
imageSrc?: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type CustomWorldSceneActStage =
|
||||
| 'opening'
|
||||
| 'expansion'
|
||||
| 'turning_point'
|
||||
| 'climax'
|
||||
| 'aftermath';
|
||||
|
||||
export type CustomWorldSceneActAdvanceRule =
|
||||
| 'after_primary_contact'
|
||||
| 'after_active_step_complete'
|
||||
| 'after_chapter_resolution';
|
||||
|
||||
export interface CustomWorldFoundationDraftSceneAct {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
stageCoverage: CustomWorldSceneActStage[];
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
primaryNpcId: string;
|
||||
linkedThreadIds: string[];
|
||||
actGoal: string;
|
||||
transitionHook: string;
|
||||
advanceRule: CustomWorldSceneActAdvanceRule;
|
||||
}
|
||||
|
||||
export interface CustomWorldFoundationDraftSceneChapter {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
linkedThreadIds: string[];
|
||||
linkedLandmarkIds: string[];
|
||||
acts: CustomWorldFoundationDraftSceneAct[];
|
||||
}
|
||||
|
||||
export interface CustomWorldFoundationDraftProfile {
|
||||
name: string;
|
||||
subtitle: string;
|
||||
@@ -264,6 +311,7 @@ export interface CustomWorldFoundationDraftProfile {
|
||||
factions: CustomWorldFoundationDraftFaction[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
chapters: CustomWorldFoundationDraftChapter[];
|
||||
sceneChapters: CustomWorldFoundationDraftSceneChapter[];
|
||||
worldHook: string;
|
||||
playerPremise: string;
|
||||
openingSituation: string;
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { JsonObject } from './common';
|
||||
|
||||
export const SAVE_SNAPSHOT_VERSION = 2;
|
||||
export const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
export const DEFAULT_PLATFORM_THEME = 'light';
|
||||
export const PLATFORM_THEMES = ['light', 'dark'] as const;
|
||||
export type PlatformTheme = (typeof PLATFORM_THEMES)[number];
|
||||
|
||||
export type SavedGameSnapshot<
|
||||
TGameState = unknown,
|
||||
@@ -28,6 +31,7 @@ export type SavedGameSnapshotInput<
|
||||
|
||||
export type RuntimeSettings = {
|
||||
musicVolume: number;
|
||||
platformTheme: PlatformTheme;
|
||||
};
|
||||
|
||||
export type BasicOkResult = {
|
||||
@@ -73,6 +77,31 @@ export type ProfilePlayStatsResponse = {
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ProfileSaveArchiveSummary = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
lastPlayedAt: string;
|
||||
};
|
||||
|
||||
export type ProfileSaveArchiveListResponse = {
|
||||
entries: ProfileSaveArchiveSummary[];
|
||||
};
|
||||
|
||||
export type ProfileSaveArchiveResumeResponse<
|
||||
TGameState = unknown,
|
||||
TBottomTab extends string = string,
|
||||
TCurrentStory = unknown,
|
||||
> = {
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
snapshot: SavedGameSnapshot<TGameState, TBottomTab, TCurrentStory>;
|
||||
};
|
||||
|
||||
export type CustomWorldPublicationStatus = 'draft' | 'published';
|
||||
export type CustomWorldThemeMode =
|
||||
| 'martial'
|
||||
|
||||
@@ -9,8 +9,7 @@ export const QUEST_NARRATIVE_TYPES = [
|
||||
'relationship',
|
||||
'trial',
|
||||
] as const;
|
||||
export type SharedQuestNarrativeType =
|
||||
(typeof QUEST_NARRATIVE_TYPES)[number];
|
||||
export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
|
||||
|
||||
export const QUEST_OBJECTIVE_KINDS = [
|
||||
'defeat_hostile_npc',
|
||||
@@ -20,8 +19,7 @@ export const QUEST_OBJECTIVE_KINDS = [
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
] as const;
|
||||
export type SharedQuestObjectiveKind =
|
||||
(typeof QUEST_OBJECTIVE_KINDS)[number];
|
||||
export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
|
||||
|
||||
export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const;
|
||||
export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
|
||||
@@ -40,8 +38,7 @@ export const QUEST_REWARD_THEMES = [
|
||||
'intel',
|
||||
'rare_item',
|
||||
] as const;
|
||||
export type SharedQuestRewardTheme =
|
||||
(typeof QUEST_REWARD_THEMES)[number];
|
||||
export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number];
|
||||
|
||||
export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [
|
||||
'heal',
|
||||
@@ -60,8 +57,7 @@ export const RUNTIME_ITEM_TONE_VALUES = [
|
||||
'ritual',
|
||||
'survival',
|
||||
] as const;
|
||||
export type SharedRuntimeItemTone =
|
||||
(typeof RUNTIME_ITEM_TONE_VALUES)[number];
|
||||
export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
|
||||
|
||||
export type StoryRequestOptionsPayload = {
|
||||
availableOptions?: JsonObject[];
|
||||
@@ -87,6 +83,26 @@ export type PlainTextResponse = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type NpcChatTurnLimitReason = 'negative_affinity';
|
||||
|
||||
export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close';
|
||||
|
||||
export type NpcChatTurnDirective = {
|
||||
sceneActId?: string | null;
|
||||
turnLimit?: number | null;
|
||||
remainingTurns?: number | null;
|
||||
limitReason?: NpcChatTurnLimitReason | null;
|
||||
closingMode?: NpcChatTurnClosingMode | null;
|
||||
forceExitAfterTurn?: boolean;
|
||||
};
|
||||
|
||||
export type NpcChatTurnCompletionDirective = {
|
||||
turnLimit?: number | null;
|
||||
remainingTurns?: number | null;
|
||||
forceExit?: boolean;
|
||||
closingMode?: NpcChatTurnClosingMode;
|
||||
};
|
||||
|
||||
export type CharacterChatReplyRequest<
|
||||
TCharacter = unknown,
|
||||
TStoryMoment = unknown,
|
||||
@@ -164,23 +180,41 @@ export type NpcChatTurnRequest<
|
||||
TContext = unknown,
|
||||
TConversationTurn = unknown,
|
||||
TNpcState = unknown,
|
||||
TQuestOfferState = unknown,
|
||||
TQuestOfferEncounter = unknown,
|
||||
TChatDirective = NpcChatTurnDirective,
|
||||
> = {
|
||||
worldType: string;
|
||||
character: TCharacter;
|
||||
character?: TCharacter;
|
||||
player?: TCharacter;
|
||||
encounter: TEncounter;
|
||||
monsters: TMonster[];
|
||||
history: TStoryMoment[];
|
||||
context: TContext;
|
||||
conversationHistory: TConversationTurn[];
|
||||
conversationHistory?: TConversationTurn[];
|
||||
dialogue?: TConversationTurn[];
|
||||
playerMessage: string;
|
||||
npcState: TNpcState;
|
||||
questOfferContext?: {
|
||||
state: TQuestOfferState;
|
||||
encounter: TQuestOfferEncounter;
|
||||
turnCount: number;
|
||||
} | null;
|
||||
chatDirective?: TChatDirective | null;
|
||||
};
|
||||
|
||||
export type NpcChatTurnResult = {
|
||||
export type NpcChatPendingQuestOffer<TQuest = unknown> = {
|
||||
quest: TQuest;
|
||||
introText?: string;
|
||||
};
|
||||
|
||||
export type NpcChatTurnResult<TQuest = unknown> = {
|
||||
npcReply: string;
|
||||
affinityDelta: number;
|
||||
affinityText: string;
|
||||
suggestions: string[];
|
||||
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
|
||||
chatDirective?: NpcChatTurnCompletionDirective | null;
|
||||
};
|
||||
|
||||
export type NpcRecruitDialogueRequest<
|
||||
@@ -259,6 +293,8 @@ export const TASK5_RUNTIME_FUNCTION_IDS = [
|
||||
'idle_observe_signs',
|
||||
'idle_rest_focus',
|
||||
'idle_travel_next_scene',
|
||||
'battle_attack_basic',
|
||||
'battle_use_skill',
|
||||
'battle_all_in_crush',
|
||||
'battle_escape_breakout',
|
||||
'battle_feint_step',
|
||||
@@ -311,6 +347,28 @@ export type RuntimeStoryChoicePayload = JsonObject & {
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type RuntimeStoryOptionInteraction =
|
||||
| {
|
||||
kind: 'npc';
|
||||
npcId: string;
|
||||
action:
|
||||
| 'chat'
|
||||
| 'help'
|
||||
| 'fight'
|
||||
| 'leave'
|
||||
| 'recruit'
|
||||
| 'spar'
|
||||
| 'trade'
|
||||
| 'gift'
|
||||
| 'quest_accept'
|
||||
| 'quest_turn_in';
|
||||
questId?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'treasure';
|
||||
action: 'inspect' | 'leave' | 'secure';
|
||||
};
|
||||
|
||||
export type RuntimeStoryChoiceAction = RuntimeAction<
|
||||
'story_choice',
|
||||
RuntimeStoryChoicePayload
|
||||
@@ -324,6 +382,8 @@ export type RuntimeStoryOptionView = {
|
||||
actionText: string;
|
||||
detailText?: string;
|
||||
scope: Task5RuntimeOptionScope;
|
||||
interaction?: RuntimeStoryOptionInteraction;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
146
packages/shared/src/prompts/qwenSprite.ts
Normal file
146
packages/shared/src/prompts/qwenSprite.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
const BODY_RATIO_TEXT =
|
||||
'横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
return (
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`,
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}。`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`,
|
||||
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
|
||||
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
|
||||
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`,
|
||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
|
||||
`角色设定:${options.characterBrief.trim()}`,
|
||||
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
|
||||
].join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user