o2o-infinith-demo/scripts/make_share_assets.py
Haewon Kam bfde7f61b2 feat(landing): 히어로 문구를 'AI에게 병원을 묻습니다'로
특정 서비스명(ChatGPT와 Perplexity) 대신 AI로 바꿨다. webforai.kr 공유 이미지의
같은 문장도 함께 바꿨다. 아직 배포하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:55:59 +09:00

210 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""링크 공유 미리보기 이미지(og:image 1200x630 JPEG)와 파비콘 세트를 만든다.
카카오톡·슬랙·iMessage 에 주소를 붙였을 때 회색 링크 아이콘 대신 이 이미지와 아이콘이 나오게 한다.
# AI Discovery 랜딩(webforai.kr). 결과: public/og-discovery.jpg, public/discovery-favicon-*.png 외
# public/ 은 infinith-demo 배포와 공용이라 파일명에 접두어를 붙이고, 링크 태그는 VITE_SITE=discovery 빌드에만 넣는다.
python3 scripts/make_share_assets.py discovery --out public
# 서포터즈 사이트. 워커 build 단계가 부른다. 결과: <site>/public/og.jpg, favicon.ico 외
python3 scripts/make_share_assets.py supporters --site ~/supporters-builds/oracle/site
서포터즈 입력: src/data/site.json(shareNameEn·buildingImage·heroImage), src/data/factSheet.json(shortName),
vercel.json build.env.SITE_URL(하단 주소 표기). 사진은 heroImage, 없으면 buildingImage. 사진이 없으면 사진 칸 없이 글자 판만 만든다.
서포터즈 이미지는 해외 환자용이라 영어로 낸다. 파비콘은 병원 로고 대신 영문 상호 첫 글자를 쓴다. 가로형 로고는 16px 에서 읽히지 않는다.
"""
import argparse
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
W, H = 1200, 630
INK = (10, 17, 40) # #0A1128
INDIGO = (29, 0, 36) # #1D0024
VIOLET = (79, 29, 161) # #4F1DA1
NAVY = (2, 19, 65) # #021341
LIGHT = (247, 248, 252) # #F7F8FC
SLATE = (71, 85, 105) # #475569
LAVENDER = (233, 213, 255) # #E9D5FF, 어두운 배경 전용
FONT_DIRS = [Path.home() / "Library/Fonts", Path("/Library/Fonts"), Path("/usr/share/fonts"), Path("/usr/local/share/fonts")]
def font(weight: str, size: int) -> ImageFont.FreeTypeFont:
for d in FONT_DIRS:
for name in (f"Pretendard-{weight}.otf", f"Pretendard-{weight}.ttf"):
for p in [d / name, *d.rglob(name)] if d.exists() else []:
if p.exists():
return ImageFont.truetype(str(p), size)
for fallback in ("/System/Library/Fonts/AppleSDGothicNeo.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc"):
if Path(fallback).exists():
return ImageFont.truetype(fallback, size)
sys.exit("한글 글꼴을 찾지 못했습니다. Pretendard 를 설치하세요.")
def gradient(size, start, end, diagonal=True):
w, h = size
base = Image.new("RGB", size, start)
top = Image.new("RGB", size, end)
mask = Image.new("L", size)
md = mask.load()
for y in range(h):
for x in range(0, w, 1 if w <= 64 else 2):
t = ((x / max(w - 1, 1)) + (y / max(h - 1, 1))) / 2 if diagonal else y / max(h - 1, 1)
v = int(255 * t)
md[x, y] = v
if w > 64 and x + 1 < w:
md[x + 1, y] = v
return Image.composite(top, base, mask)
def wrap(draw, text, fnt, max_w):
"""한국어 어절 단위 줄바꿈(keep-all). 줄바꿈 문자는 그대로 존중한다."""
lines = []
for para in text.split("\n"):
cur = ""
for word in para.split(" "):
trial = f"{cur} {word}".strip()
if draw.textlength(trial, font=fnt) <= max_w or not cur:
cur = trial
else:
lines.append(cur)
cur = word
lines.append(cur)
return lines
def save_favicons(out: Path, label: str, prefix: str = ""):
"""상호 첫 글자(또는 AI) 모노그램. 둥근 정사각 그라디언트 위 흰 글자."""
out.mkdir(parents=True, exist_ok=True)
def icon(px, radius_ratio=0.22, full_bleed=False):
s = px * 4
bg = gradient((s, s), VIOLET, NAVY)
mask = Image.new("L", (s, s), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, s - 1, s - 1], radius=0 if full_bleed else int(s * radius_ratio), fill=255)
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
img.paste(bg, (0, 0), mask)
d = ImageDraw.Draw(img)
size = int(s * (0.62 if len(label) == 1 else 0.46))
f = font("Bold", size)
box = d.textbbox((0, 0), label, font=f)
tw, th = box[2] - box[0], box[3] - box[1]
d.text(((s - tw) / 2 - box[0], (s - th) / 2 - box[1]), label, font=f, fill="white")
return img.resize((px, px), Image.LANCZOS)
written = []
for name, px, bleed in [("favicon-32.png", 32, False), ("favicon-192.png", 192, False), ("favicon-512.png", 512, False), ("apple-touch-icon.png", 180, True)]:
icon(px, full_bleed=bleed).save(out / f"{prefix}{name}")
written.append(f"{prefix}{name}")
icon(256).save(out / f"{prefix}favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
written.append(f"{prefix}favicon.ico")
return written
def discovery(out: Path):
img = Image.new("RGB", (W, H), LIGHT)
d = ImageDraw.Draw(img)
# 왼쪽 세로 띠
img.paste(gradient((16, H), VIOLET, NAVY, diagonal=False), (0, 0))
x = 96
d.text((x, 92), "INFINITH AI Discovery", font=font("SemiBold", 30), fill=VIOLET)
title = font("ExtraBold", 84)
d.text((x, 170), "AI가 추천하는 병원은", font=title, fill=INDIGO)
d.text((x, 272), "따로 있습니다.", font=title, fill=VIOLET)
sub = font("Medium", 34)
d.text((x, 410), "고객은 이제 AI에게 병원을 묻습니다.", font=sub, fill=SLATE)
d.text((x, 458), "답변에 인용될 근거를 실측해 진단하고, 8주 안에 구축합니다.", font=sub, fill=SLATE)
d.line([(x, 540), (W - 96, 540)], fill=(226, 232, 240), width=2)
d.text((x, 556), "webforai.kr", font=font("SemiBold", 28), fill=INDIGO)
img.save(out / "og-discovery.jpg", quality=88, optimize=True, progressive=True)
return ["og-discovery.jpg", *save_favicons(out, "AI", prefix="discovery-")]
def playfair(size: int) -> ImageFont.FreeTypeFont:
for d in FONT_DIRS:
p = d / "PlayfairDisplay-Bold.ttf"
if p.exists():
return ImageFont.truetype(str(p), size)
return font("ExtraBold", size)
def supporters(site: Path):
"""해외 환자가 받는 링크라 영어로 낸다. 병원 영문 표기는 site.json shareNameEn(병원 공식 영문 표기, briefs 에서 전달)만 쓴다.
값이 없으면 한국어 상호를 그대로 쓴다. 영문 이름을 지어내지 않는다."""
data = site / "src/data"
s = json.loads((data / "site.json").read_text())
fact = json.loads((data / "factSheet.json").read_text())
short_ko = (fact.get("shortName") or "").strip() or (s.get("siteName") or "").replace(" 서포터즈", "").strip()
name = (s.get("shareNameEn") or "").strip() or short_ko
if not name:
sys.exit("site.json shareNameEn 과 factSheet.json shortName 이 모두 비어 있습니다.")
url = ""
vj = site / "vercel.json"
if vj.exists():
url = json.loads(vj.read_text()).get("build", {}).get("env", {}).get("SITE_URL", "")
host = url.replace("https://", "").replace("http://", "").rstrip("/")
photo = None
for key in ("heroImage", "buildingImage"):
src = (s.get(key) or {}).get("src") if isinstance(s.get(key), dict) else None
if src and (site / "public" / src.lstrip("/")).exists():
photo = site / "public" / src.lstrip("/")
break
img = Image.new("RGB", (W, H), INK)
panel_w = 700 if photo else W
img.paste(gradient((panel_w, H), VIOLET, NAVY), (0, 0))
if photo:
ph = ImageOps.fit(Image.open(photo).convert("RGB"), (W - panel_w, H), Image.LANCZOS, centering=(0.5, 0.4))
img.paste(ph, (panel_w, 0))
d = ImageDraw.Draw(img)
x, max_w = 72, panel_w - 72 * 2
d.text((x, 78), "SUPPORTERS · FOR INTERNATIONAL PATIENTS", font=font("SemiBold", 24), fill=LAVENDER)
name_f = playfair(72)
while max(d.textlength(w, font=name_f) for w in name.split(" ")) > max_w and name_f.size > 44:
name_f = playfair(name_f.size - 4)
y = 150
for line in wrap(d, name, name_f, max_w):
d.text((x, y), line, font=name_f, fill="white")
y += int(name_f.size * 1.18)
y += 30
title_f = font("SemiBold", 42)
for line in wrap(d, "Answers to the questions\npatients ask first.", title_f, max_w):
d.text((x, y), line, font=title_f, fill="white")
y += int(title_f.size * 1.32)
d.text((x, H - 96), "Recovery planner · Getting here · Clinic facts", font=font("Medium", 26), fill=LAVENDER)
if host:
d.text((x, H - 58), host, font=font("SemiBold", 24), fill="white")
pub = site / "public"
# JPEG 로 낸다. WhatsApp 등 일부 미리보기는 300KB 를 넘는 이미지를 버린다.
img.save(pub / "og.jpg", quality=86, optimize=True, progressive=True)
if (pub / "og.png").exists():
(pub / "og.png").unlink()
# 템플릿 기본(Astro) 파비콘 제거. 로고 기반 favicon.svg 가 있어도 16px 에서 읽히지 않아 모노그램으로 통일한다.
for old in ("favicon.svg",):
if (pub / old).exists():
(pub / old).unlink()
return ["og.jpg", *save_favicons(pub, name[0].upper())]
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="mode", required=True)
a = sub.add_parser("discovery"); a.add_argument("--out", default="public")
b = sub.add_parser("supporters"); b.add_argument("--site", required=True)
args = ap.parse_args()
if args.mode == "discovery":
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
files = discovery(out)
else:
files = supporters(Path(args.site).expanduser())
print("공유 이미지·파비콘: " + ", ".join(files))
if __name__ == "__main__":
main()