OpenTV
说一个想法,开拍一整季。
一块无限画布配一个懂创作的 Agent 精雕精品,一套工作流无人值守跑量产 —— 把一句话做成一部持续更新的短剧。

这是什么
OpenTV 是规划 + 生产连续型短视频内容的工具。一个项目 就是一块无限画布: 你把想法/剧本丢进来,在画布上用节点(脚本、图片、视频、音频…)推进创作,右侧的 Agent 作为副驾帮你建节点、改内容、答疑优化。
两种产品形态,刻意分开做:
| 形态 | 取向 | 状态 |
|---|---|---|
| 创作画布 — 无限画布 + 节点 + 侧边 Agent | 质量 > 效率,人在环内 | 在建,可用 |
| 生产管线 — 剧本→生图→视频→发布 自动跑 | 效率 > 质量,无人值守 | 设计已定稿,尚未开工 |
已经是真的:登录注册(邮箱验证码 + 邀请码门禁)、个人资料、无限画布、统一节点系统、
侧边 Agent(真流式对话、节点引用、「/」技巧引用)、脚本节点的 LangGraph 分镜管线、
Evolink 真实出图/出片、素材库、关键帧抽取、Remotion 合成渲染。
还是假数据:市场页与账单页(lib/*-data.ts)。
完整清单见 docs/features-and-todo.md,架构见 docs/specs/2026-07-24-canvas-dual-form-architecture.md。
技术栈
- 前端:Next.js 16(App Router / Turbopack)· React 19 · TypeScript · Tailwind CSS v4 · shadcn + Base UI · Motion · GSAP · three.js(落地页 3D)
- 画布 / AI:@xyflow/react(React Flow v12)· AI SDK v7 · @langchain/langgraph(脚本节点编排)· Remotion(合成渲染,独立子进程)· ffmpeg-static(抽帧)· 模型走 Evolink 中转
- 服务端:better-auth(会话/邮箱验证码/组织)· Prisma + PostgreSQL · Redis(限流 + 会话缓存)· 阿里云 OSS(S3 兼容协议)· Resend(验证码邮件)
目录速览
app/ 页面与 API 路由(canvas/[projectId] 是全屏工作台;(app) 是带侧边栏的应用外壳)
components/ canvas/ 画布与节点 · landing/ 落地页 · auth/ 登录注册 · ui/ 基础组件
lib/ script/ 脚本节点引擎 · ai/ Evolink 与模型注册表 · agent/ 侧边 Agent ·
media/ 抽帧 · video/ Remotion 渲染入口 · assets/ 素材库 · invite.ts 邀请码门禁
prisma/ 数据库 schema 与迁移
remotion/ Remotion 合成(子进程渲染,不进 Next 打包图)
docs/ 设计文档与功能清单
本地开发
1. 需要什么
- Node.js 22+ 和 npm(本项目只用 npm,锁文件是
package-lock.json) - PostgreSQL 16 和 Redis 7(用 Docker 起最省事,见下)
- 几个外部服务的密钥:阿里云 OSS(存素材)、Resend(发注册验证码邮件; 本地可不配,验证码会打印在终端里)。Evolink 的 key 不配在这里 —— 登录后到「设置 → AI 服务」页面填,按工作区存进数据库(见下面「AI 服务密钥」一节)
2. 起数据库和 Redis
docker run -d --name opentv-db -p 5432:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=opentv postgres:16
docker run -d --name opentv-redis -p 6379:6379 redis:7-alpine3. 配环境变量
cp .env.example .env.env 里至少要填这几项:
| 变量 | 说明 |
|---|---|
DATABASE_URL | 上面那个库,如 postgresql://postgres:postgres@localhost:5432/opentv?schema=public |
REDIS_URL | 如 redis://localhost:6379 |
BETTER_AUTH_SECRET | 会话签名密钥,用 openssl rand -base64 32 生成 |
BETTER_AUTH_URL | 本地 http://localhost:3000;线上填你的对外地址 |
INVITE_CODES | 注册邀请码(见下节)。本地留空则注册免填 |
OSS_* | 阿里云 OSS 的 region / key / bucket,生成的素材传这里 |
RESEND_API_KEY | 发验证码邮件。本地可留空(打印到终端);生产必填,否则启动报错 |
4. 装依赖、建表、跑起来
npm install
npx prisma migrate deploy # 按 prisma/migrations 建表
npm run dev # → http://localhost:3000注册时验证码会打印在跑 npm run dev 的终端里(没配 Resend 的情况下)。
5. 改代码之后
npm run lint # eslint
npm run typecheck # TypeScript(生产构建会因类型错误失败,先过这个)
npm test # vitest
npm run build # 生产构建(⚠️ 别在 npm run dev 还开着的时候跑,两者共用 .next)改了 prisma/schema.prisma 一定要配一个迁移:npx prisma migrate dev --name <名字>。
AI 服务密钥(在页面上配,不在 .env)
Evolink 的 key 按工作区(组织)存在数据库里,由用户自己在「设置 → AI 服务」页面填 ——
谁的画布花谁的额度。.env 里没有 EVOLINK_*,任何环境都不回落读它。
- 加密落库:AES-256-GCM,密钥用 HKDF 从
BETTER_AUTH_SECRET派生 (lib/crypto/secret.ts)。接口只回显尾 4 位,明文永不出服务端。 ⚠️ 换掉BETTER_AUTH_SECRET后旧密钥解不开,到页面重填一次即可。 - 可选覆盖:图像/视频基址、文本基址、默认文本模型(高级选项里),留空用供应商默认值。
- 测试连通:页面上那个按钮会真发一次极小的请求(几个 token),因为格式对不代表 key 有效 —— 过期、欠费、模型无权限都只有调用时才知道。
- 没配会怎样:所有生成类接口回 400 并提示去设置页;读画布、看历史消息等不受影响
(lib/credentials/route.ts 的
requireProjectAi)。 - 怎么流到调用点:路由入口取一次组织凭证并进入请求上下文 (lib/ai/credentials.ts,AsyncLocalStorage),LangGraph 图节点内部、 分离执行的后台生成任务都能拿到同一份,不必层层传参。
注册需要邀请码
生成能力是花钱的(每个新账号都能调用 Evolink),所以线上不接受自助注册: 建号必须带一个服务端认可的邀请码。
配置:.env 的 INVITE_CODES,逗号分隔可配多个(发给不同的人,便于单独作废):
INVITE_CODES="OPENTV-ZS4V-D7GR,OPENTV-GKQQ-N7RM"生成一个新码:
node -e "const{randomInt}=require('crypto');const A='ABCDEFGHJKLMNPQRSTUVWXYZ23456789';const g=n=>Array.from({length:n},()=>A[randomInt(A.length)]).join('');console.log('OPENTV-'+g(4)+'-'+g(4))"作废:把那串从 INVITE_CODES 里删掉并重启服务(已注册的账号不受影响)。
门禁的三条规则(实现在 lib/invite.ts 与
lib/auth.ts 的 databaseHooks.user.create.before):
- 建号的唯一咽喉在服务端:不管走哪条路(密码注册 / 邮箱验证码 / 以后接的社交登录), 新建用户都要过邀请码校验 —— 不是靠前端藏个输入框。
- 生产环境 fail closed:
NODE_ENV=production时即使忘配INVITE_CODES, 也不会对全网敞开,而是一律拒绝注册(页面提示「本站暂未开放注册」)。 - 验证码只能登录已有账号,不能顺带注册;陌生邮箱来要码会静默返回, 既不发信(不花钱)也不泄漏该邮箱是否注册过。
校验忽略大小写、空格和连字符,所以 opentv zs4v d7gr 也能过。
Docker 部署
镜像里包含 Next 生产构建、Prisma 迁移工具,以及 Remotion 渲染要用的无头 Chrome 依赖。
docker-compose.yml 一起拉起 应用 + PostgreSQL + Redis。
1. 服务器上准备
需要 Docker 24+ 和 Docker Compose v2。把代码拉到服务器,然后:
cp .env.example .env
# 必填:BETTER_AUTH_SECRET / BETTER_AUTH_URL(你的对外地址) / INVITE_CODES
# OSS_* / RESEND_API_KEY(Evolink 的 key 在页面上配,不在这里)
vim .env或者从飞书配置表拉(改配置不用再登服务器,见下面「配置放在飞书表格里」):
cp .feishu.env.example .feishu.env && vim .feishu.env # 只填 3 行飞书凭证
npm run env:pull -- --write # 表格 → .env
.env里的DATABASE_URL和REDIS_URL不用改:compose 会用容器网络里的db/redis覆盖它们(compose 的environment优先于env_file),所以同一份.env本地开发也能继续用。带引号的值 compose 会自动去引号,照常写。
另外这几个变量只有 compose 读,应用本身不读(.env.example 末尾一节里都列了,
默认值够用就不用管):
| 变量 | 默认 | 作用 |
|---|---|---|
POSTGRES_PASSWORD | postgres | 数据库密码,compose 用它拼容器内的 DATABASE_URL |
APP_PORT | 3000 | 对外映射的宿主机端口(容器内部固定 3000,别写成 PORT) |
NODE_IMAGE | docker.1ms.run/...node:22-bookworm-slim | 基础镜像,海外换回 node:22-bookworm-slim |
NPM_REGISTRY | https://registry.npmmirror.com | npm 源,海外换回 https://registry.npmjs.org |
APT_MIRROR | mirrors.aliyun.com | Debian 源,留空则用官方 deb.debian.org |
2. 启动
docker compose up -d --build
docker compose logs -f app # 看启动日志(会先跑 prisma migrate deploy 再起服务)好了之后访问 http://服务器地址:3000,用邀请码注册第一个账号。
3. 更新版本
git pull
docker compose up -d --build # 重建镜像;容器启动时自动应用新迁移4. 常用运维
docker compose ps # 看状态(app 带健康检查)
docker compose logs -f app # 应用日志
docker compose exec app node ./node_modules/prisma/build/index.js migrate status
docker compose exec db pg_dump -U postgres opentv > backup-$(date +%F).sql # 备份
docker compose down # 停(数据在 volume 里,不会丢)5. 几个坑
- HTTPS:容器只听 3000 的 HTTP,线上前面套一层 Nginx/Caddy 做 TLS 和反代,
并把
BETTER_AUTH_URL设成https://你的域名—— 否则登录 cookie 和邮件里的回调地址会对不上。 - Mac 上给 x86 服务器打包:本机是 arm64,要显式指定平台
docker build --platform linux/amd64 -t opentv:latest .(或者直接在服务器上 build)。 - 下载源默认走国内加速,不用额外配置:基础镜像
docker.1ms.run、npmregistry.npmmirror.com、aptmirrors.aliyun.com。实测差别很大:官方源装 ffmpeg 要 4 分半、npm ci撞上超时能拖到 20 分钟;镜像源下npm ci约 30 秒、apt 秒级。 (ffmpeg 用系统包而不是ffmpeg-static下载的二进制,也是为了绕开经常超时的 GitHub Releases。) 海外服务器想换回官方源,在.env里覆盖:直接NODE_IMAGE=node:22-bookworm-slim NPM_REGISTRY=https://registry.npmjs.org APT_MIRROR=docker build时对应--build-arg NODE_IMAGE=... --build-arg NPM_REGISTRY=... --build-arg APT_MIRROR=。 - 视频渲染要能下无头 Chrome:构建时会试着预装,失败不影响启动;但首次渲染视频时 Remotion 会去 Google 的地址拉 chrome-headless-shell,服务器出不去网就渲染不了 —— 画布上生图/生视频(走 Evolink)不受影响。
- 别把社交登录随手打开:配了
GOOGLE_*/GITHUB_*之后,那两个入口的新账号 仍然会被邀请码门禁挡住(报错在 OAuth 回调页,提示不如表单里友好),已有账号登录不受影响。 .env里别用$:compose 会把$VAR当变量插值(要写成$$);值两侧的引号 compose v2 会自动去掉,可以照常写。- 镜像不小(含 Remotion 的无头 Chrome 和 ffmpeg),首次构建按网速可能要十几分钟。
6. 机器规格:2c2g 够不够(全是实测,不是估算)
先给结论:画布那条线 2c2g 完全够;视频合成(Remotion)2c2g 扛不住,要 4G 内存。
内存实测(按【进程树】精确统计,不是按进程名匹配——那样会把本机的 VS Code / Edge 也算进来):
| 场景 | 峰值内存 | 耗时 | 峰值在谁身上 |
|---|---|---|---|
| 稳态(app 137 + PG 28 + Redis 10) | ~175MB;加系统与 Docker 约 500MB | — | — |
| 合成:占位画面 240 帧 → 1080×1920 | 828MB | 14.3s | ffmpeg 编码 710MB |
| 合成:真实视频 3×1080p 素材 / 360 帧 → 1080×1920 | 1536MB | 39.1s | 无头 Chrome 798MB + Rust compositor 305MB |
| 同上,输出降到 720×1280 | 1427MB(只省 7%) | 32.4s | 同上——Chrome 的开销来自解码 1080p 源,与输出尺寸无关 |
| 同上,帧缓存 192MB → 64MB | 1593MB(无效,噪声内) | 40.8s | 同上 |
算一笔账:真实合成 1.5GB + 基线 0.5GB = 2.0GB,正好把 2GB 的机器撑满 → OOM
(而且报错只有 退出码 137,极难查)。降分辨率、压帧缓存都试过,都不是杠杆——
1.4-1.6GB 是当前实现的地板。
所以按用途选:
并发的账(同一条真实素材,只改并发):
| 并发 | 峰值内存 | 耗时 | 4GB 机器上总占用 |
|---|---|---|---|
| 1 | 1536MB | 39.1s | 2.0GB / 4GB |
| 2(compose 默认) | 2106MB | 23.6s | 2.6GB / 4GB ← 甜点 |
| 4 | 2631MB | 15.9s | 3.1GB / 4GB(只剩 900MB,同时有人生图就吃紧) |
按机器选:
| 机器 | 建议 |
|---|---|
| 4c4g | 够,合成也能用。REMOTION_CONCURRENCY=2(compose 已默认)。注意 4GB 机器 free -h 实际显示约 3.6Gi(内核/固件占 0.4G),按 3.6G 算:基线 0.5 + 合成 2.1 = 2.6G,余量约 1.0G;想再快可以试 4,但那样只剩约 0.4G,不建议 |
| 2c2g | 只做画布创作够用(生成都在 Evolink 云端算,本机基本不耗);别开合成——真实合成 1.5GB + 基线 0.5GB 正好撑满 2GB → OOM(报错只有 退出码 137) |
⚠️ 不管并发调多少,渲染必须串行(已在 lib/video/render.ts 里排队):并发 2 的两条
渲染撞一起就是 4.2GB,4GB 机器照样崩。队列是让上面这些数字成立的前提。
不管哪种,这三条都要做:
- 构建时别同时跑渲染:
next build峰值 2-4GB。4GB 机器构建得动(CI 部署就是在服务器 上构建的,内存不够会自动停掉 app 腾内存重试),但构建和视频合成撞一起必 OOM。 2GB 机器别在上面构建——无 swap 必被 OOM Killer 杀。 - 加 4GB swap(命令见上文)。
- 渲染已强制串行(
lib/video/render.ts队列)+ 并发默认 1 (REMOTION_CONCURRENCY可调)。并发默认值实测 1418MB → 809MB(占位场景), 这是唯一真正有效的内存杠杆;两条渲染撞一起必崩,所以宁可排队。
已做但没有实测收益的两处,留着只为可预测,别当成省内存手段:产物包磁盘缓存 (省一次 esbuild、耗时 -1s,内存基本不动)、帧缓存钉死 192MB(对峰值无影响)。
自动部署(merge 到 main 就上线)
推到 main → GitHub Actions 先跑检查,检查过了才部署:把这份代码 rsync 到服务器, SSH 进去就地构建、换容器、等健康检查转绿。
merge dev → main
↓
GitHub Actions(免费 runner)
├─ 检查:prisma validate / typecheck / lint / test ← 红了就到此为止,不部署
└─ 部署:rsync 源码到服务器
↓ SSH
服务器:docker compose build app && up -d app
├─ 容器启动时自动 prisma migrate deploy
└─ 轮询健康检查,转绿才算这次部署成功
流程定义在 .github/workflows/deploy.yml。
📋 第一次上线照着这份一步步敲:docs/deploy-runbook.md (服务器打底 → 送配置 → 配 Secrets → 让 CI 跑第一次 → 上域名 HTTPS → 日常运维 + 排错表)
为什么在服务器上构建
先试过「更专业」的那条路:runner 构建镜像推腾讯云 TCR,服务器只 pull。实测首次部署
26 分钟,而构建本身早就完了 —— 时间全花在两处:GHA 缓存导出 339 秒,以及 runner
在墙外往国内镜像仓库推 ~1.5GB。runner 每次都是全新机器,Docker 层缓存只能靠 GHA
缓存来回搬,搬的成本就把构建的便宜抵掉了。
服务器就地构建:层缓存留在本机(下次通常只重跑 next build 那一层),代码增量只有几十 KB,
零跨境传输。首次 10-20 分钟,之后 3-6 分钟。代价是构建期间占服务器 CPU/内存,
可能有几分钟不可用 —— 我们接受。
配 GitHub Secrets
仓库 → Settings → Secrets and variables → Actions。不需要镜像仓库凭证,只要 SSH:
| Secret | 值 |
|---|---|
SSH_HOST / SSH_USER / SSH_KEY | 服务器 IP、登录用户、为 CI 单独生成的私钥全文 |
SSH_PORT | 可选,默认 22 |
DEPLOY_DIR | 服务器上放代码与 .env 的目录,如 /opt/opentv |
CI 专用密钥这样生成(别把你自己的私钥交给 CI):
ssh-keygen -t ed25519 -C "opentv-ci" -f ~/.ssh/opentv_ci -N ""
ssh-copy-id -i ~/.ssh/opentv_ci.pub <用户>@<服务器IP>
cat ~/.ssh/opentv_ci # 全文(含 BEGIN/END 两行)贴进 SSH_KEY源码是 rsync 过去的,服务器不需要仓库权限。本来更自然的做法是让服务器
git pull私有仓库,但 CloudNook 组织禁用了 deploy key,而往服务器塞 PAT 比只读 key 更糟; 国内机器直连 github.com 也慢。rsync 还有个额外好处:传过去的正是检查通过的那份代码。
服务器上准备一次(之后就全自动)
服务器上只有 .env 要你手动放 —— 代码和 docker-compose.yml 由 CI 每次同步过去,
.env 在 rsync 的排除名单里,放上去就一直在。
sudo apt update && sudo apt install -y docker.io docker-compose-v2 rsync
sudo usermod -aG docker $USER && newgrp docker
# ⚠️ 必做:配 Docker 镜像源,否则拉 postgres/redis 会卡死(国内连不上 Docker Hub)
sudo mkdir -p /etc/docker && sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"registry-mirrors": ["https://mirror.ccs.tencentyun.com"],
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "3" }
}
EOF
sudo systemctl restart docker
# 腾讯云机器实测:mirror.ccs.tencentyun.com 走内网 0.07s,拉 postgres:16 共 37 秒。
# 它只能当 daemon 的 registry-mirror,不能当镜像名前缀。配好后 .env 里写官方镜像名:
# POSTGRES_IMAGE="postgres:16" REDIS_IMAGE="redis:7-alpine" NODE_IMAGE="node:22-bookworm-slim"
sudo mkdir -p /opt/opentv && sudo chown $USER /opt/opentv && cd /opt/opentv
# 方式一:本机直接 scp 一份上来(最省事)
# scp .env <用户>@<服务器IP>:/opt/opentv/.env
#
# 方式二:从飞书表格拉(需要服务器上有 node)
cat > .feishu.env <<'EOF'
FEISHU_APP_ID=cli_xxx
FEISHU_APP_SECRET=xxx
FEISHU_ENV_WIKI_URL=https://xxx.feishu.cn/wiki/xxx
EOF
npm run env:pull -- --write
# 加 2G swap 当保险(腾讯云 Ubuntu 镜像通常自带一块,先 swapon --show 看看)
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab.env 必填:BETTER_AUTH_SECRET、BETTER_AUTH_URL(必须等于你实际访问的地址,
否则登录报 Invalid origin)、INVITE_CODES、OSS_*、RESEND_API_KEY、
POSTGRES_PASSWORD(⚠️ 只在数据卷首次初始化时生效,第一次 up -d 前就要定好)。
之后的每次上线与回滚
git checkout main && git merge dev && git push # 就这一步首次冷构建实测 18 分 57 秒,之后有层缓存通常 3-6 分钟(只重跑 next build 那一层)。
部署脚本在服务器上是后台跑的(docker/deploy.sh),CI 只轮询结果 —— 因为我们真被
一次 SSH 断连掐死过 19 分钟的构建。
回滚不用重新构建:每次部署 CI 会给镜像额外打一个 opentv-app:<commit前12位> 标签
(最近 5 个留着),几秒切回去:
ssh <用户>@<服务器IP> && cd /opt/opentv
docker images opentv-app # 看可回滚的标签
APP_IMAGE=opentv-app:abc123def456 docker compose up -d app # 命令行变量优先于 .env⚠️ 回滚只换代码,不回退数据库迁移。带破坏性 schema 变更的那次上线,回滚前先想清楚。
配置放在飞书表格里
一张两列的飞书多维表格(第一列 key、第二列 value)是线上配置的唯一事实来源。
每次部署都会自动把表格拉成服务器上的 .env —— 改线上配置 = 改表格 + 触发一次部署,
不用再 ssh 上去 vim .env。
本地开发不参与这套:npm run dev 用你本机的 .env,和表格互不干涉。
cp .feishu.env.example .feishu.env # 填 FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_ENV_WIKI_URL
npm run env:pull # 预览表格与当前 .env 的差异(值打码,不落盘)
npm run env:pull -- --write # 写 .env(原文件备份成 .env.bak)
npm run env:push # 反向:把 .env 推上表格(加 --dry 只看)两道防呆,都是踩出来的:
pull --write会校验必填项(REQUIRED_KEYS:BETTER_AUTH_SECRET/BETTER_AUTH_URL/POSTGRES_PASSWORD/INVITE_CODES/OSS_*/RESEND_API_KEY)。表格里少填一行 = 线上少一项配置, 所以缺了就拒绝写盘、保留原文件。POSTGRES_PASSWORD尤其致命:一丢,compose 退回默认密码, 应用直接连不上库。push默认跳过BETTER_AUTH_URL/DATABASE_URL/REDIS_URL。这三项本地线上本就不同 (本地localhost、线上公网地址),不跳的话本地跑一次 push 就把线上地址覆盖成 localhost, 线上登录立刻报Invalid origin。真要改就在表格里直接改,或加--force。
服务器上不用装 node:部署脚本借一个 node 镜像跑完就扔
(docker run --rm --user $(id -u):$(id -g) ... node scripts/feishu-env.mjs pull --write)。
--user 不能省 —— 不加会让 .env 变成 root 所有,之后普通用户改不动。
拉取失败不拦部署,保留服务器上原有的 .env 继续跑。
飞书应用要开 bitable:app(多维表格读写)和 wiki:wiki:readonly(读知识库),
发布版本后还要把这个应用加进那张表:打开表格 → 右上「···」→「添加文档应用」。
少这一步会报 91403 —— 有 API 权限 ≠ 有这张表的权限。
三条边界,别踩:
- 表格的可见范围就是生产密钥的可见范围。 里面是 OSS AccessKeySecret、
BETTER_AUTH_SECRET、数据库密码 —— 这页只给自己,别开组织内可见,别发链接分享。 - 它没有消灭「服务器上放密钥」,只是把一整份
.env换成两行飞书凭证。 - 拉取发生在部署时,不在
docker build时。 密钥进了镜像层就等于泄漏 (docker history就能翻出来),而.env本来就是 compose 在运行时注入的, 构建阶段根本不读。FEISHU_*自身不进表格,pull也不会覆盖掉本地那几行。
许可
私有项目,暂未开源许可。落地页用到的 3D 模型来自 Quaternius(CC0)。