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 传过的键必须去控制台清缓存,重传不会让边缘失效。
This commit is contained in:
@@ -1901,7 +1901,8 @@ function renderHostEditor(){
|
||||
tip.className = 'hint';
|
||||
tip.textContent = can === false
|
||||
? '当前是普通静态服务,没有上传接口。用 python dev/theme-builder/serve.py 启动生成器才能上传。'
|
||||
: '密钥只存在当前标签页(sessionStorage)与内存里,不进工程文件、不进导出 zip。'
|
||||
: '密钥只存在当前标签页(sessionStorage):刷新还在,关掉标签页就没了,也不进工程文件与导出 zip。'
|
||||
+ '想复用就别关标签页,或用脚本直传(见 dev/theme-tools/export_assets.py)。'
|
||||
+ '上传成功后会自动把「素材前缀」填成上面的图片地址。';
|
||||
el.appendChild(tip);
|
||||
|
||||
|
||||
@@ -63,6 +63,17 @@ def main():
|
||||
check("Content-Type = image/webp", ctype == "image/webp", str(ctype))
|
||||
check("Cache-Control = immutable", "immutable" in (cache or ""), str(cache))
|
||||
|
||||
# 2b) 逐文件覆盖缓存头:README/清单这类「键不变、内容会变」的文件不能 immutable
|
||||
serve.upload_files(cfg, [
|
||||
{"key": "shiroko/INSTALL.txt", "contentType": "text/plain; charset=utf-8",
|
||||
"cacheControl": "public, max-age=60",
|
||||
"data": base64.b64encode(b"readme").decode()},
|
||||
])
|
||||
_, _, cc = MockS3.store.get("themes/shiroko/INSTALL.txt", (b"", None, None))
|
||||
check("可覆盖 Cache-Control", cc == "public, max-age=60", str(cc))
|
||||
# 这一步只为验头,别让它污染后面的对象计数
|
||||
MockS3.store.pop("themes/shiroko/INSTALL.txt", None)
|
||||
|
||||
# 3) 错误密钥:HEAD 拿不到响应体(规范如此),只能按状态码归因;
|
||||
# 真正能读到 S3 错误码的是带 body 的 PUT,所以用上传路径验证还原能力。
|
||||
bad = serve.S3Config(dict(base, accessKey="BADKEY"))
|
||||
|
||||
@@ -326,8 +326,10 @@ def upload_files(cfg, files):
|
||||
raise ValueError("内容为空")
|
||||
ctype = str(f.get("contentType") or "application/octet-stream")
|
||||
# 素材键里带内容版本(导出时由 id 决定),内容不会原地变化 → immutable。
|
||||
status, _, body = s3_request(
|
||||
cfg, "PUT", key, data, ctype, "public, max-age=31536000, immutable")
|
||||
# 但清单/说明这类「键不变、内容会变」的文件不能 immutable,否则一年内看不到更新,
|
||||
# 所以允许逐个文件覆盖。
|
||||
cache = str(f.get("cacheControl") or "public, max-age=31536000, immutable")
|
||||
status, _, body = s3_request(cfg, "PUT", key, data, ctype, cache)
|
||||
if status >= 400:
|
||||
code, msg = _parse_s3_error(body)
|
||||
results.append({"key": key, "name": name, "ok": False,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""把 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()
|
||||
@@ -56,15 +56,16 @@ def branch(wood, light, deep, heart, flip=False):
|
||||
fl = ' transform="translate(420,0) scale(-1,1)"' if flip else ""
|
||||
# 花要比枝小得多、数量多几朵。早先 5 朵 scale≈1 的花配一根细枝,缩到 300px
|
||||
# 显示时是一串糖葫芦;真实樱花枝是"小碎花沿枝密布",所以花收到 0.42~0.62。
|
||||
# 第四项是旋转角:一朵朵都朝正上方会像贴纸糊上去的,真实花枝上朝向各异
|
||||
blossoms = [
|
||||
(302, 74, 0.62), (256, 110, 0.55), (214, 138, 0.60), (172, 176, 0.58),
|
||||
(352, 42, 0.50), (140, 214, 0.46), (116, 246, 0.40), (326, 128, 0.42),
|
||||
(302, 74, 0.62, 14), (256, 110, 0.55, -22), (214, 138, 0.60, 34), (172, 176, 0.58, -6),
|
||||
(352, 42, 0.50, 48), (140, 214, 0.46, 20), (116, 246, 0.40, -32), (326, 128, 0.42, 28),
|
||||
]
|
||||
petals = [(272, 158, 22), (318, 150, -18), (196, 92, 40), (150, 196, -26)]
|
||||
body = []
|
||||
for x, y, s in blossoms:
|
||||
for x, y, s, rot in blossoms:
|
||||
body.append(
|
||||
f'<g transform="translate({x} {y}) scale({s}) translate(-50 -50)">'
|
||||
f'<g transform="translate({x} {y}) rotate({rot}) scale({s}) translate(-50 -50)">'
|
||||
'<g fill="%s">' % light
|
||||
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
|
||||
for a in (0, 72, 144, 216, 288))
|
||||
@@ -81,7 +82,7 @@ def branch(wood, light, deep, heart, flip=False):
|
||||
f'<g{fl}>'
|
||||
f'<path d="M2 318C74 276 112 212 176 184c62-28 102-72 146-118 26-28 52-46 82-58" '
|
||||
f'fill="none" stroke="{wood}" stroke-width="4.2" stroke-linecap="round" opacity=".62"/>'
|
||||
f'<path d="M176 184c-12-34-20-60-16-94M262 126c16-28 32-48 46-58" '
|
||||
f'<path d="M176 184c-4-30 4-56 16-78M262 126c16-28 32-48 46-58" '
|
||||
f'fill="none" stroke="{wood}" stroke-width="2.8" stroke-linecap="round" opacity=".5"/>'
|
||||
+ "".join(body) + "</g></svg>"
|
||||
)
|
||||
@@ -124,7 +125,22 @@ DARK = dict(
|
||||
)
|
||||
|
||||
|
||||
def uris(p):
|
||||
def uris(p, asset_base=""):
|
||||
"""asset_base 非空 -> 引用对象存储上的位图(键名与 export_assets.py 的产物一致)。
|
||||
|
||||
内联 SVG 是默认:gzip 后约 3.9KB、零额外请求。位图版 CSS 只有 10.6KB,但要多个
|
||||
5 个请求共 ~49KB,总传输反而更大 —— 它存在的理由是素材能被非 CSS 场景复用。
|
||||
换之前先确认桶已对公网可读(自定义域或 r2.dev),否则主题会整片 403。
|
||||
"""
|
||||
if asset_base:
|
||||
b = asset_base.rstrip("/")
|
||||
return {
|
||||
"blossom_l": 'url("%s/blossom.webp")' % b,
|
||||
"petal_l": 'url("%s/petal.webp")' % b,
|
||||
"branch_l": 'url("%s/branch-left.webp")' % b,
|
||||
"branch_r": 'url("%s/branch-right.webp")' % b,
|
||||
"pattern": 'url("%s/pattern.webp")' % b,
|
||||
}
|
||||
return {
|
||||
"blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])),
|
||||
"petal_l": uri(petal(p["light"])),
|
||||
@@ -214,9 +230,9 @@ def scope_block(prefix, layers, indent=" "):
|
||||
)
|
||||
|
||||
|
||||
def build():
|
||||
u = uris(LIGHT)
|
||||
d = uris(DARK)
|
||||
def build(asset_base=""):
|
||||
u = uris(LIGHT, asset_base)
|
||||
d = uris(DARK, asset_base)
|
||||
|
||||
vars_light = "\n".join([
|
||||
f" --sk-blossom: {u['blossom_l']};",
|
||||
@@ -368,7 +384,17 @@ html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
css = build()
|
||||
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="生成 new-sakura 主题 CSS")
|
||||
ap.add_argument("--asset-base", default="",
|
||||
help="引用对象存储位图的前缀,如 https://r2.example.com/themes/new-sakura;"
|
||||
"留空 = 内联 SVG(默认)")
|
||||
ap.add_argument("--out", default=OUT, help="输出路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
css = build(args.asset_base)
|
||||
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(css)
|
||||
print("已生成 %s(%.1f KB)" % (OUT, len(css.encode()) / 1024))
|
||||
print("已生成 %s(%.1f KB)%s" % (args.out, len(css.encode()) / 1024,
|
||||
" [位图版]" if args.asset_base else " [内联 SVG]"))
|
||||
|
||||
Reference in New Issue
Block a user