469 lines
20 KiB
Python
469 lines
20 KiB
Python
#!/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")
|