mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +00:00
老大给的 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 的源图。
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
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()
|
||
}
|