"""《回声地窟》cover.png 生成:1080x1920,深色洞窟底 + 声波脉冲/等高线意象 + 标题。 可复现脚本(random.seed 固定)。PIL 探测顺序:系统 python3 → .venv-tts/bin/python, 都没有则 uv pip install pillow --python .venv-tts/bin/python(阿里云镜像)。 """ import math import os import random import subprocess import sys W, H = 1080, 1920 OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png") FONT_CANDIDATES = [ "/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", ] def find_font(): for p in FONT_CANDIDATES: if os.path.exists(p): return p raise SystemExit("no CJK font found; add path to FONT_CANDIDATES") def ensure_pil(): try: import PIL # noqa: F401 return except ImportError: pass venv_py = "/home/xsl/SoundGame/.venv-tts/bin/python" if os.path.exists(venv_py): subprocess.run([venv_py, "-m", "pip", "install", "pillow", "--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True) else: subprocess.run([sys.executable, "-m", "pip", "install", "pillow", "--index-url", "https://mirrors.aliyun.com/pypi/simple/"], check=True) ensure_pil() from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont # noqa: E402 random.seed(42) img = Image.new("RGB", (W, H)) px = img.load() # 深色洞窟竖向渐变:上 #06070c -> 下 #14121c(近黑的岩壁,底部微暖) top, bottom = (6, 7, 12), (20, 18, 28) for y in range(H): t = y / (H - 1) for x in range(W): px[x, y] = tuple(int(a + (b - a) * t) for a, b in zip(top, bottom)) d = ImageDraw.Draw(img) # 等高线意象:一组以画面中心偏下为圆心的不规则闭合环(洞窟剖面) cx, cy = W * 0.5, H * 0.46 for ring in range(9): rr = 90 + ring * 78 pts = [] for i in range(72): ang = i / 72 * math.tau wob = 1 + 0.10 * math.sin(ang * 3 + ring) + 0.06 * math.cos(ang * 5 - ring * 2) pts.append((cx + math.cos(ang) * rr * wob, cy + math.sin(ang) * rr * wob * 0.82)) shade = 26 + ring * 7 d.line(pts + [pts[0]], fill=(shade, shade + 4, shade + 14), width=3) # 声波脉冲:从洞窟中心向外的一圈圈脉冲环(越远越淡 = 回声衰减) pulse = Image.new("RGB", (W, H), (0, 0, 0)) pd = ImageDraw.Draw(pulse) for ring in range(5): rr = 60 + ring * 130 alpha = 90 - ring * 16 pd.ellipse([cx - rr, cy - rr * 0.82, cx + rr, cy + rr * 0.82], outline=(alpha, alpha + 30, alpha + 60), width=6) pulse = pulse.filter(ImageFilter.GaussianBlur(7)) img = ImageChops.add(img, pulse) d = ImageDraw.Draw(img) # 玩家光点 + 两侧回声亮斑(左右声道预 pan 意象) d.ellipse([cx - 10, cy - 10, cx + 10, cy + 10], fill=(235, 240, 255)) glow = Image.new("RGB", (W, H), (0, 0, 0)) gd = ImageDraw.Draw(glow) gd.ellipse([cx - 320, cy - 40, cx - 180, cy + 60], fill=(70, 110, 170)) # 左声道 gd.ellipse([cx + 190, cy - 120, cx + 330, cy - 10], fill=(70, 110, 170)) # 右声道 gd.ellipse([cx - 60, cy - 300, cx + 60, cy - 180], fill=(60, 100, 160)) # 正前远墙 glow = glow.filter(ImageFilter.GaussianBlur(30)) img = ImageChops.add(img, glow) d = ImageDraw.Draw(img) FONT = find_font() def text_center(y, s, size, fill, glow_color=None): f = ImageFont.truetype(FONT, size) bbox = d.textbbox((0, 0), s, font=f) x = (W - (bbox[2] - bbox[0])) / 2 - bbox[0] if glow_color: tg = Image.new("RGB", (W, H), (0, 0, 0)) ImageDraw.Draw(tg).text((x, y), s, font=f, fill=glow_color) tg = tg.filter(ImageFilter.GaussianBlur(22)) img.paste(ImageChops.add(img.crop((0, 0, W, H)), tg).crop((0, 0, W, H)), (0, 0)) d.text((x, y), s, font=f, fill=fill) else: d.text((x, y), s, font=f, fill=fill) title = "回 声 地 窟" f = ImageFont.truetype(FONT, 210) bbox = d.textbbox((0, 0), title, font=f) tx = (W - (bbox[2] - bbox[0])) / 2 - bbox[0] ty = 1210 tg = Image.new("RGB", (W, H), (0, 0, 0)) ImageDraw.Draw(tg).text((tx, ty), title, font=f, fill=(40, 90, 150)) tg = tg.filter(ImageFilter.GaussianBlur(20)) img = ImageChops.add(img, tg) d = ImageDraw.Draw(img) d.text((tx, ty), title, font=f, fill=(230, 240, 252)) text_center(1490, "ECHO CAVERN", 42, (96, 128, 176)) text_center(1600, "伸手不见五指,声音就是你的眼睛", 56, (206, 220, 244)) text_center(1700, "单击脉冲回声看路 · 按住前进 · 左右滑转身", 40, (128, 142, 170)) text_center(1770, "双击再听 · 上滑状态 · 下滑菜单", 40, (128, 142, 170)) img.save(OUT) print("saved", os.path.abspath(OUT), img.size)