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 的源图。
BIN
assets/branding/mascot-xiaozhai-avatar.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
@ -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. **三色丝带是核心识别元素**,在任何立绘/插画中必须保留
|
||||
|
||||
BIN
assets/branding/mascot-xiaozhai-portrait.webp
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
assets/branding/mascot-xiaozhai-sheet.webp
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
assets/branding/mascot-xiaozhai-turnaround.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 133 KiB |
@ -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
|
||||
|
||||
|
||||
@ -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"> :
|
||||
|
||||
## 七、示例插件参考
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 8.9 KiB |
@ -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;
|
||||
|
||||
@ -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 语义。宁可漏压也不要压坏流。
|
||||
|
||||
@ -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"} {
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 24 KiB |
167
internal/plugins/webui/mascot_asset_test.go
Normal 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()
|
||||
}
|
||||
@ -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 就该放图**。现版面是单栏文字,加图会改变
|
||||
信息密度与首屏节奏,属于设计取舍而非素材问题——所以这一步没有动。
|
||||
要放的话,素材已经就绪。
|
||||
|
||||
## 插件卡片
|
||||
|
||||
|
||||