qwen_agent/skills_developing/z-card-image/scripts/render_card.py
2026-03-17 21:55:10 +08:00

179 lines
6.1 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.

#!/usr/bin/env python3
"""
render_card.py — 用模板渲染卡片图Chrome headless 截图输出 PNG
用法:
python3 render_card.py \
--template poster-3-4 \
--out /tmp/card.png \
--line1 "OpenClaw" \
--line2 "有两层" \
--line3 "model 配置" \
--highlight "#22a854" \
--bg "#e6f5ef" \
--footer "公众号 · 早早集市"
模板位于 assets/templates/<template>.html相对于本脚本所在的 skills/z-card-image/
"""
import argparse, shutil, subprocess, sys, tempfile
from html import escape
from pathlib import Path
SKILL_DIR = Path(__file__).parent.parent
TEMPLATES_DIR = SKILL_DIR / "assets" / "templates"
ICONS_DIR = SKILL_DIR / "assets" / "icons"
WECHAT_SPLIT_DEFAULT_ICON = SKILL_DIR / "assets" / "icons" / "openclaw-logo.svg"
CHROME_PATHS = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"google-chrome",
"chromium",
]
WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT = 87
def find_chrome():
for p in CHROME_PATHS:
if Path(p).exists() or shutil.which(p):
return p
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--template", default="poster-3-4")
ap.add_argument("--out", required=True)
ap.add_argument("--line1", default="")
ap.add_argument("--line2", default="")
ap.add_argument("--line3", default="")
ap.add_argument("--hl1", action="store_true", help="第一行高亮")
ap.add_argument("--hl2", action="store_true", help="第二行高亮")
ap.add_argument("--hl3", action="store_true", help="第三行高亮")
ap.add_argument("--highlight", default="#22a854")
ap.add_argument("--bg", default="#e6f5ef")
ap.add_argument("--footer", default="公众号")
ap.add_argument("--icon", default=None, help="顶部图标路径,不传则自动判断")
ap.add_argument("--highlight-words", default="", help="要高亮的词,逗号分隔,如 '测试,openclaw'")
args = ap.parse_args()
if args.template == "wechat-cover-split":
lines = [line for line in [args.line1, args.line2, args.line3] if line]
args.line1 = lines[0] if lines else ""
args.line2 = "".join(lines[1:]) if len(lines) > 1 else ""
args.line3 = ""
args.hl2 = args.hl2 or args.hl3
args.hl3 = False
# 自动选图标
if args.icon:
icon_path = args.icon
elif args.template == "wechat-cover-split":
icon_path = WECHAT_SPLIT_DEFAULT_ICON
else:
texts = " ".join([args.line1, args.line2, args.line3]).lower()
if "openclaw" in texts:
icon_path = str(ICONS_DIR / "openclaw-logo.svg")
else:
icon_path = str(ICONS_DIR / "zzclub-logo-gray.svg")
tpl_path = TEMPLATES_DIR / f"{args.template}.html"
if not tpl_path.exists():
sys.exit(f"Template not found: {tpl_path}")
html = tpl_path.read_text(encoding="utf-8")
replacements = {
"{{MAIN_TEXT_LINE1}}": escape(args.line1),
"{{MAIN_TEXT_LINE2}}": escape(args.line2),
"{{MAIN_TEXT_LINE3}}": escape(args.line3),
"{{LINE1_CLASS}}": "highlight" if args.hl1 else "",
"{{LINE2_CLASS}}": "highlight" if args.hl2 else "",
"{{LINE3_CLASS}}": "highlight" if args.hl3 else "",
"{{HIGHLIGHT_COLOR}}": args.highlight,
"{{BG_COLOR}}": args.bg,
"{{FOOTER_TEXT}}": escape(args.footer),
"{{ICON_PATH}}": icon_path,
"{{FONT_PATH}}": str(SKILL_DIR / "assets" / "fonts" / "AlimamaShuHeiTi-Bold.ttf"),
"{{AVATAR_PATH}}": str(SKILL_DIR / "assets" / "icons" / "avatar_jinx_cartoon.jpg"),
}
for k, v in replacements.items():
html = html.replace(k, v)
# 词级高亮:把指定词用 <span class="highlight"> 包起来
# 注意line 内容已经被 html.escape所以匹配时用转义后的词
if args.highlight_words:
import re
words = [w.strip() for w in args.highlight_words.split(",") if w.strip()]
for word in words:
escaped_word = escape(word)
html = re.sub(
re.escape(escaped_word),
f'<span class="highlight">{escaped_word}</span>',
html
)
# 判断尺寸
size_map = {
"poster-3-4": (900, 1200),
"wechat-cover-split": (1340, 400),
}
w, h = size_map.get(args.template, (900, 1200))
chrome = find_chrome()
if not chrome:
sys.exit("Chrome/Chromium not found")
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f:
f.write(html)
tmp_html = f.name
# 输出路径统一用 workspace/tmp/,不要用 /tmp/(飞书无法上传系统临时目录)
out = Path(args.out)
screenshot_path = out
window_h = h
if args.template == "wechat-cover-split":
window_h = h + WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT
screenshot_path = out.with_name(f"{out.stem}.raw{out.suffix}")
cmd = [
chrome,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--disable-web-security=false",
f"--screenshot={screenshot_path}",
f"--window-size={w},{window_h}",
f"file://{tmp_html}",
]
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
sys.exit(f"Chrome failed:\n{result.stderr.decode()}")
if args.template == "wechat-cover-split":
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
sys.exit("wechat-cover-split 需要 ffmpeg 做顶部裁切")
crop_cmd = [
ffmpeg,
"-y",
"-loglevel",
"error",
"-i",
str(screenshot_path),
"-vf",
f"crop={w}:{h}:0:0",
"-frames:v",
"1",
str(out),
]
crop_result = subprocess.run(crop_cmd, capture_output=True)
screenshot_path.unlink(missing_ok=True)
if crop_result.returncode != 0:
sys.exit(f"ffmpeg crop failed:\n{crop_result.stderr.decode()}")
Path(tmp_html).unlink(missing_ok=True)
print(f"✅ Saved to {out}")
if __name__ == "__main__":
main()