Source code for batools.resourcesmakefile

# Released under the MIT License. See LICENSE for details.
#
"""Generate our resources Makefile.

(builds things like icons, banners, images, etc.)
"""

from __future__ import annotations

import os
from pathlib import Path
from dataclasses import dataclass

from efro.error import CleanError


# These paths need to be relative to the dir we're writing the Makefile to.
ROOT_DIR = '$(PROJ_DIR)'
TOOLS_DIR = '$(TOOLS_DIR)'
BUILD_DIR = '$(BUILD_DIR)'
RESIZE_CMD = os.path.join(TOOLS_DIR, 'pcommand resize_image')


[docs] @dataclass class Target: """A target to be added to the Makefile.""" src: list[str] dst: str cmd: str mkdir: bool = False
[docs] def emit(self) -> str: """Gen a Makefile target.""" out: str = self.dst.replace(' ', '\\ ') out += ( ' : ' + ' '.join(s for s in self.src) + ( ('\n\t@mkdir -p "' + os.path.dirname(self.dst) + '"') if self.mkdir else '' ) + '\n\t@' + self.cmd + '\n' ) return out
[docs] class ResourcesMakefileGenerator: """Does the thing.""" def __init__( self, projroot: str, existing_data: str, projname: str, ) -> None: from efrotools.project import getprojectconfig self.public = getprojectconfig(Path(projroot))['public'] assert isinstance(self.public, bool) self.existing_data = existing_data self.projroot = projroot self.targets: list[Target] = [] # Regular and lowercase project name. self.nameu = projname self.namel = projname.lower()
[docs] def run(self) -> str: """Does the thing.""" # fname = 'src/resources/Makefile' # with open(fname, encoding='utf-8') as infile: # original = infile.read() original = self.existing_data lines = original.splitlines() auto_start_public = lines.index('# __AUTOGENERATED_PUBLIC_BEGIN__') auto_end_public = lines.index('# __AUTOGENERATED_PUBLIC_END__') auto_start_private = lines.index('# __AUTOGENERATED_PRIVATE_BEGIN__') auto_end_private = lines.index('# __AUTOGENERATED_PRIVATE_END__') # Public targets (full sources available in public) basename = 'public' our_lines_public = ( _empty_line_if(bool(self.targets)) + self._emit_group_build_lines(basename) + self._emit_group_clean_lines(basename) + [t.emit() for t in self.targets] ) # Only rewrite the private section in the private repo; otherwise # keep the existing one intact. if self.public: our_lines_private = lines[auto_start_private + 1 : auto_end_private] else: # Private targets (available in public through efrocache) self.targets = [] basename = 'private' self._add_windows_icon(generic=True, oculus=False, inputs=False) our_lines_private_1 = ( _empty_line_if(bool(self.targets)) + self._emit_group_build_lines(basename) + self._emit_group_clean_lines(basename) + ['# __EFROCACHE_TARGET__\n' + t.emit() for t in self.targets] + self._emit_group_efrocache_lines() ) # Private-internal targets (not available at all in public) self.targets = [] basename = 'private-internal' self._add_windows_icon(generic=False, oculus=True, inputs=True) self._add_ios_app_icon() self._add_macos_app_icon() self._add_android_app_icon() self._add_android_app_icon_new() self._add_android_cardboard_app_icon() self._add_android_cardboard_app_icon_new() self._add_android_tv_banner() self._add_apple_tv_top_shelf() self._add_apple_tv_3d_icon() self._add_apple_tv_store_icon() self._add_google_vr_icon() self._add_macos_cursor() our_lines_private_2 = ( ['# __PUBSYNC_STRIP_BEGIN__'] + _empty_line_if(bool(self.targets)) + self._emit_group_build_lines(basename) + self._emit_group_clean_lines(basename) + [t.emit() for t in self.targets] + ['# __PUBSYNC_STRIP_END__'] ) our_lines_private = our_lines_private_1 + our_lines_private_2 filtered = ( lines[: auto_start_public + 1] + our_lines_public + lines[auto_end_public : auto_start_private + 1] + our_lines_private + lines[auto_end_private:] ) out = '\n'.join(filtered) + '\n' return out
def _emit_group_build_lines(self, basename: str) -> list[str]: """Gen a group build target.""" del basename # Unused. out: list[str] = [] if not self.targets: return out all_dsts = set() for target in self.targets: all_dsts.add(target.dst) out.append( "# Add this section's targets to the overall resources target.\n" 'resources: \\\n ' + ' \\\n '.join( dst.replace(' ', '\\ ') for dst in sorted(all_dsts) ) + '\n' ) return out def _emit_group_clean_lines(self, basename: str) -> list[str]: """Gen a group clean target.""" out: list[str] = [] if not self.targets: return out all_dsts = set() for target in self.targets: all_dsts.add(target.dst) out.append( f'clean-{basename}:\n\trm -f ' + ' \\\n '.join('"' + dst + '"' for dst in sorted(all_dsts)) + '\n' ) out.append( '# Include this section in an overall clean.\n' f'clean: clean-{basename}\n' ) return out def _emit_group_efrocache_lines(self) -> list[str]: """Gen a group clean target.""" out: list[str] = [] if not self.targets: return out all_dsts = set() for target in self.targets: # We may need to make pipeline adjustments if/when we get filenames # with spaces in them. if ' ' in target.dst: raise CleanError( 'FIXME: need to account for spaces in filename' f' "{target.dst}".' ) all_dsts.add(target.dst) out.append( 'efrocache-list:\n\t@echo ' + ' \\\n '.join('"' + dst + '"' for dst in sorted(all_dsts)) + '\n' ) out.append('efrocache-build: resources\n') return out def _add_windows_icon( self, generic: bool, oculus: bool, inputs: bool ) -> None: sizes = [256, 128, 96, 64, 48, 32, 16] all_icons = [] for size in sizes: dst_base = BUILD_DIR src = os.path.join('icon', 'icon_clipped.png') dst = os.path.join(dst_base, 'win_icon_' + str(size) + '_tmp.png') cmd = ' '.join( [ RESIZE_CMD, str(size), str(size), '"' + src + '"', '"' + dst + '"', ] ) all_icons.append(dst) if inputs: self.targets.append( Target(src=[src], dst=dst, cmd=cmd, mkdir=True) ) # Assemble all the bits we just made into .ico files. for path, enable in [ ( f'{ROOT_DIR}/{self.namel}-windows/Generic/{self.nameu}.ico', generic, ), ( f'{ROOT_DIR}/{self.namel}-windows/Oculus/{self.nameu}.ico', oculus, ), ]: cmd = ( 'convert ' + ''.join([' "' + f + '"' for f in all_icons]) + ' "' + path + '"' ) if enable: self.targets.append(Target(src=all_icons, dst=path, cmd=cmd)) def _add_ios_app_icon(self) -> None: sizes = [ # (20, 2), # (20, 3), # (29, 2), # (29, 3), # (40, 2), # (40, 3), # (60, 2), # (60, 3), # (20, 1), # (29, 1), # (40, 1), # (76, 1), # (76, 2), # (83.5, 2), (1024, 1), ] for size in sizes: res = int(size[0] * size[1]) src = os.path.join('icon', 'icon_flat.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'AppIcon iOS.appiconset', 'icon_' + str(size[0]) + 'x' + str(size[1]) + '.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd)) def _add_macos_app_icon(self) -> None: sizes = [ (16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2), ] for size in sizes: res = int(size[0] * size[1]) # The largest size gets used by the Mac App Store, and lots # of games seem to fill their entire icon canvas instead of # sticking with the big-sur icon size, so ours looks kinda # small next to those if we don't do the same. Strangely, # iOS apps in the Mac App Store also show up large like that # (as of Nov 2023). So we use a separate as-big-as-possible # icon for our largest size only. The downside of this is # our icon changes in appearance if someone cranks the # finder view options icon size slider all the way up, but # who actually does that? srcname = ( 'icon_clipped_mac_app_store.png' if size[0] == 512 else 'icon_clipped_mac.png' ) src = os.path.join('icon', srcname) dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'AppIcon macOS.appiconset', 'icon_' + str(size[0]) + 'x' + str(size[1]) + '.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd)) def _add_android_app_icon( self, src_name: str = 'icon_clipped.png', variant_name: str = 'main', ) -> None: sizes = [ ('mdpi', 48), ('hdpi', 72), ('xhdpi', 96), ('xxhdpi', 144), ('xxxhdpi', 192), ] for size in sizes: res = size[1] src = os.path.join('icon', src_name) dst = os.path.join( ROOT_DIR, f'{self.namel}-android', f'{self.nameu}', 'src', variant_name, 'res', 'mipmap-' + size[0], 'ic_launcher.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd, mkdir=True)) def _add_android_app_icon_new( self, src_fg_name: str = 'icon_android_layered_fg.png', src_bg_name: str = 'icon_android_layered_bg.png', variant_name: str = 'main', ) -> None: sizes = [ ('mdpi', 108), ('hdpi', 162), ('xhdpi', 216), ('xxhdpi', 324), ('xxxhdpi', 432), ] for size in sizes: res = size[1] # Foreground component. src = os.path.join('icon', src_fg_name) dst = os.path.join( ROOT_DIR, f'{self.namel}-android', f'{self.nameu}', 'src', variant_name, 'res', 'mipmap-' + size[0], 'ic_launcher_foreground.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd, mkdir=True)) # Background component. src = os.path.join('icon', src_bg_name) dst = os.path.join( ROOT_DIR, f'{self.namel}-android', f'{self.nameu}', 'src', variant_name, 'res', 'mipmap-' + size[0], 'ic_launcher_background.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd, mkdir=True)) def _add_android_cardboard_app_icon(self) -> None: self._add_android_app_icon( src_name='icon_clipped_vr.png', variant_name='cardboard', ) def _add_android_cardboard_app_icon_new(self) -> None: self._add_android_app_icon_new( src_fg_name='icon_android_layered_fg_vr.png', variant_name='cardboard', ) def _add_android_tv_banner(self) -> None: res = (320, 180) src = os.path.join('banner', 'banner_16x9.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-android', f'{self.nameu}', 'src', 'main', 'res', 'drawable-xhdpi', 'banner.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res[0]), str(res[1]), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd, mkdir=True)) def _add_apple_tv_top_shelf(self) -> None: instances = [ ('24x9', '', '', 1920, 720), ('29x9', ' Wide', '_wide', 2320, 720), ] for instance in instances: for scale in [1, 2]: res = (instance[3] * scale, instance[4] * scale) src = os.path.join('banner', 'banner_' + instance[0] + '.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'tvOS App Icon & Top Shelf Image.brandassets', 'Top Shelf Image' + instance[1] + '.imageset', 'shelf' + instance[2] + '_' + str(scale) + 'x.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res[0]), str(res[1]), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd)) def _add_apple_tv_3d_icon(self) -> None: res = (400, 240) for layer in ['Layer1', 'Layer2', 'Layer3', 'Layer4', 'Layer5']: for scale in [1, 2]: src = os.path.join('icon_appletv', layer.lower() + '.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'tvOS App Icon & Top Shelf Image.brandassets', 'App Icon.imagestack', layer + '.imagestacklayer', 'Content.imageset', layer.lower() + '_' + str(scale) + 'x.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res[0] * scale), str(res[1] * scale), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd)) def _add_apple_tv_store_icon(self) -> None: res = (1280, 768) for layer in ['Layer1', 'Layer2', 'Layer3', 'Layer4', 'Layer5']: for scale in [1]: src = os.path.join('icon_appletv', layer.lower() + '.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'tvOS App Icon & Top Shelf Image.brandassets', 'App Icon - App Store.imagestack', layer + '.imagestacklayer', 'Content.imageset', layer.lower() + '_' + str(scale) + 'x.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res[0] * scale), str(res[1] * scale), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd)) def _add_google_vr_icon(self) -> None: res = (512, 512) for layer in ['vr_icon_background', 'vr_icon']: src = os.path.join('icon_googlevr', layer + '.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-android', f'{self.nameu}', 'src', 'cardboard', 'res', 'drawable-nodpi', layer + '.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res[0]), str(res[1]), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd, mkdir=True)) def _add_macos_cursor(self) -> None: sizes = [ (64, 1), (64, 2), ] for size in sizes: res = int(size[0] * size[1]) src = os.path.join('cursor.png') dst = os.path.join( ROOT_DIR, f'{self.namel}-xcode', f'{self.nameu} Shared', 'Assets.xcassets', 'Cursor macOS.imageset', 'cursor_' + str(size[0]) + 'x' + str(size[1]) + '.png', ) cmd = ' '.join( [ RESIZE_CMD, str(res), str(res), '"' + src + '"', '"' + dst + '"', ] ) self.targets.append(Target(src=[src], dst=dst, cmd=cmd))
def _empty_line_if(condition: bool) -> list[str]: return [''] if condition else [] # Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum