chore: rebuild release package

This commit is contained in:
shiyue
2026-07-20 15:44:07 +08:00
parent a038485ab5
commit 9adc7c99f1
11 changed files with 662 additions and 9 deletions

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""从归档图标板的 16px 原生规格生成 Plan List 暗金主题进度图标。"""
from __future__ import annotations
from hashlib import sha256
import json
from pathlib import Path
from PIL import Image, ImageFilter
ROOT = Path(__file__).resolve().parents[4]
SOURCE = ROOT / '.trellis/tasks/07-17-gilded-wasteland-theme/references/source-assets/wasteland-icon-sheet.webp'
OUTPUT_DIR = ROOT / 'public/assets/themes/wasteland/icons/plan-progress'
MANIFEST_PATH = OUTPUT_DIR / 'manifest.json'
EXPECTED_SOURCE_SHA256 = '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e'
CANVAS_SIZE = (20, 20)
# 卡片坐标来自归档图标板的几何审计;只读取每张卡片左侧的 16px 原生规格。
SPECS = (
{
'name': 'complete',
'semantic': '已完成',
'source_card': 'R2C3 STATUS (ONLINE)',
'card': (582, 201, 247, 138),
'usage': 'Plan List 已完成任务的绿色宝石',
},
{
'name': 'remaining',
'semantic': '未完成',
'source_card': 'R2C4 LOADING',
'card': (846, 201, 246, 138),
'usage': 'Plan List 未完成任务的暗金分段环',
},
)
def file_sha256(path: Path) -> str:
digest = sha256()
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(64 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def source_slot(card: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
"""返回卡片左侧 16px 规格的检测带,排除标题、尺寸文字和卡片边框。"""
x, y, _width, height = card
return x + 14, y + 30, x + 74, min(y + 105, y + height - 24)
def icon_alpha(pixel: tuple[int, int, int]) -> int:
"""分离暗金高光与状态绿,同时压掉 WebP 暗背景噪点。"""
red, green, blue = pixel
gold = red > 44 and green > 31 and red - blue > 15 and green - blue > 7
pale = red > 118 and green > 86 and red - blue > 28
status_green = green > 58 and green - red > 5 and green - blue > 10
if not (gold or pale or status_green):
return 0
if status_green:
score = (green - min(red, blue)) * 4 + green
else:
score = (red - blue) * 3 + (green - blue) * 2 + (red + green) // 4
return max(0, min(255, score))
def alpha_metrics(image: Image.Image) -> tuple[list[int], list[float]]:
alpha = image.getchannel('A')
bbox = alpha.getbbox()
if bbox is None:
raise RuntimeError('输出图标没有 alpha 主体')
pixels = alpha.load()
total = weighted_x = weighted_y = 0
for y in range(image.height):
for x in range(image.width):
value = pixels[x, y]
if not value:
continue
total += value
weighted_x += x * value
weighted_y += y * value
return (
[bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]],
[round(weighted_x / total, 3), round(weighted_y / total, 3)],
)
def extract_icon(sheet: Image.Image, spec: dict[str, object]) -> dict[str, object]:
slot = source_slot(spec['card'])
region = sheet.crop(slot).convert('RGB')
mask = Image.new('L', region.size, 0)
source_pixels = region.load()
mask_pixels = mask.load()
points: list[tuple[int, int]] = []
for y in range(region.height):
for x in range(region.width):
alpha = icon_alpha(source_pixels[x, y])
if alpha < 18:
continue
mask_pixels[x, y] = alpha
points.append((x, y))
if not points:
raise RuntimeError(f"未检测到图标主体:{spec['name']}")
left = max(0, min(x for x, _y in points) - 3)
top = max(0, min(y for _x, y in points) - 3)
right = min(region.width, max(x for x, _y in points) + 4)
bottom = min(region.height, max(y for _x, y in points) + 4)
detected = region.crop((left, top, right, bottom)).convert('RGBA')
detected.putalpha(mask.crop((left, top, right, bottom)).filter(ImageFilter.GaussianBlur(0.12)))
target_width, target_height = CANVAS_SIZE
scale = min((target_width - 2) / detected.width, (target_height - 2) / detected.height)
resized = detected.resize(
(max(1, round(detected.width * scale)), max(1, round(detected.height * scale))),
Image.Resampling.LANCZOS,
)
output = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
output.alpha_composite(resized, ((target_width - resized.width) // 2, (target_height - resized.height) // 2))
# 按 alpha 加权重心做最后 1px 级光学校正。
_bbox, center = alpha_metrics(output)
offset_x = round((target_width - 1) / 2 - center[0])
offset_y = round((target_height - 1) / 2 - center[1])
if offset_x or offset_y:
centered = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
centered.alpha_composite(output, (offset_x, offset_y))
output = centered
output_path = OUTPUT_DIR / f"{spec['name']}.png"
output.save(output_path, optimize=True)
alpha_bbox, alpha_center = alpha_metrics(output)
return {
'name': spec['name'],
'semantic': spec['semantic'],
'source': SOURCE.name,
'source_sha256': EXPECTED_SOURCE_SHA256,
'source_card': spec['source_card'],
'source_variant': '16px',
'slot_box': [slot[0], slot[1], slot[2] - slot[0], slot[3] - slot[1]],
'detected_box': [slot[0] + left, slot[1] + top, right - left, bottom - top],
'output': f"icons/plan-progress/{spec['name']}.png",
'size': list(CANVAS_SIZE),
'alpha_bbox': alpha_bbox,
'alpha_center': alpha_center,
'sha256': file_sha256(output_path),
'usage': spec['usage'],
}
def main() -> None:
actual_sha256 = file_sha256(SOURCE)
if actual_sha256 != EXPECTED_SOURCE_SHA256:
raise RuntimeError(f'图标板 hash 不匹配:{actual_sha256}')
sheet = Image.open(SOURCE).convert('RGB')
if sheet.size != (1672, 941):
raise RuntimeError(f'图标板尺寸不匹配:{sheet.size}')
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
assets = [extract_icon(sheet, spec) for spec in SPECS]
manifest = {
'source': SOURCE.name,
'source_path': str(SOURCE.relative_to(ROOT)),
'source_sha256': EXPECTED_SOURCE_SHA256,
'source_size': list(sheet.size),
'assets': assets,
}
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,50 @@
# Plan List 进度图标主题兼容审计
审计时间2026-07-20
## 范围
- 扩展既有 `wasteland`(暗金荒野)主题,不新增或替换主题 ID。
- 只修改 Plan List 标题中的 `.plan-progress-dot` 视觉层。
- `washi``coolvibe``editorial``sage``ink``dawn``carbon``nocturne``cinder``gilded` 保持 14px 语义色圆点。
- `wasteland` 使用独立的本地原色图标;不修改 Plan 数据、DOM 结构和其他工具块。
## 素材台账
| 角色 | 归档路径 | 尺寸/格式 | SHA-256 | Alpha | 用途 |
| --- | --- | --- | --- | --- | --- |
| 图标板 | `references/source-assets/wasteland-icon-sheet.webp` | 1672×941 RGB WebP | `482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e` | 无 | 仅作为可复现切片源 |
运行时不依赖会话附件或远程 URL切片脚本只读取上述归档文件。
## 参考区域到实现映射
| 进度语义 | 图标板区域 | 目标资产 | 实际 DOM | CSS 责任 |
| --- | --- | --- | --- | --- |
| 已完成 | R2C3 `STATUS (ONLINE)` 的 16px 规格 | `icons/plan-progress/complete.png` | `.plan-progress-dot.is-complete` | 暗金主题显示绿色宝石原色图标 |
| 未完成 | R2C4 `LOADING` 的 16px 规格 | `icons/plan-progress/remaining.png` | `.plan-progress-dot.is-remaining` | 暗金主题显示暗金分段环原色图标 |
两枚图标输出到 20×20 透明画布,按 alpha 加权重心居中;桌面和窄屏均保持主体清晰,不再从 64px PNG 二次缩小。
## 验收清单
- 两枚 PNG 来自归档图标板的 16px 原生规格manifest 记录源 hash、卡片、检测框、画布与用途。
- PNG 为 RGBA透明背景无黑边、棋盘格或文字残留。
- 主题专属选择器全部限定 `html[data-theme='wasteland']`
- 其他 10 个主题仍使用 14×14、左边距 8px 的圆点。
- Wasteland 完成/未完成图标可区分,标题与长计划不溢出。
- `style.css` / `app.js` 缓存版本同步推进,资产与静态资源 HTTP 200。
- `plan-list-progress``wasteland-theme``gilded-theme` 和全量回归通过。
- Firefox 检查 11 个主题及 1440×900、500×844、390×844 视口。
## 实际验收记录
验收日期2026-07-20
- Firefox 152 实际加载 `style.css?v=20260720-plan-progress-icons`
- `washi``coolvibe``editorial``sage``ink``dawn``carbon``nocturne``cinder``gilded`:完成/未完成均为 14×14左边距 8px未完成圆点命中 1px inset 描边与 `plan-progress-breathe 1.8s`,没有加载主题图片。
- `wasteland`:桌面图标 20×20、移动端 16×16完成与未完成分别加载 `complete.png``remaining.png``box-shadow: none`,没有方形描边。
- 呼吸动画在 700ms 采样间隔内从 `scale(0.944)` / `opacity 0.820` 变化到 `scale(0.984)` / `opacity 0.948`
- Firefox `ui.prefersReducedMotion=1` 时:`animation-name: none``opacity: 1``transform: none`,图标仍正常显示且无描边。
- 1672×941、1440×900、500×844、390×844 均检查短计划与 12 图标长计划;标题、长计划和页面横向溢出量均为 0。
- 两枚 PNG、manifest、CSS、JS 均返回 HTTP 200专项、暗金主题、旧 Warframe 主题与全量回归均通过。