① 最近事件随筛选过滤 此前事件只受后端 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 项通过。
125 lines
5.4 KiB
Python
125 lines
5.4 KiB
Python
#!/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()
|