feat(editor): add step-0 scene templates for the mass-market track

Three finished scene documents — screenshot tutorial, before/after
comparison, and knowledge carousel — plus their preview renders and the
generator scripts that produced them.

These have lived outside version control since 2026-07-27 while the
mass-market direction was still provisional. They are about to become
the seed content for the scene-template entry point, so the documents
and the scripts that regenerate them need to travel together: a template
whose generator is lost can only be edited by hand from then on.

The `_generators` scripts build the documents through the canonical
schema rather than exporting from a live editor, which keeps them
reproducible and free of editor-session state.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-02 11:00:34 +08:00
parent ecebc3e748
commit d64a4ffe9b
29 changed files with 3292 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""把空态提示烘焙成 assets/*.png —— 必须先于 tpl1.py / tpl3.py 运行。
为什么要烤拖图时编辑器写的是 fill[0]
(`op-editor-core/src/image_fill_upload.rs`: `fills[0] = body`)把提示做成
fill[0] 的内嵌 PNG用户拖图就是"直接替换提示"零手工随框自适应
不依赖任何 jian 布局修复也不用写死浮层尺寸
用真实渲染器烤保证与矢量版逐像素一致实测差异 0.138%纯抗锯齿
"""
import json, os, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
BIN = os.path.join(REPO, "target", "release", "openpencil-desktop")
ASSETS = os.path.join(HERE, "assets")
sys.path.insert(0, HERE)
def bake(tag, variables, groups, w, h):
os.makedirs(ASSETS, exist_ok=True)
for i, kids in enumerate(groups):
# 透明底:白卡仍由 fill[1] 的 $c-surface 驱动
box = {"type": "frame", "id": "bake", "name": "bake", "x": 0, "y": 0,
"width": w, "height": h, "layout": "vertical",
"justifyContent": "center", "alignItems": "center", "fill": [],
"children": [{"type": "group", "id": "hint", "name": "hint",
"width": "fill_container", "height": "fit_content",
"layout": "vertical", "gap": 24,
"alignItems": "center", "fill": [],
"children": kids}]}
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "bake.op")
with open(src, "w", encoding="utf-8") as fh:
json.dump({"version": "1.0.0", "variables": variables,
"children": [box]}, fh, ensure_ascii=False)
env = dict(os.environ, OPENPENCIL_RENDER_MARGIN="0")
subprocess.run([BIN, "--render-shots", src, td, "2"],
check=True, capture_output=True, env=env)
png = [f for f in os.listdir(td) if f.endswith(".png")][0]
dst = os.path.join(ASSETS, f"{tag}-{i}.png")
os.replace(os.path.join(td, png), dst)
print(f" baked {os.path.basename(dst)} "
f"{os.path.getsize(dst):,d} B")
def main():
import tpl1, tpl3
print("bake screenshot-tutorial:")
bake("screenshot-tutorial", tpl1.VARS,
[tpl1.hint_children(h, s) for h, s in tpl1.HINTS],
tpl1.SLOT_W, tpl1.SLOT_H)
print("bake before-after:")
bake("before-after", tpl3.VARS,
[tpl3.hint_children(h) for h, _ in tpl3.HINTS],
tpl3.SLOT_W, tpl3.SLOT_H)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,181 @@
"""Tiny helpers for authoring PenDocument (.op) JSON by hand.
Field names follow jian-ops-schema (camelCase, base+container flattened).
"""
import base64
import json
import os
class Ids:
def __init__(self):
self.n = 0
def __call__(self, prefix="n"):
self.n += 1
return f"{prefix}{self.n}"
def solid(color):
return [{"type": "solid", "color": color}]
def stroke(color, thickness=2):
return {"thickness": thickness, "fill": solid(color)}
def frame(ids, name, **props):
node = {"type": "frame", "id": ids("f"), "name": name}
node.update(props)
return node
def rect(ids, name, **props):
node = {"type": "rectangle", "id": ids("r"), "name": name}
node.update(props)
return node
def path(ids, name, d, **props):
node = {"type": "path", "id": ids("p"), "name": name, "d": d}
node.update(props)
return node
def group(ids, name, **props):
"""A `group` lays out exactly like a frame but is NOT an image-drop target.
`op-editor-core/src/image_drop.rs::node_accepts_image_drop` matches only
Frame | Rectangle | Ellipse | Polygon | Path | Image Group is excluded
("a structural wrapper with no painted body of its own"). Its `fill` is
also never painted, so use it purely as a transparent wrapper.
"""
node = {"type": "group", "id": ids("g"), "name": name}
node.update(props)
return node
def icon_font(ids, name, glyph, size, color, **props):
"""Lucide glyph as an `icon_font` node — non-fillable, so drops walk up."""
node = {
"type": "icon_font", "id": ids("i"), "name": name,
"iconFontName": glyph, "width": size, "height": size,
"fill": solid(color),
}
node.update(props)
return node
# Calibration, all measured off real renders (see report):
# "●" (U+25CF) at fontSize F paints a disc of 0.754*F px. Its line box is
# F*lineHeight tall, so lineHeight 1.0 wraps a 113px disc in a 149px box and
# injects ~17px of dead space below it. At lineHeight 0.78 the box is 1.0345*D
# and the ink starts at y~0, which is why that value is pinned here.
# Ink starts 0.0772*F in from the text node's left edge.
DOT_INK_RATIO = 0.754
DOT_LINE_HEIGHT = 0.78
DOT_INK_LEFT_RATIO = 0.0772
# `icon_font` scales the 24x24 lucide viewBox into the node box, so its ink is
# 0.844 of the declared size; a `path` node stretches the glyph to fill the box
# (ink 1.038 of size). This ratio keeps an icon_font swap ink-identical to the
# path it replaces.
ICONFONT_PER_PATH_PX = 1.038 / 0.844
def upload_disc(ids, name, diameter, disc_color, path_equiv_size, icon_color,
glyph="upload"):
"""Tinted disc + upload glyph, built ONLY from non-fillable node kinds.
A frame/ellipse disc or a `path` glyph is each a valid image-drop target
(`image_drop.rs::node_accepts_image_drop` matches Frame|Rectangle|Ellipse|
Polygon|Path|Image), so either would steal a drop aimed at the
placeholder's centre. group + text + icon_font are all excluded, so a drop
anywhere inside resolves outward to the placeholder box itself.
`path_equiv_size` is the size the old `path` icon used; it is converted so
the rendered glyph keeps the same ink footprint.
"""
fs = round(diameter / DOT_INK_RATIO)
disc = text(ids, f"{name} · 圆底", "", fs, 400, disc_color,
family="Inter", line_height=DOT_LINE_HEIGHT,
width="fit_content", growth="auto")
disc["x"] = -round(fs * DOT_INK_LEFT_RATIO)
disc["y"] = 0
gsize = round(path_equiv_size * ICONFONT_PER_PATH_PX)
glyph_node = icon_font(ids, f"{name} · 图标", glyph, gsize, icon_color)
glyph_node["x"] = round((diameter - gsize) / 2, 2)
glyph_node["y"] = round((diameter - gsize) / 2, 2)
# Box the group to the dot's LINE box so it can't grow past it.
box = group(ids, name, width=diameter, height=round(fs * DOT_LINE_HEIGHT),
layout="none", fill=[])
# children[0] paints last (topmost): glyph over disc.
box["children"] = [glyph_node, disc]
return box
def text(ids, name, content, size, weight, color, *, family=None,
line_height=None, width="fill_container", growth="fixed-width",
align=None, spacing=0):
"""Text node. NEVER emits height — sizing is content-driven."""
if family is None:
family = "Noto Sans SC" if weight >= 600 or size >= 34 else "Inter"
if line_height is None:
# CJK ladder: display/headings tighter, body loose (cjk-typography.md)
line_height = 1.25 if size >= 60 else 1.3 if size >= 34 else 1.6
node = {
"type": "text", "id": ids("t"), "name": name,
"content": content,
"fontFamily": family,
"fontSize": size,
"fontWeight": weight,
"fill": solid(color),
"lineHeight": line_height,
"letterSpacing": spacing,
"textGrowth": growth,
}
if width is not None:
node["width"] = width
if align:
node["textAlign"] = align
return node
# 空态占位提示的中性灰阶 —— 故意写成字面值、不走设计变量。
# 上传占位属于「产品控件」而非品牌表达,绑主色会让「换主色只改一处」出现
# 例外:用户改了 $c-accent其它元素全变、唯独占位圆还是旧色。中性灰对任何
# 主色都成立,所以这里烤死。
PLACEHOLDER_DISC = "#E5E7EB"
PLACEHOLDER_ICON = "#9CA3AF"
PLACEHOLDER_TITLE = "#4B5563"
PLACEHOLDER_SPEC = "#9CA3AF"
ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
def asset_fill(filename, mode="fit"):
"""Embed assets/<filename> as a data-URL image fill.
Baked hints live in fill[0]. A dropped image overwrites exactly that slot
(`image_fill_upload.rs`: `if fills.is_empty() { push } else { fills[0] = body }`),
so the hint is replaced rather than stacked under the user's screenshot.
"""
with open(os.path.join(ASSET_DIR, filename), "rb") as fh:
url = "data:image/png;base64," + base64.b64encode(fh.read()).decode()
return {"type": "image", "url": url, "mode": mode}
def write_doc(dst, variables, children, name):
doc = {
"version": "1.0.0",
"name": name,
"variables": variables,
"children": children,
}
with open(dst, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote {dst}")
def color_vars(mapping):
return {k: {"type": "color", "value": v} for k, v in mapping.items()}

View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 渲染三套模板的预览图每帧一张scale 2+ 整页拼合总览scale 1
set -euo pipefail
R=/Users/fini/workspace/openpencil
BIN=$R/target/release/openpencil-desktop
OUT=$R/templates/step0/previews
TMP=$(mktemp -d)
mkdir -p "$OUT"
for t in screenshot-tutorial knowledge-carousel before-after; do
op=$R/templates/step0/$t.op
rm -rf "$TMP/$t"; mkdir -p "$TMP/$t"
OPENPENCIL_RENDER_MARGIN=0 "$BIN" --render-shots "$op" "$TMP/$t" 2 >/dev/null
# 按 .op 里 children 的顺序把 <node-id>.png 重命名成 <模板>-NN.png
python3 - "$op" "$TMP/$t" "$OUT" "$t" <<'PY'
import json, shutil, sys, pathlib
op, shots, out, name = sys.argv[1:5]
doc = json.load(open(op))
kids = doc.get("children") or doc["pages"][0]["children"]
for i, n in enumerate(kids, 1):
src = pathlib.Path(shots) / f"{n['id']}.png"
dst = pathlib.Path(out) / (f"{name}.png" if len(kids) == 1
else f"{name}-{i:02d}.png")
shutil.copyfile(src, dst)
print(f" {dst.name} <- {n['name']}")
PY
# 多帧模板再导一张整页总览export_item 把所有顶层 frame 合成一张画布)
n=$(python3 -c "import json;d=json.load(open('$op'));print(len(d.get('children') or d['pages'][0]['children']))")
if [ "$n" -gt 1 ]; then
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export_item","arguments":{"itemId":"page-1","format":"png","scale":1}}}' \
| "$BIN" --mcp "$op" 2>/dev/null | tail -1 \
| python3 -c "
import sys, json, base64
t = json.loads(json.load(sys.stdin)['result']['content'][0]['text'])
open('$OUT/$t-overview.png','wb').write(base64.b64decode(t['bytes_base64']))
print(' $t-overview.png <- 五帧拼合')
"
fi
done
rm -rf "$TMP"
echo "--- previews ---"
ls -la "$OUT"

View file

@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""screenshot-tutorial.op — 小红书 3:4 截图教程卡(封面 + 3 步骤 + CTA"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, frame, rect, text, solid, stroke, write_doc,
color_vars, group, upload_disc, asset_fill,
PLACEHOLDER_DISC, PLACEHOLDER_ICON,
PLACEHOLDER_TITLE, PLACEHOLDER_SPEC)
W, H, GAP = 1080, 1440, 120
PAD_X, PAD_Y = 72, 88
VARS = color_vars({
"c-bg": "#FDF8F3",
"c-surface": "#FFFFFF",
"c-ink": "#1A1512",
"c-muted": "#7D6F64",
"c-accent": "#FF5A2C",
"c-accent-soft": "#FFEDE5",
"c-border": "#EDE0D4",
})
ids = Ids()
def page(name, children, **extra):
# gap=48 是硬下限:步骤页的占位框用 fill_container 吃掉全部余量,
# 没有这个 gap说明文案会紧贴页脚taffy 会先扣 gap 再分配剩余空间)。
node = frame(
ids, name,
width=W, height=H, layout="vertical",
padding=[PAD_Y, PAD_X], gap=48,
justifyContent="space_between", alignItems="start",
fill=solid("$c-bg"), clipContent=True,
)
node["children"] = children
node.update(extra)
return node
def block(name, children, gap=32, **extra):
"""Transparent structural wrapper — no fill (design-principles.md)."""
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", gap=gap, fill=[], alignItems="start")
node["children"] = children
node.update(extra)
return node
def badge(label, *, fill_c, text_c, size=26):
node = frame(ids, f"徽章 · {label}", width="fit_content", height="fit_content",
layout="horizontal", padding=[12, 24], gap=0, cornerRadius=999,
alignItems="center", justifyContent="center", fill=solid(fill_c))
node["children"] = [
text(ids, "徽章文字", label, size, 600, text_c, width="fit_content",
growth="auto", line_height=1.4)
]
return node
def footer(page_no, total=5):
node = frame(ids, "页脚", width="fill_container", height="fit_content",
layout="horizontal", justifyContent="space_between",
alignItems="center", gap=16, fill=[])
node["children"] = [
text(ids, "页脚品牌", "@ 你的账号名", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
text(ids, "页码", f"{page_no:02d} / {total:02d}", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
]
return node
# 三个步骤页的空态提示文案 —— bake_hints.py 用它烘焙 assets/*.png
HINTS = [
("拖入你的截图", "支持 PNG / JPG建议宽度 ≥ 1080px"),
("拖入标注后的截图", "圈选/箭头建议用品牌色,粗细 4-6px"),
("拖入成品截图", "成品图建议留 5% 以上的四周留白"),
]
SLOT_W, SLOT_H = 936, 845 # 实测占位框布局尺寸,仅用于烘焙提示图
HINT_ASSET = "screenshot-tutorial-{i}.png"
def hint_children(hint, spec):
"""空态提示的矢量构造 —— 只被 bake_hints.py 用来烘焙成 PNG。
模板本身不再挂这些节点它们被烤进占位框的 fill[0]这样拖图时
set_node_fill_image_url 直接覆写 fill[0]image_fill_upload.rs:88
`fills[0] = body`提示随之消失用户零手工
"""
return [
upload_disc(ids, "上传图标", 112, PLACEHOLDER_DISC, 52,
PLACEHOLDER_ICON),
text(ids, "占位提示", hint, 32, 600, PLACEHOLDER_TITLE, align="center",
line_height=1.4),
text(ids, "占位规格", spec, 24, 400, PLACEHOLDER_SPEC, align="center"),
]
def shot_slot(idx):
"""截图占位框 —— 空态提示是 fill[0] 的内嵌 PNG没有子节点。
fill[1] 保留 $c-surface提示图透明底白卡仍由设计变量驱动拖图后
fill[0] 被换成用户截图coverfill[1] 依旧在底下兜白
"""
node = frame(ids, "截图占位框", width="fill_container",
height="fill_container", cornerRadius=28,
fill=[asset_fill(HINT_ASSET.format(i=idx), "fit"),
solid("$c-surface")[0]],
stroke=stroke("$c-border", 3), clipContent=True)
return node
def deck_deco():
"""装饰用「一套图」示意 — 三张卡片叠放。
遵守 deck 三规则后层是纯装饰矩形无任何文字/图标偏移只做 14/28px
peek不重排前层用不透明 surface 填充压住后层
layout="none" children[0] 最靠前
"""
mini = frame(ids, "装饰卡 · 正面", x=0, y=0, width=360, height=240,
layout="vertical", padding=28, gap=16, cornerRadius=24,
alignItems="start", fill=solid("$c-surface"),
stroke=stroke("$c-border", 2))
mini["children"] = [
rect(ids, "示意 · 强调块", width=132, height=14, cornerRadius=7,
fill=solid("$c-accent")),
rect(ids, "示意 · 文本行 1", width="fill_container", height=12,
cornerRadius=6, fill=solid("$c-border")),
rect(ids, "示意 · 文本行 2", width=208, height=12, cornerRadius=6,
fill=solid("$c-border")),
]
deck = frame(ids, "封面装饰 · 一套图", width=388, height=268,
layout="none", fill=[])
deck["children"] = [
mini,
rect(ids, "装饰卡 · 后层 1", x=14, y=14, width=360, height=240,
cornerRadius=24, fill=solid("$c-accent-soft")),
rect(ids, "装饰卡 · 后层 2", x=28, y=28, width=360, height=240,
cornerRadius=24, fill=solid("$c-border")),
]
return deck
# ---------------------------------------------------------------- 01 封面
def cover():
head = block("封面头部", [
badge("新手教程", fill_c="$c-accent", text_c="#FFFFFF"),
deck_deco(),
], gap=64)
hero = block("封面主标题区", [
text(ids, "封面标题", "三步做出你的\n第一张教程图", 92, 700, "$c-ink"),
rect(ids, "标题高亮条", width=132, height=14, cornerRadius=7,
fill=solid("$c-accent")),
text(ids, "封面副标题",
"不用学软件,套模板换图换字,\n十分钟出一套能发的教程图。",
34, 400, "$c-muted"),
], gap=36)
return page("01 封面", [head, hero, footer(1)])
# ------------------------------------------------------------ 02-04 步骤页
def step(no, title, desc):
head = frame(ids, "步骤头部", width="fill_container", height="fit_content",
layout="horizontal", gap=20, alignItems="center", fill=[])
head["children"] = [
badge(f"STEP {no}", fill_c="$c-accent-soft", text_c="$c-accent"),
]
main = block(f"0{no+1} 内容", [
head,
text(ids, "步骤标题", title, 54, 700, "$c-ink"),
shot_slot(no - 1),
text(ids, "步骤说明", desc, 30, 400, "$c-muted"),
], gap=36, height="fill_container")
return page(f"0{no+1} 步骤 {no}", [main, footer(no + 1)])
# ---------------------------------------------------------------- 05 CTA
def recap_row(no, label):
dot = frame(ids, f"序号圆点 {no}", width=56, height=56, layout="horizontal",
alignItems="center", justifyContent="center", cornerRadius=28,
fill=solid("$c-accent-soft"))
dot["children"] = [
text(ids, "序号", str(no), 28, 700, "$c-accent", width="fit_content",
growth="auto", line_height=1.4)
]
row = frame(ids, f"回顾 {no}", width="fill_container", height="fit_content",
layout="horizontal", gap=24, alignItems="center", fill=[])
row["children"] = [dot, text(ids, "回顾文字", label, 32, 500, "$c-ink",
line_height=1.5)]
return row
def cta_page():
head = block("总结头部", [
badge("总结", fill_c="$c-accent", text_c="#FFFFFF"),
text(ids, "总结标题", "就这三步,\n你也能做出来。", 76, 700, "$c-ink"),
], gap=32)
recaps = block("回顾列表", [
recap_row(1, "选一套模板,直接打开"),
recap_row(2, "把截图拖进占位框"),
recap_row(3, "改文案,导出发布"),
], gap=24)
cta_inner = frame(ids, "关注卡内容", width="fill_container",
height="fit_content", layout="vertical", gap=16, fill=[],
alignItems="start")
cta_inner["children"] = [
text(ids, "关注标题", "关注我,持续更新模板", 40, 700, "#FFFFFF"),
text(ids, "关注副文案", "评论区回复「模板」,拿走这套源文件。",
28, 400, "#FFE7DE"),
]
cta = frame(ids, "关注引导卡", width="fill_container", height="fit_content",
layout="vertical", padding=[44, 44], gap=0, cornerRadius=28,
fill=solid("$c-accent"))
cta["children"] = [cta_inner]
# 四个块直接交给 page 的 space_between 分配;再包一层 fit_content 的
# body 会把内容全顶到上半页,底部留一大块空洞。
return page("05 结尾 CTA", [head, recaps, cta, footer(5)])
def build():
pages = [cover(), step(1, "截好你要讲的那一张图",
"截图只留关键区域,边缘留白裁掉。窗口截图记得关掉无关的标签页和通知,"
"画面越干净,读者越容易看懂你在讲哪一步。",),
step(2, "标出最该被看见的地方",
"在截图上加一个圈或箭头,只标一处。标注超过两个,读者就不知道该看哪里了,"
"重点越少,记住的越多。",),
step(3, "配一句人话说明",
"标题写「做什么」,正文写「怎么做」。一页只说一件事,说不完就拆成下一页,"
"不要把字塞满整张图。",),
cta_page()]
for i, p in enumerate(pages):
p["x"] = i * (W + GAP)
p["y"] = 0
dst = sys.argv[1]
write_doc(dst, VARS, pages, "截图教程卡 · 小红书 3:4 模板")
if __name__ == "__main__":
build()

View file

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""knowledge-carousel.op — 小红书 3:4 知识轮播(封面 + 3 论点 + 总结)"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, frame, rect, path, text, solid, stroke, write_doc, color_vars
W, H, GAP = 1080, 1440, 120
PAD_X, PAD_Y = 72, 88
VARS = color_vars({
"c-bg": "#F4F5FA",
"c-surface": "#FFFFFF",
"c-ink": "#14183A",
"c-muted": "#63698C",
"c-accent": "#3B4CCA",
"c-accent-soft": "#E5E8FB",
"c-border": "#DEE1F0",
})
ids = Ids()
POINTS = [
("先有结论,再有论据",
"读者划到你这一页,只会给你一眼的时间。把结论放进标题,把论据留给正文,"
"不要让人读完三行才知道你想说什么。你以为在铺垫,读者以为你没重点。",
"标题写结论,正文写理由。"),
("一页只讲一个论点",
"轮播的优势是节奏,不是容量。一页塞两个论点,读者一个都记不住;"
"拆成两页,两个都记得住。多拆一页的成本几乎为零,真正贵的是读者的耐心。",
"讲不完,就拆下一页。"),
("留白比字号更重要",
"想让一句话被看见,办法不是把它放大,而是把它周围清空。"
"四周留白足够时30px 的字也会比挤在一堆里的 60px 更醒目。"
"别用字号硬撑,先把周围清干净。",
"想突出,先清空周围。"),
]
def page(name, children):
node = frame(
ids, name, width=W, height=H, layout="vertical",
padding=[PAD_Y, PAD_X], gap=0,
justifyContent="space_between", alignItems="start",
fill=solid("$c-bg"), clipContent=True,
)
node["children"] = children
return node
def block(name, children, gap=32, **extra):
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", gap=gap, fill=[], alignItems="start")
node["children"] = children
node.update(extra)
return node
def badge(label, *, fill_c="$c-accent-soft", text_c="$c-accent", size=26):
node = frame(ids, f"徽章 · {label}", width="fit_content", height="fit_content",
layout="horizontal", padding=[12, 24], gap=0, cornerRadius=999,
alignItems="center", justifyContent="center", fill=solid(fill_c))
node["children"] = [
text(ids, "徽章文字", label, size, 600, text_c, width="fit_content",
growth="auto", line_height=1.4)
]
return node
def footer(page_no, total=5):
node = frame(ids, "页脚", width="fill_container", height="fit_content",
layout="horizontal", justifyContent="space_between",
alignItems="center", gap=16, fill=[])
node["children"] = [
text(ids, "页脚品牌", "@ 你的账号名", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
text(ids, "页码", f"{page_no:02d} / {total:02d}", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
]
return node
def rule(width=120):
return rect(ids, "强调短线", width=width, height=12, cornerRadius=6,
fill=solid("$c-accent"))
def callout(quote):
"""轻装饰 · 金句卡:左侧 6px 品牌色边PenStroke 单边 thickness"""
node = frame(ids, "金句卡", width="fill_container", height="fit_content",
layout="vertical", padding=[32, 36], gap=0, cornerRadius=16,
alignItems="start", fill=solid("$c-surface"),
stroke={"thickness": {"left": 6}, "fill": solid("$c-accent")})
node["children"] = [
text(ids, "金句", quote, 34, 600, "$c-ink", line_height=1.5)
]
return node
# ---------------------------------------------------------------- 01 封面
def toc_row(no, label):
num = text(ids, "目录序号", f"{no:02d}", 30, 700, "$c-accent",
width="fit_content", growth="auto", line_height=1.4,
family="Inter")
node = frame(ids, f"目录 {no}", width="fill_container", height="fit_content",
layout="horizontal", gap=24, alignItems="center", fill=[])
node["children"] = [
num,
text(ids, "目录标题", label, 32, 500, "$c-ink", line_height=1.5),
]
return node
def cover():
head = block("封面头部", [badge("知识拆解")])
hero = block("封面主标题区", [
text(ids, "封面标题", "把一篇长文,\n拆成五张图", 88, 700, "$c-ink"),
rule(132),
text(ids, "封面副标题",
"写得再好,没人读完也是白写。\n这套模板帮你把长文变成能划完的轮播。",
32, 400, "$c-muted"),
], gap=36)
toc = block("本期目录", [
text(ids, "目录标签", "本期三个论点", 26, 600, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
toc_row(1, POINTS[0][0]),
toc_row(2, POINTS[1][0]),
toc_row(3, POINTS[2][0]),
], gap=22)
return page("01 封面", [head, hero, toc, footer(1)])
# ------------------------------------------------------------ 02-04 论点页
def point_page(no):
title, body, quote = POINTS[no - 1]
main = block(f"0{no+1} 内容", [
text(ids, "装饰序号", f"{no:02d}", 220, 700, "$c-accent-soft",
width="fit_content", growth="auto", line_height=1.0,
family="Inter"),
rule(96),
text(ids, "论点标题", title, 72, 700, "$c-ink"),
text(ids, "论点阐述", body, 34, 400, "$c-muted", line_height=1.8),
], gap=28)
return page(f"0{no+1} 论点 {no}", [main, callout(quote), footer(no + 1)])
# ---------------------------------------------------------------- 05 总结
def recap_card(no, label, sub):
num = frame(ids, f"回顾序号 {no}", width=60, height=60, layout="horizontal",
alignItems="center", justifyContent="center", cornerRadius=30,
fill=solid("$c-accent-soft"))
num["children"] = [
text(ids, "序号", f"{no}", 30, 700, "$c-accent", width="fit_content",
growth="auto", line_height=1.4, family="Inter")
]
body = block("回顾文案", [
text(ids, "回顾标题", label, 34, 600, "$c-ink", line_height=1.4),
text(ids, "回顾说明", sub, 26, 400, "$c-muted", line_height=1.6),
], gap=8)
node = frame(ids, f"回顾卡 {no}", width="fill_container", height="fit_content",
layout="horizontal", padding=[28, 32], gap=24,
alignItems="start", cornerRadius=16, fill=solid("$c-surface"))
node["children"] = [num, body]
return node
def summary_page():
head = block("总结头部", [
badge("总结"),
text(ids, "总结标题", "三句话,\n记住这一篇。", 76, 700, "$c-ink"),
], gap=32)
cards = block("回顾列表", [
recap_card(1, "结论前置", "标题就是观点,别让人猜。"),
recap_card(2, "一页一论点", "讲不完就拆页,别硬塞。"),
recap_card(3, "先留白再放大", "清空周围,比放大字号有效。"),
], gap=20)
cta_inner = block("关注卡内容", [
text(ids, "关注标题", "觉得有用,就点个收藏", 40, 700, "#FFFFFF"),
text(ids, "关注副文案", "关注我,每周拆一篇长文成图。", 28, 400,
"#D6DBFA"),
], gap=16)
cta = frame(ids, "关注引导卡", width="fill_container", height="fit_content",
layout="vertical", padding=[44, 44], gap=0, cornerRadius=24,
fill=solid("$c-accent"))
cta["children"] = [cta_inner]
return page("05 总结", [head, cards, cta, footer(5)])
def build():
pages = [cover(), point_page(1), point_page(2), point_page(3),
summary_page()]
for i, p in enumerate(pages):
p["x"] = i * (W + GAP)
p["y"] = 0
write_doc(sys.argv[1], VARS, pages, "知识轮播 · 小红书 3:4 模板")
if __name__ == "__main__":
build()

View file

@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""before-after.op — 16:9 单帧对比图1600×900"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, frame, rect, text, solid, stroke, write_doc,
color_vars, group, upload_disc, asset_fill,
PLACEHOLDER_DISC, PLACEHOLDER_ICON,
PLACEHOLDER_TITLE, PLACEHOLDER_SPEC)
W, H = 1600, 900
PAD_X, PAD_Y = 64, 56
VARS = color_vars({
"c-bg": "#F6F7F9",
"c-surface": "#FFFFFF",
"c-ink": "#101828",
"c-muted": "#667085",
"c-before": "#98A2B3",
"c-before-soft": "#EEF0F3",
"c-after": "#0E9F6E",
"c-after-soft": "#E3F5EE",
"c-border": "#D3D8E0",
})
ids = Ids()
# 每条说明控制在 16 字以内:三栏各约 380px 宽22px 中文一行约 17 字,
# 超出会把句号挤到第二行变成孤字。
DIFFS = [
("信息层级", "标题只留一个重点,其余弱化"),
("对比度", "正文换成深色,小字也读得清"),
("留白", "模块间距拉到 32px不再拥挤"),
]
def block(name, children, gap=20, **extra):
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", gap=gap, fill=[], alignItems="start")
node["children"] = children
node.update(extra)
return node
def badge(label, *, fill_c, text_c, size=24):
node = frame(ids, f"徽章 · {label}", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], gap=0, cornerRadius=999,
alignItems="center", justifyContent="center", fill=solid(fill_c))
node["children"] = [
text(ids, "徽章文字", label, size, 700, text_c, width="fit_content",
growth="auto", line_height=1.4, family="Inter")
]
return node
SLOT_W, SLOT_H = 716, 420 # 实测占位框布局尺寸,仅用于烘焙提示图
# 两栏的空态提示文案 —— bake_hints.py 用它烘焙 assets/*.png
HINTS = [("拖入改版前的截图", "支持 PNG / JPG两侧建议用同一尺寸"),
("拖入改版后的截图", "支持 PNG / JPG两侧建议用同一尺寸")]
HINT_ASSET = "before-after-{i}.png"
def hint_children(hint):
"""空态提示的矢量构造 —— 只被 bake_hints.py 用来烘焙成 PNG。
两栏都用中性灰不再分别取 before/after 的色占位是控件不是品牌表达
Before/After 的色彩区分由上方徽章承担
"""
return [
upload_disc(ids, "上传图标", 88, PLACEHOLDER_DISC, 40,
PLACEHOLDER_ICON),
text(ids, "占位提示", hint, 28, 600, PLACEHOLDER_TITLE, align="center",
line_height=1.4),
text(ids, "占位规格", "支持 PNG / JPG两侧建议用同一尺寸", 22, 400,
PLACEHOLDER_SPEC, align="center"),
]
def shot_slot(kind, idx):
"""截图占位框 —— 空态提示是 fill[0] 的内嵌 PNG没有子节点。"""
return frame(ids, f"截图占位框 · {kind}", width="fill_container",
height="fill_container", cornerRadius=20,
fill=[asset_fill(HINT_ASSET.format(i=idx), "fit"),
solid("$c-surface")[0]],
stroke=stroke("$c-border", 3), clipContent=True)
def column(label, kind, tint, accent, idx):
head = frame(ids, f"{kind}标签行", width="fill_container",
height="fit_content", layout="horizontal", gap=16,
alignItems="center", fill=[])
head["children"] = [
badge(label, fill_c=tint, text_c=accent),
text(ids, "标签说明", kind, 26, 500, "$c-muted", width="fit_content",
growth="auto", line_height=1.4),
]
node = frame(ids, f"对比栏 · {kind}", width="fill_container",
height="fill_container", layout="vertical", gap=18, fill=[],
alignItems="start")
node["children"] = [head, shot_slot(kind, idx)]
return node
def diff_item(no, title, desc):
num = frame(ids, f"差异序号 {no}", width=44, height=44, layout="horizontal",
alignItems="center", justifyContent="center", cornerRadius=22,
fill=solid("$c-after-soft"))
num["children"] = [
text(ids, "序号", str(no), 24, 700, "$c-after", width="fit_content",
growth="auto", line_height=1.4, family="Inter")
]
body = block("差异文案", [
text(ids, "差异标题", title, 26, 600, "$c-ink", line_height=1.4),
text(ids, "差异说明", desc, 22, 400, "$c-muted", line_height=1.6),
], gap=6)
node = frame(ids, f"差异点 {no}", width="fill_container",
height="fit_content", layout="horizontal", gap=18,
alignItems="start", fill=[])
node["children"] = [num, body]
return node
def build():
header = frame(ids, "页头", width="fill_container", height="fit_content",
layout="horizontal", justifyContent="space_between",
alignItems="center", gap=24, fill=[])
header["children"] = [
block("页头文案", [
text(ids, "页头标题", "改版前后对比", 40, 700, "$c-ink",
line_height=1.3),
text(ids, "页头副标题", "同一个页面,只改了三件事。", 24, 400,
"$c-muted", line_height=1.5),
], gap=10, width="fit_content"),
badge("BEFORE / AFTER", fill_c="$c-before-soft", text_c="$c-muted",
size=22),
]
compare = frame(ids, "对比区", width="fill_container",
height="fill_container", layout="horizontal", gap=40,
alignItems="start", fill=[])
compare["children"] = [
column("BEFORE", "改版前", "$c-before-soft", "$c-before", 0),
column("AFTER", "改版后", "$c-after-soft", "$c-after", 1),
]
diffs = frame(ids, "差异点卡", width="fill_container", height="fit_content",
layout="horizontal", padding=[28, 32], gap=40,
alignItems="start", cornerRadius=20,
fill=solid("$c-surface"))
diffs["children"] = [diff_item(i + 1, t, d) for i, (t, d) in enumerate(DIFFS)]
root = frame(ids, "改版前后对比图", x=0, y=0, width=W, height=H,
layout="vertical", padding=[PAD_Y, PAD_X], gap=32,
alignItems="start", fill=solid("$c-bg"), clipContent=True)
root["children"] = [header, compare, diffs]
write_doc(sys.argv[1], VARS, [root], "改版前后对比图 · 16:9 模板")
if __name__ == "__main__":
build()

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

File diff suppressed because one or more lines are too long