diff --git a/assets/branding/mascot-xiaozhai-avatar.webp b/assets/branding/mascot-xiaozhai-avatar.webp new file mode 100644 index 00000000..7914f9df Binary files /dev/null and b/assets/branding/mascot-xiaozhai-avatar.webp differ diff --git a/assets/branding/mascot-xiaozhai-guide.md b/assets/branding/mascot-xiaozhai-guide.md index 51997114..ba1752bf 100644 --- a/assets/branding/mascot-xiaozhai-guide.md +++ b/assets/branding/mascot-xiaozhai-guide.md @@ -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. **三色丝带是核心识别元素**,在任何立绘/插画中必须保留 diff --git a/assets/branding/mascot-xiaozhai-portrait.webp b/assets/branding/mascot-xiaozhai-portrait.webp new file mode 100644 index 00000000..ff397204 Binary files /dev/null and b/assets/branding/mascot-xiaozhai-portrait.webp differ diff --git a/assets/branding/mascot-xiaozhai-sheet.webp b/assets/branding/mascot-xiaozhai-sheet.webp new file mode 100644 index 00000000..0ae12b9c Binary files /dev/null and b/assets/branding/mascot-xiaozhai-sheet.webp differ diff --git a/assets/branding/mascot-xiaozhai-turnaround.webp b/assets/branding/mascot-xiaozhai-turnaround.webp new file mode 100644 index 00000000..4de33544 Binary files /dev/null and b/assets/branding/mascot-xiaozhai-turnaround.webp differ diff --git a/assets/branding/mascot-xiaozhai.webp b/assets/branding/mascot-xiaozhai.webp index 9c006401..9c384f40 100644 Binary files a/assets/branding/mascot-xiaozhai.webp and b/assets/branding/mascot-xiaozhai.webp differ diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md index d6b9146f..0b01c684 100644 --- a/assets/docs/en/PLUGIN_DEV.md +++ b/assets/docs/en/PLUGIN_DEV.md @@ -2,7 +2,7 @@ # HomeAgent Plugin Development Guide - : + : ## Overview @@ -35,7 +35,7 @@ type Plugin interface { --- - : + : ## 1. Quick Start: Using the hmapdev Toolchain @@ -243,7 +243,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \ --- - : + : ## 2. Go Plugin Development in Detail @@ -538,7 +538,7 @@ outputCh := s.OutputChan() --- - : + : ## 3. Lua Plugin Development in Detail @@ -680,7 +680,7 @@ Each `attachments` item: `{ digest=, mime=, name=, data= }`; with `data` --- - : + : ## 4. Built-in Plugins @@ -745,7 +745,7 @@ import ( --- - : + : ## 5. Best Practices @@ -759,7 +759,7 @@ import ( --- - : + : ## 6. Plugin Management @@ -801,7 +801,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl --- - : + : ## 7. Example Plugin Reference diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md index c5ef5898..f032857f 100644 --- a/assets/docs/zh/PLUGIN_DEV.md +++ b/assets/docs/zh/PLUGIN_DEV.md @@ -2,7 +2,7 @@ # HomeAgent 插件开发指南 - : + : ## 概述 @@ -36,7 +36,7 @@ type Plugin interface { --- - : + : ## 一、快速开始:使用 hmapdev 工具链 @@ -241,7 +241,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \ --- - : + : ## 二、Go 插件开发详解 @@ -531,7 +531,7 @@ outputCh := s.OutputChan() --- - : + : ## 三、Lua 插件开发详解 @@ -673,7 +673,7 @@ end) --- - : + : ## 四、内置插件 @@ -738,7 +738,7 @@ import ( --- - : + : ## 五、最佳实践 @@ -752,7 +752,7 @@ import ( --- - : + : ## 六、插件管理 @@ -792,7 +792,7 @@ pmgr.ReloadPlugins() // 重载所有插件 --- - : + : ## 七、示例插件参考 diff --git a/cmd/gui/renderer/mascot.webp b/cmd/gui/renderer/mascot.webp index 9c006401..bf3aaf61 100644 Binary files a/cmd/gui/renderer/mascot.webp and b/cmd/gui/renderer/mascot.webp differ diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index f3a7ff28..2b7b49ac 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -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; diff --git a/internal/plugins/webui/gzip.go b/internal/plugins/webui/gzip.go index ac896222..db2b34cb 100644 --- a/internal/plugins/webui/gzip.go +++ b/internal/plugins/webui/gzip.go @@ -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 语义。宁可漏压也不要压坏流。 diff --git a/internal/plugins/webui/gzip_test.go b/internal/plugins/webui/gzip_test.go index 9c7f1070..8eb5b311 100644 --- a/internal/plugins/webui/gzip_test.go +++ b/internal/plugins/webui/gzip_test.go @@ -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"} { diff --git a/internal/plugins/webui/mascot.webp b/internal/plugins/webui/mascot.webp index 9c006401..45036e08 100644 Binary files a/internal/plugins/webui/mascot.webp and b/internal/plugins/webui/mascot.webp differ diff --git a/internal/plugins/webui/mascot_asset_test.go b/internal/plugins/webui/mascot_asset_test.go new file mode 100644 index 00000000..acfbec15 --- /dev/null +++ b/internal/plugins/webui/mascot_asset_test.go @@ -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() +} diff --git a/site/README.md b/site/README.md index e57382ed..91ee23a5 100644 --- a/site/README.md +++ b/site/README.md @@ -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 就该放图**。现版面是单栏文字,加图会改变 +信息密度与首屏节奏,属于设计取舍而非素材问题——所以这一步没有动。 +要放的话,素材已经就绪。 ## 插件卡片