From 77d33af6519dac454b684c4cc8165d4100b2227a Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 8 Jul 2026 04:10:14 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=89=8D=E7=AB=AF=E6=8A=A0?= =?UTF-8?q?=E7=BB=BF=E4=B8=8E=20qwenSprite=20=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除无调用方的前端像素抠绿链(chromaKey.ts 及 characterAssetWorkflowModel 中的视频采帧/抠绿函数) - 删除已被 server-rs 提示词实现取代的 qwenSprite 共享骨架 Co-Authored-By: Claude Fable 5 --- packages/shared/src/assets/chromaKey.test.ts | 67 --- packages/shared/src/assets/chromaKey.ts | 478 ------------------ packages/shared/src/assets/qwenSprite.ts | 1 - packages/shared/src/index.ts | 1 - packages/shared/src/prompts/qwenSprite.ts | 176 ------- .../characterAssetWorkflowModel.ts | 208 -------- 6 files changed, 931 deletions(-) delete mode 100644 packages/shared/src/assets/chromaKey.test.ts delete mode 100644 packages/shared/src/assets/chromaKey.ts delete mode 100644 packages/shared/src/assets/qwenSprite.ts delete mode 100644 packages/shared/src/prompts/qwenSprite.ts diff --git a/packages/shared/src/assets/chromaKey.test.ts b/packages/shared/src/assets/chromaKey.test.ts deleted file mode 100644 index 125bb5393..000000000 --- a/packages/shared/src/assets/chromaKey.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/shared/src/assets/chromaKey.ts b/packages/shared/src/assets/chromaKey.ts deleted file mode 100644 index 7b517cc66..000000000 --- a/packages/shared/src/assets/chromaKey.ts +++ /dev/null @@ -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; -} diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts deleted file mode 100644 index 67957218c..000000000 --- a/packages/shared/src/assets/qwenSprite.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../prompts/qwenSprite.js'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cc84c6f11..23bbb0c45 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,3 @@ -export * from './assets/qwenSprite'; export * from './contracts/auth'; export type * from './contracts/bigFish'; export * from './contracts/common'; diff --git a/packages/shared/src/prompts/qwenSprite.ts b/packages/shared/src/prompts/qwenSprite.ts deleted file mode 100644 index 560a22b45..000000000 --- a/packages/shared/src/prompts/qwenSprite.ts +++ /dev/null @@ -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(' '); -} diff --git a/src/components/asset-studio/characterAssetWorkflowModel.ts b/src/components/asset-studio/characterAssetWorkflowModel.ts index a37239d2c..b9e945bd2 100644 --- a/src/components/asset-studio/characterAssetWorkflowModel.ts +++ b/src/components/asset-studio/characterAssetWorkflowModel.ts @@ -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((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((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: {