云端录制
合流/单流/Web 录制、任务状态机、Agora 兼容 acquire→start→query→updateLayout→stop、存储与配额。
云端录制把「频道里正在发生的音视频」录制到对象存储。ni-meet 把录制建模成任务形资源:创建后由独立的 Egress 节点池执行,控制面无状态推进;每次状态变化都会落库、进 Outbox、产生回调。
前置条件
- 已在控制台创建项目并拿到
appId/customerId/customerSecret/appCertificate(见 开通服务与凭证)。 - 已有可用的 API 基址;服务端调用使用 Basic 凭证。
录制服务需在控制台开通;未开通时创建任务返回
403 service_not_enabled。
录制模式
| 模式 | 说明 | 产物 |
|---|---|---|
composite | 合流录制:所有(或指定 uid 的)音视频按布局混成一路 | recording.mp4 |
individual | 单流录制:每个正在发布的参与者各录一路 | <uid>.mp4 ×N |
web | 页面式录制:合流 + 关闭 manifest 的 Web 场景布局 | recording.mp4 |
individual 模式要求频道内至少有一个可发布的参与者,否则创建直接失败(503 no_server_resources)。声网兼容接口的 mode 段映射:mix → composite、individual → individual、web → web。
布局预设:grid、bestFit、pip、single,可选 canvasWidth / canvasHeight(16–4096)、backgroundColor。
任务生命周期
starting ──tick/egress 启动──▶ running ──stop──▶ stopping ──▶ stopped
│ │
└────────上游失败──────────────┴──▶ failed| 状态 | 含义 |
|---|---|
starting | 任务已落库并入队,Egress 尚未确认启动 |
running | Egress 正在录制,startedAt 已写入 |
stopping | 已请求停止,等待 Egress 收尾并产出文件 |
stopped | 正常结束,files[] 与 usageSeconds 已就绪 |
failed | 上游失败,error 记录原因 |
expired | 预留状态(录制当前不产生;推流 idle 回收使用) |
创建时可选 maxDurationSeconds(10–86400)兜底失控任务;idempotencyKey 保证重试不会创建第二个任务(相同 key 返回已存在任务)。
平台接口
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /v1/recordings | 创建任务,202 |
GET | /v1/recordings?channelName=&state=&limit= | 列出任务 |
GET | /v1/recordings/{id} | 查询单任务(会顺带同步调度器状态) |
POST | /v1/recordings/{id}/stop | 停止(幂等:已停止直接返回) |
POST | /v1/recordings/{id}/layout | 更新布局(仅 composite,且 starting/running) |
创建请求体:
| 字段 | 说明 |
|---|---|
channelName | 必填;频道必须存在且未销毁,否则 404 channel_not_found |
mode | composite(默认)/ individual / web |
layout | { preset, canvasWidth, canvasHeight, backgroundColor } |
subscribeAudioUids / subscribeVideoUids | 只录制指定 uid |
maxDurationSeconds | 上限保护 |
idempotencyKey | 幂等键 |
任务资源里的诊断字段值得关注:jobIds(每个 Egress job 一行)、queueWaitMs(排队等待时长,反映闸门压力)、usageSeconds(计费秒数,优先取产物真实时长)、files[].objectKey。
前置条件与配额:
- 项目未开通
recording→403 service_not_enabled; - 项目并发配额
quotas.recordingConcurrent(默认 5)已满 →429 recording_quota_exceeded(错误信封里的details会带limit与active); - 频道不存在 →
404 channel_not_found。
声网兼容流程(acquire → start → query → updateLayout → stop)
| 步骤 | 路径 | 说明 |
|---|---|---|
| acquire | POST /v1/apps/{appid}/cloud_recording/acquire | 返回单次使用的 resourceId,30 秒内有效 |
| start | POST .../resourceid/{resourceId}/mode/{mode}/start | 校验 resourceId 属于本项目且 cname 一致,成功后消费掉它 |
| query | GET .../resourceid/{resourceId}/sid/{sid}/mode/{mode}/query | 返回 serverResponse.{status,taskId,fileList} |
| updateLayout | POST .../updateLayout | mixedVideoLayout 0/1/2/3 → grid/bestFit/pip/single |
| stop | POST .../stop | 返回最终 fileList |
请求/响应骨架(用 text 围栏:里面有三段响应与注释,不是单个合法 JSON):
// acquire →
{ "resourceId": "res_9f2c…" }
// start →
{ "resourceId": "res_9f2c…", "sid": "rec_1a2b…", "serverResponse": { "taskId": "rec_1a2b…", "state": "starting" } }
// query →
{ "resourceId": "res_9f2c…", "sid": "rec_1a2b…",
"serverResponse": { "taskId": "rec_1a2b…", "status": "running", "fileList": [{ "fileName": "recordings/rec_1a2b…/recording.mp4", "duration": 60, "size": 1048576 }] } }完整可运行示例(平台流程 + 声网流程 + 重复 resourceId 被拒绝):
import { seedChannel, tick, uniqueChannel } from './_lib.mjs';
export const meta = {
id: '13-recordings-lifecycle',
title: '云端录制:平台任务与声网 acquire→start 流程',
safe: false, // 会创建任务并写入演示项目数据
needsApi: true,
};
/**
* 平台接口:POST /v1/recordings → GET /v1/recordings/{id} → POST /v1/recordings/{id}/layout → stop
* 声网兼容:POST /v1/apps/{appid}/cloud_recording/acquire → .../mode/mix/start → query →
* updateLayout → stop
*
* 任务状态机:starting → running → stopping → stopped(失败为 failed,空闲超时为 expired)。
* 真实的 Egress 由独立节点池执行;本地 memory adapter 下由调度器推进。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channelName = uniqueChannel('docs-rec');
const uid = 92001;
const seeded = await seedChannel(api, appId, channelName, uid);
// ---------------------------------------------------------------- 平台接口
const created = await api('POST', '/v1/recordings', {
body: { channelName, mode: 'composite', layout: { preset: 'grid' }, idempotencyKey: `docs-${channelName}` },
});
ctx.assert(created.status === 202, `创建录制应返回 202,实际 ${created.status}`);
const taskId = created.json.id;
ctx.assert(created.json.state === 'starting', '新任务应为 starting');
const duplicate = await api('POST', '/v1/recordings', {
body: { channelName, mode: 'composite', idempotencyKey: `docs-${channelName}` },
});
ctx.assert(duplicate.json.id === taskId, '相同 idempotencyKey 应返回同一任务');
await tick(api);
const afterTick = await api('GET', `/v1/recordings/${taskId}`);
const layoutUpdate = await api('POST', `/v1/recordings/${taskId}/layout`, { body: { preset: 'bestFit' } });
ctx.assert(layoutUpdate.json.layout?.preset === 'bestFit', '布局应更新为 bestFit');
const stopped = await api('POST', `/v1/recordings/${taskId}/stop`);
ctx.assert(['stopping', 'stopped'].includes(stopped.json.state), `停止后应为 stopping/stopped,实际 ${stopped.json.state}`);
// ------------------------------------------------------------- 声网兼容流程
const acquire = await api('POST', `/v1/apps/${appId}/cloud_recording/acquire`, {
body: { cname: channelName, uid: String(uid), clientRequest: { scene: 0 } },
});
const resourceId = acquire.json.resourceId;
ctx.assert(typeof resourceId === 'string' && resourceId.startsWith('res_'), 'acquire 应返回 resourceId');
const start = await api('POST', `/v1/apps/${appId}/cloud_recording/resourceid/${resourceId}/mode/mix/start`, {
body: { cname: channelName, uid: String(uid), clientRequest: { recordingConfig: { mixedVideoLayout: 1 } } },
});
const sid = start.json.sid;
ctx.assert(start.json.resourceId === resourceId, 'start 应回显 resourceId');
ctx.assert(typeof sid === 'string' && sid.length > 0, 'start 应返回 sid');
await tick(api);
const query = await api('GET', `/v1/apps/${appId}/cloud_recording/resourceid/${resourceId}/sid/${sid}/mode/mix/query`);
ctx.assert(query.json.serverResponse?.taskId === sid, 'query 应返回 taskId');
ctx.assert(Array.isArray(query.json.serverResponse?.fileList), 'query 应返回 fileList');
const agoraLayout = await api(
'POST',
`/v1/apps/${appId}/cloud_recording/resourceid/${resourceId}/sid/${sid}/mode/mix/updateLayout`,
{ body: { cname: channelName, uid: String(uid), clientRequest: { mixedVideoLayout: 2 } } },
);
ctx.assert(agoraLayout.json.serverResponse?.layout?.preset === 'pip', 'mixedVideoLayout=2 应映射为 pip');
const agoraStop = await api(
'POST',
`/v1/apps/${appId}/cloud_recording/resourceid/${resourceId}/sid/${sid}/mode/mix/stop`,
{ body: { cname: channelName, uid: String(uid), clientRequest: {} } },
);
ctx.assert(
['stopping', 'stopped'].includes(agoraStop.json.serverResponse?.status),
`声网 stop 状态应为 stopping/stopped,实际 ${agoraStop.json.serverResponse?.status}`,
);
// 复用已消费的 resourceId 必须失败(单次使用 + 30s TTL)
const reused = await ctx.client('POST', `/v1/apps/${appId}/cloud_recording/resourceid/${resourceId}/mode/mix/start`, {
body: { cname: channelName, uid: String(uid) },
});
ctx.assert(reused.status === 400, 'resourceId 不可复用,应返回 400');
return {
summary:
`频道 ${channelName}:平台任务 ${taskId}(tick 后 ${afterTick.json.state} → ${stopped.json.state});` +
`声网 sid=${sid}(query status=${query.json.serverResponse.status});重复 resourceId 正确拒绝`,
details: {
platformTask: { id: taskId, stateAfterTick: afterTick.json.state, layout: layoutUpdate.json.layout, finalState: stopped.json.state },
agora: { resourceId, sid, query: query.json.serverResponse.status, layout: agoraLayout.json.serverResponse.layout },
seededUid: seeded.uid,
},
};
}curl 版本:
#!/usr/bin/env bash
# 云端录制(声网兼容流程):acquire → start → query → updateLayout → stop
# 注意:acquire 返回的 resourceId 单次使用、30 秒内有效;start 会消耗它。
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
: "${CNAME:=weekly-sync}"
: "${REC_UID:=10001}"
: "${MODE:=mix}" # mix | individual | web
CRED_FILE=$([ -f 控制面 class="tok-flag">-credentials.json ] && echo 控制面 class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")
APP_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).appId)" "$CRED_FILE")
BASE="$NIMEET_API_BASE/v1/apps/$APP_ID/cloud_recording"
# 1) acquire:拿一次性 resourceId
ACQUIRE=$(curl -s -X POST "$BASE/acquire" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"cname\":\"$CNAME\",\"uid\":\"$REC_UID\",\"clientRequest\":{\"scene\":0}}")
RESOURCE_ID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).resourceId)" "$ACQUIRE")
echo "resourceId=$RESOURCE_ID"
# 2) start:mixedVideoLayout 0=grid 1=bestFit 2=pip 3=single
START=$(curl -s -X POST "$BASE/resourceid/$RESOURCE_ID/mode/$MODE/start" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"cname\":\"$CNAME\",\"uid\":\"$REC_UID\",\"clientRequest\":{\"recordingConfig\":{\"mixedVideoLayout\":1}}}")
SID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).sid)" "$START")
echo "sid=$SID"
# 3) query:状态 + 文件列表
curl -s "$BASE/resourceid/$RESOURCE_ID/sid/$SID/mode/$MODE/query" \
-H "Authorization: Basic $CRED"
# 4) updateLayout:运行中改布局(仅 composite/mix 模式)
curl -s -X POST "$BASE/resourceid/$RESOURCE_ID/sid/$SID/mode/$MODE/updateLayout" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"cname\":\"$CNAME\",\"uid\":\"$REC_UID\",\"clientRequest\":{\"mixedVideoLayout\":2}}"
# 5) stop:返回最终 fileList
curl -s -X POST "$BASE/resourceid/$RESOURCE_ID/sid/$SID/mode/$MODE/stop" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"cname\":\"$CNAME\",\"uid\":\"$REC_UID\",\"clientRequest\":{}}"获取录制文件
任务进入 stopped 后,通过 GET /v1/recordings/{id} 读取 files[]:每个文件包含 name、size、url(或由你的 CDN / 下载签名服务补全)。建议在业务侧校验文件大小与可读性后再对外提供下载;individual 模式会为每个 uid 生成一个文件。
存储配置
任务创建时写入 storage:
| 字段 | 说明 |
|---|---|
vendor | s3 / oss / cos / gcs / local(默认 local) |
region | 存储区域,可空 |
bucket | 桶名,默认 ni-meet-recordings |
prefix | 对象键前缀,默认 recordings |
accessKeyRef | 引用而不是明文凭证(secret manager 路径) |
对象键规则:<prefix>/<taskId>/<filename>;local vendor 还会加上本地根目录前缀。部署级默认值来自环境变量:RECORDING_STORAGE_VENDOR、RECORDING_BUCKET、RECORDING_PREFIX、RECORDING_LOCAL_ROOT。files[].url 仅在产物本身是绝对 URL 时填充,否则为 null(由你的 CDN/下载签名服务补全)。
并发、队列与隔离
录制不在控制面进程内执行:任务投递到 Egress 调度器,由独立节点池按容量执行,这保证「录制压力不拖垮媒体面」。
| 参数 | 默认值 | 环境变量 |
|---|---|---|
| 全局并发上限 | 8 | EGRESS_MAX_CONCURRENT |
| 队列长度上限 | 200 | EGRESS_QUEUE_LIMIT |
| 节点数 | 2 | EGRESS_NODE_COUNT |
| 单节点容量 | 4 | EGRESS_NODE_CAPACITY |
| 调度轮询 | 1000 ms | EGRESS_POLL_INTERVAL_MS |
可观测性:
- 控制面暴露 Egress 运行数、排队数、队列上限与排队等待 p95 等指标;
- 任务资源里的
queueWaitMs是该任务的实际排队时长。
队列满时新任务会被拒绝(不入队);项目级并发由 recordingConcurrent 单独限制,两者是「全局闸门 + 租户闸门」。
回调
每次状态变化产生事件码 201 recording_state_changed:
{
"noticeId": "ntc_…",
"productId": 1,
"eventType": 201,
"notifyMs": 1793000000000,
"sid": "prj_…:recording_state_changed",
"payload": {
"taskId": "rec_1a2b…",
"channelName": "weekly-sync",
"mode": "composite",
"state": "stopped",
"files": [{ "objectKey": "recordings/rec_1a2b…/recording.mp4", "durationSeconds": 60 }],
"ts": 1793000000
}
}投递、重试与签名规则见 回调事件。
与声网的已知差异
| 项目 | 声网 | 本实现 |
|---|---|---|
start 的 clientRequest.recordingConfig.maxIdleTime | 无人空闲超时 | 被映射为 maxDurationSeconds(最大录制时长),语义不同 |
query 的 status | 数值状态码(0–8 等) | 字符串任务状态:starting/running/stopping/stopped/failed |
acquire 的 resourceExpiredHour、excludeResourceIds、resourceToken | 有语义 | 仅接受字段,不改变行为(握手标记固定 30s 单次使用) |
individual 模式的 subscribeUidGroup 等调参 | 支持 | 仅接受字段,不改变行为 |
下一步
- REST API 参考:完整端点、参数与 curl。
- 错误码:失败时按
code/ HTTP 状态定位。 - 常见问题:配额、限制与排障。