Files
HomeAgent/internal/plugins/webui/mascot_asset_test.go
JianFeeeee ef08ebb7d3 chore(mascot): 立绘 v2 —— 真 alpha 素材 + 头部裁切头像 + 素材判据
老大给的 4 张新立绘里有 3 张是「棋盘格假透明」JPEG(像素是灰白格子,
不是 alpha)。直接进仓库会在深色面板上露出格子,所以先抠成真 alpha 再落盘。

## 为什么主图与头像要分两个文件

现役 mascot.webp 唯一的消费点是**聊天消息的 30px 圆形头像**
(dashboard.js 4 处 aiAvatar + GUI app.js 2 处),概览页并没有放立绘。
主图是坐姿全身,按现有 CSS(object-fit:cover + scale(1.5))裁成圆后
只剩裙子、看不到脸 —— 只换图不做头像版,等于把头像换成一张看不懂的图。

- assets/branding/mascot-xiaozhai.webp        1024² 主立绘(官方展示)
- assets/branding/mascot-xiaozhai-avatar.webp  512² 头部方裁(运行期源)
- internal/plugins/webui/mascot.webp           256² 头像版(原 133KB→24KB)
- cmd/gui/renderer/mascot.webp                 128² 头像版(原 133KB→9KB)

参考稿另存三张(不参与运行):sheet(设定总图)/ portrait(半身)/
turnaround(三视图)。

## 抠图怎么做的(可复现,不是手搓)

背景是 20.45px 周期的棋盘格(237/254 两色)。判据取「中性灰 且 灰度≥228」
的像素做候选,再从图像边界做连通域传播 —— 这样白裙子/白围裙因为不中性
(带蓝调)不会被误判,避免了当年 site/README 记录的 flood-fill 渗色问题。
边界再内缩 1px 抹白边,0.7px 高斯羽化去锯齿。抠完透明占比 55.8%,
深/浅两种背景上目检均无残留格子与白边。

## CSS:scale(1.5) → 1.25

原来的 1.5 是为「全身图裁圆」补的放大。换成头部方裁后,1.5 会切掉两侧
发绳与呆毛 —— 按真实 30px 渲染放大对照看过 1.0/1.15/1.25/1.4 四档。

## 新增判据(含反向验证)

internal/plugins/webui/mascot_asset_test.go 直接读 WebP 容器头,
断言 VP8X 的 Alpha 标志位与 ALPH chunk **同时存在**,外加正方形/≥128px/
≤128KB。理由:这类退化在肉眼 review 里抓不住(缩到 30px 就是一团浅色),
而用户每天都在看;且 site/README.md 记着当年正因为「白底图没法抠」
干脆不放立绘,这个坑值得一个自动化判据。

反向验证(已实跑,非声称):把素材换成 HEAD 旧图,判据报红并准确指出
「Alpha 标志位未置位 / 缺 ALPH chunk / 体积超限」;换回新图转绿。
另有一条独立用例构造「带 VP8X 但不置 Alpha 位」的最小容器,
确认解析器不会对退化素材误判为绿。

## 顺带修的两处

1. assets/docs/{zh,en}/PLUGIN_DEV.md 的 mascot 引用路径是坏的:
   `../../assets/branding/...` 从 assets/docs/zh/ 出发解析成
   `assets/assets/branding/...`,文件不存在(旧版一直是坏的)。
   已改为 `../../branding/...` 并逐条验证路径可解析。
   同时指向新的头像版(那处是 20px 圆形内联小图标)。
2. gzip.go / gzip_test.go 注释里的「mascot.webp(133KB)」改为不带死数字
   的说法 —— 体积会随素材变,注释写死数字只会变成误导。

## 验证

go build ./...            → 0
go vet ./internal/plugins/webui/ ./cmd/gui/ → 干净
gofmt -l                  → 空
go test ./internal/plugins/webui/ -count=1 → ok(15.1s,含新增 2 个用例)

## 没做的事

- 没碰 main、没开 PR、没部署、没重启服务。
- **概览页仍未放立绘**。site/README 里「素材不可用」这条已不成立(素材有
  alpha 了),但版面是否该放图属于设计取舍,不在本次范围 —— 文档已相应改写,
  不再用「素材不行」当理由。
- 抠图对发丝边缘是「够用」不是「完美」:0.7px 羽化在 1024 下看不出来,
  但若将来要印物料级大图,需要重出带原生 alpha 的源图。
2026-10-18 07:38:10 +08:00

168 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"bytes"
"encoding/binary"
"errors"
"testing"
)
// 这个小测试钉住一件很具体、也很容易被无声破坏的事:
// **看板娘素材必须自带真 alpha,而不是「白底图」或「棋盘格假透明」。**
//
// 为什么值得一个判据:
//
// 历史上 mascot.webp 是不透明 WebP(白底 + 蓝紫渐变外框)。放在深色面板的
// 30px 圆形头像里,就是一个白方块;site/README.md 里记着因为「没法抠图」
// 最终干脆不放立绘。后来换素材时又出现过「棋盘格 JPEG」—— 那种图**看起来**
// 是透明的,实际像素是灰白格子,直接塞进仓库依然会露出格子。
//
// 这类退化在肉眼 review 里几乎抓不住(缩到 30px 就是一团浅色),但用户每天
// 都在看。所以用容器格式本身来判定:判据看的是 VP8X 的 Alpha 标志位与
// ALPH chunk 是否**一致存在**,而不是「渲染出来像不像透明」。
func TestMascotAssetHasRealAlpha(t *testing.T) {
data, err := dashboardFS.ReadFile("mascot.webp")
if err != nil {
t.Fatalf("读 mascot.webp: %v", err)
}
info, err := parseWebPInfo(data)
if err != nil {
t.Fatalf("mascot.webp 不是合法 WebP: %v", err)
}
if !info.hasAlphaFlag {
t.Errorf("mascot.webp 的 VP8X Alpha 标志位未置位(flags=0x%02x)——"+
"说明这张图没有真 alpha 通道,深色面板下会露出白底/棋盘格", info.vp8xFlags)
}
if !info.hasALPHChunk {
t.Errorf("mascot.webp 缺 ALPH chunk —— alpha 标志位与实际数据不一致")
}
// 尺寸:30px 圆形头像在 2x 屏上需要 60px;留够余量但别把大图塞进二进制。
if info.width != info.height {
t.Errorf("mascot.webp 应为正方形(圆形头像裁剪依赖它),实际 %dx%d", info.width, info.height)
}
if info.width < 128 {
t.Errorf("mascot.webp 只有 %dpx,2x/3x 屏上会糊(要求 >= 128)", info.width)
}
if len(data) > 128<<10 {
t.Errorf("mascot.webp 体积 %.1fKB 超过 128KB —— 它进二进制、每次请求都发,"+
"头像用途不需要这个量级", float64(len(data))/1024)
}
}
// 反向验证:把真实素材「退化」成无 alpha 的容器,确认判据会报红。
//
// 这条来自实测教训:形状类判据如果只喂真实数据,它可能对任何输入都绿。
// 这里不依赖外部工具,直接构造一个带 VP8X 但不置 Alpha 位的最小容器。
func TestMascotAssetHasRealAlpha_RejectsOpaqueContainer(t *testing.T) {
var chunks bytes.Buffer
payload := make([]byte, 10) // flags=0x00:Alpha 位为 0
payload[4], payload[5] = 0x7f, 0x00 // 宽 = 0x7f+1 = 128
payload[7], payload[8] = 0x7f, 0x00 // 高 = 0x7f+1 = 128
writeChunk(&chunks, "VP8X", payload)
info, err := parseWebPInfo(buildRIFF(chunks.Bytes()))
if err != nil {
t.Fatalf("构造的容器应能被解析: %v", err)
}
if info.hasAlphaFlag {
t.Fatal("容器本来就声明不透明,解析器却报了 Alpha —— 判据会对退化素材失效")
}
if info.hasALPHChunk {
t.Fatal("构造的容器里没有 ALPH chunk,解析器却说有")
}
}
type webpInfo struct {
width, height int
vp8xFlags byte
hasAlphaFlag bool
hasALPHChunk bool
}
var errNotWebP = errors.New("不是 RIFF/WEBP 容器")
// parseWebPInfo 只解析容器头,不解码像素 —— 判据要看的是「格式声明」,
// 不是「看起来透明」。纯标准库即可,不给依赖加负担。
func parseWebPInfo(data []byte) (*webpInfo, error) {
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, errNotWebP
}
info := &webpInfo{}
// 遍历 RIFF chunk:4 字节 ID + 4 字节 LE 长度 + 负载(按 2 字节对齐)。
for off := 12; off+8 <= len(data); {
id := string(data[off : off+4])
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
payload := off + 8
if payload+size > len(data) {
break
}
switch id {
case "VP8X":
if size >= 10 {
info.vp8xFlags = data[payload]
info.hasAlphaFlag = info.vp8xFlags&0x10 != 0 // bit 4 = Alpha
// 画布尺寸存的是「实际值 - 1」,各 3 字节 LE。
info.width = int(readUint24LE(data[payload+4:])) + 1
info.height = int(readUint24LE(data[payload+7:])) + 1
}
case "ALPH":
info.hasALPHChunk = true
case "VP8 ":
// 无损/有损的「简单格式」(无 VP8X):宽高在帧头里。
// 这种文件必然没有 alpha —— 正是判据要拒的那种。
if size >= 10 && data[payload+3] == 0x9d && data[payload+4] == 0x01 && data[payload+5] == 0x2a {
info.width = int(binary.LittleEndian.Uint16(data[payload+6:]) & 0x3FFF)
info.height = int(binary.LittleEndian.Uint16(data[payload+8:]) & 0x3FFF)
}
case "VP8L":
if size >= 5 {
// 无损格式:signature 0x2f + 14bit 宽 + 14bit 高 + alpha_is_used 位
b := data[payload+1 : payload+5]
info.width = int(binary.LittleEndian.Uint16(b)&0x3FFF) + 1
info.height = int(binary.LittleEndian.Uint16(b[2:])&0x3FFF) + 1
if data[payload+4]&0x10 != 0 {
info.hasAlphaFlag = true
}
}
}
adv := payload + size
if size%2 == 1 {
adv++ // RIFF 要求 chunk 负载补齐到偶数
}
off = adv
}
if info.width == 0 || info.height == 0 {
return nil, errNotWebP
}
return info, nil
}
func readUint24LE(b []byte) int {
return int(b[0]) | int(b[1])<<8 | int(b[2])<<16
}
func writeChunk(buf *bytes.Buffer, id string, payload []byte) {
buf.WriteString(id)
var sz [4]byte
binary.LittleEndian.PutUint32(sz[:], uint32(len(payload)))
buf.Write(sz[:])
buf.Write(payload)
if len(payload)%2 == 1 {
buf.WriteByte(0)
}
}
func buildRIFF(chunks []byte) []byte {
var out bytes.Buffer
out.WriteString("RIFF")
var sz [4]byte
binary.LittleEndian.PutUint32(sz[:], uint32(4+len(chunks)))
out.Write(sz[:])
out.WriteString("WEBP")
out.Write(chunks)
return out.Bytes()
}