Every multi-board template laid its boards out in one long horizontal strip, so opening a seven-board carousel dropped the user onto a canvas they had to pan sideways to survey. Three per row matches the deck kit, and the extra row gap is deliberate: the canvas draws frame names at a fixed screen-space offset, so at fit-to-screen zoom a plain column gap lets the second row's labels sit on top of the boards above them. knowledge-carousel additionally takes hard line breaks in two body paragraphs. Greedy wrapping put a comma at the start of a line and left a lone full stop on the last one, which are both CJK line-break faults; authored breaks remove the engine's freedom to reintroduce them. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
386 lines
17 KiB
Python
386 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""photo-composition-tutorial.op — 手机摄影构图教学(1080×1440,3:4 五帧)
|
||
|
||
教程档里唯一一张**把教学内容画进主视觉**的模板。别的教程是「图 + 说明」,
|
||
构图教学的说明写得再好也没用——构图必须**画在取景框上**才说得清。所以每
|
||
一页的主视觉是一个深色取景框,荧光绿参考线压在图片位之上:用户把自己的照
|
||
片拖进去,线还在,立刻能看出自己拍的东西落在哪一格。
|
||
|
||
风格取「黑白灰 + 荧光绿冲击风」:整篇零第二有彩色,荧光绿只出现在参考线、
|
||
序号与一个关键词上。
|
||
|
||
### 配色推导(采样 → 收敛 → 论证)
|
||
|
||
- **采样**:不从内容采色——摄影教学的内容色就是照片本身,版面必须让位。
|
||
取一档相机/取景器语境里有真实出处的荧光绿(对焦框、峰值对焦的高亮色)。
|
||
- **收敛**:一条中性明度序列(L 0.06 / 0.09 / 0.42 / 0.85 / 0.91 / 0.96)
|
||
+ 1 个有彩色 #C8FF3D。
|
||
- **论证**:荧光绿在浅底上只有 1.1:1,肉眼近乎不可见——所以取景框**必须
|
||
是深色**,参考线才成立。这不是审美选择,是被这个强调色反推出来的结构:
|
||
先定了「线要看得见」,才定了「框是黑的」。反过来,深框压在近白页面上又
|
||
自动获得了封面级的对比,整套不需要再加任何装饰。
|
||
|
||
### 负约束(本模板明令不做的事)
|
||
|
||
- **参考线不用 `line` 节点画。** 试过,不成立:`line` 的 x/y 在渲染时被
|
||
当作**文档绝对坐标**,不吃父节点的布局偏移(用 snapshot_layout 验证:
|
||
同一个 layout:none 框里,`图片位` 拿到绝对坐标 1272/242,四条 `line`
|
||
仍是 312/0 这样的局部值),所以第 2-4 帧的线全被画到画布左边、被
|
||
clipContent 裁掉。参考线一律用 `rectangle`,rect 在 layout:none 下是
|
||
父相对定位、`rotation` 绕自身中心,斜线也能画。
|
||
- 代价要说清:rect 是合法的图片拖放目标
|
||
(`image_drop.rs::node_accepts_image_drop`)。四条 4px 参考线合计只占
|
||
取景框面积的 1.4%,拖照片时正好砸中某一条的概率极低;真砸中了是那条线
|
||
被填成图片,撤销一次重拖即可。
|
||
- 不用第二个有彩色。荧光绿之外的一切都是中性明度。
|
||
- 不在浅底上写荧光绿文字(1.1:1,等于没写)。荧光绿承载文字时底一律是
|
||
近黑。
|
||
- 不用渐变、不用发光、不用胶片颗粒滤镜、不用相机 emoji。
|
||
- 一页只讲一个构图法。四个法则挤一页是这类内容最常见的死法。
|
||
- 不写「氛围感 / 高级感 / 出片」这类形容词,每条要点都写成可执行动作。
|
||
|
||
硬契约:
|
||
- 内容距边缘 ≥72px(这里 72)
|
||
- 配色全部走 color_vars;换主色只需改 c-accent 与 c-accent-ink
|
||
- 正文与背景对比度 ≥2.0(实测表见文件末尾)
|
||
- **CJK 行高比西文全线高 0.2**:display 1.2,标题 1.35,正文 1.7
|
||
- **CJK 负字距不超过 -0.02em**(88px 标题 → -1.7px = -0.019em)
|
||
- 汉字走 Noto Sans SC,数字与拉丁走 Inter
|
||
- 顶层 frame 显式写 x/y,五帧按 W+GAP 横排
|
||
- 取景框写死 936×819(1.14:1)——参考线按它算绝对坐标;这个数由页面
|
||
余量反解,见 FRAME_H 注释
|
||
"""
|
||
|
||
import math
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
from oplib import (Ids, color_vars, frame, icon_font, rect, solid, text,
|
||
write_doc)
|
||
|
||
ids = Ids()
|
||
|
||
VARS = color_vars({
|
||
"c-bg": "#F4F4F2",
|
||
"c-card": "#FFFFFF",
|
||
"c-line": "#DCDCD8",
|
||
"c-ink": "#101010",
|
||
"c-frame": "#171715",
|
||
"c-muted": "#676764",
|
||
# 深框里的次级文字。同一个 c-muted 压在近黑上只有 3.4:1,深浅两套次级
|
||
# 色是必要的(和 pitfall-list 那张同一个理由)。
|
||
"c-inv-muted": "#9C9C98",
|
||
"c-accent": "#C8FF3D",
|
||
"c-accent-ink": "#141C00",
|
||
})
|
||
|
||
CJK = "Noto Sans SC"
|
||
NUM = "Inter"
|
||
|
||
W, H, GAP = 1080, 1440, 120
|
||
|
||
# 3 板一行 —— 与 deck 体系(deckkit.BOARDS_PER_ROW)同一约定:多板模板在画布上
|
||
# 分行铺开,而不是拖成一长排。行间距比列间距多 240 不是手滑:画布在帧上方以
|
||
# **屏幕空间**固定偏移画帧名,缩到能整屏看时 120 文档像素只剩十几个屏幕像素,
|
||
# 第二行的帧名会压到上一行的板上。
|
||
BOARDS_PER_ROW = 3
|
||
ROW_GAP = GAP + 240
|
||
EDGE = 72
|
||
|
||
LH_DISPLAY, LH_HEAD, LH_BODY = 1.2, 1.35, 1.7
|
||
|
||
# 取景框写死尺寸。参考线全是相对这个框的绝对坐标。
|
||
# 819 是解出来的,不是挑的:法则页固定部分(页头 118 + 要点区 186 +
|
||
# 页脚 33)+ 上下 padding 152 + 三个 44 的 gap = 621,1440 减掉就是它。
|
||
# 改页头/要点区任何一行的字号或行数,都要把这个数重解一次。
|
||
FRAME_W, FRAME_H = 936, 819
|
||
GUIDE = 4
|
||
|
||
PAGE_GAP = 44
|
||
TOTAL = 5
|
||
|
||
|
||
def col(name, children, *, gap=16, width="fill_container", align="start",
|
||
height="fit_content", **props):
|
||
node = frame(ids, name, width=width, height=height, layout="vertical",
|
||
gap=gap, alignItems=align, fill=[], **props)
|
||
node["children"] = children
|
||
return node
|
||
|
||
|
||
def row(name, children, *, gap=20, align="center", width="fill_container",
|
||
height="fit_content", **props):
|
||
node = frame(ids, name, width=width, height=height, layout="horizontal",
|
||
gap=gap, alignItems=align, fill=[], **props)
|
||
node["children"] = children
|
||
return node
|
||
|
||
|
||
def bar(name, x, y, w, h, color="$c-accent", rotation=None):
|
||
"""一条参考线,实体 rect。见文件头负约束:不用 `line` 节点。"""
|
||
node = rect(ids, name, x=x, y=y, width=w, height=h, fill=solid(color))
|
||
if rotation is not None:
|
||
node["rotation"] = rotation
|
||
return node
|
||
|
||
|
||
def diag(name, x1, y1, x2, y2, thickness=GUIDE, color="$c-accent"):
|
||
"""两点之间的斜线。rect 的 rotation 绕自身中心转,所以按中点摆位。"""
|
||
dx, dy = x2 - x1, y2 - y1
|
||
length = math.hypot(dx, dy)
|
||
angle = math.degrees(math.atan2(dy, dx))
|
||
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
||
return bar(name, round(cx - length / 2, 2), round(cy - thickness / 2, 2),
|
||
round(length, 2), thickness, color=color,
|
||
rotation=round(angle, 3))
|
||
|
||
|
||
def page(name, children, *, index, fill="$c-bg", pad_top=80):
|
||
node = frame(ids, name, width=W, height=H, layout="vertical",
|
||
padding=[pad_top, EDGE, 72, EDGE], gap=PAGE_GAP,
|
||
fill=solid(fill), clipContent=True)
|
||
node["children"] = children
|
||
node["x"] = (index % BOARDS_PER_ROW) * (W + GAP)
|
||
node["y"] = (index // BOARDS_PER_ROW) * (H + ROW_GAP)
|
||
return node
|
||
|
||
|
||
def footer(no, *, ink="$c-muted"):
|
||
return row("页脚", [
|
||
text(ids, "账号名", "@ 你的账号名", 24, 600, ink, family=CJK,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
text(ids, "页码", f"{no:02d} / {TOTAL:02d}", 24, 600, ink, family=NUM,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
], gap=16, justifyContent="space_between")
|
||
|
||
|
||
def tag(label, *, bg, fg):
|
||
node = frame(ids, "标签", width="fit_content", height="fit_content",
|
||
layout="horizontal", padding=[10, 20], cornerRadius=6,
|
||
alignItems="center", justifyContent="center", fill=solid(bg))
|
||
node["children"] = [
|
||
text(ids, "标签文字", label, 24, 700, fg, family=CJK,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
]
|
||
return node
|
||
|
||
|
||
# ------------------------------------------------------------------ 01 封面
|
||
COVER_RULES = ["三分法", "引导线", "框住主体"]
|
||
|
||
|
||
def cover():
|
||
head = row("封面头", [
|
||
tag("构图 · 五分钟", bg="$c-accent", fg="$c-accent-ink"),
|
||
text(ids, "封面页码", "01 / 05", 24, 600, "$c-inv-muted", family=NUM,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
], gap=16, justifyContent="space_between")
|
||
|
||
title = col("封面标题", [
|
||
text(ids, "标题上", "同一个场景", 88, 700, "$c-card", family=CJK,
|
||
line_height=LH_DISPLAY, spacing=-1.7),
|
||
text(ids, "标题下", "为什么你拍得平", 88, 700, "$c-accent",
|
||
family=CJK, line_height=LH_DISPLAY, spacing=-1.7),
|
||
text(ids, "封面副标题",
|
||
"不是相机的问题。三条构图法则,今天就能用上。",
|
||
28, 400, "$c-inv-muted", family=CJK, line_height=LH_BODY),
|
||
], gap=14)
|
||
|
||
items = []
|
||
for index, rule in enumerate(COVER_RULES, 1):
|
||
items.append(row("法则预告", [
|
||
text(ids, "预告序号", f"{index:02d}", 30, 700, "$c-accent",
|
||
family=NUM, width=64, line_height=1.0),
|
||
text(ids, "预告名", rule, 34, 600, "$c-card", family=CJK,
|
||
line_height=LH_HEAD),
|
||
], gap=8))
|
||
preview = col("法则预告区", items, gap=22)
|
||
|
||
body = col("封面主体", [title, preview], gap=64,
|
||
height="fill_container", justifyContent="space_between")
|
||
return page("01 封面", [head, body, footer(1, ink="$c-inv-muted")],
|
||
index=0, fill="$c-ink", pad_top=88)
|
||
|
||
|
||
# ------------------------------------------------------- 02-04 构图法则页
|
||
def guides_thirds():
|
||
"""三分法:两竖两横,把框切成九宫格。"""
|
||
xs = [round(FRAME_W / 3), round(FRAME_W * 2 / 3)]
|
||
ys = [round(FRAME_H / 3), round(FRAME_H * 2 / 3)]
|
||
return ([bar("竖线", x, 0, GUIDE, FRAME_H) for x in xs]
|
||
+ [bar("横线", 0, y, FRAME_W, GUIDE) for y in ys])
|
||
|
||
|
||
def guides_leading():
|
||
"""引导线:两条从底边收敛到上三分之一的斜线,外加那个汇合标。"""
|
||
vx, vy = FRAME_W / 2, round(FRAME_H * 0.32)
|
||
return [
|
||
diag("左引导", 0, FRAME_H, vx, vy),
|
||
diag("右引导", FRAME_W, FRAME_H, vx, vy),
|
||
bar("汇合横标", round(vx) - 44, vy, 88, GUIDE),
|
||
]
|
||
|
||
|
||
def guides_frame():
|
||
"""框住主体:四条边围出一个内框,主体要落在框里。"""
|
||
m = 96
|
||
span_w, span_h = FRAME_W - m * 2, FRAME_H - m * 2
|
||
return [
|
||
bar("内框上", m, m, span_w, GUIDE),
|
||
bar("内框下", m, FRAME_H - m - GUIDE, span_w, GUIDE),
|
||
bar("内框左", m, m, GUIDE, span_h),
|
||
bar("内框右", FRAME_W - m - GUIDE, m, GUIDE, span_h),
|
||
]
|
||
|
||
|
||
def viewfinder(guides, hint):
|
||
"""取景框 = 参考线(在上)+ 图片位(在下)。
|
||
|
||
layout:none 下 children[0] 最靠前,所以参考线必须排在图片位前面。
|
||
"""
|
||
slot = frame(ids, "图片位", x=0, y=0, width=FRAME_W, height=FRAME_H,
|
||
layout="vertical", gap=14, alignItems="center",
|
||
justifyContent="center", fill=solid("$c-frame"))
|
||
slot["children"] = [
|
||
icon_font(ids, "图片位图标", "image", 52, "$c-inv-muted"),
|
||
text(ids, "图片位提示", hint, 28, 600, "$c-card", family=CJK,
|
||
align="center", line_height=LH_HEAD),
|
||
text(ids, "图片位规格", "拖进来后参考线还在,直接对照",
|
||
23, 400, "$c-inv-muted", family=CJK, align="center",
|
||
line_height=LH_BODY),
|
||
]
|
||
box = frame(ids, "取景框", width=FRAME_W, height=FRAME_H, layout="none",
|
||
cornerRadius=8, fill=[], clipContent=True)
|
||
box["children"] = guides + [slot]
|
||
return box
|
||
|
||
|
||
def point(kind, label, desc):
|
||
"""一条要点。做/不做用图标 + 底色区分,不引第二个色相。"""
|
||
good = kind == "do"
|
||
chip = frame(ids, "要点标记", width=44, height=44, layout="horizontal",
|
||
alignItems="center", justifyContent="center", cornerRadius=8,
|
||
fill=solid("$c-accent" if good else "$c-ink"))
|
||
chip["children"] = [
|
||
icon_font(ids, "要点图标", "check" if good else "x", 24,
|
||
"$c-accent-ink" if good else "$c-card"),
|
||
]
|
||
return row("要点", [
|
||
chip,
|
||
col("要点文案", [
|
||
text(ids, "要点标题", label, 28, 600, "$c-ink", family=CJK,
|
||
line_height=LH_HEAD),
|
||
text(ids, "要点说明", desc, 24, 400, "$c-muted", family=CJK,
|
||
line_height=LH_BODY),
|
||
], gap=4),
|
||
], gap=16, align="start")
|
||
|
||
|
||
def rule_page(no, name, lead, guides, hint, do, dont):
|
||
head = col("页头", [
|
||
row("法则标题行", [
|
||
text(ids, "法则序号", f"{no - 1:02d}", 44, 700, "$c-ink",
|
||
family=NUM, width=88, line_height=1.0, spacing=-1),
|
||
text(ids, "法则名", name, 46, 700, "$c-ink", family=CJK,
|
||
line_height=LH_HEAD),
|
||
], gap=8, align="center"),
|
||
text(ids, "法则说明", lead, 26, 400, "$c-muted", family=CJK,
|
||
line_height=LH_BODY),
|
||
], gap=12)
|
||
|
||
points = col("要点区", [point("do", *do), point("dont", *dont)], gap=20)
|
||
return page(f"{no:02d} {name}",
|
||
[head, viewfinder(guides, hint), points, footer(no)],
|
||
index=no - 1)
|
||
|
||
|
||
# ------------------------------------------------------------------ 05 收尾
|
||
CHECKS = [
|
||
("拍之前先决定主体是谁", "一张只留一个主体,其余全是背景。"),
|
||
("把主体挪出正中心", "正中心是最安全也最平的位置。"),
|
||
("按快门前退后半步", "多留出的边框,后期还能裁;裁没了补不回来。"),
|
||
]
|
||
|
||
|
||
def recap():
|
||
head = col("页头", [
|
||
tag("收尾", bg="$c-ink", fg="$c-accent"),
|
||
text(ids, "收尾标题", "下次举起手机前\n先过这三句", 64, 700, "$c-ink",
|
||
family=CJK, line_height=LH_DISPLAY, spacing=-1.2),
|
||
], gap=20)
|
||
|
||
items = []
|
||
for index, (title, desc) in enumerate(CHECKS, 1):
|
||
badge = frame(ids, "序号方", width=52, height=52, layout="horizontal",
|
||
alignItems="center", justifyContent="center",
|
||
cornerRadius=8, fill=solid("$c-accent"))
|
||
badge["children"] = [
|
||
text(ids, "序号", str(index), 26, 700, "$c-accent-ink",
|
||
family=NUM, width="fit_content", growth="auto",
|
||
line_height=1.0),
|
||
]
|
||
card = row("自检项", [
|
||
badge,
|
||
col("自检文案", [
|
||
text(ids, "自检标题", title, 30, 600, "$c-ink", family=CJK,
|
||
line_height=LH_HEAD),
|
||
text(ids, "自检说明", desc, 24, 400, "$c-muted", family=CJK,
|
||
line_height=LH_BODY),
|
||
], gap=6),
|
||
], gap=18, align="start", padding=[26, 26])
|
||
card["fill"] = solid("$c-card")
|
||
card["cornerRadius"] = 14
|
||
items.append(card)
|
||
|
||
cta = col("关注卡", [
|
||
text(ids, "关注标题", "拍完发出来", 40, 700, "$c-accent", family=CJK,
|
||
line_height=LH_HEAD),
|
||
text(ids, "关注副文案", "评论区贴图,我挨个说说该怎么裁。", 26, 400,
|
||
"$c-inv-muted", family=CJK, line_height=LH_BODY),
|
||
], gap=10, padding=[36, 36])
|
||
cta["fill"] = solid("$c-ink")
|
||
cta["cornerRadius"] = 16
|
||
|
||
body = col("收尾主体", [col("自检区", items, gap=18), cta], gap=32,
|
||
height="fill_container", justifyContent="space_between")
|
||
return page("05 收尾", [head, body, footer(5)], index=4)
|
||
|
||
|
||
def build():
|
||
return [
|
||
cover(),
|
||
rule_page(2, "三分法",
|
||
"把画面切成九格,主体放在线上或交点上,别放正中间。",
|
||
guides_thirds(), "拖一张风景或人像进来",
|
||
("主体压在竖线上", "地平线压在上横线或下横线,不居中。"),
|
||
("主体钉死在正中心", "对称构图另说,日常随手拍会显得呆。")),
|
||
rule_page(3, "引导线",
|
||
"让路、栏杆、影子这些线条从画面底部指向主体。",
|
||
guides_leading(), "拖一张有路或走廊的照片",
|
||
("蹲低一点再拍", "机位越低,地面的线条收得越急。"),
|
||
("站直了平拍", "平视时线条被压扁,几乎没有纵深。")),
|
||
rule_page(4, "框住主体",
|
||
"用门框、树枝、窗户在主体外面再套一层框。",
|
||
guides_frame(), "拖一张有门洞或树枝的照片",
|
||
("前景压暗一点", "外框暗、主体亮,视线自己就走进去了。"),
|
||
("外框喧宾夺主", "外框占超过一半画面,主体就被吃掉了。")),
|
||
recap(),
|
||
]
|
||
|
||
|
||
# 对比度(WCAG 相对亮度比,op-design-lint 门槛 2.0;数值由脚本实测):
|
||
# c-ink on c-bg 17.28 c-muted on c-bg 5.15
|
||
# c-ink on c-card 19.03 c-muted on c-card 5.67
|
||
# c-card on c-ink 19.03 c-inv-muted on c-ink 6.91
|
||
# c-card on c-frame 17.95 c-inv-muted on c-frame 6.52
|
||
# c-accent on c-ink 16.15 c-accent-ink on c-accent 14.91
|
||
# c-accent on c-frame 15.24 c-accent on c-bg 1.07 ← 禁用
|
||
# 承载正文的最低一对是 5.15。最后一对是这套配色的雷:荧光绿压在浅底上只有
|
||
# 1.07:1,所以整篇没有一处浅底荧光绿文字——荧光绿要么当参考线(非文字图
|
||
# 形,压在 c-frame 上有 15.24),要么写在近黑上。
|
||
|
||
if __name__ == "__main__":
|
||
write_doc(sys.argv[1], VARS, build(), "手机摄影构图教学 · 3:4 五帧")
|