OGKit

动态 OG 图:Next / Satori 与体积坑

  • open-graph
  • 动态图片
  • Next.js
  • Satori

产品文档里每一页标题都不同,运营却只准备了一张固定的 1200×630 主视觉。Slack 里展开时,二十条链接长得一模一样——没人知道点的是哪一节。

这时候你需要的不是「再换一张漂亮一点的 PNG」,而是按 URL / 标题在请求时生成卡片图。工程主流是 Next 的 opengraph-image + Satori(next/og);运营主流是 Bannerbear、Placid 一类模板 API。两条路都能出图,成本和翻车点完全不同。

何时真的需要动态图

先问:静态一张图是不是就够?

场景 静态图通常够 值得上动态图
营销首页、单一落地页 很少
博客 / changelog / 文档站,每页标题不同 勉强能用统一品牌图 ✅ 标题进图,辨识度高
用户生成内容(活动页、个人主页) 不行 ✅ 或模板 SaaS
多语言同一路径不同文案 易混 ✅ 按 locale 出图
活动期每天换主视觉 可手工 看更新频率

动态图解决的是卡片辨识度,不解决「标签写错了」「爬虫抓不到 HTML」「缓存还是旧图」。那些仍走标签、SSR、rescrape 链路——见系列里的空白卡片与缓存篇。

代码路径 vs 模板 SaaS:选型表

代码路径(Next opengraph-image / Satori / @vercel/og 模板 SaaS(Bannerbear、Placid 等)
谁改文案 开发改代码或读 CMS / frontmatter 运营在模板里拖文案层
出图时机 构建期静态生成,或请求时边缘 / Node 渲染 调 API 生成后存 CDN URL
字体与中文 自己 subset、自己背锅 厂商字库与排版,受套餐限制
成本形态 算力 / 冷启动 / 依赖体积 按张数或套餐订阅
版本与回滚 跟仓库走,可 PR review 模板在 SaaS 后台,和代码分叉
适合 标题 + 品牌底图这种可编程布局 设计迭代频繁、非技术同学要自助出图

OGKit 明确不做模板设计器。 产品边界是诊断与门禁(规则、扫描、CI、扩展 rescrape),不是和 Bannerbear 抢拖拽画板。需要可视化模板时:走 SaaS,或自建设计稿导出再挂绝对 URL。本文只写代码路径里最容易踩的坑,以及如何在生成之后用规则验最终 URL。

最小 Next 结构(概念级)

Next App Router 约定:在路由段放 opengraph-image.tsx(或 .jsx / 静态 .png),框架会把生成图挂到该段页面的 Open Graph 图片上。底层常用 ImageResponsenext/og),渲染引擎是 Satori——把受限的 JSX 布局画成 PNG,不是完整浏览器。

下面是示意结构,字段名与 import 路径会随 Next 小版本微调,以你当前文档为准:

// app/blog/[slug]/opengraph-image.tsx  (示意)
import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Article share card";

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await loadPost(slug); // 你的数据层

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: 64,
          background: "#0b1120",
          color: "#f8fafc",
          fontSize: 56,
          fontFamily: "YourSubsetFont",
        }}
      >
        <div style={{ display: "flex", fontSize: 28, color: "#94a3b8" }}>
          Blog
        </div>
        <div style={{ display: "flex", fontWeight: 600, lineHeight: 1.15 }}>
          {post.title}
        </div>
        <div style={{ display: "flex", fontSize: 26, color: "#94a3b8" }}>
          example.com
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [
        {
          name: "YourSubsetFont",
          data: await loadFontBytes(),
          weight: 600,
          style: "normal",
        },
      ],
    },
  );
}

要点(比 API 细节更重要):

  1. 布局子集 — Satori 支持 flex 为主的样式,不是完整 CSS。position: absolute、复杂 grid、任意 Web 字体加载方式都会踩坑。
  2. 字体必须显式传入 — 没有系统字体兜底。英文可 subset Inter;中文必须自己准备字重与字符集(见下一节)。
  3. size 与真实像素一致 — 行业默认分享尺寸约 1200×630(约 1.91:1)。过小会糊,见 og-image-dimensions-too-small
  4. 导出稳定 — 静态路由尽量 generateStaticParams 把图打进构建产物;动态路由要想清楚缓存头与冷启动延迟。
  5. meta 仍要正确 — 动态图只是 og:image 的来源之一;og:title / og:url / 绝对 HTTPS 等规则不变。

同站博客的真实实现也是这条路径:opengraph-image + ImageResponse + 预 subset 字体,标题来自 frontmatter,而不是运营后台。

体积、字体、中文、emoji

动态图最容易在「能本地预览」之后挂在生产或 IM 里。按频率排序:

1. 平台字节预算(WhatsApp 最苛)

大 PNG 在浏览器里看没事,WhatsApp 等 IM 可能静默丢图。OGKit 平台档案里 WhatsApp 硬上限约 300KB,软上限约 200KB(以 平台档案 与规则为准;数字会随档案更新)。更稳妥的聊天场景目标常是 约 100–300KB

规则:og-image-bytes-exceeds-platform-budget
完整压缩、格式、重定向与 Content-Type 见系列《OG 图片工程》。

Satori / ImageResponse 默认出 PNG。文字卡片通常还能压在预算内;一旦加摄影底图、大面积渐变、未压缩位图,很容易破线。需要时:生成后二次压成 JPEG/WebP 再托管,或简化构图。

2. 字体加载与中文 subset

  • 整包 Noto Sans SC 动辄十余 MB,不能塞进边缘函数或每次请求读全量。
  • 做法:按实际会出现的字符 + 字重做 subset(例如只要 600 字重、只含站点文案与标点)。本站 OG 用的 CJK 子集大约几十 KB 量级——这是有意的工程取舍,不是「随便丢个 woff 就行」。
  • 英文字体与中文字体混排时,字重与 x-height 要对齐,否则标题一中一英「一边粗一边细」。
  • 缺字时 Satori 常表现为空白方框或直接省略,本地英文标题测过关、中文标题上线才炸。

3. Emoji

Emoji 不是普通字形:需要彩色 emoji 字体或事先栅格化。Satori 默认栈对 emoji 支持有限。标题里「🚀 发布」在卡片上变成方框,是预期内的坑。选择:去掉 emoji、换成 SVG 图标节点,或预渲染含 emoji 的静态图。

4. 依赖与冷启动

@vercel/og / next/og 链路带 resvg、字体解码等原生向依赖。边缘运行时有包体与 CPU 预算;Node runtime 更宽,但冷启动仍会拖「第一次分享」的抓取超时。能静态生成的路由,优先构建期出图。

5. 颜色与对比度

深色底 + 浅色字在缩略图里更稳。低对比在 LinkedIn / Slack 的小图预览里会糊成一团——这是设计问题,不是协议问题,但用户体感是「图坏了」。

动态图 URL 稳定性与缓存

生成逻辑对了,分享仍可能显示旧图,因为:

  1. 平台缓存的是第一次成功抓到的图(URL 不变时尤其明显)。
  2. 你的 opengraph-image 若带不稳定 query、或 CDN 对同一 path 回了不同字节,调试会非常痛苦。
  3. 改标题后图内容变了,但 og:image URL 路径未变 → 需要 官方 rescrape,而不是再刷新自己的浏览器。

建议:

  • 同一文章的 OG 图 URL 路径稳定(框架默认的 /…/opengraph-image 通常即可)。
  • 内容变更后,对真正在意的平台走 rescrape;系列专文见《缓存与 rescrape》。
  • 不要用每次随机的签名 URL 当 og:image(除非你能接受缓存永远对不齐,或主动做版本化 path)。

上线 checklist

  • 每条需要差异化卡片的路由都有 opengraph-image(或等价绝对 HTTPS 图 URL)
  • 输出约 1200×630contentType 与真实字节一致
  • 中文 / 英文标题各抽几条真实数据渲染,确认无缺字、无方框
  • 成品体积落在最紧平台预算内(聊天场景优先压到约 300KB 以下)
  • 标题过长时有字号或换行策略,避免裁切到关键信息
  • emoji / 特殊符号有策略(去掉、替换图标,或接受方框)
  • 生产环境用社交爬虫 UA 能 200 拉到图;无登录墙、无无限跳转(og-image-redirectsog-image-forbidden
  • 改文案或改模板后,对 Meta / LinkedIn 等做了 rescrape
  • (可选)CI 里对关键 URL 跑图片字节与维度规则,防止某次加背景图把 WhatsApp 打爆

生成之后:用 OGKit 验最终 URL

出图只是链路中段。平台看到的是最终可抓取的图片 URL 与页面上的 meta,不是你本地的 JSX 预览。

把线上文章 URL 贴进首页扫描:规则会检查相对路径、HTTPS、重定向、维度、字节预算等。机器可读清单见规则参考。第三方预览站可以看布局近似,但权威缓存失效仍以各平台官方 debugger 为准——和产品文案一致。

OGKit 不替你拖拽模板,但可以在你用 Next/Satori 或 SaaS 生成之后,告诉你这张卡会不会被 IM 静默丢掉。

下一篇读什么

  1. OG 图片工程:尺寸、体积、格式、重定向 —— 动态图也逃不开的字节与格式细节
  2. 缓存与 rescrape —— 图对了为什么分享还是旧的
  3. Open Graph 到底在解决什么 —— 全流程与工具地图
  4. 为什么你的 Open Graph 卡片一片空白 —— 五分钟分诊

需要模板设计器时请走 SaaS 或自建;需要确认「这张最终 URL 过不过平台预算」时,用规则与扫描,而不是再截一张自己浏览器的图。