archy/image-recipe/branding/generate-plymouth-logo.py
Dorian 436f337a13 feat: custom boot branding, MBR fix, Plymouth theme, CI smoke tests
Boot fix:
- Ship proven Debian Live MBR (4552) as branding/isohdpfx.bin — the
  ISOLINUX package MBR (33ed) doesn't boot on all hardware. This was
  the root cause of "machine doesn't pick up the USB".

Branding:
- Custom GRUB background: pixel-art floating island (1024x574)
- Archipelago pixel-art logo for Plymouth boot splash
- GRUB theme: dark background, orange selected item, no broken font refs
- Plymouth theme: script-based with progress bar, LUKS prompt support
- Plymouth + splash added to target rootfs packages
- GRUB theme installed on both installer ISO and target system
- Serial console (ttyS0) added to kernel params for QEMU debugging

CI improvements:
- Smoke test step: mounts ISO, verifies all critical files, checks
  initrd has live-boot, confirms boot=live in grub.cfg. Fails build
  before copying to Builds if any check fails.

Dev workflow:
- dev-branding.sh: extract ISO, swap branding, repackage, boot in QEMU
  (~10 seconds vs 20 min full rebuild)
- generate-grub-background.py: procedural cyberpunk background generator
- generate-plymouth-logo.py: procedural logo generator
- Improved test-iso-qemu.sh: --bios/--nographic flags, serial logging

Build:
- Simplified live-boot install (clean chroot, no complex fallbacks)
- Static branding images preferred, generators as fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:34:29 +00:00

150 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Generate the Archipelago Plymouth boot logo — pixel-art 'a' with neon glow.
Outputs a 256x256 PNG with transparent background (RGBA).
"""
import struct
import zlib
import math
import sys
W, H = 256, 256
ORANGE = (251, 146, 60)
ORANGE_BRIGHT = (255, 180, 100)
# Lowercase pixel-art "a" — 6x6 grid
LOGO_A = [
[0, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1],
]
# "archipelago" text — same pixel font as GRUB generator
PIXEL_CHARS = {
'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]],
'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]],
'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]],
'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]],
'i': [[0,1],[0,0],[0,1],[0,1],[0,1]],
'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]],
'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]],
'l': [[1,0],[1,0],[1,0],[1,0],[1,1]],
'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]],
'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]],
' ': [[0,0],[0,0],[0,0],[0,0],[0,0]],
}
def create_png_rgba(width, height, pixels):
"""Create a PNG with RGBA pixel data."""
def make_chunk(chunk_type, data):
chunk = chunk_type + data
return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF)
signature = b'\x89PNG\r\n\x1a\n'
ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0) # 8-bit RGBA
ihdr = make_chunk(b'IHDR', ihdr_data)
raw_data = bytearray()
for y in range(height):
raw_data.append(0)
offset = y * width * 4
raw_data.extend(pixels[offset:offset + width * 4])
compressed = zlib.compress(bytes(raw_data), 9)
idat = make_chunk(b'IDAT', compressed)
iend = make_chunk(b'IEND', b'')
return signature + ihdr + idat + iend
def generate():
pixels = bytearray(W * H * 4) # RGBA
cx, cy = W // 2, W // 2 - 30
logo_rows = len(LOGO_A)
logo_cols = len(LOGO_A[0])
pixel_size = 18
logo_w = logo_cols * pixel_size
logo_h = logo_rows * pixel_size
logo_x = cx - logo_w // 2
logo_y = 30
# Glow
glow_radius = 60
for y in range(H):
for x in range(W):
dx = max(0, logo_x - x, x - (logo_x + logo_w))
dy = max(0, logo_y - y, y - (logo_y + logo_h))
d = math.sqrt(dx * dx + dy * dy)
if d < glow_radius:
alpha = (1.0 - d / glow_radius) ** 2 * 0.25
idx = (y * W + x) * 4
pixels[idx] = ORANGE[0]
pixels[idx + 1] = ORANGE[1]
pixels[idx + 2] = ORANGE[2]
pixels[idx + 3] = int(alpha * 255)
# Logo pixels with 3D bevel
for row in range(logo_rows):
for col in range(logo_cols):
if LOGO_A[row][col]:
px = logo_x + col * pixel_size
py = logo_y + row * pixel_size
for dy in range(pixel_size - 1):
for dx in range(pixel_size - 1):
x, y = px + dx, py + dy
if 0 <= x < W and 0 <= y < H:
t = (dx + dy) / (2 * pixel_size)
r = int(ORANGE[0] * (1.0 - t * 0.3))
g = int(ORANGE[1] * (1.0 - t * 0.3))
b = int(ORANGE[2] * (1.0 - t * 0.3))
if dx < 2 or dy < 2:
r = min(255, r + 40)
g = min(255, g + 30)
b = min(255, b + 10)
idx = (y * W + x) * 4
pixels[idx] = r
pixels[idx + 1] = g
pixels[idx + 2] = b
pixels[idx + 3] = 255
# Text "archipelago" below logo
text = "archipelago"
text_pixel = 3
text_gap = 2
total_w = sum(len(PIXEL_CHARS.get(c, PIXEL_CHARS[' '])[0]) * text_pixel + text_gap for c in text) - text_gap
text_x = cx - total_w // 2
text_y = logo_y + logo_h + 16
cursor = text_x
for ch in text:
char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' '])
for row in range(len(char_data)):
for col in range(len(char_data[0])):
if char_data[row][col]:
for dy in range(text_pixel - 1):
for dx in range(text_pixel - 1):
x = cursor + col * text_pixel + dx
y = text_y + row * text_pixel + dy
if 0 <= x < W and 0 <= y < H:
idx = (y * W + x) * 4
pixels[idx] = 180
pixels[idx + 1] = 100
pixels[idx + 2] = 30
pixels[idx + 3] = 200
cursor += len(char_data[0]) * text_pixel + text_gap
return create_png_rgba(W, H, bytes(pixels))
if __name__ == '__main__':
out_path = sys.argv[1] if len(sys.argv) > 1 else 'logo.png'
data = generate()
with open(out_path, 'wb') as f:
f.write(data)
print(f'Generated {out_path} ({len(data)} bytes)')