nimeet 文档中心
English

云端录制

合流/单流/Web 录制、任务状态机、Agora 兼容 acquire→start→query→updateLayout→stop、存储与配额。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

云端录制把「频道里正在发生的音视频」录制到对象存储。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。

任务生命周期

text
starting ──tick/egress 启动──▶ running ──stop──▶ stopping ──▶ stopped
    │                              │
    └────────上游失败──────────────┴──▶ failed
状态含义
starting任务已落库并入队,Egress 尚未确认启动
runningEgress 正在录制,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
modecomposite(默认)/ 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)

步骤路径说明
acquirePOST /v1/apps/{appid}/cloud_recording/acquire返回单次使用的 resourceId,30 秒内有效
startPOST .../resourceid/{resourceId}/mode/{mode}/start校验 resourceId 属于本项目且 cname 一致,成功后消费掉它
queryGET .../resourceid/{resourceId}/sid/{sid}/mode/{mode}/query返回 serverResponse.{status,taskId,fileList}
updateLayoutPOST .../updateLayoutmixedVideoLayout 0/1/2/3 → grid/bestFit/pip/single
stopPOST .../stop返回最终 fileList

请求/响应骨架(用 text 围栏:里面有三段响应与注释,不是单个合法 JSON):

text
// 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 被拒绝):

javascript
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 版本:

bash
#!/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:

字段说明
vendors3 / 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 调度器,由独立节点池按容量执行,这保证「录制压力不拖垮媒体面」。

参数默认值环境变量
全局并发上限8EGRESS_MAX_CONCURRENT
队列长度上限200EGRESS_QUEUE_LIMIT
节点数2EGRESS_NODE_COUNT
单节点容量4EGRESS_NODE_CAPACITY
调度轮询1000 msEGRESS_POLL_INTERVAL_MS

可观测性:

  • 控制面暴露 Egress 运行数、排队数、队列上限与排队等待 p95 等指标;
  • 任务资源里的 queueWaitMs 是该任务的实际排队时长。

队列满时新任务会被拒绝(不入队);项目级并发由 recordingConcurrent 单独限制,两者是「全局闸门 + 租户闸门」。

回调

每次状态变化产生事件码 201 recording_state_changed:

json
{
  "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 等调参支持仅接受字段,不改变行为

下一步

本页由文档构建流水线生成 · 2026-10-31 01:24:02 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。