openpencil/templates/step0/_generators/tpl_gradient_tech.py

469 lines
20 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
"""gradient-tech.op — 渐变科技风 deck(6 页 1920×1080)
深底 + 大面积渐变 + 玻璃拟态卡,开发者产品口吻。这套同时是引擎能力的展台:
线性渐变、径向光晕、玻璃卡三样都是 schema 原生支持、渲染栈真画得出来的
(渲染探针见交付说明)。
这一版把渐变从「深紫到深蓝」推成真正的**紫 → 青**对角全幅,饱和度拉到
对比度预算允许的上限(见下面 GRADIENT 的注释),并给每页加了一冷一暖两团
径向光晕(`oplib.stack` 的装饰层)。标题字重与字号同步拉满:封面 112、
页标题 76,都收紧字距到 -2 ~ -3。
## 玻璃卡为什么不用真 alpha
玻璃拟态的自然写法是半透明白 `#FFFFFF1F`。渲染没问题,但 op-design-lint
的对比度检测器 `parse_hex_color` 会丢掉 alpha 通道,把这张卡当成**纯白不
透明**:卡里的白字于是被判成 1.00:1,而真实合成结果是深紫上的白字。后果
不只是误报——它会让这张卡内的所有文字都失去有效检测,真出现低对比配色也
照样不报。
所以这里的玻璃卡用「不透明渐变」:stops 是「白 @ GLASS_ALPHA 合成到页面
渐变对应 stop」的结果,光学上与真 alpha 一致(对照渲染验证过),但不含
alpha 通道。代价是 stops 是烤死的——改了下面的 GRADIENT 就必须重跑本生成
器,不能只改 .op 里的变量。
排版遵循 skills/domains/slides.md 的硬契约:
- 每帧固定 1920×1080,绝不 fit_content;内容距边 ≥100(这里 120)
- 正文 ≥24(取 26-30),标题 ≥40,关键数字 80-200(数据页取 132)
- 行高:标题 1.1-1.2,正文 1.45+
- 最多 2 个字体族(Noto Sans SC 排中文,Inter 排数字与代码)
- 强调色只用于强调
渐变底上的对比度按**每个 stop 分别实测**(比单点检测更严),最低一对
4.74:1,全部高于 4.5。数值见文件末尾。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, radial, rect, solid, stack, text,
write_doc)
ids = Ids()
# 页面渐变的三个 stop:紫 → 靛 → 青。玻璃卡的合成色由它们算出来,所以
# 这里是整套配色的唯一真源。
#
# 这一版把饱和度拉到「在预算内能拉到的最满」:色相真的从紫(276°)走到
# 青(203°),三个 stop 的彩度都接近满格。**上限不是审美而是算出来的**——
# 玻璃卡把底色往白里提 12%,青色那一端提完之后最亮,强调色压在那儿的比值
# 就是整套的下限。试过更亮的一版(#3A0CA3 / #4B23C9 / #0B6BC4):好看,但
# 强调色掉到 2.23:1,白正文也只剩 4.30:1,整套报废。现在这组在青端仍有
# 4.74:1。改这三个值必须重跑文件末尾那张表。
GRADIENT = [(0.0, "#43056E"), (0.55, "#2A0C7A"), (1.0, "#00415F")]
# 玻璃的白覆盖率。这个数是承重的:0.12 时强调色压在最亮那段玻璃上是
# 4.74:1,提到 0.18 就掉破 4.5 —— 想让玻璃更亮,得先把强调色一起调。
GLASS_ALPHA = 0.12
GLASS_EDGE_ALPHA = 0.30
def over(alpha, background):
"""白色以 alpha 覆盖 background 后的不透明结果(直接的 source-over)。"""
channels = [int(background[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(
f"{round(alpha * 255 + (1 - alpha) * c):02X}" for c in channels
)
VARS = color_vars({
"c-grad-a": GRADIENT[0][1],
"c-grad-b": GRADIENT[1][1],
"c-grad-c": GRADIENT[2][1],
"c-ink": "#FFFFFF",
"c-muted": "#C9D4F5",
# 比上一版的 #3AC8FF 更亮一档。不是为了更抢眼,是为了在更饱和的底上
# 买回对比度余量:同一个底,#3AC8FF 只有 4.04:1,这一档是 4.74:1。
"c-accent": "#63D8FF",
# 对比条里「被比下去」的那根。用正文的 c-muted 会让基线条比强调条还
# 抢眼(它更长),所以单独给一档更收的合成色;条不是文字,不参与对比
# 度门槛。
"c-bar-base": over(0.45, GRADIENT[1][1]),
# 玻璃卡的三段合成色(由 GRADIENT + GLASS_ALPHA 算出,改了要重跑)。
"c-glass-a": over(GLASS_ALPHA, GRADIENT[0][1]),
"c-glass-b": over(GLASS_ALPHA, GRADIENT[1][1]),
"c-glass-c": over(GLASS_ALPHA, GRADIENT[2][1]),
# 描边不参与对比度检测,这里可以放心用真 alpha 取那道细白边。
"c-glass-edge": "#FFFFFF4D",
# 装饰光晕的两端。光晕是文字的**兄弟**不是祖先,lint 找底色只走祖先链
# 碰不到它;而渐到全透明是唯一能让光晕压在整页渐变上不露出圆形硬边的
# 写法(实色外沿必然与底色对不上)。一冷一暖,对应渐变的两端。
"c-glow-cyan": "#63D8FF33",
"c-glow-cyan-out": "#63D8FF00",
"c-glow-violet": "#B14DFF38",
"c-glow-violet-out": "#B14DFF00",
})
W, H = 1920, 1080
EDGE = 120
CJK = "Noto Sans SC"
NUM = "Inter"
# 角度 135 = 左上 → 右下(渲染实测)。
PAGE_ANGLE = 135
def page_fill():
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-grad-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_fill():
"""玻璃卡体。见文件头:不透明渐变,不是 alpha。"""
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-glass-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_edge():
return {"thickness": 2, "fill": solid("$c-glass-edge")}
def glow(size, x, y, *, warm=False):
"""一团光晕。渐到全透明,所以压在整页渐变上也不会露出圆形硬边。
半径取 0.5:径向渐变正好在圆边收干净,边界外那圈被 TileMode::Clamp 刷
成最后一个 stop(全透明),什么都不留下。
"""
core = "$c-glow-violet" if warm else "$c-glow-cyan"
edge = "$c-glow-violet-out" if warm else "$c-glow-cyan-out"
node = rect(ids, "光晕", width=size, height=size, cornerRadius=size // 2,
fill=radial([(0.0, core), (1.0, edge)]))
node["x"], node["y"] = x, y
return node
def slide(name, *, gap=56, justify="start", decor=()):
"""一帧。固定 1920×1080,底是整页渐变,装饰层单独一层压在内容之下。"""
body = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=[])
body["children"] = []
shell = stack(ids, name, body, list(decor), width=W, height=H,
fill=page_fill())
shell["content"] = body
return shell
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", align="start", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=32, align="start", width="fill_container",
height="fit_content", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def glass_card(name, children, *, gap=18, height="fit_content",
padding=(40, 32), **props):
return col(name, children, gap=gap, height=height,
padding=[padding[0], padding[1]], cornerRadius=20,
fill=glass_fill(), stroke=glass_edge(), **props)
def centered_body(name, child):
"""内容块在页标题以下的整块空间里垂直居中,页标题每页同高。"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
def eyebrow(label):
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], gap=0,
cornerRadius=999, alignItems="center",
justifyContent="center", fill=glass_fill(),
stroke=glass_edge())
node["children"] = [
text(ids, "标签文字", label, 26, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.4,
spacing=1.0),
]
return node
def page_head(title, subtitle=None, *, size=76):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.12, spacing=-2)]
if subtitle:
kids.append(text(ids, "页副标题", subtitle, 30, 400, "$c-muted",
family=CJK, line_height=1.45))
return col("页头", kids, gap=18)
def mono(name, content, size=26, color="$c-accent"):
"""代码腔的一行。Inter 不是等宽,但整套只允许两个字族,用字重区分。"""
return text(ids, name, content, size, 500, color, family=NUM,
width="fit_content", growth="auto", line_height=1.5)
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("主张", [
text(ids, "主标题", "让每一次调用\n都在 20 毫秒内返回", 112, 700,
"$c-ink", family=CJK, line_height=1.08, spacing=-3),
text(ids, "副标题", "边缘推理网关 v3.0,现已开放公测。", 32, 400,
"$c-muted", family=CJK, line_height=1.45),
], gap=32)
install = glass_card("安装行", [
mono("安装命令", "$ npm i -g edgegate && edgegate up", 28),
], gap=0, padding=(26, 32), width="fit_content")
s = slide("01 封面", gap=48, justify="space_between",
decor=[glow(1500, 1020, -400), glow(900, -320, 560, warm=True)])
s["content"]["children"] = [eyebrow("v3.0 · 公测"), lede, install]
return s
# ------------------------------------------------------------------ 02 痛点
PAINS = [
("冷启动吃掉了首包",
"函数实例每次唤醒要 800ms,用户看到的却是整整一秒的白屏。"),
("账单没法归因",
"月底一张总额,分不清哪个模型、哪条链路花掉的。"),
("换云就得重写",
"每家的运行时都不一样,迁移一次等于把接入层重做一遍。"),
]
def problems():
cards = [
glass_card("痛点卡", [
text(ids, "痛点序号", f"0{index}", 30, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.1),
text(ids, "痛点标题", title, 36, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "痛点说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=18, height="fill_container", padding=(44, 36))
for index, (title, desc) in enumerate(PAINS, 1)
]
s = slide("02 痛点", decor=[glow(1200, -400, -320, warm=True),
glow(900, 1300, 620)])
s["content"]["children"] = [
page_head("在边缘跑推理,卡在三件事上",
"都不是模型的问题,是它前面那一层的问题。"),
centered_body("痛点区", row("痛点网格", cards, gap=32,
align="stretch")),
]
return s
# ------------------------------------------------------------------ 03 架构
LAYERS = [
("接入层", "Gateway", "一个 Anycast 入口,就近落到最快的可用区。"),
("调度层", "Scheduler", "按延迟预算和单价实时选模型,超时自动降级。"),
("运行层", "Runtime", "常驻实例池,冷启动摊薄到 3 毫秒以内。"),
]
def architecture():
cards = []
for index, (name, en, desc) in enumerate(LAYERS, 1):
head = row("层头", [
text(ids, "层名", name, 36, 600, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.25),
mono("层英文名", en, 26, "$c-accent"),
], gap=16, align="center", width="fit_content")
cards.append(glass_card("架构层", [
head,
text(ids, "层说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=12, padding=(32, 36)))
s = slide("03 架构", decor=[glow(1300, 1120, -420),
glow(820, -260, 640, warm=True)])
s["content"]["children"] = [
page_head("三层,一层只做一件事",
"自上而下,每一层都能单独替换。"),
centered_body("架构区", col("架构堆叠", cards, gap=20)),
]
return s
# ------------------------------------------------------------------ 04 性能
METRICS = [
("20", "ms", "P50 端到端", "含调度与网络"),
("3", "ms", "冷启动", "常驻池摊薄后"),
("62", "%", "成本下降", "同等 QPS 对比自建"),
]
# 对比条:条长按真实数值成比例(越短越快),数字写在标签里。
BAR_TRACK = 1600
BAR_OURS_MS = 20
BAR_BASE_MS = 210
def metric_card(value, unit, label, note):
head = row("数值", [
text(ids, "数值文字", value, 132, 700, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
# 单位跟着数字基线走:lineHeight 1.0 下 `end` 会把小字压到基线以
# 下,这层只有下内距的盒子把它抬回来(jian 没有真 baseline 对齐)。
col("单位", [
text(ids, "单位文字", unit, 40, 600, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
], gap=0, width="fit_content", padding=[0, 0, round((132 - 40) * 0.2), 0]),
], gap=8, align="end", width="fit_content")
return glass_card("性能卡", [
head,
text(ids, "指标名", label, 30, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "指标说明", note, 26, 400, "$c-muted", family=CJK,
line_height=1.45),
], gap=16, height="fill_container", padding=(40, 32))
def bar(label, milliseconds, color):
width = round(BAR_TRACK * milliseconds / BAR_BASE_MS)
return col("对比条组", [
text(ids, "条目标签", label, 26, 500, "$c-muted", family=CJK,
line_height=1.4),
rect(ids, "对比条", width=width, height=16, cornerRadius=999,
fill=solid(color)),
], gap=10)
def performance():
bars = glass_card("延迟对比", [
text(ids, "对比标题", "端到端延迟对比", 30, 600, "$c-ink",
family=CJK, line_height=1.25),
bar("本产品 · 20ms", BAR_OURS_MS, "$c-accent"),
bar("自建方案 · 210ms", BAR_BASE_MS, "$c-bar-base"),
], gap=20, padding=(32, 40))
# 三张卡与对比条合成一块「性能区」:直接摊在帧下会让页头、网格、对比
# 卡成为三个平级兄弟,其中只有对比卡有圆角,mixed-sibling-corner-radius
# 会照实报出来(也确实读着像手滑)。
body = col("性能区", [
row("性能网格", [metric_card(*entry) for entry in METRICS],
gap=32, align="stretch"),
bars,
], gap=32)
s = slide("04 性能", gap=44, decor=[glow(1100, -360, -300),
glow(980, 1240, 560, warm=True)])
s["content"]["children"] = [
page_head("数字是压测出来的,不是估的"),
centered_body("性能居中", body),
]
return s
# ------------------------------------------------------------------ 05 客户
LOGO_SLOTS = 8
def customers():
# 每个格子是 frame 而不是 rectangle:frame 会递归渲染子节点(里面那行
# 提示才显示得出来),同时它是合法的图片拖放目标,把客户 logo 拖上去
# 就直接替换掉占位。
slots = []
for index in range(LOGO_SLOTS):
slot = frame(ids, f"Logo 位 {index + 1}", width="fill_container",
height="fill_container", layout="vertical", gap=0,
alignItems="center", justifyContent="center",
cornerRadius=16, fill=glass_fill(), stroke=glass_edge())
slot["children"] = [
text(ids, "占位提示", "拖入 Logo", 24, 500, "$c-muted",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
]
slots.append(slot)
grid = col("Logo 网格", [
row("Logo 行", slots[0:4], gap=28, align="stretch",
height="fill_container"),
row("Logo 行", slots[4:8], gap=28, align="stretch",
height="fill_container"),
], gap=28, height=420)
s = slide("05 客户", decor=[glow(1400, 900, -500, warm=True),
glow(760, -220, 620)])
s["content"]["children"] = [
page_head("已经在生产环境上跑",
"八家团队,日均 27 亿次调用。"),
centered_body("Logo 区", grid),
]
return s
# ------------------------------------------------------------------ 06 CTA
def call_to_action():
lede = col("结语", [
text(ids, "结语标题", "十分钟接完\n剩下的交给我们", 96, 700,
"$c-ink", family=CJK, line_height=1.1, spacing=-3),
text(ids, "结语副标题", "公测期不限调用量,接入文档一页写完。",
30, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=30)
actions = row("行动区", [
glass_card("安装卡", [
mono("安装命令", "$ npm i -g edgegate", 28),
], gap=0, padding=(26, 32), width="fit_content"),
glass_card("文档卡", [
mono("文档地址", "docs.edgegate.dev", 28, "$c-ink"),
], gap=0, padding=(26, 32), width="fit_content"),
], gap=24, align="center", width="fit_content")
s = slide("06 CTA", gap=48, justify="space_between",
decor=[glow(1600, -560, -480, warm=True),
glow(1000, 1180, 520)])
s["content"]["children"] = [eyebrow("公测开放中"), lede, actions]
return s
# 顶层 frame 必须显式写 x/y:缺省时每帧都落在原点,六页会叠成一页。纵向
# gap 比横向多留 240,是给画布上固定在帧顶部的帧名标签让位。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), problems(), architecture(), performance(),
customers(), call_to_action()]
for index, board in enumerate(boards):
board.pop("content", None)
board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP_X)
board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP_Y)
return boards
# 对比度(WCAG,按渐变**三个 stop 分别量**取最差值,比 lint 只读 stops[0]
# 严得多;门槛 4.5):
# 压在页面渐变上(#43056E / #2A0C7A / #00415F):
# c-ink 14.14 / 14.66 / 10.93 c-muted 9.57 / 9.93 / 7.40
# c-accent 8.61 / 8.94 / 6.66
# 压在玻璃卡上(白 @0.12 合成后的 #5A237F / #44298A / #1F5872):
# c-ink 10.60 / 10.88 / 7.79 c-muted 7.18 / 7.37 / 5.27
# c-accent 6.46 / 6.63 / 4.74 ← 最低一对(青色那一端最亮)
# 三个承重参数,动哪个都要把上面这张表重跑:GRADIENT(底色饱和度上限就是
# 它定的)、GLASS_ALPHA(提到 0.18 就掉破 4.5)、c-accent(从 #3AC8FF 提到
# #63D8FF 才在新底色上买回余量)。
# 光晕不参与:它是纯装饰兄弟,且渐到全透明。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "渐变科技风 · 16:9")