148 lines
7.7 KiB
Python
148 lines
7.7 KiB
Python
"""Compose Work-Death Balance's splash/icon using the existing MatGames mascot.
|
|
|
|
The raw mascot comes from an image generator on a flat magenta background (generators
|
|
do not emit real alpha). This script keys that background to transparency, removes
|
|
magenta spill, then draws the title with the OFL-licensed VT323 font and saves a
|
|
transparent PNG ready for application/boot_splash/image.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
from make_favicon import build_favicon
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('raw', type=pathlib.Path, help='raw generated image (magenta background)')
|
|
parser.add_argument('--title', default='Work-Death Balance')
|
|
parser.add_argument('--subtitle', default='MatGames — "...because he was bored..."',
|
|
help='second line under the title; empty string for none')
|
|
parser.add_argument('--subtitle-height', type=int, default=100)
|
|
parser.add_argument('--bottom-padding', type=int, default=30,
|
|
help='empty band under the text: the shell progress bar sits there')
|
|
parser.add_argument('--canvas', type=int, default=1024)
|
|
parser.add_argument('--title-height', type=int, default=190)
|
|
parser.add_argument('--bottom-fade', type=int, default=50,
|
|
help='soften the generator frame cut at the bottom of the bust')
|
|
parser.add_argument('--colors', type=int, default=256,
|
|
help='palette colors for the shipped PNG; 0 keeps 32-bit RGBA')
|
|
parser.add_argument('--provenance', action='append', type=pathlib.Path, default=[],
|
|
help='earlier files of the generation/edit chain, in order; repeatable')
|
|
args = parser.parse_args()
|
|
args.raw = args.raw.resolve()
|
|
root = pathlib.Path(__file__).resolve().parents[1]
|
|
branding = root / 'assets/branding'
|
|
branding.mkdir(parents=True, exist_ok=True)
|
|
source_tools = root / 'tools/branding'
|
|
font_path = source_tools / 'fonts/VT323-Regular.ttf'
|
|
|
|
# ---------------------------------------------------------------- chroma key
|
|
source = Image.open(args.raw).convert('RGB')
|
|
corners = [source.getpixel(p) for p in
|
|
[(2, 2), (source.width - 3, 2), (2, source.height - 3), (source.width - 3, source.height - 3)]]
|
|
background = tuple(sorted(channel)[1] for channel in zip(*corners)) # median per channel
|
|
T0, T1 = 55, 130
|
|
alpha = Image.new('L', source.size)
|
|
spill = Image.new('L', source.size)
|
|
pixels = source.load()
|
|
alpha_pixels = alpha.load()
|
|
spill_pixels = spill.load()
|
|
for y in range(source.height):
|
|
for x in range(source.width):
|
|
r, g, b = pixels[x, y]
|
|
distance = ((r - background[0]) ** 2 + (g - background[1]) ** 2 + (b - background[2]) ** 2) ** 0.5
|
|
alpha_pixels[x, y] = max(0, min(255, round((distance - T0) / (T1 - T0) * 255)))
|
|
# Magenta contamination raises both red and blue above green.
|
|
spill_pixels[x, y] = max(0, min(r, b) - g)
|
|
mask = alpha.filter(ImageFilter.MedianFilter(3))
|
|
fixed = Image.new('RGB', source.size)
|
|
fixed_pixels = fixed.load()
|
|
for y in range(source.height):
|
|
for x in range(source.width):
|
|
r, g, b = pixels[x, y]
|
|
amount = spill_pixels[x, y]
|
|
fixed_pixels[x, y] = (max(0, r - amount), g, max(0, b - amount))
|
|
mascot = fixed.convert('RGBA')
|
|
mascot.putalpha(mask)
|
|
favicon = build_favicon(mascot, root)
|
|
mascot = mascot.crop(mascot.getbbox())
|
|
if args.bottom_fade > 0:
|
|
fade = min(args.bottom_fade, mascot.height)
|
|
ramp = Image.new('L', (1, fade))
|
|
for y in range(fade):
|
|
ramp.putpixel((0, y), round(255 * (1.0 - y / fade)))
|
|
band = mascot.crop((0, mascot.height - fade, mascot.width, mascot.height))
|
|
band.putalpha(Image.composite(band.getchannel('A'), Image.new('L', band.size, 0), ramp.resize((1, fade)).resize(band.size, Image.NEAREST)))
|
|
mascot.paste(band, (0, mascot.height - fade))
|
|
|
|
# ---------------------------------------------------------------- composition
|
|
canvas_width = args.canvas
|
|
title_area = args.title_height + (args.subtitle_height if args.subtitle else 0)
|
|
scale = (canvas_width - 2 * 40) / mascot.width
|
|
mascot = mascot.resize((round(mascot.width * scale), round(mascot.height * scale)), Image.LANCZOS)
|
|
height = mascot.height + title_area + args.bottom_padding
|
|
canvas = Image.new('RGBA', (canvas_width, height), (0, 0, 0, 0))
|
|
canvas.alpha_composite(mascot, ((canvas_width - mascot.width) // 2, 0))
|
|
|
|
|
|
def _draw_line(text: str, block_top: int, block_height: int, size: int,
|
|
glow_color: tuple, text_color: tuple, blur: int) -> None:
|
|
font = ImageFont.truetype(str(font_path), size)
|
|
while font.getbbox(text)[2] - font.getbbox(text)[0] > canvas_width - 60 and size > 8:
|
|
size -= 2
|
|
font = ImageFont.truetype(str(font_path), size)
|
|
box = font.getbbox(text)
|
|
position = ((canvas_width - (box[2] - box[0])) // 2 - box[0],
|
|
block_top + (block_height - box[3]) // 2 - box[1])
|
|
glow = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
|
ImageDraw.Draw(glow).text(position, text, font=font, fill=glow_color)
|
|
canvas.alpha_composite(glow.filter(ImageFilter.GaussianBlur(blur)))
|
|
ImageDraw.Draw(canvas).text(position, text, font=font, fill=text_color)
|
|
|
|
|
|
_draw_line(args.title, mascot.height, args.title_height, int(args.title_height * 0.95),
|
|
(255, 150, 20, 190), (255, 196, 64, 255), 9)
|
|
if args.subtitle:
|
|
_draw_line(args.subtitle, mascot.height + args.title_height, args.subtitle_height,
|
|
int(args.subtitle_height * 0.8), (255, 150, 20, 150), (255, 176, 58, 240), 6)
|
|
|
|
out = branding / 'boot_splash.png'
|
|
if args.colors > 0:
|
|
# Flat cartoon art tolerates a palette well; keeps ~95 alpha levels for edges.
|
|
canvas = canvas.quantize(colors=args.colors, method=Image.FASTOCTREE, dither=Image.Dither.NONE)
|
|
canvas.save(out, optimize=True)
|
|
manifest = json.loads((source_tools / 'manifest.json').read_text()) if (source_tools / 'manifest.json').exists() else {}
|
|
manifest.update({
|
|
'favicon': favicon,
|
|
'boot_splash': {'file': str(out.relative_to(root)), 'size': list(canvas.size), 'mode': canvas.mode,
|
|
'palette_colors': args.colors,
|
|
'sha256': hashlib.sha256(out.read_bytes()).hexdigest()},
|
|
'mascot_source': {'file': str(args.raw.relative_to(root)),
|
|
'sha256': hashlib.sha256(args.raw.read_bytes()).hexdigest()},
|
|
'generator': {
|
|
'tool': 'agy_generate (Antigravity direct API)',
|
|
'model': 'gemini-3.1-flash-image',
|
|
'date': '2026-10-11',
|
|
'prompt': ('single cartoon zombie head-and-bust mascot, unsettling wide grin, thumbs up, '
|
|
'2D cartoon style with bold outlines, flat magenta chroma-key background, '
|
|
'no text and no logo; then edited to a bald scalp with a sparse hair fringe '
|
|
'on the sides only, then edited again to wear thick nerd glasses'),
|
|
'terms_note': ('generated from no third-party source material; the provider terms do not claim '
|
|
'ownership of the output. The generator emits no real alpha channel, so the '
|
|
'background is keyed out locally by this script.'),
|
|
},
|
|
'chroma_key': {'background_rgb': list(background), 'transparent_below': T0, 'opaque_above': T1},
|
|
'title': {'text': args.title, 'subtitle': args.subtitle,
|
|
'font': 'tools/branding/fonts/VT323-Regular.ttf',
|
|
'license': 'SIL Open Font License 1.1 (tools/branding/fonts/OFL.txt)'},
|
|
'bottom_fade_px': args.bottom_fade,
|
|
'bottom_padding_px': args.bottom_padding,
|
|
})
|
|
chain = list(args.provenance) + [args.raw]
|
|
manifest['mascot_provenance'] = [
|
|
{'file': str(path.resolve().relative_to(root)),
|
|
'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in chain]
|
|
manifest.pop('mascot_derived_from', None)
|
|
(source_tools / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
|
print(out, canvas.size, canvas.mode, 'mascot', mascot.size)
|