清理前端抠绿与 qwenSprite 死代码

- 删除无调用方的前端像素抠绿链(chromaKey.ts 及
  characterAssetWorkflowModel 中的视频采帧/抠绿函数)
- 删除已被 server-rs 提示词实现取代的 qwenSprite 共享骨架

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 04:10:14 +00:00
parent c53d6ffef0
commit 77d33af651
6 changed files with 0 additions and 931 deletions
@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeBackgroundFromRgba } from './chromaKey';
function createSolidRgbaBuffer({
width,
height,
color,
}: {
width: number;
height: number;
color: [number, number, number, number];
}) {
const pixels = new Uint8ClampedArray(width * height * 4);
for (let index = 0; index < width * height; index += 1) {
pixels.set(color, index * 4);
}
return pixels;
}
function setPixel(
pixels: Uint8ClampedArray,
width: number,
x: number,
y: number,
color: [number, number, number, number],
) {
pixels.set(color, (y * width + x) * 4);
}
function alphaAt(
pixels: Uint8ClampedArray,
width: number,
x: number,
y: number,
) {
return pixels[(y * width + x) * 4 + 3] ?? 0;
}
describe('chromaKey', () => {
it('removes near-white canvas background without breaking an enclosed white character', () => {
const width = 9;
const height = 9;
const pixels = createSolidRgbaBuffer({
width,
height,
color: [250, 250, 250, 255],
});
for (let y = 2; y <= 6; y += 1) {
for (let x = 2; x <= 6; x += 1) {
setPixel(pixels, width, x, y, [92, 80, 72, 255]);
}
}
for (let y = 3; y <= 5; y += 1) {
for (let x = 3; x <= 5; x += 1) {
setPixel(pixels, width, x, y, [246, 246, 244, 255]);
}
}
expect(removeBackgroundFromRgba(pixels, width, height)).toBe(true);
expect(alphaAt(pixels, width, 0, 0)).toBe(0);
expect(alphaAt(pixels, width, 4, 4)).toBe(255);
expect(alphaAt(pixels, width, 2, 2)).toBeGreaterThan(200);
});
});
-478
View File
@@ -1,478 +0,0 @@
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
View File
@@ -1 +0,0 @@
export * from '../prompts/qwenSprite.js';
-1
View File
@@ -1,4 +1,3 @@
export * from './assets/qwenSprite';
export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
-176
View File
@@ -1,176 +0,0 @@
/**
* 共享 sprite / 角色资产正式 prompt 模板。
*
* 这份脚本属于“正式模型 prompt 模板层”,不负责从角色卡里挑默认文本。
* 它的定位是:
* - 给后端角色主图生成链路提供标准主图 prompt 骨架
* - 给后端角色动作视频生成链路提供标准动作 prompt 骨架
*
* 当前角色资产主链中的关系是:
* 1. 前端或 Rust 后端先拿到一段较短的描述文本
* 2. 当前角色资产链路调用本文件 buildMasterPrompt / buildVideoActionPrompt
* 把短描述扩成正式给模型吃的 prompt
*
* 因此本文件不要承载“角色卡字段挑选”或“UI 默认值”职责,
* 只维护共享的正式 prompt 骨架与动作模板。
*/
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]
);
}
/**
* 正式角色主图 prompt 骨架。
*
* 输入应该是一段已经整理好的角色摘要或视觉描述,
* 这里会把它嵌进统一的 sprite 资产约束中,
* 输出真正发给图像模型的完整 prompt。
*/
export function buildMasterPrompt(characterBrief: string) {
return [
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`,
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
characterBrief.trim(),
]
.filter(Boolean)
.join('\n');
}
/**
* 正式动作视频 prompt 骨架。
*
* 输入应该是已经整理好的动作细节与角色摘要,
* 这里负责统一拼装成 sprite 动作生成所需的正式 prompt,
* 包括视角、像素风格、动作模板、绿幕约束等。
*/
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;
useChromaKey: boolean;
characterBrief: string;
}) {
return [
`单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}`,
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`,
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}
@@ -1,4 +1,3 @@
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
import {
AnimationState,
type Character,
@@ -453,19 +452,6 @@ export function loadImageFromSource(source: string) {
});
}
function loadVideoFromSource(source: string) {
return new Promise<HTMLVideoElement>((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.muted = true;
video.playsInline = true;
video.onloadeddata = () => resolve(video);
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
video.src = source;
});
}
function createCanvas(width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
@@ -708,200 +694,6 @@ export async function buildAnimationClipFromMaster(
} satisfies DraftAnimationClip;
}
function applyGreenScreenAlpha(
context: CanvasRenderingContext2D,
width: number,
height: number,
) {
const imageData = context.getImageData(0, 0, width, height);
removeBackgroundFromRgba(imageData.data, width, height);
context.putImageData(imageData, 0, 0);
}
async function normalizeFrameSourceToDataUrl(
frameSource: string,
options: {
frameWidth: number;
frameHeight: number;
applyChromaKey: boolean;
},
) {
const image = await loadImageFromSource(frameSource);
const { canvas, context } = createCanvas(
options.frameWidth,
options.frameHeight,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
return canvas.toDataURL('image/png');
}
export async function normalizeMasterVisualSourceToDataUrl(
source: string,
options: {
applyChromaKey?: boolean;
} = {},
) {
const image = await loadImageFromSource(source);
const { canvas, context } = createCanvas(
MASTER_VISUAL_WIDTH,
MASTER_VISUAL_HEIGHT,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey !== false) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
return {
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
};
}
function seekVideo(video: HTMLVideoElement, targetTime: number) {
return new Promise<void>((resolve, reject) => {
if (Math.abs(video.currentTime - targetTime) < 0.001) {
window.requestAnimationFrame(() => resolve());
return;
}
const handleSeeked = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error('视频定位失败'));
};
const cleanup = () => {
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('error', handleError);
};
video.addEventListener('seeked', handleSeeked, { once: true });
video.addEventListener('error', handleError, { once: true });
video.currentTime = Math.max(0, targetTime);
});
}
export async function buildAnimationClipFromImageSources(
sources: string[],
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
},
) {
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const frames = await Promise.all(
sources.map((source) =>
normalizeFrameSourceToDataUrl(source, {
frameWidth,
frameHeight,
applyChromaKey: options.applyChromaKey ?? false,
}),
),
);
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
} satisfies DraftAnimationClip;
}
export async function buildAnimationClipFromVideoSource(
videoSource: string,
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameCount?: number;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
sampleStartRatio?: number;
sampleEndRatio?: number;
},
) {
const video = await loadVideoFromSource(videoSource);
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const duration =
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
const derivedFrameCount = Math.max(
2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
);
const sampleStartRatio = Math.min(
0.85,
Math.max(0, options.sampleStartRatio ?? 0),
);
const sampleEndRatio = Math.min(
1,
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
);
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = [];
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
const progress = options.loop
? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(
duration - 0.001,
duration * sampleStartRatio + sampleWindowDuration * progress,
);
await seekVideo(video, targetTime);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
frames.push(canvas.toDataURL('image/png'));
}
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
previewVideoPath: videoSource,
} satisfies DraftAnimationClip;
}
async function buildReferenceVideoFromFrameSources(
frameSources: string[],
options: {