173 lines
7.7 KiB
Python
173 lines
7.7 KiB
Python
"""Build-only PetalumaScript sprites and content-addressed Pico title images."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import struct
|
|
|
|
import PIL.Image
|
|
import PIL.ImageDraw
|
|
import PIL.ImageFont
|
|
import PIL.features
|
|
import fontTools.ttLib
|
|
|
|
TITLE_WIDTH = 296
|
|
TITLE_HEIGHT = 64
|
|
TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
|
|
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
|
|
MAX_TITLES = 8192
|
|
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE')
|
|
SPRITES = LABELS + tuple('0123456789/')
|
|
DIGIT_WIDTH = 22
|
|
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
|
|
|
|
class Renderer:
|
|
def __init__(self, font_path: pathlib.Path):
|
|
if not PIL.features.check_feature('raqm'):
|
|
raise RuntimeError('Pico artwork requires Pillow with Raqm support')
|
|
self.font_path = font_path
|
|
with fontTools.ttLib.TTFont(font_path) as font:
|
|
self.characters = set(font.getBestCmap())
|
|
self.fonts = {}
|
|
def font(self, size: int) -> PIL.ImageFont.FreeTypeFont:
|
|
if size not in self.fonts:
|
|
self.fonts[size] = PIL.ImageFont.truetype(str(self.font_path), size, layout_engine=PIL.ImageFont.Layout.RAQM)
|
|
return self.fonts[size]
|
|
def validate(self, text: str) -> None:
|
|
if not isinstance(text, str) or not text.strip() or len(text) > 200:
|
|
raise ValueError('Pico title and part must contain 1 to 200 characters')
|
|
if any(ord(character) < 32 or ord(character) not in self.characters for character in text):
|
|
raise ValueError(f'Unsupported PetalumaScript text: {text!r}')
|
|
def line(self, text: str, width: int, height: int, size: int) -> PIL.Image.Image:
|
|
self.validate(text)
|
|
if width < 5 or height < 3:
|
|
raise ValueError('Pico text tile is too small')
|
|
font = self.font(size)
|
|
while size > 14:
|
|
box = font.getbbox(text)
|
|
if box[2] - box[0] <= width - 4 and box[3] - box[1] <= height - 2:
|
|
break
|
|
size -= 1
|
|
font = self.font(size)
|
|
if font.getbbox(text)[2] - font.getbbox(text)[0] > width - 4:
|
|
if font.getbbox('...')[2] - font.getbbox('...')[0] > width - 4:
|
|
raise ValueError('Pico text tile cannot fit an ellipsis')
|
|
original = text
|
|
while original and font.getbbox(original + '...')[2] - font.getbbox(original + '...')[0] > width - 4:
|
|
original = original[:-1]
|
|
text = original + '...'
|
|
box = font.getbbox(text)
|
|
if box[3] - box[1] > height - 2:
|
|
raise ValueError('Pico text tile is too short')
|
|
image = PIL.Image.new('L', (width, height))
|
|
PIL.ImageDraw.Draw(image).text(((width - box[2] + box[0]) // 2 - box[0],
|
|
(height - box[3] + box[1]) // 2 - box[1]), text, font=font, fill=255)
|
|
return image
|
|
def digit(self, text: str) -> PIL.Image.Image:
|
|
font = self.font(24)
|
|
box = font.getbbox(text)
|
|
if text not in '0123456789/' or len(text) != 1 or box[2] - box[0] > DIGIT_WIDTH - 4 or box[1] + 2 < 0 or box[3] + 2 > 28:
|
|
raise ValueError('Invalid Pico counter tile')
|
|
image = PIL.Image.new('L', (DIGIT_WIDTH, 28))
|
|
PIL.ImageDraw.Draw(image).text(((DIGIT_WIDTH - box[2] + box[0]) // 2 - box[0], 2), text, font=font, fill=255)
|
|
return image
|
|
def title(self, title: str) -> bytes:
|
|
self.validate(title)
|
|
for size in range(32, 15, -1):
|
|
lines = ['']
|
|
for word in title.split():
|
|
candidate = (lines[-1] + ' ' + word).strip()
|
|
box = self.font(size).getbbox(candidate)
|
|
if lines[-1] and box[2] - box[0] > TITLE_WIDTH - 4:
|
|
lines.append(word)
|
|
else:
|
|
lines[-1] = candidate
|
|
if len(lines) <= 2:
|
|
break
|
|
if len(lines) > 2:
|
|
lines = [lines[0], ' '.join(lines[1:])]
|
|
image = PIL.Image.new('L', (TITLE_WIDTH, TITLE_HEIGHT))
|
|
for index, line in enumerate(lines):
|
|
image.paste(self.line(line, TITLE_WIDTH, 32, size), (0, (TITLE_HEIGHT - len(lines) * 32) // 2 + index * 32))
|
|
return TITLE_HEADER + pack_coverage(image)
|
|
|
|
def pack_coverage(image: PIL.Image.Image) -> bytes:
|
|
pixels = image.tobytes()
|
|
if len(pixels) % 2:
|
|
pixels += b'\0'
|
|
levels = [(pixel + 8) // 17 for pixel in pixels]
|
|
return bytes((levels[index] << 4) | levels[index + 1] for index in range(0, len(levels), 2))
|
|
|
|
def generate_static(output: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
|
|
renderer = Renderer(font_root / 'PetalumaScript.otf')
|
|
directory = bytearray(struct.pack('<4sHH', b'VPSA', len(SPRITES), 0))
|
|
payload = bytearray()
|
|
for text in SPRITES:
|
|
image = renderer.line(text, 176, 28, 24) if text in LABELS else renderer.digit(text)
|
|
if text in LABELS:
|
|
box = image.getbbox()
|
|
image = image.crop((max(0, box[0] - 1), 0, min(image.width, box[2] + 1), image.height))
|
|
directory.extend(struct.pack('<HHI', image.width, image.height, 8 + 8 * len(SPRITES) + len(payload)))
|
|
payload.extend(pack_coverage(image))
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_bytes(directory + payload)
|
|
|
|
def generate_titles(root: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
|
|
"""Call after score generation, before catalog validation and atomic promotion.
|
|
|
|
Adds presentation.title_image to score bundles. The identifier is SHA-256 of
|
|
the complete fixed-size .mask file, shared across parts with the same title.
|
|
"""
|
|
renderer = Renderer(font_root / 'PetalumaScript.otf')
|
|
destination = root / 'pico' / 'titles'
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
titles = {}
|
|
for category in ('songs', 'practice'):
|
|
for source in sorted((root / category).rglob('*.json')):
|
|
document = json.loads(source.read_text(encoding='utf-8'))
|
|
title = document['music']['metadata']['title']
|
|
if title not in titles:
|
|
if len(titles) >= MAX_TITLES:
|
|
raise ValueError('Too many Pico titles')
|
|
image = renderer.title(title)
|
|
titles[title] = hashlib.sha256(image).hexdigest()
|
|
(destination / f'{titles[title]}.mask').write_bytes(image)
|
|
document['presentation']['title_image'] = titles[title]
|
|
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
|
|
|
|
def validate_title(root: pathlib.Path, identifier: str) -> None:
|
|
if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier):
|
|
raise ValueError('Invalid Pico title identifier; regenerate assets')
|
|
try:
|
|
asset_root = root.resolve(strict=True)
|
|
directory = (root / 'pico' / 'titles').resolve(strict=True)
|
|
source = (directory / f'{identifier}.mask').resolve(strict=True)
|
|
if not directory.is_relative_to(asset_root) or source.parent != directory:
|
|
raise ValueError('Pico title escapes the generated title directory')
|
|
if source.stat().st_size != TITLE_BYTES:
|
|
raise ValueError('Invalid Pico title size')
|
|
with source.open('rb') as stream:
|
|
image = stream.read(TITLE_BYTES + 1)
|
|
if len(image) != TITLE_BYTES or image[:len(TITLE_HEADER)] != TITLE_HEADER or hashlib.sha256(image).hexdigest() != identifier:
|
|
raise ValueError('Invalid Pico title header or digest')
|
|
except OSError as error:
|
|
raise ValueError(f'Pico title unavailable: {error}') from error
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--font-root', type=pathlib.Path, default=DEFAULT_FONT_ROOT)
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument('--static-output', type=pathlib.Path)
|
|
group.add_argument('--assets', type=pathlib.Path)
|
|
arguments = parser.parse_args()
|
|
if arguments.static_output:
|
|
generate_static(arguments.static_output, arguments.font_root)
|
|
else:
|
|
generate_titles(arguments.assets, arguments.font_root)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|