Files
shiroko-monitor/scripts/prepare-artwork.py
T
tamakyi bf27f7b385 feat(ui): 最近事件随分组页签联动过滤 + favicon 提供 WebP
① 最近事件随筛选过滤

此前事件只受后端 dataFilter 约束,与面板上方的分组页签无关 —— 切页签后
监控列表变了,事件列表却纹丝不动。

两个真实缺陷(都是实测发现,不是推断):
1. setFilter() 只调 renderTabs() + renderView(),**没调 renderEvents()**
2. 过滤依据用 allMonitors(),它只受 dataFilter 约束、不受分组影响
   → 改为 visibleGroups().flatMap(...)
3. 无匹配事件时提前 return,没清空 list.innerHTML → 旧行仍留在 DOM 里,
   换个分组再切回来会看到过期内容

实测:「全部」8 条 → 「UptimeRobot」1 条(正是「狼的评论系统 /
UptimeRobot:403 Forbidden」)→「基础设施」0 条且区块自动隐藏。

过滤用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 ——
外部数据源(UptimeRobot)的事件在快照里没有 group 字段,按名字反查才准。

② favicon 提供 WebP(保留原图与 PNG)

先核实了实际情况:assets-src 下三个原图(bike 1.8MB / figure 366KB /
icon 277KB)此前已转成 WebP,public/assets 里唯一的 PNG 是 favicon-shiroko.png。
因此本次只处理 favicon:新增 64×64 WebP(2.7KB,比 PNG 的 5.6KB 省一半)。

**PNG 不删** —— favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接
不显示图标,留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。
原图 assets-src/ 完全未动,prepare-artwork.py 可随时重新生成。

页面用两个 <link rel="icon" type=...> 让浏览器按格式自动挑选
(认识 WebP 就取 WebP,否则回落 PNG / SVG)。

③ 顺带修一个既存缺陷
探针的 MIME 表**缺 .webp**,导致三张 webp 素材一直被当作
application/octet-stream 伺服(能显示但类型不对)。已补。

测试:verify-views 49→52 项(事件随页签过滤、切回全部恢复、无事件时清空 DOM)。
verify.cjs 实为 24 项(此前 README 记的 17 是旧数据)。全项目 8 套 346 项通过。
2026-10-14 17:35:01 +08:00

125 lines
5.4 KiB
Python
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.
#!/usr/bin/env python3
"""从 assets-src/ 的原始插画生成 public/assets/ 下的透明素材。
为什么要这么处理:
图像生成返回的是**白底**图,需要抠成透明。抠白底分两步,缺一不可——
1) 边界洪泛:把与图片边界连通的白色变透明(背景主体)
2) 封闭区域补抠:被图形围起来的白色区域与边界不连通,第 1 步碰不到它们。
光环环内、单车车架三角、轮圈内扇形都会因此留成白块(v0.8.3 之前就踩过)
注意:不能简单「全局抠白」,那会把角色的脸/袜子/衣服白一起抠掉。
本脚本按连通域面积处理,并跳过角色躯干区域,保留服装白。
用法(PyPI 的 Pillow):
python scripts/prepare-artwork.py
"""
from collections import deque
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets-src"
DST = ROOT / "public" / "assets"
SENTINEL = (255, 0, 255)
WHITE_TOLERANCE = 18 # 与纯白的最大通道差
def is_white(px):
return px[3] > 128 and px[0] > 255 - WHITE_TOLERANCE and px[1] > 255 - WHITE_TOLERANCE and px[2] > 255 - WHITE_TOLERANCE
def connected_regions(im, w, h):
"""列出所有「近白且连通」的区域:[(面积, [(x, y), ...]), ...]"""
px = im.load()
seen = [[False] * w for _ in range(h)]
regions = []
for y in range(h):
for x in range(w):
if seen[y][x] or not is_white(px[x, y]):
continue
queue = deque([(x, y)])
seen[y][x] = True
cells = []
while queue:
cx, cy = queue.popleft()
cells.append((cx, cy))
for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and is_white(px[nx, ny]):
seen[ny][nx] = True
queue.append((nx, ny))
regions.append((len(cells), cells))
return regions
def cut_out(src: Path, dst: Path, target_height: int, *, min_area: int, square: bool = False,
skip_below: float | None = None, quality: int = 92):
"""抠白底 -> 裁边 -> 按高度缩放 -> 存 webp。
square: 裁边后补成正方形(头像/图标用,避免圆形裁切与 favicon 变形)
skip_below: 只清理位于该纵向比例(0~1)以上的封闭区域;None 表示全部清理
"""
im = Image.open(src).convert("RGBA")
width, height = im.size
# 第 1 步:边界洪泛(连通到边界的背景)
rgb = im.convert("RGB")
for x, y in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1),
(width // 2, 0), (0, height // 2), (width - 1, height // 2), (width // 2, height - 1)):
if rgb.getpixel((x, y)) != SENTINEL:
ImageDraw.floodfill(rgb, (x, y), SENTINEL, thresh=WHITE_TOLERANCE + 24)
sentinel_px = rgb.load()
px = im.load()
for y in range(height):
for x in range(width):
if sentinel_px[x, y] == SENTINEL:
px[x, y] = (0, 0, 0, 0)
# 第 2 步:封闭区域补抠(按面积阈值 + 可选纵向范围)
removed = 0
for area, cells in connected_regions(im, width, height):
if area < min_area:
continue
if skip_below is not None and min(cy for _, cy in cells) > height * skip_below:
continue # 疑似服装白,保留
for x, y in cells:
px[x, y] = (0, 0, 0, 0)
removed += area
box = im.getbbox()
if box:
im = im.crop(box)
if square:
side = max(im.size)
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(im, ((side - im.width) // 2, (side - im.height) // 2))
im = canvas
scale = target_height / im.height
im = im.resize((round(im.width * scale), target_height), Image.LANCZOS)
im.save(dst, "WEBP", quality=quality, method=6)
print(f"{dst.name}: {im.size} 补抠封闭白区 {removed}px,体积 {dst.stat().st_size / 1024:.1f}KB")
return im
def main():
# 立绘:560 高(与 CSS 里 min(40vh, 340px) 的显示尺寸对应,留 1.6 倍余量给高 DPI)
figure = cut_out(SRC / "figure-original.png", DST / "shiroko-figure.webp", 560, min_area=150)
# 图标:补成正方形再缩到 180,供圆形头像与 favicon 使用
icon = cut_out(SRC / "icon-original.png", DST / "shiroko-icon.webp", 180, min_area=15, square=True)
# favicon 两种格式都产出:PNG 64 与 WebP 64。
# PNG 不删——favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接不显示图标,
# 留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。
icon64 = icon.resize((64, 64), Image.LANCZOS)
icon64.save(DST / "favicon-shiroko.png", "PNG", optimize=True)
icon64.save(DST / "favicon-shiroko.webp", "WEBP", quality=90, method=6)
png_kb = (DST / "favicon-shiroko.png").stat().st_size / 1024
webp_kb = (DST / "favicon-shiroko.webp").stat().st_size / 1024
print(f"favicon-shiroko: 64x64,PNG {png_kb:.1f}KB / WebP {webp_kb:.1f}KB")
# 单车:200 高,通体无服装白,全部清理
cut_out(SRC / "bike-original.png", DST / "shiroko-bike.webp", 200, min_area=20)
print("完成。视觉上请确认:光环环内、车架三角、轮圈内扇形均为透明,且角色的脸/袜子/衣服完好。")
if __name__ == "__main__":
main()