@optima-chat/optima-agent 0.9.12 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/.kb-skills-managed.json +9 -9
- package/.claude/skills/ads/SKILL.md +244 -244
- package/.claude/skills/ads/template/campaign/CREATIVES.md +18 -18
- package/.claude/skills/ads/template/campaign/NOTES.md +10 -10
- package/.claude/skills/ads/template/campaign/STRATEGY.md +29 -29
- package/.claude/skills/ads/template/user/ADS.md +29 -29
- package/.claude/skills/ads/template/user/LEARNINGS.md +15 -15
- package/.claude/skills/ads/template/user/PROGRESS.md +20 -20
- package/.claude/skills/ads/template/user/README.md +25 -25
- package/.claude/skills/ads/template/user/assets/.gitignore +2 -2
- package/.claude/skills/bi/SKILL.md +131 -131
- package/.claude/skills/browser/SKILL.md +201 -201
- package/.claude/skills/channels/SKILL.md +188 -188
- package/.claude/skills/collection/SKILL.md +88 -88
- package/.claude/skills/douyin/SKILL.md +408 -408
- package/.claude/skills/ffmpeg/SKILL.md +164 -164
- package/.claude/skills/gen/SKILL.md +279 -279
- package/.claude/skills/growth/SKILL.md +90 -90
- package/.claude/skills/growth/template/ACCOUNTS.md +14 -14
- package/.claude/skills/growth/template/CALENDAR.md +7 -7
- package/.claude/skills/growth/template/COMMENTS.md +7 -7
- package/.claude/skills/growth/template/GROWTH.md +37 -37
- package/.claude/skills/growth/template/PROGRESS.md +4 -4
- package/.claude/skills/growth/template/README.md +20 -20
- package/.claude/skills/growth/template/TOPICS.md +7 -7
- package/.claude/skills/homepage/SKILL.md +177 -177
- package/.claude/skills/i18n/SKILL.md +517 -517
- package/.claude/skills/ingesting-sources/SKILL.md +94 -94
- package/.claude/skills/initializing-kb/SKILL.md +117 -117
- package/.claude/skills/instagram/SKILL.md +321 -321
- package/.claude/skills/inventory/SKILL.md +328 -328
- package/.claude/skills/kol-outreach/SKILL.md +232 -232
- package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -60
- package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -6
- package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -3
- package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -88
- package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -36
- package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -6
- package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -16
- package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -4
- package/.claude/skills/kol-outreach/template/merchant/README.md +20 -20
- package/.claude/skills/linting-the-wiki/SKILL.md +68 -68
- package/.claude/skills/logistics/SKILL.md +180 -180
- package/.claude/skills/markdown-pdf/SKILL.md +72 -72
- package/.claude/skills/merchant/SKILL.md +110 -110
- package/.claude/skills/multigrid-poster/SKILL.md +192 -192
- package/.claude/skills/multigrid-poster/layouts/2x2.json +34 -34
- package/.claude/skills/multigrid-poster/layouts/3x3.json +43 -43
- package/.claude/skills/multigrid-poster/scripts/compose.py +116 -116
- package/.claude/skills/order/SKILL.md +452 -452
- package/.claude/skills/product/SKILL.md +379 -379
- package/.claude/skills/product-page/SKILL.md +106 -106
- package/.claude/skills/querying-the-wiki/SKILL.md +59 -59
- package/.claude/skills/reddit/SKILL.md +277 -277
- package/.claude/skills/review/SKILL.md +321 -321
- package/.claude/skills/scout/SKILL.md +575 -575
- package/.claude/skills/sentinel/SKILL.md +281 -281
- package/.claude/skills/shein/SKILL.md +246 -246
- package/.claude/skills/shipping/SKILL.md +200 -200
- package/.claude/skills/shop-content/SKILL.md +101 -101
- package/.claude/skills/shopify/SKILL.md +282 -282
- package/.claude/skills/skillify/SKILL.md +114 -114
- package/.claude/skills/taobao/SKILL.md +238 -238
- package/.claude/skills/tiktok/SKILL.md +381 -381
- package/.claude/skills/twitter/SKILL.md +302 -302
- package/.claude/skills/updating-related-pages/SKILL.md +65 -65
- package/.claude/skills/video-edit/SKILL.md +123 -138
- package/.claude/skills/video-gen/SKILL.md +720 -630
- package/.claude/skills/video-gen/templates/INDEX.md +78 -78
- package/.claude/skills/video-gen/templates/before-after-beauty.md +183 -183
- package/.claude/skills/video-gen/templates/drama-fmcg.md +183 -183
- package/.claude/skills/video-gen/templates/kol-reaction-food.md +193 -193
- package/.claude/skills/video-gen/templates/multi-point-apparel.md +185 -185
- package/.claude/skills/video-gen/templates/pain-solution-home.md +184 -184
- package/.claude/skills/video-gen/templates/pdp-360-showcase.md +189 -189
- package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +182 -182
- package/.claude/skills/video-gen/templates/scene-digital.md +183 -183
- package/.claude/skills/wechat/SKILL.md +174 -174
- package/.claude/skills/xhs/SKILL.md +170 -170
- package/README.md +276 -276
- package/dist/bin/optima.js +26 -26
- package/dist/bin/serve.js +23 -23
- package/dist/src/agent.d.ts +1 -1
- package/dist/src/agent.js +4 -4
- package/dist/src/system-prompt.js +175 -175
- package/dist/src/tools/memory.js +10 -10
- package/dist/src/ui/headless.js +7 -7
- package/package.json +79 -79
- package/dist/bin/video-edit.d.ts +0 -3
- package/dist/bin/video-edit.d.ts.map +0 -1
- package/dist/bin/video-edit.js +0 -153
- package/dist/bin/video-edit.js.map +0 -1
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_comment": "通用 2×2 网格布局 — 适合 4 张 cell 的所有 intent (创业故事/对比测评/教程/场景)",
|
|
3
|
-
"canvas_size": [1242, 1660],
|
|
4
|
-
"cells": {
|
|
5
|
-
"positions": [[0, 0], [621, 0], [0, 830], [621, 830]],
|
|
6
|
-
"sizes": [[621, 830], [621, 830], [621, 830], [621, 830]]
|
|
7
|
-
},
|
|
8
|
-
"text_zones": {
|
|
9
|
-
"title": {
|
|
10
|
-
"_comment": "中央偏上 2 行标题 - 8-12 字 / 行最佳",
|
|
11
|
-
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
12
|
-
"size": 110,
|
|
13
|
-
"color": "#FFB940",
|
|
14
|
-
"stroke_w": 8,
|
|
15
|
-
"stroke_color": "#D63D3D",
|
|
16
|
-
"lines": [
|
|
17
|
-
{"position": [621, 480], "anchor": "mm"},
|
|
18
|
-
{"position": [621, 620], "anchor": "mm"}
|
|
19
|
-
]
|
|
20
|
-
},
|
|
21
|
-
"caption": {
|
|
22
|
-
"_comment": "底部 2 行 caption - 15-20 字 / 行最佳",
|
|
23
|
-
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
24
|
-
"size": 78,
|
|
25
|
-
"color": "#FFB940",
|
|
26
|
-
"stroke_w": 6,
|
|
27
|
-
"stroke_color": "#D63D3D",
|
|
28
|
-
"lines": [
|
|
29
|
-
{"position": [621, 1340], "anchor": "mm"},
|
|
30
|
-
{"position": [621, 1450], "anchor": "mm"}
|
|
31
|
-
]
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"_comment": "通用 2×2 网格布局 — 适合 4 张 cell 的所有 intent (创业故事/对比测评/教程/场景)",
|
|
3
|
+
"canvas_size": [1242, 1660],
|
|
4
|
+
"cells": {
|
|
5
|
+
"positions": [[0, 0], [621, 0], [0, 830], [621, 830]],
|
|
6
|
+
"sizes": [[621, 830], [621, 830], [621, 830], [621, 830]]
|
|
7
|
+
},
|
|
8
|
+
"text_zones": {
|
|
9
|
+
"title": {
|
|
10
|
+
"_comment": "中央偏上 2 行标题 - 8-12 字 / 行最佳",
|
|
11
|
+
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
12
|
+
"size": 110,
|
|
13
|
+
"color": "#FFB940",
|
|
14
|
+
"stroke_w": 8,
|
|
15
|
+
"stroke_color": "#D63D3D",
|
|
16
|
+
"lines": [
|
|
17
|
+
{"position": [621, 480], "anchor": "mm"},
|
|
18
|
+
{"position": [621, 620], "anchor": "mm"}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"caption": {
|
|
22
|
+
"_comment": "底部 2 行 caption - 15-20 字 / 行最佳",
|
|
23
|
+
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
24
|
+
"size": 78,
|
|
25
|
+
"color": "#FFB940",
|
|
26
|
+
"stroke_w": 6,
|
|
27
|
+
"stroke_color": "#D63D3D",
|
|
28
|
+
"lines": [
|
|
29
|
+
{"position": [621, 1340], "anchor": "mm"},
|
|
30
|
+
{"position": [621, 1450], "anchor": "mm"}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_comment": "通用 3×3 网格布局 - 适合 9 张 cell (商品清单 / 多角度展示)",
|
|
3
|
-
"canvas_size": [1242, 1660],
|
|
4
|
-
"cells": {
|
|
5
|
-
"_comment": "9 格 414×420。顶部 200px 标题区,cells y=200..1460,底部 200px caption 区。3×420 + 200×2 = 1660 = canvas_h ✓",
|
|
6
|
-
"positions": [
|
|
7
|
-
[0, 200], [414, 200], [828, 200],
|
|
8
|
-
[0, 620], [414, 620], [828, 620],
|
|
9
|
-
[0, 1040], [414, 1040], [828, 1040]
|
|
10
|
-
],
|
|
11
|
-
"sizes": [
|
|
12
|
-
[414, 420], [414, 420], [414, 420],
|
|
13
|
-
[414, 420], [414, 420], [414, 420],
|
|
14
|
-
[414, 420], [414, 420], [414, 420]
|
|
15
|
-
]
|
|
16
|
-
},
|
|
17
|
-
"text_zones": {
|
|
18
|
-
"title": {
|
|
19
|
-
"_comment": "顶部白边 2 行标题(y < 200 区间)",
|
|
20
|
-
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
21
|
-
"size": 78,
|
|
22
|
-
"color": "#FFB940",
|
|
23
|
-
"stroke_w": 6,
|
|
24
|
-
"stroke_color": "#D63D3D",
|
|
25
|
-
"lines": [
|
|
26
|
-
{"position": [621, 60], "anchor": "mm"},
|
|
27
|
-
{"position": [621, 150], "anchor": "mm"}
|
|
28
|
-
]
|
|
29
|
-
},
|
|
30
|
-
"caption": {
|
|
31
|
-
"_comment": "底部白边 2 行 caption(cells 结束于 y=1460,留 200px)",
|
|
32
|
-
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
33
|
-
"size": 60,
|
|
34
|
-
"color": "#FFB940",
|
|
35
|
-
"stroke_w": 5,
|
|
36
|
-
"stroke_color": "#D63D3D",
|
|
37
|
-
"lines": [
|
|
38
|
-
{"position": [621, 1530], "anchor": "mm"},
|
|
39
|
-
{"position": [621, 1610], "anchor": "mm"}
|
|
40
|
-
]
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"_comment": "通用 3×3 网格布局 - 适合 9 张 cell (商品清单 / 多角度展示)",
|
|
3
|
+
"canvas_size": [1242, 1660],
|
|
4
|
+
"cells": {
|
|
5
|
+
"_comment": "9 格 414×420。顶部 200px 标题区,cells y=200..1460,底部 200px caption 区。3×420 + 200×2 = 1660 = canvas_h ✓",
|
|
6
|
+
"positions": [
|
|
7
|
+
[0, 200], [414, 200], [828, 200],
|
|
8
|
+
[0, 620], [414, 620], [828, 620],
|
|
9
|
+
[0, 1040], [414, 1040], [828, 1040]
|
|
10
|
+
],
|
|
11
|
+
"sizes": [
|
|
12
|
+
[414, 420], [414, 420], [414, 420],
|
|
13
|
+
[414, 420], [414, 420], [414, 420],
|
|
14
|
+
[414, 420], [414, 420], [414, 420]
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"text_zones": {
|
|
18
|
+
"title": {
|
|
19
|
+
"_comment": "顶部白边 2 行标题(y < 200 区间)",
|
|
20
|
+
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
21
|
+
"size": 78,
|
|
22
|
+
"color": "#FFB940",
|
|
23
|
+
"stroke_w": 6,
|
|
24
|
+
"stroke_color": "#D63D3D",
|
|
25
|
+
"lines": [
|
|
26
|
+
{"position": [621, 60], "anchor": "mm"},
|
|
27
|
+
{"position": [621, 150], "anchor": "mm"}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"caption": {
|
|
31
|
+
"_comment": "底部白边 2 行 caption(cells 结束于 y=1460,留 200px)",
|
|
32
|
+
"font": "shared/fonts/MaShanZheng-Regular.ttf",
|
|
33
|
+
"size": 60,
|
|
34
|
+
"color": "#FFB940",
|
|
35
|
+
"stroke_w": 5,
|
|
36
|
+
"stroke_color": "#D63D3D",
|
|
37
|
+
"lines": [
|
|
38
|
+
{"position": [621, 1530], "anchor": "mm"},
|
|
39
|
+
{"position": [621, 1610], "anchor": "mm"}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""multigrid-poster compose — Pillow 版渲染器
|
|
3
|
-
|
|
4
|
-
输入:layout.json + N 张 cell 图片 + 标题文字 + caption 文字
|
|
5
|
-
输出:1242×1660 PNG(小红书封面标准)
|
|
6
|
-
|
|
7
|
-
用法:
|
|
8
|
-
python compose.py \
|
|
9
|
-
--layout <SKILL_DIR>/layouts/2x2.json \
|
|
10
|
-
--cells cell_0.png cell_1.png cell_2.png cell_3.png \
|
|
11
|
-
--title-line "26岁一个人创业" \
|
|
12
|
-
--title-line "跨境电商月入10w+" \
|
|
13
|
-
--caption-line "只需要一部手机就可以完成!" \
|
|
14
|
-
--caption-line "跨境人的必备app推荐" \
|
|
15
|
-
--output cover.png
|
|
16
|
-
|
|
17
|
-
依赖:Pillow (容器自带,无需额外安装)
|
|
18
|
-
字体:从 SKILL_DIR/shared/fonts/ 加载(layout.json 里指定)
|
|
19
|
-
"""
|
|
20
|
-
import argparse
|
|
21
|
-
import json
|
|
22
|
-
import sys
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def compose(layout_path: Path, cell_paths: list[Path],
|
|
28
|
-
title_lines: list[str], caption_lines: list[str],
|
|
29
|
-
output_path: Path) -> Path:
|
|
30
|
-
"""根据 layout.json 渲染海报。
|
|
31
|
-
|
|
32
|
-
layout.json 字段:
|
|
33
|
-
canvas_size [w, h]
|
|
34
|
-
cells.positions [[x,y], ...] cell 左上角
|
|
35
|
-
cells.sizes [[w,h], ...] cell 尺寸
|
|
36
|
-
text_zones.title {lines: [...], size, color, stroke_w, stroke_color, font}
|
|
37
|
-
text_zones.caption {同上}
|
|
38
|
-
"""
|
|
39
|
-
layout = json.loads(layout_path.read_text(encoding="utf-8"))
|
|
40
|
-
# 约定:layout 必须放在 <skill>/layouts/ 下,字体路径相对 <skill>/ 解析
|
|
41
|
-
skill_dir = layout_path.parent.parent
|
|
42
|
-
|
|
43
|
-
# 文本行数 vs layout 配置行数:行多了 zip 会静默截断,显式 fail-fast
|
|
44
|
-
# 错误信息用英文 — sys.exit 走 stderr,Windows GBK locale 下中文会乱码
|
|
45
|
-
for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
|
|
46
|
-
zone = layout.get("text_zones", {}).get(zone_key)
|
|
47
|
-
if not zone:
|
|
48
|
-
continue
|
|
49
|
-
max_lines = len(zone["lines"])
|
|
50
|
-
if len(lines) > max_lines:
|
|
51
|
-
sys.exit(f"too many {zone_key} lines: got {len(lines)}, layout supports {max_lines}")
|
|
52
|
-
|
|
53
|
-
# Canvas
|
|
54
|
-
canvas = Image.new("RGB", tuple(layout["canvas_size"]), "white")
|
|
55
|
-
|
|
56
|
-
# Cells
|
|
57
|
-
cell_count = len(layout["cells"]["positions"])
|
|
58
|
-
if len(cell_paths) != cell_count:
|
|
59
|
-
sys.exit(f"cell count mismatch: layout expects {cell_count}, got {len(cell_paths)}")
|
|
60
|
-
for cp, pos, size in zip(cell_paths,
|
|
61
|
-
layout["cells"]["positions"],
|
|
62
|
-
layout["cells"]["sizes"]):
|
|
63
|
-
img = Image.open(cp).convert("RGB").resize(tuple(size), Image.LANCZOS)
|
|
64
|
-
canvas.paste(img, tuple(pos))
|
|
65
|
-
|
|
66
|
-
draw = ImageDraw.Draw(canvas)
|
|
67
|
-
|
|
68
|
-
# Text zones (title + caption 通用绘制)
|
|
69
|
-
for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
|
|
70
|
-
if zone_key not in layout["text_zones"]:
|
|
71
|
-
continue
|
|
72
|
-
zone = layout["text_zones"][zone_key]
|
|
73
|
-
font_path = skill_dir / zone["font"]
|
|
74
|
-
font = ImageFont.truetype(str(font_path), zone["size"])
|
|
75
|
-
for text, line_cfg in zip(lines, zone["lines"]):
|
|
76
|
-
if not text:
|
|
77
|
-
continue
|
|
78
|
-
draw.text(
|
|
79
|
-
tuple(line_cfg["position"]), text,
|
|
80
|
-
fill=zone["color"], font=font,
|
|
81
|
-
stroke_width=zone.get("stroke_w", 0),
|
|
82
|
-
stroke_fill=zone.get("stroke_color", zone["color"]),
|
|
83
|
-
anchor=line_cfg.get("anchor", "la"),
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
canvas.save(output_path, optimize=True)
|
|
87
|
-
return output_path
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def main():
|
|
91
|
-
ap = argparse.ArgumentParser(description=__doc__,
|
|
92
|
-
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
93
|
-
ap.add_argument("--layout", required=True, type=Path,
|
|
94
|
-
help="layout.json 路径(如 SKILL_DIR/layouts/2x2.json)")
|
|
95
|
-
ap.add_argument("--cells", required=True, nargs="+", type=Path,
|
|
96
|
-
help="N 张 cell 图片路径,顺序对应 layout.cells.positions")
|
|
97
|
-
ap.add_argument("--title-line", action="append", default=[],
|
|
98
|
-
help="标题行(可重复)")
|
|
99
|
-
ap.add_argument("--caption-line", action="append", default=[],
|
|
100
|
-
help="底部 caption 行(可重复)")
|
|
101
|
-
ap.add_argument("--output", required=True, type=Path,
|
|
102
|
-
help="输出 PNG 路径")
|
|
103
|
-
args = ap.parse_args()
|
|
104
|
-
|
|
105
|
-
out = compose(
|
|
106
|
-
layout_path=args.layout,
|
|
107
|
-
cell_paths=args.cells,
|
|
108
|
-
title_lines=args.title_line,
|
|
109
|
-
caption_lines=args.caption_line,
|
|
110
|
-
output_path=args.output,
|
|
111
|
-
)
|
|
112
|
-
print(f"saved {out} ({out.stat().st_size // 1024} KB)")
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if __name__ == "__main__":
|
|
116
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""multigrid-poster compose — Pillow 版渲染器
|
|
3
|
+
|
|
4
|
+
输入:layout.json + N 张 cell 图片 + 标题文字 + caption 文字
|
|
5
|
+
输出:1242×1660 PNG(小红书封面标准)
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python compose.py \
|
|
9
|
+
--layout <SKILL_DIR>/layouts/2x2.json \
|
|
10
|
+
--cells cell_0.png cell_1.png cell_2.png cell_3.png \
|
|
11
|
+
--title-line "26岁一个人创业" \
|
|
12
|
+
--title-line "跨境电商月入10w+" \
|
|
13
|
+
--caption-line "只需要一部手机就可以完成!" \
|
|
14
|
+
--caption-line "跨境人的必备app推荐" \
|
|
15
|
+
--output cover.png
|
|
16
|
+
|
|
17
|
+
依赖:Pillow (容器自带,无需额外安装)
|
|
18
|
+
字体:从 SKILL_DIR/shared/fonts/ 加载(layout.json 里指定)
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compose(layout_path: Path, cell_paths: list[Path],
|
|
28
|
+
title_lines: list[str], caption_lines: list[str],
|
|
29
|
+
output_path: Path) -> Path:
|
|
30
|
+
"""根据 layout.json 渲染海报。
|
|
31
|
+
|
|
32
|
+
layout.json 字段:
|
|
33
|
+
canvas_size [w, h]
|
|
34
|
+
cells.positions [[x,y], ...] cell 左上角
|
|
35
|
+
cells.sizes [[w,h], ...] cell 尺寸
|
|
36
|
+
text_zones.title {lines: [...], size, color, stroke_w, stroke_color, font}
|
|
37
|
+
text_zones.caption {同上}
|
|
38
|
+
"""
|
|
39
|
+
layout = json.loads(layout_path.read_text(encoding="utf-8"))
|
|
40
|
+
# 约定:layout 必须放在 <skill>/layouts/ 下,字体路径相对 <skill>/ 解析
|
|
41
|
+
skill_dir = layout_path.parent.parent
|
|
42
|
+
|
|
43
|
+
# 文本行数 vs layout 配置行数:行多了 zip 会静默截断,显式 fail-fast
|
|
44
|
+
# 错误信息用英文 — sys.exit 走 stderr,Windows GBK locale 下中文会乱码
|
|
45
|
+
for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
|
|
46
|
+
zone = layout.get("text_zones", {}).get(zone_key)
|
|
47
|
+
if not zone:
|
|
48
|
+
continue
|
|
49
|
+
max_lines = len(zone["lines"])
|
|
50
|
+
if len(lines) > max_lines:
|
|
51
|
+
sys.exit(f"too many {zone_key} lines: got {len(lines)}, layout supports {max_lines}")
|
|
52
|
+
|
|
53
|
+
# Canvas
|
|
54
|
+
canvas = Image.new("RGB", tuple(layout["canvas_size"]), "white")
|
|
55
|
+
|
|
56
|
+
# Cells
|
|
57
|
+
cell_count = len(layout["cells"]["positions"])
|
|
58
|
+
if len(cell_paths) != cell_count:
|
|
59
|
+
sys.exit(f"cell count mismatch: layout expects {cell_count}, got {len(cell_paths)}")
|
|
60
|
+
for cp, pos, size in zip(cell_paths,
|
|
61
|
+
layout["cells"]["positions"],
|
|
62
|
+
layout["cells"]["sizes"]):
|
|
63
|
+
img = Image.open(cp).convert("RGB").resize(tuple(size), Image.LANCZOS)
|
|
64
|
+
canvas.paste(img, tuple(pos))
|
|
65
|
+
|
|
66
|
+
draw = ImageDraw.Draw(canvas)
|
|
67
|
+
|
|
68
|
+
# Text zones (title + caption 通用绘制)
|
|
69
|
+
for zone_key, lines in [("title", title_lines), ("caption", caption_lines)]:
|
|
70
|
+
if zone_key not in layout["text_zones"]:
|
|
71
|
+
continue
|
|
72
|
+
zone = layout["text_zones"][zone_key]
|
|
73
|
+
font_path = skill_dir / zone["font"]
|
|
74
|
+
font = ImageFont.truetype(str(font_path), zone["size"])
|
|
75
|
+
for text, line_cfg in zip(lines, zone["lines"]):
|
|
76
|
+
if not text:
|
|
77
|
+
continue
|
|
78
|
+
draw.text(
|
|
79
|
+
tuple(line_cfg["position"]), text,
|
|
80
|
+
fill=zone["color"], font=font,
|
|
81
|
+
stroke_width=zone.get("stroke_w", 0),
|
|
82
|
+
stroke_fill=zone.get("stroke_color", zone["color"]),
|
|
83
|
+
anchor=line_cfg.get("anchor", "la"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
canvas.save(output_path, optimize=True)
|
|
87
|
+
return output_path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
92
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
93
|
+
ap.add_argument("--layout", required=True, type=Path,
|
|
94
|
+
help="layout.json 路径(如 SKILL_DIR/layouts/2x2.json)")
|
|
95
|
+
ap.add_argument("--cells", required=True, nargs="+", type=Path,
|
|
96
|
+
help="N 张 cell 图片路径,顺序对应 layout.cells.positions")
|
|
97
|
+
ap.add_argument("--title-line", action="append", default=[],
|
|
98
|
+
help="标题行(可重复)")
|
|
99
|
+
ap.add_argument("--caption-line", action="append", default=[],
|
|
100
|
+
help="底部 caption 行(可重复)")
|
|
101
|
+
ap.add_argument("--output", required=True, type=Path,
|
|
102
|
+
help="输出 PNG 路径")
|
|
103
|
+
args = ap.parse_args()
|
|
104
|
+
|
|
105
|
+
out = compose(
|
|
106
|
+
layout_path=args.layout,
|
|
107
|
+
cell_paths=args.cells,
|
|
108
|
+
title_lines=args.title_line,
|
|
109
|
+
caption_lines=args.caption_line,
|
|
110
|
+
output_path=args.output,
|
|
111
|
+
)
|
|
112
|
+
print(f"saved {out} ({out.stat().st_size // 1024} KB)")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|