设计实践

Gamma API:把 Markdown 大纲生成演示文稿

把一份 Markdown 大纲直接变成演示文稿。卡片之间用 \n---\n 分隔,设置 cardSplit=inputTextBreaks 按分隔符切卡片,textMode=preserve 保留原文不改写,exportAs=pdf 自动导出。 提交后按官方建议每 5 秒轮询一次,完成后立刻下载导出文件,并打印 gammaUrl 和本次消耗的额度。导出链接在

BINBHO · 图文教程

把一份 Markdown 大纲直接变成演示文稿。卡片之间用 \n---\n 分隔,设置 cardSplit=inputTextBreaks 按分隔符切卡片,textMode=preserve 保留原文不改写,exportAs=pdf 自动导出。 提交后按官方建议每 5 秒轮询一次,完成后立刻下载导出文件,并打印 gammaUrl 和本次消耗的额度。导出链接在日志里只显示域名。

先看清成品(box 上对着模拟服务器运行)

  • 4 段大纲生成 4 页 PDF,中文显示正常。
  • 真实接口用错误的 Key 返回 401。
  • pytest 3 项通过。

合规提醒:调用 Gamma API 需要 Pro、Ultra、Teams、Business 或 Enterprise 计划的 API Key(sk-gamma-…),Key 只放在服务器端。exportUrl 在大约一周内任何拿到链接的人都能下载,要当作密钥来保管。输入的文字和图片必须是你有权使用的;开启网络图片来源时,要注意图片的授权条款,商用场景优先选 webFreeToUseCommercially 或 noImages。在中国境内公开发布 AI 生成的内容,要遵守《生成式人工智能服务管理暂行办法》,并按《人工智能生成合成内容标识办法》加上 AI 标识。Gamma 按额度(credits)计费,本文不列价格。

运行说明:box 上没有 Gamma API Key,所以没有真正生成过文稿。流程跑在我自己写的模拟服务器 fake_gamma.py 上,导出的 PDF 是 Pillow 画的,额度数字也是模拟器编的。用错误的 Key 请求真实接口确实运行过,返回 401 {"message":"Invalid API key","statusCode":401}。

做完之后你会得到什么?

成果 说明
gamma_deck.py 大纲 → 请求体 → 创建 → 轮询 → 下载,B 篇和 C 篇都会复用它
fake_gamma.py 模拟服务器(127.0.0.1:18531),带限流响应头、429 和导出 PDF
outline.md 示例大纲(社区活动方案)
test_deck.py 测试完整流程、链接脱敏、错误的 Key

需要准备哪些环境?

项目 要求
Python 3.9 以上,pip install requests flask pillow pytest
真实使用 在 Gamma 设置页创建 API Key(需要上面说的付费计划),设到环境变量 GAMMA_API_KEY

接口要点(来自官方文档)

  • 创建:POST https://public-api.gamma.app/v1.0/generations,请求头 X-API-KEY,返回 generationId(可能附带 warnings)。
  • 请求体常用字段:
    • inputText(最多 40 万字符)、textMode(generate、condense 或 preserve)
    • format(presentation、document、webpage 或 social)、cardSplit(auto 或 inputTextBreaks)
    • exportAs(pdf、pptx 或 png;png 导出的是一个 zip,每张卡片一张图)
    • textOptions.language(如 zh-cn)、imageOptions.source、cardOptions.dimensions、themeId
  • 轮询:GET /v1.0/generations/{id},建议每 5 秒一次,通常 1 到 3 分钟完成。状态是 pending、completed 或 failed;完成后返回 gammaUrl、exportUrl、gammaId 和 credits.deducted/remaining。

文件结构

53-gamma-A-src/
├── gamma_deck.py
├── fake_gamma.py
├── outline.md
└── test_deck.py

第 1 步:大纲

# 秋日夜市:社区活动方案
- 时间:10 月最后一个周末
- 地点:社区中心广场
---
# 活动亮点
- 20 个本地小吃摊位
- 手作市集与亲子游戏
- 晚间露天电影
---
# 预算与分工
- 摊位招募:志愿者 A 组
- 宣传物料:志愿者 B 组
- 安全与清洁:物业协助
---
# 下一步
- 本周五前确认摊主名单
- 下周一发布海报

第 2 步:代码

"""大纲 → Gamma 演示文稿:用 \\n---\\n 分隔卡片,cardSplit=inputTextBreaks、textMode=preserve 保留原文,
POST /v1.0/generations 后每 5 秒轮询 GET /v1.0/generations/{id},完成后立刻下载 exportUrl。
exportUrl 在约一周内任何人拿到都能下载,所以日志里只打印脱敏后的地址。"""
import argparse, json, os, pathlib, time, re
import requests

API = os.environ.get("GAMMA_API_BASE") or "https://public-api.gamma.app"
UA = "binbho-tutorial-gamma/1.0"


def mask(url):
    """只保留协议和域名。真实 exportUrl 的凭据可能在查询串里,也可能就在路径里,所以整段都隐藏。"""
    m = re.match(r"^(https?://[^/]+)", url or "")
    return (m.group(1) if m else "") + "/…(已隐藏)"


def body_from_outline(text, export="pdf", lang="zh-cn", theme=None, title=None):
    b = {"inputText": text.strip(), "textMode": "preserve", "format": "presentation", "cardSplit": "inputTextBreaks",
         "exportAs": export, "textOptions": {"language": lang},
         "imageOptions": {"source": "noImages"}, "cardOptions": {"dimensions": "16x9"}}
    if theme: b["themeId"] = theme
    if title: b["title"] = title
    return b


def session():
    s = requests.Session()
    s.headers.update({"X-API-KEY": os.environ["GAMMA_API_KEY"], "Content-Type": "application/json", "User-Agent": UA})
    return s


def create(s, body):
    r = s.post(f"{API}/v1.0/generations", json=body, timeout=60)
    if r.status_code >= 400:
        raise SystemExit(f"HTTP {r.status_code}: {r.text[:300]}")
    d = r.json()
    for w in d.get("warnings", []):
        print("⚠ 警告:", w)
    return d["generationId"]


def wait(s, gid, interval=5.0, timeout=600):
    t0 = time.time()
    while time.time() - t0 < timeout:
        r = s.get(f"{API}/v1.0/generations/{gid}", timeout=60)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "10"))); continue
        if r.status_code >= 400:
            raise SystemExit(f"HTTP {r.status_code}: {r.text[:300]}")
        d = r.json()
        if d["status"] in ("completed", "failed"):
            return d
        time.sleep(interval)
    raise TimeoutError(gid)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("outline"); ap.add_argument("--export", default="pdf", choices=["pdf", "pptx", "png"])
    ap.add_argument("--theme"); ap.add_argument("--title"); ap.add_argument("--interval", type=float, default=5)
    ap.add_argument("--out", default="outputs")
    a = ap.parse_args()
    text = pathlib.Path(a.outline).read_text(encoding="utf-8")
    body = body_from_outline(text, a.export, theme=a.theme, title=a.title)
    print(f"卡片数(按 --- 分隔):{len(body['inputText'].split(chr(10) + '---' + chr(10)))}")
    s = session()
    gid = create(s, body); print("generationId:", gid)
    d = wait(s, gid, a.interval)
    if d["status"] == "failed":
        raise SystemExit("生成失败:" + json.dumps(d.get("error"), ensure_ascii=False))
    print("✓ 完成:", d["gammaUrl"], "|消耗", d["credits"]["deducted"], "额度,剩余", d["credits"]["remaining"])
    if d.get("exportUrl"):
        out = pathlib.Path(a.out); out.mkdir(exist_ok=True)
        ext = "zip" if a.export == "png" else a.export      # png 导出是一个 zip,每张卡片一张 PNG
        f = out / f"deck.{ext}"
        f.write_bytes(requests.get(d["exportUrl"], timeout=300).content)
        print(f"✓ 已下载 {f}({f.stat().st_size} 字节),导出地址:{mask(d['exportUrl'])}")


if __name__ == "__main__":
    main()

第 3 步:模拟服务器

"""本地模拟服务器(自己写的,不是 Gamma 的):模拟 POST/GET /v1.0/generations、限流响应头、导出文件。
导出的 PDF 用 Pillow 画:每张卡片一页,标题取 inputText 中按 \\n---\\n 切开的每一段的第一行。"""
import io, os, uuid, time
from flask import Flask, request, jsonify, send_file
from PIL import Image, ImageDraw, ImageFont

app = Flask(__name__)
app.json.ensure_ascii = False
BASE = os.environ.get("FAKE_BASE", "http://127.0.0.1:18531")
FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"
JOBS, STATE = {}, {"burst": int(os.environ.get("FAKE_BURST", "10000")), "polls": 0, "busy429": int(os.environ.get("FAKE_429", "0"))}
KEY = "sk-gamma-demo"


def headers(resp):
    STATE["burst"] -= 1
    resp.headers.update({"x-ratelimit-limit-burst": "10000", "x-ratelimit-remaining-burst": str(max(STATE["burst"], 0)),
                         "x-ratelimit-limit": "40000", "x-ratelimit-remaining": "39988",
                         "x-ratelimit-limit-daily": "200000", "x-ratelimit-remaining-daily": "199950"})
    return resp


@app.post("/v1.0/generations")
def create():
    if request.headers.get("X-API-KEY") != KEY:
        return jsonify(message="Invalid API key", statusCode=401), 401
    b = request.get_json()
    if not b.get("inputText") and not b.get("pages"):
        return headers(jsonify(message="Input validation errors", statusCode=400)), 400
    gid = uuid.uuid4().hex[:20]
    JOBS[gid] = {"body": b, "polls": 0}
    warn = ["numCards is ignored when cardSplit is inputTextBreaks"] if b.get("cardSplit") == "inputTextBreaks" and b.get("numCards") else []
    return headers(jsonify(generationId=gid, **({"warnings": warn} if warn else {})))


@app.get("/v1.0/generations/<gid>")
def status(gid):
    if request.headers.get("X-API-KEY") != KEY:
        return jsonify(message="Invalid API key", statusCode=401), 401
    STATE["polls"] += 1
    if STATE["busy429"] > 0:
        STATE["busy429"] -= 1
        r = jsonify(message="Too many requests", statusCode=429); r.headers["Retry-After"] = "2"
        return headers(r), 429
    j = JOBS.get(gid)
    if not j:
        return jsonify(message=f"Generation ID not found. generationId: {gid}", statusCode=404), 404
    j["polls"] += 1
    if j["polls"] < 3:
        return headers(jsonify(generationId=gid, status="pending"))
    texts = [p["inputText"] for p in j["body"]["pages"]] if j["body"].get("pages") else [j["body"]["inputText"]]
    cards = sum(len(t.split("\n---\n")) for t in texts)
    out = {"generationId": gid, "status": "completed", "gammaUrl": f"https://gamma.app/docs/mock-{gid}",
           "gammaId": f"g_mock{gid[:10]}", "credits": {"deducted": 4 * cards, "remaining": 400 - 4 * cards}}
    if j["body"].get("exportAs"):
        out["exportUrl"] = f"{BASE}/export/{gid}.{j['body']['exportAs']}?token=SECRET{uuid.uuid4().hex[:8]}"
    return headers(jsonify(out))


@app.get("/export/<gid>.pdf")
def export(gid):
    b = JOBS[gid]["body"]
    texts = [p["inputText"] for p in b["pages"]] if b.get("pages") else [b["inputText"]]
    pages = []
    for t in texts:
        for card in t.split("\n---\n"):
            im = Image.new("RGB", (960, 540), (245, 240, 230)); d = ImageDraw.Draw(im)
            lines = [l.strip("# ").strip() for l in card.strip().splitlines() if l.strip()]
            d.text((60, 60), lines[0] if lines else "", fill=(40, 40, 40), font=ImageFont.truetype(FONT, 44))
            for k, l in enumerate(lines[1:6]):
                d.text((80, 150 + k * 60), l.lstrip("-* "), fill=(80, 80, 80), font=ImageFont.truetype(FONT, 28))
            pages.append(im)
    buf = io.BytesIO(); pages[0].save(buf, "PDF", save_all=True, append_images=pages[1:]); buf.seek(0)
    return send_file(buf, mimetype="application/pdf")


@app.get("/_state")
def st():
    return jsonify(STATE)


if __name__ == "__main__":
    app.run("127.0.0.1", 18531, threaded=True)

第 4 步:运行

$ python fake_gamma.py &
$ GAMMA_API_BASE=http://127.0.0.1:18531 GAMMA_API_KEY=sk-gamma-demo python gamma_deck.py outline.md --interval 1 --title "秋日夜市"
卡片数(按 --- 分隔):4
generationId: d2f7a4f6c205443da530
✓ 完成: https://gamma.app/docs/mock-d2f7a4f6c205443da530 |消耗 16 额度,剩余 384
✓ 已下载 outputs/deck.pdf(82601 字节),导出地址:http://127.0.0.1:18531/…(已隐藏)

$ pdfinfo outputs/deck.pdf | grep Pages
Pages:           4

$ GAMMA_API_BASE= GAMMA_API_KEY=sk-gamma-not-real python gamma_deck.py outline.md     # 真实接口
卡片数(按 --- 分隔):4
HTTP 401: {"message":"Invalid API key","statusCode":401}

$ python -m pytest -q
3 passed in 1.15s

检查点:

  • PDF 页数等于大纲里的段数。
  • 用真实接口时 --interval 保持默认的 5 秒。

调试示例

脱敏函数没有真正隐藏凭据。 第一版的 mask() 只去掉了 ? 后面的查询串,在模拟服务器上看起来没问题,因为模拟器的凭据就放在 ?token= 里。但我并不知道真实 exportUrl 的凭据放在哪里:它可能在查询串里(比如预签名链接),也可能就是路径里的一段随机字符。如果是后者,第一版的写法会把整条可用的链接原样打进日志。现在改成只保留协议和域名,测试用例也改成了“凭据在路径里”的情况。

常见问题

现象 原因 处理
401 Invalid API key Key 错误,或者当前计划不支持 API 检查计划和 Key
402 额度不够 到 gamma.app/settings/billing 充值
卡片数和段数对不上 没有设置 cardSplit=inputTextBreaks 设置后严格按 --- 切卡片
文字被改写了 textMode 用了 generate 或 condense 改成 preserve
exportUrl 打不开 链接已过期(约一周) 生成后立刻下载,或者重新导出

模板规则

  • 大纲里用 --- 控制卡片,用 preserve 模式保留原文。
  • 每 5 秒轮询一次;拿到导出链接后马上下载。
  • 日志里只出现域名,不出现完整链接。

项目资源

用途 地址
Gamma 开发者文档 https://developers.gamma.app/
POST /generations(创建生成) https://developers.gamma.app/generations-classic/create-generation
GET /generations/{id}(轮询状态) https://developers.gamma.app/generations-classic/get-generation-status
参数说明(Generate from text) https://developers.gamma.app/guides/generate-api-parameters-explained
错误码 https://developers.gamma.app/reference/error-codes

一份大纲几分钟就能变成可以直接放映的文稿,下一步看 B 篇怎样一次生成多页网站。