Files
tamabox/dev/theme-tools/export_assets.py
T
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00

220 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把 new-sakura 的内联 SVG 导出成位图,并上传到对象存储。
为什么要这一步:主题本身是纯 CSS(SVG 内联在 CSS 里),线上不依赖任何外部素材。
但对象存储上留一份位图有它的用处:
· 桶开了公开域名后,可以把 CSS 里的 url() 换成绝对地址(注意:服务端有 gzip,
内联版总传输 3.9KB 比位图版的 51KB 更小,换位图不为省流量);
· 分享卡 / 其它非 CSS 场景只能用位图;
· 换人维护时不用去读那一长串 data URI。
用法(项目根):
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出,并把 CSS + 位图 + INSTALL.txt 传到桶里
"""
import argparse
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(ROOT, "dev", "theme-builder"))
import gen_new_sakura as G # noqa: E402
OUT_DIR = os.path.join(ROOT, "outputs", "sakura-assets")
# (文件名, svg 源, 导出像素宽, 高)
def assets():
p = G.LIGHT
return [
("branch-right", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False), 840, 640),
("branch-left", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True), 840, 640),
("blossom", G.blossom(p["light"], p["deep"], p["heart"]), 400, 400),
("petal", G.petal(p["light"]), 240, 336),
("pattern", G.pattern(p["pattern"]), 600, 600),
]
def render(svg, w, h, out_png):
"""用无头 Chrome 渲染成透明底 PNG。
走命令行而不是 Playwright:本机只有 node 版 playwright,python 侧没装;
而 `--default-background-color=00000000` 正好能给透明底。
⚠️ `--window-size` 不等于 CSS 视口(实测 1080 只有 985),所以窗口开大一圈
并让内容居中,再按目标尺寸从中心裁 —— 这样尺寸误差被吸收掉,不必去猜差多少。
"""
import subprocess
import tempfile
from PIL import Image
pad = 120
html = (
'<!doctype html><meta charset="utf-8">'
"<style>html,body{margin:0;background:transparent;height:100vh;"
"display:flex;align-items:center;justify-content:center}"
f"svg{{display:block;width:{w}px;height:{h}px}}</style>" + svg
)
fd, html_path = tempfile.mkstemp(suffix=".html")
os.close(fd)
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"
cmd = [
chrome, "--headless=old", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", "--default-background-color=00000000",
"--window-size=%d,%d" % (w + pad, h + pad),
"--screenshot=" + out_png,
"file:///" + html_path.replace("\\", "/"),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
os.remove(html_path)
img = Image.open(out_png).convert("RGBA")
cw, ch = img.size
left, top = max(0, (cw - w) // 2), max(0, (ch - h) // 2)
img.crop((left, top, left + w, top + h)).save(out_png)
def to_webp(png_path, webp_path, quality=92):
from PIL import Image
img = Image.open(png_path).convert("RGBA")
img.save(webp_path, "WEBP", quality=quality, method=6)
def read_r2(path):
cfg = {}
for line in open(path, encoding="utf-8"):
m = re.match(r'^\s*([a-z_]+)\s*=\s*"?([^"\n]*)"?\s*$', line.strip())
if m:
cfg[m.group(1)] = m.group(2).strip()
return cfg
def pack_readme(prefix, object_base, sizes):
"""生成包内 INSTALL.txt。域名与体积都写实数,避免 README 与实际脱节。"""
base = (object_base or "").rstrip("/")
total_kb = sum(v for k, v in sizes.items() if k.endswith(".webp")) / 1024
css_kb = sizes.get("new-sakura.css", 0) / 1024
urls = ""
if base:
urls = "\n公开地址(已验证可匿名 GET)\n--------------------------\n" + "\n".join(
" %s/%s" % (base, n) for n in ["new-sakura.css"] + [
"%s.webp" % k for k in ["branch-right", "branch-left", "blossom", "petal", "pattern"]])
return """new-sakura(新·樱花)主题包
============================
来源:TamaBox 仓库 static/css/themes/new-sakura.css(生成产物,勿手改)
生成:dev/theme-tools/gen_new_sakura.py(花形/位置/配色都在里面)
位图:dev/theme-tools/export_assets.py(从同一份 SVG 导出,不是另画的)
说明:docs/theme-new-sakura.md
对象前缀:%s/
文件
----
new-sakura.css 主题本体 %5.1f KB(纯 CSS,樱花是内联 SVG,零外部请求)
branch-right.webp 花枝 840x640 透明底(细端在右上)
branch-left.webp 同上镜像
blossom.webp 单朵樱花 400x400 透明底
petal.webp 单瓣 240x336 透明底
pattern.webp 平铺底纹 600x600 透明底
位图合计 %5.1f KB
怎么用
------
线上只需要 new-sakura.css:放进仓库 static/css/themes/,在 conf/app.ini 的
[theme] available 里加上 new-sakura,再重新 go build(主题 CSS 是编译期 embed,
只重启不重编不会生效)。
WebP 不是必需品 —— CSS 里的樱花是矢量,比位图清晰且没有额外请求。留着的用处:
1) 素材要单独复用:python dev/theme-tools/gen_new_sakura.py --asset-base %s
(CSS 从 %.1f KB 降到 10.6 KB,但多 5 个请求共 48.9 KB,总传输更大)
2) 非 CSS 场景(分享卡、海报)只能用位图
%s
注意
----
直连桶的 S3 endpoint(*.r2.cloudflarestorage.com)会 403,那里需要签名。
浏览器直读只能用自定义域 / r2.dev 或 CDN。
缓存:位图 1 天、CSS 5 分钟、本文件 60 秒 —— 对象键不带版本,所以**不用** immutable。
要一年长缓存,得把内容哈希写进文件名(届时 CSS 里的地址也要跟着换)。
""" % (prefix, css_kb, total_kb,
base or "https://<公开域名>/" + prefix.strip("/"), css_kb, urls)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--upload", action="store_true", help="导出后上传到 data/r2.txt 里的桶")
ap.add_argument("--prefix", default="themes/new-sakura", help="对象键前缀,默认 themes/new-sakura")
ap.add_argument("--public-base", default="",
help="公开访问的主机(如 https://r2.example.com,不带路径);"
"默认读 data/r2.txt 的 image_public_base 或 image_bucket_cdn_host,"
"都没有则在 INSTALL.txt 里写占位")
ap.add_argument("--r2", default=os.path.join(ROOT, "data", "r2.txt"))
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
files, sizes = [], {}
for name, svg, w, h in assets():
png = os.path.join(OUT_DIR, name + ".png")
webp = os.path.join(OUT_DIR, name + ".webp")
render(svg, w, h, png)
to_webp(png, webp)
os.remove(png)
size = os.path.getsize(webp)
sizes[name + ".webp"] = size
print("导出 %-13s %dx%d %.1f KB" % (name, w, h, size / 1024))
files.append((name + ".webp", webp))
if not args.upload:
print("\n只导出,未上传(加 --upload 才传)。目录:%s" % OUT_DIR)
return
import base64
import serve
raw = read_r2(args.r2)
# 与 conf/app.ini 同名:image_bucket_cdn_host 只是主机名(不带 scheme)
cdn_host = raw.get("image_bucket_cdn_host", "").strip().strip("/")
public_host = (args.public_base or raw.get("image_public_base")
or ("https://" + cdn_host if cdn_host else "")).rstrip("/")
object_base = ("%s/%s" % (public_host, args.prefix.strip("/"))) if public_host else ""
cfg = serve.S3Config(dict(
endpoint=raw["image_endpoint"], region="auto", bucket=raw["image_bucket"],
prefix=args.prefix, pathStyle=True,
accessKey=raw["image_access_id"], secretKey=raw["image_access_secret"],
))
# 键名不带版本,就不配 immutable —— 否则重新导出后一年内谁看到的都还是旧图。
payload = [{"key": name, "name": name, "contentType": "image/webp",
"cacheControl": "public, max-age=86400",
"data": base64.b64encode(open(path, "rb").read()).decode()}
for name, path in files]
css_path = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
css = open(css_path, "rb").read()
sizes["new-sakura.css"] = len(css)
payload.append({"key": "new-sakura.css", "contentType": "text/css; charset=utf-8",
"cacheControl": "public, max-age=300",
"data": base64.b64encode(css).decode()})
readme = pack_readme(args.prefix, object_base, sizes).encode("utf-8")
payload.append({"key": "INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(readme).decode()})
res = serve.upload_files(cfg, payload)
for r in res["files"]:
print(("OK " if r["ok"] else "FAIL ") + r["key"] + ("" if r["ok"] else " " + str(r.get("error"))))
print("\n上传 %d/%d" % (res["total"] - res["failed"], res["total"]))
if object_base:
print("公开地址:%s/" % object_base)
if __name__ == "__main__":
main()