nimeet Docs
中文

Cloud Recording

Composite/individual/web recording, task state machine, Agora-compatible acquire → start → query → stop flow, storage and quota.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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

ModeDescriptionArtifacts
compositeMixed recording: all audio and video (or that of the selected uids) is mixed into a single stream according to the layoutrecording.mp4
individualIndividual recording: every participant that is publishing gets its own recording<uid>.mp4 ×N
webPage-style recording: mixed output plus the Web scene layout with the manifest switched offrecording.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

text
starting ──tick/egress start──▶ running ──stop──▶ stopping ──▶ stopped
    │                               │
    └────────upstream failure───────┴──▶ failed
StateMeaning
startingThe task is persisted and queued; Egress has not confirmed the start yet
runningEgress is recording; startedAt has been written
stoppingStop has been requested; waiting for Egress to wind down and produce files
stoppedFinished normally; files[] and usageSeconds are ready
failedUpstream failure; error records the reason
expiredReserved 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

MethodPathDescription
POST/v1/recordingsCreate 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}/stopStop (idempotent: an already stopped task returns immediately)
POST/v1/recordings/{id}/layoutUpdate the layout (composite only, and only while starting/running)

Create request body:

FieldDescription
channelNameRequired; the channel must exist and must not be destroyed, otherwise 404 channel_not_found
modecomposite (default) / individual / web
layout{ preset, canvasWidth, canvasHeight, backgroundColor }
subscribeAudioUids / subscribeVideoUidsRecord only the given uids
maxDurationSecondsUpper-bound protection
idempotencyKeyIdempotency 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 (the details in the error envelope carry limit and active);
  • the channel does not exist → 404 channel_not_found.

Agora-compatible flow (acquire → start → query → updateLayout → stop)

StepPathDescription
acquirePOST /v1/apps/{appid}/cloud_recording/acquireReturns a single-use resourceId, valid for 30 seconds
startPOST .../resourceid/{resourceId}/mode/{mode}/startChecks that resourceId belongs to this project and that cname matches, then consumes it
queryGET .../resourceid/{resourceId}/sid/{sid}/mode/{mode}/queryReturns serverResponse.{status,taskId,fileList}
updateLayoutPOST .../updateLayoutmixedVideoLayout 0/1/2/3 → grid/bestFit/pip/single
stopPOST .../stopReturns the final fileList

Request/response skeletons (fenced as text, because the block holds three responses plus comments rather than a single valid JSON document):

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 }] } }

The complete runnable example (platform flow + Agora flow + a reused resourceId being rejected):

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 version:

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 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:

FieldDescription
vendors3 / oss / cos / gcs / local (default local)
regionStorage region, may be empty
bucketBucket name, default ni-meet-recordings
prefixObject key prefix, default recordings
accessKeyRefA 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".

ParameterDefaultEnvironment variable
Global concurrency limit8EGRESS_MAX_CONCURRENT
Queue length limit200EGRESS_QUEUE_LIMIT
Node count2EGRESS_NODE_COUNT
Per-node capacity4EGRESS_NODE_CAPACITY
Scheduler poll1000 msEGRESS_POLL_INTERVAL_MS

Observability:

  • GET /metrics exposes nimeet_egress_running, nimeet_egress_queued, nimeet_egress_queue_limit, nimeet_egress_queue_wait_p95_ms, and per node nimeet_egress_node_running{node=…};
  • `GET the operator surface returns running/queued/completed/failed plus p50/p95;
  • queueWaitMs on 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:

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
  }
}

Delivery, retry and signature rules are in Callback events.

Known differences from Agora

ItemAgoraThis implementation
clientRequest.recordingConfig.maxIdleTime on startIdle timeout when nobody is in the channelMapped to maxDurationSeconds (maximum recording duration); the semantics differ
status of queryNumeric status codes (0–8, etc.)String task states: starting/running/stopping/stopped/failed
resourceExpiredHour, excludeResourceIds, resourceToken of acquireHave semanticsThe fields are accepted but do not change behaviour (the handshake marker is fixed at 30s single use)
Tuning such as subscribeUidGroup in individual modeSupportedThe fields are accepted but do not change behaviour

Next steps

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.