把一份 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 篇怎样一次生成多页网站。