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 的源图。
This commit is contained in:
JianFeeeee
2026-10-18 07:38:10 +08:00
parent 5a3a8f484b
commit ef08ebb7d3
15 changed files with 249 additions and 25 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -258,6 +258,33 @@ dynamic flowing hair and fabric,
---
## 素材清单(assets/branding/)
| 文件 | 尺寸 | 用途 | alpha |
|------|------|------|-------|
| `mascot-xiaozhai.webp` | 1024×1024 | **主立绘**(坐姿全身,官方展示用) | ✅ 真 alpha |
| `mascot-xiaozhai-avatar.webp` | 512×512 | **头像**(头部方裁,脸居中) | ✅ 真 alpha |
| `mascot-xiaozhai-sheet.webp` | 1024×1536 | 设定总图(配色/通道线/道具标注) | — |
| `mascot-xiaozhai-portrait.webp` | 1024×1536 | 半身胸像(三只工具特写清晰) | — |
| `mascot-xiaozhai-turnaround.webp` | 1200×800 | 三视图(正/侧/背) | — |
**为什么主图与头像分两个文件**:主图是全身坐姿,直接塞进 30px 圆形头像里
(`object-fit: cover` 会把画面中心裁成正方形)只会剩裙子、看不到脸。
头像版是**头部方裁**,圆形裁切后脸正好居中。
运行期副本(由上面两张生成,勿单独手改):
| 路径 | 尺寸 | 说明 |
|------|------|------|
| `internal/plugins/webui/mascot.webp` | 256×256 | WebUI 对外 `/mascot.webp`(嵌入二进制) |
| `cmd/gui/renderer/mascot.webp` | 128×128 | GUI 聊天头像 + agent 光标(以 data URL 注入) |
这两个文件的 alpha 由 `internal/plugins/webui/mascot_asset_test.go` 判定:
直接读 WebP 容器头,要求 VP8X Alpha 标志位与 `ALPH` chunk 同时存在。
换素材时若塞进一张「白底图」或「棋盘格图」,判据会红。
---
## 设计注意事项
1. **三色丝带是核心识别元素**,在任何立绘/插画中必须保留

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -2,7 +2,7 @@
# HomeAgent Plugin Development Guide
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## Overview
@ -35,7 +35,7 @@ type Plugin interface {
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 1. Quick Start: Using the hmapdev Toolchain
@ -243,7 +243,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 2. Go Plugin Development in Detail
@ -538,7 +538,7 @@ outputCh := s.OutputChan()
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 3. Lua Plugin Development in Detail
@ -680,7 +680,7 @@ Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data`
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 4. Built-in Plugins
@ -745,7 +745,7 @@ import (
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 5. Best Practices
@ -759,7 +759,7 @@ import (
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 6. Plugin Management
@ -801,7 +801,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 7. Example Plugin Reference

View File

@ -2,7 +2,7 @@
# HomeAgent 插件开发指南
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 概述
@ -36,7 +36,7 @@ type Plugin interface {
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 一、快速开始:使用 hmapdev 工具链
@ -241,7 +241,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 二、Go 插件开发详解
@ -531,7 +531,7 @@ outputCh := s.OutputChan()
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 三、Lua 插件开发详解
@ -673,7 +673,7 @@ end)
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 四、内置插件
@ -738,7 +738,7 @@ import (
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 五、最佳实践
@ -752,7 +752,7 @@ import (
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 六、插件管理
@ -792,7 +792,7 @@ pmgr.ReloadPlugins() // 重载所有插件
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
<img src="../../branding/mascot-xiaozhai-avatar.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 七、示例插件参考

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -1417,8 +1417,11 @@
object-fit: cover;
display: block;
}
/* mascot.webp 是**头部方裁**(脸居中),cover 已能把脸填满圆形。
原先 scale(1.5) 是给「全身立绘裁圆」补的——现在图本身换了,
再放大 1.5 会切掉两侧发绳与呆毛,所以降到 1.25 只做轻微推近。 */
.msg-assistant .msg-avatar img {
transform: scale(1.5);
transform: scale(1.25);
}
.msg-avatar svg {
display: block;

View File

@ -45,7 +45,8 @@ const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐
// gzipCompressibleContentType 判定是否值得压。
//
// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍
// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。
// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(约 25KB)就是这类
// ——注意体积大小与本判定无关:值得压的是「文本」,不是「大文件」。
//
// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压):
// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。

View File

@ -112,7 +112,7 @@ func TestGzipSkipsSmallResponses(t *testing.T) {
// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。
//
// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
// mascot.webp 在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
// 纯浪费 CPU——而 CPU 正是压缩要省的东西。
func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) {
for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,167 @@
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()
}

View File

@ -84,18 +84,24 @@ server {
所有 listener 走 `requestAnimationFrame` 节流;标签页隐藏时暂停粒子。
## 为什么最终没有图
## 看板娘素材:从「没法用」到「能用了」
试过放看板娘立绘,失败,最后决定不放。过程留在这里,免得下次重复踩:
> 结论先写在这:**素材侧的问题已经解决**(现役 `mascot-xiaozhai.webp` 自带真 alpha,
> 1024×1024)。下面保留当年的失败记录,因为「为什么当时不行」这件事本身
> 仍是有用约束——它解释了为什么不能拿一张白底图硬塞进深色版面。
原图 `mascot-xiaozhai.webp` 是**不透明** WebP(无 alpha),白底 + 蓝紫渐变外框。
### 当年为什么不行
试过放看板娘立绘,失败,最后决定不放。
原图(旧版)是**不透明** WebP(无 alpha),白底 + 蓝紫渐变外框。
直接用 = 深色页面上一个刺眼的白色方块;抠图又不可行:
```bash
python3 -c "
from PIL import Image
im = Image.open('assets/branding/mascot-xiaozhai.webp')
print(im.mode) # RGB —— 没有 alpha 通道
print(im.mode) # 旧版是 RGB —— 没有 alpha 通道
"
```
@ -104,8 +110,28 @@ print(im.mode) # RGB —— 没有 alpha 通道
「圆角卡面 + 发光边框 + 底部渐隐」,视觉上能接受,但本质上是在方形图上盖装饰,
而非让图融入页面。
结论:**这类素材要么重新出一张带透明通道的图,要么就不放**。
现在 Hero 是单栏文字,版面反而更稳。
### 现在为什么能了
新版素材走的是另一条路:出图时背景就是**棋盘格提示**,再按「背格连通域 +
中性灰判据」抠成真 alpha,落盘为带 `ALPH` chunk 的 WebP。
```bash
python3 -c "
from PIL import Image
im = Image.open('assets/branding/mascot-xiaozhai.webp')
print(im.mode) # RGBA
"
```
同一件事在代码里也有判据钉住:`internal/plugins/webui/mascot_asset_test.go`
直接读 WebP 容器头,断言 VP8X 的 Alpha 标志位与 `ALPH` chunk 同时存在——
防止日后有人把一张「看起来透明」的棋盘格 JPEG 换进来。
### 但版面仍未放图
素材不再是阻碍,**但这不等于 Hero 就该放图**。现版面是单栏文字,加图会改变
信息密度与首屏节奏,属于设计取舍而非素材问题——所以这一步没有动。
要放的话,素材已经就绪。
## 插件卡片