Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
465
src/tools/qwenSpriteSheetToolModel.ts
Normal file
465
src/tools/qwenSpriteSheetToolModel.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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 DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
|
||||
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 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
return (
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
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 sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,全身,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
'画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
|
||||
`风格要求:${CHIBI_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。`,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT}`,
|
||||
`动作名:${options.actionTemplate.label}`,
|
||||
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
|
||||
`身体位移:${options.actionTemplate.bodyTravel}`,
|
||||
`武器规则:${options.actionTemplate.weaponRule}`,
|
||||
...options.actionTemplate.sequenceLines,
|
||||
`结尾要求:${options.actionTemplate.ending}`,
|
||||
'输出要求:每一格都要清晰分开,网格顺序从左到右、从上到下,动作连续,首尾关系明确,轮廓稳定,发型稳定,服装结构稳定,武器始终在正确的手中,背景为纯浅色,适合后续切成 sprite frames。',
|
||||
options.characterBrief.trim(),
|
||||
options.extraDirection.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '上一帧' | '下一帧';
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为角色身份与服装武器的唯一标准,参考图2的动作连续性,修复图3这一个单帧。图2代表${options.useNeighborLabel}。`,
|
||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,全身完整,脚底位置稳定,保持与图2连续,并且与图1是同一个角色。${CHIBI_STYLE_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
||||
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
|
||||
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
Reference in New Issue
Block a user