"""JJ_MKVMaker 아이콘 만들기 (환경 설정 > 일반 > 앱 아이콘 에서 고르는 여러 가지) 아이콘 종류 (id): yellow 노란 바탕 + 검은 J · J + 검은 재생 버튼 (두 가지 색만, 기본) black 검은 바탕 + 노란 J · J + 흰 재생 버튼 + 필름 띠 film_jj 빨강 → 주황 바탕 + 노란 J · J + 흰 재생 버튼 + 필름 띠 film 빨강 → 주황 바탕 + 큰 흰 재생 버튼 + 필름 띠 blue 청록 → 보라 바탕 + 흰 JJ + 재생 표시 (처음 아이콘) 사용법: python tool/make_icon.py 결과: assets/icon/variants/_256.png (설정 미리보기) · _button.png (앱 안 왼쪽 위 버튼) · .ico (Windows 창 · 트레이) assets/icon/app_icon_1024.png · windows/runner/resources/app_icon.ico · assets/tray_icon.ico (기본 아이콘) android/app/src/main/res/mipmap-*/ic_launcher[_].png · ic_launcher[_]_bg.png · _fg.png + mipmap-anydpi-v26/ic_launcher[_].xml (Android 8 이상 adaptive icon) build/icon_preview.png (한눈에 보기) """ from pathlib import Path from PIL import Image, ImageDraw, ImageFilter, ImageFont ROOT = Path(__file__).resolve().parent.parent S = 1024 # 원본 크기 FONT = 'C:/Windows/Fonts/seguibl.ttf' # Segoe UI Black DEFAULT = 'yellow' IDS = ['yellow', 'black', 'film_jj', 'film', 'blue'] WHITE = (255, 255, 255) BLACK = (0, 0, 0) YELLOW = (255, 210, 0) # 노란 아이콘 바탕 JJ_YELLOW = (255, 214, 0) # 검은 · 빨간 아이콘의 JJ 글자 def lerp(a, b, t): return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3)) def rounded_mask(radius): m = Image.new('L', (S, S), 0) ImageDraw.Draw(m).rounded_rectangle((0, 0, S - 1, S - 1), radius=int(S * radius), fill=255) return m def gradient(c1, c2, glow=0): """대각선 그라데이션 (왼쪽 위 c1 → 오른쪽 아래 c2) + 왼쪽 위의 은은한 빛""" g = Image.new('RGB', (S, S)) px = g.load() for y in range(S): for x in range(S): px[x, y] = lerp(c1, c2, (x + y) / (2 * (S - 1))) if glow: m = Image.new('L', (S, S), 0) ImageDraw.Draw(m).ellipse((-S * 0.4, -S * 0.5, S * 0.8, S * 0.45), fill=glow) m = m.filter(ImageFilter.GaussianBlur(S * 0.15)) g = Image.composite(Image.new('RGB', (S, S), WHITE), g, m) return g def play(scale=1.0, cx=0.5, cy=0.5, color=WHITE, shadow=(0, 0, 0, 200)): """재생 버튼 (모서리가 둥근 굵은 삼각형). shadow=None: 그림자 없음 (단색 아이콘)""" layer = Image.new('RGBA', (S, S), (0, 0, 0, 0)) h = S * 0.46 * scale w = h * 0.92 x0 = S * cx - w * 0.40 # 무게 중심이 가운데 오도록 약간 오른쪽으로 y0 = S * cy - h / 2 pts = [(x0, y0), (x0, y0 + h), (x0 + w, y0 + h / 2)] r = S * 0.05 * scale def draw(img, fill, off=(0, 0)): d = ImageDraw.Draw(img) p = [(x + off[0], y + off[1]) for x, y in pts] d.polygon(p, fill=fill) d.line(p + [p[0]], fill=fill, width=int(r * 2), joint='curve') for x, y in p: d.ellipse((x - r, y - r, x + r, y + r), fill=fill) if shadow: sh = Image.new('RGBA', (S, S), (0, 0, 0, 0)) draw(sh, shadow, off=(S * 0.012 * scale, S * 0.028 * scale)) layer = Image.alpha_composite(layer, sh.filter(ImageFilter.GaussianBlur(S * 0.025 * scale))) draw(layer, color + (255,)) return layer def jj(cx, top, bottom, color, outline=None, shadow=True): """J 를 위 · 아래로 하나씩. outline: 테두리 색 (글자와 같은 색이면 더 굵게), shadow: 그림자""" layer = Image.new('RGBA', (S, S), (0, 0, 0, 0)) h = (bottom - top) / 2 font = ImageFont.truetype(FONT, int(h * 1.18)) # 글자와 같은 색 테두리는 살짝만 (위아래 J 가 붙지 않게) stroke = 0 if not outline else int(h * (0.03 if outline == color else 0.09)) sh = Image.new('RGBA', (S, S), (0, 0, 0, 0)) for i in range(2): d = ImageDraw.Draw(layer) l, t, r, b = d.textbbox((0, 0), 'J', font=font, stroke_width=stroke) x = cx - (l + r) / 2 y = top + h * i + (h - (b - t)) / 2 - t if outline and shadow: ImageDraw.Draw(sh).text((x + h * 0.04, y + h * 0.07), 'J', font=font, fill=(0, 0, 0, 200), stroke_width=stroke, stroke_fill=(0, 0, 0, 200)) d.text((x, y), 'J', font=font, fill=color + (255,), stroke_width=stroke, stroke_fill=(outline or color) + (255,)) if outline and shadow: layer = Image.alpha_composite(sh.filter(ImageFilter.GaussianBlur(h * 0.05)), layer) return layer def film_bands(top, band, film, hole, holes=6): """위아래 필름 띠 (어두운 띠 + 밝은 구멍)""" layer = Image.new('RGBA', (S, S), (0, 0, 0, 0)) d = ImageDraw.Draw(layer) for y0 in (top, S - top - band): d.rectangle((0, y0, S, y0 + band), fill=film + (255,)) hw, hh = S / (holes * 2.2), band * 0.46 gap = S / holes for i in range(holes): cx, cy = gap * (i + 0.5), y0 + band / 2 d.rounded_rectangle((cx - hw / 2, cy - hh / 2, cx + hw / 2, cy + hh / 2), radius=hh * 0.3, fill=hole + (255,)) return layer def on(bg, *layers, radius=0.22): """둥근 사각형 바탕 위에 그림들을 겹친다 (바깥으로 나간 부분은 자름)""" mask = rounded_mask(radius) img = Image.new('RGBA', (S, S), (0, 0, 0, 0)) img.paste(bg, (0, 0), mask) for l in layers: img = Image.alpha_composite(img, Image.composite(l, Image.new('RGBA', (S, S)), mask)) return img def big_jj(color, outline=None, size=0.62): """가운데 큰 JJ (앱 안 26px 버튼처럼 작아도 또렷하게)""" layer = Image.new('RGBA', (S, S), (0, 0, 0, 0)) font = ImageFont.truetype(FONT, int(S * size)) d = ImageDraw.Draw(layer) stroke = int(S * 0.035) if outline else 0 l, t, r, b = d.textbbox((0, 0), 'JJ', font=font, stroke_width=stroke) d.text(((S - (r - l)) / 2 - l, (S - (b - t)) / 2 - t), 'JJ', font=font, fill=color + (255,), stroke_width=stroke, stroke_fill=(outline or color) + (255,)) return layer def variant(vid): """아이콘 하나: (큰 아이콘, 16~32px 용, 앱 안 버튼, Android 바탕, Android 앞)""" if vid == 'yellow': # 노랑 + 검정 두 가지 색만: 그림자 · 테두리 없이 단순하게 bg = Image.new('RGB', (S, S), YELLOW) master = on(bg, jj(S * 0.215, S * 0.2, S * 0.8, BLACK, BLACK, shadow=False), play(0.95, cx=0.6, color=BLACK, shadow=None)) small = on(bg, play(1.3, color=BLACK, shadow=None)) button = on(bg, big_jj(BLACK)) fg = Image.alpha_composite(jj(S * 0.345, S * 0.3, S * 0.7, BLACK, BLACK, shadow=False), play(0.55, cx=0.575, color=BLACK, shadow=None)) return master, small, button, bg.convert('RGBA'), fg if vid in ('black', 'film_jj', 'film'): if vid == 'black': bg, film, hole, outline = gradient((58, 58, 66), (6, 6, 8), glow=22), BLACK, (235, 235, 240), BLACK else: bg, film, hole, outline = gradient((255, 45, 85), (255, 149, 0), glow=45), (25, 10, 30), WHITE, (60, 0, 30) bands = film_bands(int(S * 0.055), int(S * 0.13), film, hole) if vid == 'film': master = on(bg, bands, play(1.0)) fg = play(0.62) else: master = on(bg, bands, jj(S * 0.19, S * 0.23, S * 0.77, JJ_YELLOW, outline), play(0.92, cx=0.6)) fg = Image.alpha_composite(jj(S * 0.335, S * 0.31, S * 0.69, JJ_YELLOW, outline), play(0.55, cx=0.57)) small = on(bg, play(1.25)) button = on(bg, big_jj(JJ_YELLOW, outline)) a_bg = Image.alpha_composite(bg.convert('RGBA'), film_bands(int(S * 0.17), int(S * 0.1), film, hole, holes=7)) return master, small, button, a_bg, fg # blue: 처음 아이콘 (청록 → 보라 + 흰 JJ + 오른쪽 아래 흰 동그라미 재생 표시) violet = (108, 92, 255) bg = gradient((0, 196, 204), violet, glow=38) def white_jj(size, dx, dy): layer = Image.new('RGBA', (S, S), (0, 0, 0, 0)) font = ImageFont.truetype(FONT, int(S * size)) l, t, r, b = ImageDraw.Draw(layer).textbbox((0, 0), 'JJ', font=font) x, y = (S - (r - l)) / 2 - l + dx, (S - (b - t)) / 2 - t + dy sh = Image.new('RGBA', (S, S), (0, 0, 0, 0)) ImageDraw.Draw(sh).text((x + S * 0.012, y + S * 0.022), 'JJ', font=font, fill=(20, 10, 60, 120)) layer = Image.alpha_composite(layer, sh.filter(ImageFilter.GaussianBlur(S * 0.018))) ImageDraw.Draw(layer).text((x, y), 'JJ', font=font, fill=WHITE + (255,)) return layer def badge(cx, cy, rad): b = Image.new('RGBA', (S, S), (0, 0, 0, 0)) ImageDraw.Draw(b).ellipse((cx - rad * 1.12, cy - rad * 1.12, cx + rad * 1.12, cy + rad * 1.12), fill=(40, 20, 110, 90)) b = b.filter(ImageFilter.GaussianBlur(S * 0.012)) d = ImageDraw.Draw(b) d.ellipse((cx - rad, cy - rad, cx + rad, cy + rad), fill=WHITE + (255,)) tri = rad * 0.52 d.polygon([(cx - tri * 0.62, cy - tri), (cx - tri * 0.62, cy + tri), (cx + tri * 0.95, cy)], fill=violet + (255,)) return b master = on(bg, white_jj(0.56, -S * 0.035, -S * 0.02), badge(S * 0.765, S * 0.765, S * 0.135), radius=0.23) small = on(bg, white_jj(0.66, 0, 0), radius=0.23) fg = Image.alpha_composite(white_jj(0.36, -S * 0.02, -S * 0.01), badge(S * 0.64, S * 0.64, S * 0.085)) return master, small, small, bg.convert('RGBA'), fg def save_ico(path, master, small, sizes): imgs = [(small if s <= 32 else master).resize((s, s), Image.LANCZOS) for s in sizes] imgs[-1].save(path, format='ICO', sizes=[(s, s) for s in sizes], append_images=imgs[:-1]) def main(): icon_dir = ROOT / 'assets' / 'icon' out = icon_dir / 'variants' out.mkdir(parents=True, exist_ok=True) res = ROOT / 'android' / 'app' / 'src' / 'main' / 'res' any_dpi = res / 'mipmap-anydpi-v26' any_dpi.mkdir(parents=True, exist_ok=True) dpis = {'mdpi': 48, 'hdpi': 72, 'xhdpi': 96, 'xxhdpi': 144, 'xxxhdpi': 192} # 예전 판에서 만든 파일 정리 for name in ('app_icon_256.png', 'app_icon_small_256.png', 'app_button_256.png', 'preview.png'): (icon_dir / name).unlink(missing_ok=True) for d in res.glob('mipmap-*'): for f in d.glob('ic_launcher*'): f.unlink() all_icons = {} for vid in IDS: master, small, button, a_bg, a_fg = all_icons[vid] = variant(vid) master.resize((256, 256), Image.LANCZOS).save(out / f'{vid}_256.png') button.resize((128, 128), Image.LANCZOS).save(out / f'{vid}_button.png') save_ico(out / f'{vid}.ico', master, small, [16, 20, 24, 32, 40, 48, 64, 128, 256]) # Android: 고를 수 있는 아이콘마다 ic_launcher_, 기본 아이콘은 ic_launcher 로도 for name in [f'_{vid}'] + ([''] if vid == DEFAULT else []): for dpi, px in dpis.items(): d = res / f'mipmap-{dpi}' d.mkdir(parents=True, exist_ok=True) master.resize((px, px), Image.LANCZOS).save(d / f'ic_launcher{name}.png') a = px * 108 // 48 # adaptive 는 108dp a_bg.resize((a, a), Image.LANCZOS).save(d / f'ic_launcher{name}_bg.png') a_fg.resize((a, a), Image.LANCZOS).save(d / f'ic_launcher{name}_fg.png') (any_dpi / f'ic_launcher{name}.xml').write_text( '\n' '\n' f' \n' f' \n' f' \n' '\n', encoding='utf-8') if vid == DEFAULT: master.save(icon_dir / 'app_icon_1024.png') save_ico(ROOT / 'windows' / 'runner' / 'resources' / 'app_icon.ico', master, small, [16, 20, 24, 32, 40, 48, 64, 128, 256]) save_ico(ROOT / 'assets' / 'tray_icon.ico', master, small, [16, 20, 24, 32, 48]) # 한눈에 보기: 줄마다 큰 아이콘 · Android 동그라미 · 앱 안 버튼 / 16px prev = Image.new('RGBA', (len(IDS) * 280 + 20, 660), (128, 128, 136, 255)) circle = Image.new('L', (256, 256), 0) ImageDraw.Draw(circle).ellipse((0, 0, 255, 255), fill=255) c = int(S * 18 / 108) for i, vid in enumerate(IDS): master, small, button, a_bg, a_fg = all_icons[vid] x = 20 + i * 280 prev.alpha_composite(master.resize((256, 256), Image.LANCZOS), (x, 20)) full = Image.alpha_composite(a_bg, a_fg).crop((c, c, S - c, S - c)).resize((256, 256), Image.LANCZOS) prev.paste(full, (x, 296), circle) prev.alpha_composite(button.resize((52, 52), Image.LANCZOS), (x, 580)) prev.alpha_composite(small.resize((32, 32), Image.LANCZOS), (x + 80, 590)) prev.alpha_composite(small.resize((16, 16), Image.LANCZOS), (x + 130, 598)) (ROOT / 'build').mkdir(exist_ok=True) prev.save(ROOT / 'build' / 'icon_preview.png') print('icons ok') if __name__ == '__main__': main()