Cloud Recording
Composite/individual/web recording, task state machine, Agora-compatible acquire → start → query → stop flow, storage and quota.
Cloud Recording records "the audio and video happening in a channel" into object storage. ni-meet models recording as a task-shaped resource: once created, it is executed by a separate pool of Egress nodes while the control plane advances state statelessly; every state change is persisted, written to the Outbox, and emitted as a callback.
Prerequisites
- A project created in the console with
appId/customerId/customerSecret/appCertificate(see Enable services and get credentials). - A reachable API base URL; server-side calls use the Basic credentials.
- Recording enabled in the console; otherwise creating a job returns
403 service_not_enabled.
Recording modes
| Mode | Description | Artifacts |
|---|---|---|
composite | Mixed recording: all audio and video (or that of the selected uids) is mixed into a single stream according to the layout | recording.mp4 |
individual | Individual recording: every participant that is publishing gets its own recording | <uid>.mp4 ×N |
web | Page-style recording: mixed output plus the Web scene layout with the manifest switched off | recording.mp4 |
individual mode requires at least one participant that can publish in the channel, otherwise creation fails outright (503 no_server_resources). The mode segment of the Agora-compatible interface maps as follows: mix → composite, individual → individual, web → web.
Layout presets: grid, bestFit, pip, single, with optional canvasWidth / canvasHeight (16–4096) and backgroundColor.
Task lifecycle
starting ──tick/egress start──▶ running ──stop──▶ stopping ──▶ stopped
│ │
└────────upstream failure───────┴──▶ failed| State | Meaning |
|---|---|
starting | The task is persisted and queued; Egress has not confirmed the start yet |
running | Egress is recording; startedAt has been written |
stopping | Stop has been requested; waiting for Egress to wind down and produce files |
stopped | Finished normally; files[] and usageSeconds are ready |
failed | Upstream failure; error records the reason |
expired | Reserved state (recording does not produce it today; idle reclamation for push uses it) |
At creation you can pass maxDurationSeconds (10–86400) as a backstop for runaway tasks; idempotencyKey guarantees that a retry does not create a second task (the same key returns the existing task).
Platform API
| Method | Path | Description |
|---|---|---|
POST | /v1/recordings | Create a task, 202 |
GET | /v1/recordings?channelName=&state=&limit= | List tasks |
GET | /v1/recordings/{id} | Query a single task (also syncs the scheduler state along the way) |
POST | /v1/recordings/{id}/stop | Stop (idempotent: an already stopped task returns immediately) |
POST | /v1/recordings/{id}/layout | Update the layout (composite only, and only while starting/running) |
Create request body:
| Field | Description |
|---|---|
channelName | Required; the channel must exist and must not be destroyed, otherwise 404 channel_not_found |
mode | composite (default) / individual / web |
layout | { preset, canvasWidth, canvasHeight, backgroundColor } |
subscribeAudioUids / subscribeVideoUids | Record only the given uids |
maxDurationSeconds | Upper-bound protection |
idempotencyKey | Idempotency key |
The diagnostic fields on the task resource are worth watching: jobIds (one row per Egress job), queueWaitMs (time spent waiting in the queue, which reflects gate pressure), usageSeconds (billed seconds, preferring the real duration of the artifacts), and files[].objectKey.
Prerequisites and quotas:
- the project has not enabled
recording→403 service_not_enabled; - the project concurrency quota
quotas.recordingConcurrent(default 5) is exhausted →429 recording_quota_exceeded(thedetailsin the error envelope carrylimitandactive); - the channel does not exist →
404 channel_not_found.
Agora-compatible flow (acquire → start → query → updateLayout → stop)
| Step | Path | Description |
|---|---|---|
| acquire | POST /v1/apps/{appid}/cloud_recording/acquire | Returns a single-use resourceId, valid for 30 seconds |
| start | POST .../resourceid/{resourceId}/mode/{mode}/start | Checks that resourceId belongs to this project and that cname matches, then consumes it |
| query | GET .../resourceid/{resourceId}/sid/{sid}/mode/{mode}/query | Returns serverResponse.{status,taskId,fileList} |
| updateLayout | POST .../updateLayout | mixedVideoLayout 0/1/2/3 → grid/bestFit/pip/single |
| stop | POST .../stop | Returns the final fileList |
Request/response skeletons (fenced as text, because the block holds three responses plus comments rather than a single valid JSON document):
// 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 }] } }The complete runnable example (platform flow + Agora flow + a reused resourceId being rejected):
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 version:
#!/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 the control plane class="tok-flag">-credentials.json ] && echo the control plane 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\":{}}"Get the recording files
After a job reaches stopped, read files[] from GET /v1/recordings/{id}: each file has name, size and url (or the URL is completed by your CDN / download-signing service). Verify the file size and readability before serving downloads; individual mode produces one file per uid.
Storage configuration
The storage block is written when the task is created:
| Field | Description |
|---|---|
vendor | s3 / oss / cos / gcs / local (default local) |
region | Storage region, may be empty |
bucket | Bucket name, default ni-meet-recordings |
prefix | Object key prefix, default recordings |
accessKeyRef | A reference rather than plaintext credentials (secret manager path) |
Object key rule: <prefix>/<taskId>/<filename>; the local vendor also prepends the local root directory. Deployment-level defaults come from environment variables: RECORDING_STORAGE_VENDOR, RECORDING_BUCKET, RECORDING_PREFIX, RECORDING_LOCAL_ROOT. files[].url is populated only when the artifact itself is an absolute URL; otherwise it is null (your CDN or signed-download service fills it in).
Concurrency, queue and isolation
Recording does not execute inside the control-plane process: tasks are delivered to the Egress scheduler and executed by a separate node pool according to capacity, which guarantees that "recording pressure never drags down the media plane".
| Parameter | Default | Environment variable |
|---|---|---|
| Global concurrency limit | 8 | EGRESS_MAX_CONCURRENT |
| Queue length limit | 200 | EGRESS_QUEUE_LIMIT |
| Node count | 2 | EGRESS_NODE_COUNT |
| Per-node capacity | 4 | EGRESS_NODE_CAPACITY |
| Scheduler poll | 1000 ms | EGRESS_POLL_INTERVAL_MS |
Observability:
GET /metricsexposesnimeet_egress_running,nimeet_egress_queued,nimeet_egress_queue_limit,nimeet_egress_queue_wait_p95_ms, and per nodenimeet_egress_node_running{node=…};- `GET the operator surface returns running/queued/completed/failed plus p50/p95;
queueWaitMson the task resource is the actual time that task spent in the queue.
When the queue is full, new tasks are rejected (they are not enqueued); project-level concurrency is limited separately by recordingConcurrent, so the two are a "global gate plus tenant gate".
Callbacks
Every state change emits event code 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
}
}Delivery, retry and signature rules are in Callback events.
Known differences from Agora
| Item | Agora | This implementation |
|---|---|---|
clientRequest.recordingConfig.maxIdleTime on start | Idle timeout when nobody is in the channel | Mapped to maxDurationSeconds (maximum recording duration); the semantics differ |
status of query | Numeric status codes (0–8, etc.) | String task states: starting/running/stopping/stopped/failed |
resourceExpiredHour, excludeResourceIds, resourceToken of acquire | Have semantics | The fields are accepted but do not change behaviour (the handshake marker is fixed at 30s single use) |
Tuning such as subscribeUidGroup in individual mode | Supported | The fields are accepted but do not change behaviour |
Next steps
- REST API reference: complete endpoints, parameters and curl.
- Error codes: locate failures by
code/ HTTP status. - FAQ: quotas, limits and troubleshooting.