chore: rebuild release package
This commit is contained in:
@@ -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()
|
||||
@@ -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 主题与全量回归均通过。
|
||||
Reference in New Issue
Block a user