chore: rebuild release package
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从用户提供的设计稿中提取 Wasteland 边框与实际尺寸图标。"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
|
||||
ROOT = Path('/home/cc-web')
|
||||
REFERENCE = ROOT / 'sessions/_attachments/f01c1388-b603-434a-b226-2ff5771d4925.webp'
|
||||
ICON_SHEET = ROOT / 'sessions/_attachments/43bd9ed6-2ba6-4c9d-bd9d-25b068cb4711.webp'
|
||||
WELCOME_MATERIAL = ROOT / 'sessions/_attachments/2b88b81d-c5a8-4d92-a518-afeea691eade.webp'
|
||||
ASSET_ROOT = ROOT / 'public/assets/themes/wasteland'
|
||||
FRAME_DIR = ASSET_ROOT / 'frames'
|
||||
ICON_DIR = ASSET_ROOT / 'icons/ui'
|
||||
TEXTURE_DIR = ASSET_ROOT / 'textures'
|
||||
|
||||
|
||||
def gold_alpha(pixel):
|
||||
r, g, b = pixel
|
||||
warm = max(0, min(r - b, g - b + 8))
|
||||
light = max(0, (r + g) // 2 - 24)
|
||||
if r < 38 or g < 27 or warm < 8:
|
||||
return 0
|
||||
return max(0, min(238, warm * 4 + light * 2))
|
||||
|
||||
|
||||
def icon_alpha(pixel):
|
||||
r, g, b = pixel
|
||||
gold = r > 44 and g > 31 and r - b > 15 and g - b > 7
|
||||
pale = r > 118 and g > 86 and r - b > 28
|
||||
green = g > 58 and g - r > 5 and g - b > 10
|
||||
if not (gold or pale or green):
|
||||
return 0
|
||||
if green:
|
||||
score = (g - min(r, b)) * 4 + g
|
||||
else:
|
||||
score = (r - b) * 3 + (g - b) * 2 + (r + g) // 4
|
||||
return max(0, min(255, score))
|
||||
|
||||
|
||||
def crop_frame(source, name, box, zone):
|
||||
crop = source.crop(box).convert('RGB')
|
||||
width, height = crop.size
|
||||
rgba = Image.new('RGBA', crop.size, (0, 0, 0, 0))
|
||||
src = crop.load()
|
||||
dst = rgba.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if not zone(x, y, width, height):
|
||||
continue
|
||||
alpha = gold_alpha(src[x, y])
|
||||
if alpha:
|
||||
r, g, b = src[x, y]
|
||||
dst[x, y] = (r, g, b, alpha)
|
||||
rgba.save(FRAME_DIR / f'{name}.png')
|
||||
return {'name': name, 'source_box': list(box), 'size': [width, height]}
|
||||
|
||||
|
||||
def extract_welcome_card(material):
|
||||
"""从烘焙棋盘格的素材板中分离欢迎卡金属框和干净暗纹理。"""
|
||||
frame_box = (70, 0, 1165, 875)
|
||||
crop = material.crop(frame_box).convert('RGB')
|
||||
frame = Image.new('RGBA', crop.size, (0, 0, 0, 0))
|
||||
src = crop.load()
|
||||
dst = frame.load()
|
||||
for y in range(crop.height):
|
||||
source_y = y + frame_box[1]
|
||||
for x in range(crop.width):
|
||||
source_x = x + frame_box[0]
|
||||
# 730px 以下只保留主框的中央下坠徽记,排除素材板的其他组件。
|
||||
if source_y >= 730 and not 500 <= source_x <= 780:
|
||||
continue
|
||||
alpha = gold_alpha(src[x, y])
|
||||
if alpha < 18:
|
||||
continue
|
||||
r, g, b = src[x, y]
|
||||
dst[x, y] = (r, g, b, alpha)
|
||||
frame.save(FRAME_DIR / 'welcome-card.png')
|
||||
|
||||
surface_box = (350, 250, 950, 600)
|
||||
surface = material.crop(surface_box).convert('RGB')
|
||||
surface.save(TEXTURE_DIR / 'welcome-card-surface.webp', quality=92, method=6)
|
||||
return [
|
||||
{
|
||||
'name': 'welcome-card',
|
||||
'source': WELCOME_MATERIAL.name,
|
||||
'source_box': list(frame_box),
|
||||
'size': list(frame.size),
|
||||
'output': 'frames/welcome-card.png',
|
||||
},
|
||||
{
|
||||
'name': 'welcome-card-surface',
|
||||
'source': WELCOME_MATERIAL.name,
|
||||
'source_box': list(surface_box),
|
||||
'size': list(surface.size),
|
||||
'output': 'textures/welcome-card-surface.webp',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def slot_box(card, variant):
|
||||
x, y, width, height = card
|
||||
bands = {
|
||||
'16': (x + 14, x + 74),
|
||||
'24': (x + 76, x + 150),
|
||||
'32': (x + 146, x + width - 12),
|
||||
}
|
||||
sx0, sx1 = bands[variant]
|
||||
return sx0, y + 30, sx1, min(y + 105, y + height - 24)
|
||||
|
||||
|
||||
def crop_icon(sheet, name, card, variant, canvas):
|
||||
sx0, sy0, sx1, sy1 = slot_box(card, variant)
|
||||
region = sheet.crop((sx0, sy0, sx1, sy1)).convert('RGB')
|
||||
width, height = region.size
|
||||
alpha = Image.new('L', region.size, 0)
|
||||
ap = alpha.load()
|
||||
rp = region.load()
|
||||
points = []
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
value = icon_alpha(rp[x, y])
|
||||
if value:
|
||||
ap[x, y] = value
|
||||
points.append((x, y))
|
||||
if not points:
|
||||
raise RuntimeError(f'没有检测到图标:{name}')
|
||||
left = max(0, min(p[0] for p in points) - 3)
|
||||
top = max(0, min(p[1] for p in points) - 3)
|
||||
right = min(width, max(p[0] for p in points) + 4)
|
||||
bottom = min(height, max(p[1] for p in points) + 4)
|
||||
icon = region.crop((left, top, right, bottom)).convert('RGBA')
|
||||
icon.putalpha(alpha.crop((left, top, right, bottom)).filter(ImageFilter.GaussianBlur(0.18)))
|
||||
|
||||
target_w, target_h = canvas
|
||||
scale = min((target_w - 2) / icon.width, (target_h - 2) / icon.height)
|
||||
resized = icon.resize(
|
||||
(max(1, round(icon.width * scale)), max(1, round(icon.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
output = Image.new('RGBA', canvas, (0, 0, 0, 0))
|
||||
output.alpha_composite(resized, ((target_w - resized.width) // 2, (target_h - resized.height) // 2))
|
||||
|
||||
# 输入区图标按有效像素重心居中,避免纸飞机等非对称轮廓在按钮里视觉偏移。
|
||||
if name in {'note', 'queue', 'send', 'stop'}:
|
||||
alpha = output.getchannel('A')
|
||||
pixels = alpha.load()
|
||||
total = weighted_x = weighted_y = 0
|
||||
for py in range(target_h):
|
||||
for px in range(target_w):
|
||||
value = pixels[px, py]
|
||||
if not value:
|
||||
continue
|
||||
total += value
|
||||
weighted_x += px * value
|
||||
weighted_y += py * value
|
||||
if total:
|
||||
offset_x = round((target_w - 1) / 2 - weighted_x / total)
|
||||
offset_y = round((target_h - 1) / 2 - weighted_y / total)
|
||||
if offset_x or offset_y:
|
||||
centered = Image.new('RGBA', canvas, (0, 0, 0, 0))
|
||||
centered.alpha_composite(output, (offset_x, offset_y))
|
||||
output = centered
|
||||
output.save(ICON_DIR / f'{name}.png')
|
||||
return {
|
||||
'name': name,
|
||||
'variant': variant,
|
||||
'slot_box': [sx0, sy0, sx1 - sx0, sy1 - sy0],
|
||||
'detected_box': [sx0 + left, sy0 + top, right - left, bottom - top],
|
||||
'size': [target_w, target_h],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
FRAME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TEXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
reference = Image.open(REFERENCE).convert('RGB')
|
||||
sheet = Image.open(ICON_SHEET).convert('RGB')
|
||||
welcome_material = Image.open(WELCOME_MATERIAL).convert('RGB')
|
||||
|
||||
border_zone = lambda x, y, w, h: x < 8 or x >= w - 8 or y < 8 or y >= h - 8
|
||||
frames = [
|
||||
crop_frame(reference, 'new-chat', (15, 9, 304, 58), lambda x, y, w, h: border_zone(x, y, w, h) or 225 <= x <= 239),
|
||||
crop_frame(reference, 'search', (15, 67, 304, 103), border_zone),
|
||||
crop_frame(reference, 'project', (15, 117, 304, 164), border_zone),
|
||||
crop_frame(reference, 'active-session', (15, 163, 304, 209), lambda x, y, w, h: x < 12 or x >= w - 7 or y < 7 or y >= h - 7),
|
||||
crop_frame(
|
||||
reference,
|
||||
'composer',
|
||||
(557, 830, 1364, 931),
|
||||
lambda x, y, w, h: (
|
||||
(y < 10)
|
||||
or (x < 10 and y < 77)
|
||||
or (x >= w - 10 and y < 77)
|
||||
or (64 <= y < 79)
|
||||
or (w // 2 - 56 <= x <= w // 2 + 56 and y >= 58)
|
||||
),
|
||||
),
|
||||
crop_frame(reference, 'composer-action', (1211, 845, 1257, 891), border_zone),
|
||||
crop_frame(reference, 'tool-call', (421, 603, 1381, 664), border_zone),
|
||||
]
|
||||
|
||||
# 设计稿输入框含两层顶线。UI 叠加时只保留内侧主蚀刻线,
|
||||
# 同时对称收紧左右 5px,使主竖线贴合真实容器边界;徽记保持几何中心不变。
|
||||
composer_path = FRAME_DIR / 'composer.png'
|
||||
composer = Image.open(composer_path).convert('RGBA')
|
||||
composer = composer.crop((5, 5, composer.width - 5, composer.height))
|
||||
composer = composer.resize((807, 101), Image.Resampling.LANCZOS)
|
||||
|
||||
# 设计稿原始裁区的中心徽记视觉中心比画布中心向右约 8px。
|
||||
# 只校正下半部装饰层,保留左右框边和顶部线的原始几何位置。
|
||||
centered_composer = composer.copy()
|
||||
ornament_region = (96, 50, 711, 101)
|
||||
centered_composer.paste((0, 0, 0, 0), ornament_region)
|
||||
centered_composer.alpha_composite(composer.crop(ornament_region), (88, 50))
|
||||
composer = centered_composer
|
||||
composer.save(composer_path)
|
||||
|
||||
# 输入框不能把 807×101 的整图同时做横纵拉伸:窄窗口会让底部徽记
|
||||
# 与真实 74px 框体错层。拆出只含设计稿顶部/侧边的框壳和独立徽记,
|
||||
# 由 CSS 分别控制尺寸,保证任意宽度下都只出现一条边框。
|
||||
shell_height = 74
|
||||
shell = Image.new('RGBA', (composer.width, shell_height), (0, 0, 0, 0))
|
||||
composer_pixels = composer.load()
|
||||
shell_pixels = shell.load()
|
||||
for y in range(shell_height):
|
||||
for x in range(composer.width):
|
||||
if y < 14 or x < 14 or x >= composer.width - 14:
|
||||
shell_pixels[x, y] = composer_pixels[x, y]
|
||||
shell.save(FRAME_DIR / 'composer-shell.png')
|
||||
|
||||
ornament_box = (130, 50, 677, 101)
|
||||
composer.crop(ornament_box).save(FRAME_DIR / 'composer-ornament.png')
|
||||
frames.extend([
|
||||
{
|
||||
'name': 'composer-shell',
|
||||
'derived_from': 'composer.png',
|
||||
'source_box': [0, 0, composer.width, shell_height],
|
||||
'size': [composer.width, shell_height],
|
||||
},
|
||||
{
|
||||
'name': 'composer-ornament',
|
||||
'derived_from': 'composer.png',
|
||||
'source_box': [
|
||||
ornament_box[0],
|
||||
ornament_box[1],
|
||||
ornament_box[2] - ornament_box[0],
|
||||
ornament_box[3] - ornament_box[1],
|
||||
],
|
||||
'size': [ornament_box[2] - ornament_box[0], ornament_box[3] - ornament_box[1]],
|
||||
},
|
||||
])
|
||||
frames.extend(extract_welcome_card(welcome_material))
|
||||
|
||||
cards = {
|
||||
'search': (582, 50, 247, 137),
|
||||
'dropdown': (315, 50, 247, 137),
|
||||
'theme': (50, 201, 245, 138),
|
||||
'ai': (315, 201, 247, 138),
|
||||
'status': (582, 201, 247, 138),
|
||||
'attachment': (1111, 201, 247, 138),
|
||||
'send': (1377, 201, 244, 138),
|
||||
'queue': (846, 201, 246, 138),
|
||||
'stop': (50, 354, 245, 136),
|
||||
'settings': (315, 354, 247, 136),
|
||||
'user': (582, 354, 247, 136),
|
||||
'terminal': (846, 354, 246, 136),
|
||||
'note': (582, 506, 247, 138),
|
||||
}
|
||||
specs = [
|
||||
('search', '16', (20, 20)),
|
||||
('dropdown', '16', (14, 10)),
|
||||
('theme', '24', (26, 26)),
|
||||
('ai', '32', (36, 36)),
|
||||
('status', '16', (16, 16)),
|
||||
('attachment', '24', (26, 28)),
|
||||
('send', '32', (30, 30)),
|
||||
('queue', '32', (30, 30)),
|
||||
('stop', '24', (26, 26)),
|
||||
('settings', '16', (20, 20)),
|
||||
('user', '32', (36, 36)),
|
||||
('terminal', '16', (20, 18)),
|
||||
('note', '32', (36, 36)),
|
||||
]
|
||||
icons = [crop_icon(sheet, name, cards[name], variant, canvas) for name, variant, canvas in specs]
|
||||
(FRAME_DIR / 'manifest.json').write_text(json.dumps(frames, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
(ICON_DIR / 'manifest.json').write_text(json.dumps(icons, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user