"""web/keeper 封面:1080x1920,孤岛灯塔主题——墨黑风暴海、孤塔双道光束、 雨丝与闪电微光,竖排标题《守塔人》。可复现脚本(固定 seed,无外部网络依赖)。 用法:python3 web/keeper/gen/gen_cover.py (或 .venv-tts/bin/python web/keeper/gen/gen_cover.py) """ import math import os import random from PIL import Image, ImageDraw, ImageFilter, ImageFont W, H = 1080, 1920 FONT_CANDIDATES = [ "/home/xsl/SoundGame/web/rhythm/gen/NotoSansSC-Bold.ttf", "/home/xsl/SoundGame/app/src/reflex/gen/NotoSansSC-Bold.ttf", ] OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cover.png") def load_font(size): for p in FONT_CANDIDATES: if os.path.exists(p): return ImageFont.truetype(p, size) raise SystemExit("未找到中文字体,请放一份 NotoSansSC-Bold.ttf 到 web/rhythm/gen/") def main(): rng = random.Random(2114) # 固定 seed:可复现 img = Image.new("RGB", (W, H), (6, 10, 18)) d = ImageDraw.Draw(img) # 背景竖向渐变:墨黑风暴 → 海面微光(灯塔下方最亮) for y in range(H): t = y / H glow = max(0.0, 1.0 - abs(t - 0.62) * 3.2) * 26 d.line([(0, y), (W, y)], fill=(int(6 + 8 * t), int(10 + 12 * t), int(18 + 22 * t + glow))) # 雨丝(风暴签名) rain = Image.new("RGBA", (W, H), (0, 0, 0, 0)) rd = ImageDraw.Draw(rain) for _ in range(340): x = rng.randint(-40, W + 40) y = rng.randint(0, H) ln = rng.randint(26, 64) rd.line([(x, y), (x - ln // 4, y + ln)], fill=(150, 175, 205, rng.randint(10, 30)), width=1) img.paste(rain, (0, 0), rain) d = ImageDraw.Draw(img) # 灯塔(中下偏左):塔身条纹 + 灯室 cx, base_y = W // 2 - 60, 1250 top_y = 620 for i in range(7): y0 = top_y + i * 90 y1 = y0 + 90 w0 = 46 + i * 7 w1 = 46 + (i + 1) * 7 col = (214, 218, 224) if i % 2 == 0 else (158, 42, 43) d.polygon([(cx - w0, y0), (cx + w0, y0), (cx + w1, y1), (cx - w1, y1)], fill=col) d.rectangle([cx - 40, top_y - 46, cx + 40, top_y], fill=(30, 34, 44)) d.rectangle([cx - 26, top_y - 38, cx + 26, top_y - 8], fill=(255, 214, 120)) d.polygon([(cx - 46, top_y - 46), (cx + 46, top_y - 46), (cx, top_y - 84)], fill=(24, 28, 38)) # 双道光束(旋转灯扫过雨夜,暖黄、模糊压暗) beam = Image.new("RGBA", (W, H), (0, 0, 0, 0)) bd = ImageDraw.Draw(beam) for ang, alpha in ((-18, 60), (162, 44)): a = math.radians(ang) bx, by = cx, top_y - 27 L = 950 ex, ey = bx + L * math.cos(a), by - L * math.sin(a) * 0.42 spread = 90 perp = (math.cos(a + math.pi / 2), -math.sin(a + math.pi / 2) * 0.42) bd.polygon([(bx, by), (ex + perp[0] * spread, ey + perp[1] * spread), (ex - perp[0] * spread, ey - perp[1] * spread)], fill=(255, 206, 110, alpha)) beam = beam.filter(ImageFilter.GaussianBlur(22)) img.paste(beam, (0, 0), beam) d = ImageDraw.Draw(img) # 海面(塔基下):暗色波纹横线 sea = Image.new("RGBA", (W, H), (0, 0, 0, 0)) sd = ImageDraw.Draw(sea) for i in range(60): y = base_y + rng.randint(-20, 330) x0 = rng.randint(-60, W - 200) ln = rng.randint(120, 400) sd.line([(x0, y), (x0 + ln, y)], fill=(90, 120, 150, rng.randint(16, 44)), width=2) img.paste(sea, (0, 0), sea) d = ImageDraw.Draw(img) # 标题与副题 f_title = load_font(180) f_sub = load_font(52) f_small = load_font(38) def ctext(y, s, font, fill, spacing=8): bbox = d.textbbox((0, 0), s, font=font) w = bbox[2] - bbox[0] x = (W - w) / 2 for ch in s: d.text((x, y), ch, font=font, fill=fill) bb = d.textbbox((0, 0), ch, font=font) x += (bb[2] - bb[0]) + spacing ctext(1430, "守塔人", f_title, (238, 242, 250), spacing=16) ctext(1700, "风暴夜 · 一盏灯 · 一条频道", f_sub, (146, 162, 186)) # 手势图例(极简两行) hints = ["单击 继续 / 跳句", "左右滑 抉择", "上滑 状态", "下滑 菜单", "双击 重听"] for i, s in enumerate(hints): col, row = i % 3, i // 3 d.text((110 + col * 300, 1800 + row * 60), s, font=f_small, fill=(104, 124, 152)) ctext(1180, "唱针落下是过去,风声回来是现在。", f_small, (96, 112, 140)) img.save(OUT) print(OUT, img.size) if __name__ == "__main__": main()