Skip to content

上传录像 ​

设备取得身份和短期 device_access_token 后,通过 C SDK 创建上传请求并写入已经编码的音视频帧。请求正常结束时,on_result 回调返回唯一的最终结果。

音视频格式与帧要求 ​

C SDK 接收已经编码的完整帧。写入前先确认格式是客户端能够播放的。

选择客户端能够播放的格式 ​

类型支持的格式C SDK 媒体常量
视频H.264TICLOUDSTORAGE_VIDEO_H264
视频H.265TICLOUDSTORAGE_VIDEO_H265
视频JPEG 帧(作为 MJPEG 视频播放)TICLOUDSTORAGE_VIDEO_JPEG
音频16 位 PCMTICLOUDSTORAGE_AUDIO_PCM
音频G.711 A-lawTICLOUDSTORAGE_AUDIO_ALAW
音频AAC-LC(完整 ADTS 帧)TICLOUDSTORAGE_AUDIO_AAC
音频OpusTICLOUDSTORAGE_AUDIO_OPUS

组织视频帧 ​

  • 每次调用 TiCloudStorageQueueWriteFrame() 写入一帧完整的编码数据,不要把一帧拆成多次调用。
  • H.264 和 H.265 使用带起始码的 Annex B 码流。H.264 关键帧应包含 SPS、PPS 和 IDR;H.265 关键帧应包含 VPS、SPS、PPS 和可独立解码的帧。
  • H.264 和 H.265 的关键帧必须携带完整参数集,并把 flags 设为 TICLOUDSTORAGE_FRAME_FLAG_KEY_FRAME。C SDK 会从关键帧开始上传,并自动完成后续分片;设备应用不需要管理分片边界。
  • JPEG 写入完整的 JPEG/JFIF 图片,从 FF D8 开始、以 FF D9 结束;每帧都设置 TICLOUDSTORAGE_FRAME_FLAG_KEY_FRAME。
  • 同一通道不要在录像过程中切换视频编码格式。

如果录像需要在客户端保存或导出为 MP4,H.264 和 H.265 应使用不含 B 帧或其他重排序依赖的低延迟码流。录像可以正常上传或播放,不代表它一定符合 MP4 保存要求;不符合时,客户端保存任务返回“格式不支持”。

组织音频帧 ​

格式每次写入的数据
PCMS16LE(16 位小端有符号整数)交错采样数据,不包含 WAV 文件头
G.711 A-law一包原始 A-law 数据,不包含 WAV 文件头
AAC-LC一个完整的 AAC-LC ADTS 帧,包括 7 字节或 9 字节 ADTS 头和一个音频访问单元
Opus一包 Opus 编码数据,不包含 Ogg 封装

AAC 音频必须保留 ADTS 头。每次写入一个完整 ADTS 帧,不要拆分一帧或拼接多帧;ADTS 头声明的采样率、声道数和帧长度必须与当前帧及 flags 一致。

每个录像分片从视频帧开始,首视频帧之前的音频会被丢弃;音频不能单独启动录像请求。

音频 flags 必须与数据实际采用的采样率和声道数一致。客户端 SDK 支持 8 kHz 或 16 kHz、16 位、单声道或双声道。

上传流程 ​

C SDK 从创建 Service 到上传完成的调用与回调流程

上传请求是异步任务。TiCloudStorageUploadRequest() 返回正数只表示请求已经受理。每当一段媒体上传成功或确认失败时,on_progress 返回这段范围的结果;一个请求可能收到多次。每个请求只有一次 on_result,它才是正常结束时的最终结果回调。TiCloudStorageServiceStop() 会中止活动请求,不补发 on_result。当 duration_ms 为 0 时,请求会一直保持开启;必须成功调用 TiCloudStorageUploadSetEnd() 设置结束时间,它才会正常结束。

准备设备身份和 Token ​

  • 与设备绑定的 device_id 和 device_secret_key;
  • 业务服务端签发的短期 device_access_token;
  • 与目标系统、CPU 架构和工具链匹配的 C SDK;
  • 编码器输出的音视频帧,以及可靠的 UTC 毫秒时间戳。

设备应用先用产品已有的设备鉴权方式请求业务服务端,再取得 device_access_token。签发方式见云端签发 Token,设备侧的取得方式见 C SDK 接入。AccessKeySecret 只保存在业务服务端,不能写入设备固件。

1. 初始化 SDK ​

c
#include <stdint.h>
#include <stdio.h>
#include <stdatomic.h>
#include <string.h>
#include <tirtc/ticloudstorage.h>

static void on_log(int level, const char *message) {
    fprintf(stderr, "[TiCloudStorage][%d] %s\n", level, message);
}

static int init_ti_cloud_storage(void) {
    struct TiCloudStorageOptions options = TICLOUDSTORAGE_OPTIONS_INITIALIZER;
    options.log_level = TICLOUDSTORAGE_LOG_INFO;
    options.on_log = on_log;

    int rc = TiCloudStorageInit(&options);
    if (rc != TICLOUDSTORAGE_OK) {
        fprintf(stderr, "TiCloudStorageInit failed: %s\n",
                TiCloudStorageGetErrorString(rc));
    }
    return rc;
}

TiCloudStorageInit() 是进程级初始化,应用在进程内调用一次即可。

2. 启动设备上传服务 ​

设备每启动一条独立的媒体时间线,就创建一个 Service。创建 Service 后设置短期 Token,再调用 TiCloudStorageServiceStart() 启动上传服务。回调由 SDK 分发线程执行,必须快速返回;不要在回调中调用 Stop、Destroy 或 Uninit。

配置最近帧队列 ​

buffer_size_bytes 设置当前 Service 的最近帧环形队列容量,单位为字节。队列位于设备内存中,用于保存最近写入的编码音视频帧。事件触发后,上传请求可以从队列中选择事件发生前的录像。Service 销毁或进程退出后,队列中的帧不再保留。

需要预录时,可以根据所有 Channel 的音视频总码率估算容量:

text
队列容量(字节)≈ 音视频总码率(比特/秒)÷ 8 × 预录时长(秒)

实际配置还应为码率波动和帧信息保留余量。多个 Service 分别占用各自的队列内存。传 0 时使用 SDK 默认容量。

队列空间不足时,SDK 会覆盖较早且尚未进入上传处理的帧,可用的预录时长随之缩短。如果当前空间无法释放,TiCloudStorageQueueWriteFrame() 返回 TICLOUDSTORAGE_E_QUEUE_FULL。此时应结合队列状态、网络和鉴权情况判断上传是否落后于写入,不要只通过扩大队列掩盖问题。

upload_queue_max_slices 设置待上传切片数上限,0 使用默认值 2,允许值为 1~6。它与保存最近帧的 buffer_size_bytes 是两项不同配置:前者限制已打包待上传的切片数,后者决定可保留多少预录帧。待传队列满时,SDK 丢弃最旧待传切片并记录缺口。根据网络吞吐和设备内存选择上限;增大队列不能消除持续带宽不足造成的缺口。

c
struct UploadContext {
    atomic_int completed;
    int result;
    int error;
};

static void on_token_will_expire(
    int service_id, uint64_t expires_at_ms, void *user_data
) {
    // 通知业务线程向服务端续签。取得新 Token 后,
    // 由业务线程调用 TiCloudStorageServiceUpdateToken(service_id, new_token)。
    (void)service_id;
    (void)expires_at_ms;
    (void)user_data;
}

static void on_token_expired(
    int service_id, uint64_t expires_at_ms, void *user_data
) {
    // 本地到期或平台拒绝 Token 时,通知业务线程续签;避免重复并发续签。
    (void)service_id;
    (void)expires_at_ms;
    (void)user_data;
}

static int create_started_service(
    const char *device_id,
    const char *device_secret_key,
    const char *device_access_token,
    struct UploadContext *ctx
) {
    struct TiCloudStorageServiceOptions options =
        TICLOUDSTORAGE_SERVICE_OPTIONS_INITIALIZER;
    options.device_secret_key = device_secret_key;
    options.buffer_size_bytes = 0;  // 使用 SDK 默认容量
    options.token_expire_warning_sec = 300;
    options.max_key_frame_interval_ms = 5000;
    options.on_token_will_expire = on_token_will_expire;
    options.on_token_expired = on_token_expired;
    options.user_data = ctx;

    int service_id = TiCloudStorageServiceCreate(device_id, &options);
    if (service_id < 0) {
        fprintf(stderr, "create service failed: %s\n",
                TiCloudStorageGetErrorString(service_id));
        return service_id;
    }

    int rc = TiCloudStorageServiceUpdateToken(
        service_id, device_access_token);
    if (rc != TICLOUDSTORAGE_OK) {
        TiCloudStorageServiceDestroy(service_id);
        return rc;
    }

    rc = TiCloudStorageServiceStart(service_id);
    if (rc != TICLOUDSTORAGE_OK) {
        TiCloudStorageServiceDestroy(service_id);
        return rc;
    }
    return service_id;
}

平台因 Token 无效或过期拒绝请求时,也会触发 on_token_expired,通知可能重复。业务线程收到通知后续签并更新 Token;不要反复提交同一个已过期 Token。详细规则见 TiCloudStorageServiceUpdateToken。

SDK 会复制配置字符串和回调函数指针,但不会管理 user_data。上例中的 ctx 必须保持有效,直到 Service 销毁完成。

3. 准备上传回调 ​

先准备进度回调和最终结果回调。设备写帧与上传回调并发执行;回调只记录结果或投递事件,不要在回调线程中停止 Service。

c
static void on_upload_progress(
    int service_id,
    const struct TiCloudStorageUploadRange *range,
    int error,
    void *user_data
) {
    printf("service=%d range=[%llu,%llu) bytes=%llu error=%d\n",
           service_id,
           (unsigned long long)range->start_time_ms,
           (unsigned long long)range->end_time_ms,
           (unsigned long long)range->size_bytes,
           error);
    (void)user_data;
}

static void on_upload_result(
    int service_id,
    const struct TiCloudStorageUploadResult *result,
    void *user_data
) {
    struct UploadContext *ctx = (struct UploadContext *)user_data;
    ctx->result = result->result;
    ctx->error = result->error;

    printf("service=%d upload=%d result=%d error=%d "
           "range=[%llu,%llu) net=%llu bytes=%llu\n",
           service_id,
           result->upload_id,
           result->result,
           result->error,
           (unsigned long long)result->start_time_ms,
           (unsigned long long)result->end_time_ms,
           (unsigned long long)result->net_duration_ms,
           (unsigned long long)result->size_bytes);
    // 必须在回调内全部工作完成后再发布 completed。
    atomic_store_explicit(&ctx->completed, 1, memory_order_release);
}

4. 持续写入编码帧 ​

单通道设备通常把配套的视频和音频都设为 channel_id = 0。Channel ID 由设备应用自行分配;音频和视频可以使用相同或不同的编号,但客户端必须采用同一套对应关系。同一 Channel、同一媒体类型的时间戳必须单调不减。

Service 启动后应立即启动送帧线程,并在没有上传请求时也持续写入。最近帧队列只有先收到事件前的媒体帧,事件请求才可能回溯到预录范围。下面的写帧函数在送帧线程中持续调用,与后续的请求创建和回调并发执行。

H.264 视频帧:

c
static int write_h264(
    int service_id,
    const uint8_t *data,
    uint32_t length,
    uint64_t utc_time_ms,
    int is_key_frame
) {
    struct TiCloudStorageFrameInfo frame;
    memset(&frame, 0, sizeof(frame));
    frame.channel_id = 0;
    frame.media = TICLOUDSTORAGE_VIDEO_H264;
    frame.flags = is_key_frame
        ? TICLOUDSTORAGE_FRAME_FLAG_KEY_FRAME : 0;
    frame.timestamp_ms = utc_time_ms;
    frame.length = length;

    return TiCloudStorageQueueWriteFrame(service_id, &frame, data);
}

AAC 音频帧:

c
static int write_aac(
    int service_id,
    const uint8_t *adts_frame,
    uint32_t length,
    uint64_t utc_time_ms
) {
    struct TiCloudStorageFrameInfo frame;
    memset(&frame, 0, sizeof(frame));
    frame.channel_id = 0;
    frame.media = TICLOUDSTORAGE_AUDIO_AAC;
    frame.flags = TICLOUDSTORAGE_AUDIO_SAMPLE_16K16B1C;
    frame.timestamp_ms = utc_time_ms;
    frame.length = length;

    return TiCloudStorageQueueWriteFrame(service_id, &frame, adts_frame);
}

adts_frame 必须包含一个完整的 AAC-LC ADTS 帧。不要在交给 SDK 前删除 ADTS 头。

SDK 在函数返回前复制 FrameInfo 和帧数据,因此调用返回后可以复用编码器缓冲区。返回 TICLOUDSTORAGE_OK 只表示帧进入环形队列,不表示已经上传。

写帧失败时按具体错误码处理:

  • TICLOUDSTORAGE_E_TIMESTAMP_OUT_OF_ORDER:修正同一 Channel、同一媒体类型的时间戳;
  • TICLOUDSTORAGE_E_QUEUE_FULL:检查网络、鉴权和队列压力,不要无界重试;
  • TICLOUDSTORAGE_E_UNSUPPORTED_MEDIA:media 不在音频或视频的预留编号范围内,改用 TICLOUDSTORAGE_AUDIO_* 或 TICLOUDSTORAGE_VIDEO_* 常量;
  • TICLOUDSTORAGE_E_SERVICE_INVALID_STATE:确认 Service 已启动且尚未停止。

上传多路录像 ​

设备有多个摄像头或多路码流时,为每一路来源分配稳定的 channel_id,并把编号随每个编码帧写入队列。客户端播放或导出时必须使用同一套对应关系;录像查询结果不会返回或推断 Channel ID,业务服务应把设备的媒体通道配置提供给客户端。

例如,设备包含两路视频和一路音频时,可以这样约定:

媒体来源channel_id
摄像头 A 视频0
麦克风音频0
摄像头 B 视频1

同一个 Channel 最多包含一路视频和一路音频,所以摄像头 A 与配套麦克风可以共用 channel_id = 0;摄像头 B 使用 channel_id = 1。如果两个摄像头需要使用同一路音频,音频只写入一次即可,客户端在创建两个 MP4 任务时都选择音频 Channel 0。

设备端仍然调用同一个写帧接口,只需为来自不同编码器的帧填写对应编号:

c
/* 摄像头 A:channel_id = 0;摄像头 B:channel_id = 1。 */
int write_h264_channel(
    int service_id,
    uint8_t channel_id,
    const uint8_t *data,
    uint32_t length,
    uint64_t utc_time_ms,
    int is_key_frame
) {
    struct TiCloudStorageFrameInfo frame;
    memset(&frame, 0, sizeof(frame));
    frame.channel_id = channel_id;
    frame.media = TICLOUDSTORAGE_VIDEO_H264;
    frame.flags = is_key_frame
        ? TICLOUDSTORAGE_FRAME_FLAG_KEY_FRAME : 0;
    frame.timestamp_ms = utc_time_ms;
    frame.length = length;
    return TiCloudStorageQueueWriteFrame(service_id, &frame, data);
}

两路视频分别从关键帧开始写入。同一 Channel、同一媒体类型的时间戳必须单调不减;不同 Channel 可以按实际采集顺序交错调用 TiCloudStorageQueueWriteFrame()。后续创建的一个录像请求会按时间范围处理队列中的这些媒体帧,不需要为每个 Channel 分别创建上传请求。

5. 创建录像请求 ​

上传请求只选择时间范围,不拥有媒体帧。请求之间可以重叠;同一段底层媒体只上传一次。

事件录像 ​

事件发生时,用“事件时刻减去预录时长”作为开始时间。把 duration_ms 设为 0,可以继续写入事件后的媒体;事件结束后再按下一节设置结束边界。

c
static int start_event_upload(
    int service_id,
    uint64_t event_time_ms,
    uint64_t preroll_ms,
    struct UploadContext *ctx
) {
    struct TiCloudStorageUploadRequestOptions request =
        TICLOUDSTORAGE_UPLOAD_REQUEST_OPTIONS_INITIALIZER;
    request.start_time_ms = event_time_ms >= preroll_ms
        ? event_time_ms - preroll_ms : TICLOUDSTORAGE_TIME_EARLIEST;
    request.duration_ms = 0;
    request.on_progress = on_upload_progress;
    request.on_result = on_upload_result;
    request.user_data = ctx;
    return TiCloudStorageUploadRequest(service_id, &request);
}

预录范围受最近帧队列容量和第一个合法视频关键帧限制。请求的实际起点可能晚于 start_time_ms;不要在事件触发后才开始送帧。

连续录像 ​

连续录像可以创建暂不指定结束时间的上传请求。将 duration_ms 设为 0,等录像计划结束或应用准备滚动到下一请求时,再通过 TiCloudStorageUploadSetEnd 设置结束时间。开始时间可以指向队列中的一段预录,也可以使用 TICLOUDSTORAGE_TIME_EARLIEST 从当前可用的最早关键帧开始。

c
struct UploadContext upload_context = {0};
struct TiCloudStorageUploadRequestOptions request =
    TICLOUDSTORAGE_UPLOAD_REQUEST_OPTIONS_INITIALIZER;
request.start_time_ms = TICLOUDSTORAGE_TIME_EARLIEST;
request.duration_ms = 0;
request.on_progress = on_upload_progress;
request.on_result = on_upload_result;
request.user_data = &upload_context;

int upload_id = TiCloudStorageUploadRequest(service_id, &request);
if (upload_id < 0) {
    fprintf(stderr, "request upload failed: %s\n",
            TiCloudStorageGetErrorString(upload_id));
    return upload_id;
}

正数 upload_id 只表示请求已受理,不表示已经找到起始关键帧或上传成功。应用必须等待这个请求的 on_result。upload_context 保持有效到所属 Service 停止成功,期间不要释放或用于另一个请求。

回调与业务线程之间必须使用互斥量、条件变量或 C11 原子操作同步。上例通过 atomic_load_explicit(&upload_context.completed, memory_order_acquire) 判断回调是否完成。不要用普通变量或 volatile 变量跨线程传递完成状态。

6. 设置录像结束时间并等待结果 ​

录像事件结束时,先把事件结束前的最后一帧写入队列,再设置录像结束时间。end_time_ms 是 UTC 毫秒时间戳,也是录像范围的右开边界:

c
int rc = TiCloudStorageUploadSetEnd(
    service_id, upload_id, event_end_time_ms);
if (rc != TICLOUDSTORAGE_OK) {
    fprintf(stderr, "set end failed: %s\n",
            TiCloudStorageGetErrorString(rc));
    // 交由业务线程修正参数并重试;需要放弃时,
    // 先停止所有送帧线程,再按下方停止流程逐步检查并释放资源。
    return rc;
}

只有设置成功后才能等待最终结果。设置失败不会改变请求边界:先修正 end_time_ms 并重试;如果无法恢复,业务线程先停止所有送帧线程,再按停止并释放资源逐步检查返回值、停止并销毁 Service 来放弃请求;被中止的请求不会补发 on_result,不能继续等待它。设置成功只表示结束边界被接受,仍要保持 Service、请求上下文和回调有效,直到取得最终结果;请求上下文继续保留到 Service 停止成功。

on_result 的结果分为三种:

  • TICLOUDSTORAGE_UPLOAD_COMPLETE:所选范围的媒体数据已按逻辑时间槽完成上传,未记录缺口;
  • TICLOUDSTORAGE_UPLOAD_PARTIAL:部分媒体范围上传成功,但请求范围内存在缺口,可能缺失开头或结尾;
  • TICLOUDSTORAGE_UPLOAD_FAILED:没有媒体范围上传成功,结合 error 处理。

通过 result 判断完整、部分成功或失败,并结合请求范围和成功范围处理缺失片段。时间范围和累计时长的统计口径见上传结果。

收到 COMPLETE 或业务明确接受的 PARTIAL 后,由客户端查询并验证录像。如果业务要求录像已经可查询,应保持 Service 运行,等待客户端或业务服务确认查询结果后再停止;查询暂时为空时重试,并结合 SDK 中索引上报的错误日志排查。

7. 检查上传队列 ​

c
struct TiCloudStorageQueueInfo info;
int rc = TiCloudStorageQueueGetInfo(service_id, &info);
if (rc == TICLOUDSTORAGE_OK) {
    printf("queue=%llu/%llu inflight=%llu frames=%llu\n",
           (unsigned long long)info.used_bytes,
           (unsigned long long)info.capacity_bytes,
           (unsigned long long)info.inflight_bytes,
           (unsigned long long)info.frame_count);
}

inflight_bytes 持续升高表示上传处理落后于写入速度,常见原因是网络不可用、Token 失效或云端重试。结合错误码和 SDK 日志处理,不要只扩大队列掩盖问题。

8. 停止并释放资源 ​

正常结束时,取得所需上传结果,并确认业务要求的录像查询状态后,先停止所有送帧线程,再释放资源。因错误决定放弃 Service 时,同样先停止送帧线程,但不再等待被放弃请求的最终结果。后续每一步成功后才进入下一步:

c
int rc = TiCloudStorageServiceStop(service_id);
if (rc == TICLOUDSTORAGE_OK) {
    rc = TiCloudStorageServiceDestroy(service_id);
}
if (rc == TICLOUDSTORAGE_OK) {
    rc = TiCloudStorageUninit();
}

TiCloudStorageServiceStop() 不会排空上传队列,也不保证索引已经上报。它会等待在途网络请求和正在执行的回调结束,不能立即打断网络 I/O;关机流程和看门狗应为此保留等待时间。不要在回调内执行耗时存储、同步网络请求或等待业务锁。停止成功后不再触发 Service 回调,此时可释放请求上下文;用于 Service 回调的上下文保留到销毁成功。停止后的 Service 不能重新启动。

确认录像已经上传 ​

完成接入后,应能从日志和回调确认:

  1. SDK 与 Service 初始化成功;
  2. 上传请求取得正数 upload_id;
  3. 第一个合法视频关键帧进入后产生上传进度;
  4. 设置结束边界后收到唯一 on_result;
  5. 客户端用对应设备的 app_access_token 查询到该时间段并能播放。

客户端查询到对应时间段并能正常播放后,即可确认录像可用。若能够查到录像,但播放或下载返回“录像不可读”,回到前面的格式、关键帧和音频帧要求,修正设备编码后重新上传。

若请求以 TICLOUDSTORAGE_E_UPLOAD_START_TIMEOUT 结束,检查是否持续写入合法视频关键帧。也要确认 max_key_frame_interval_ms 覆盖编码器真实关键帧间隔。

收到 TICLOUDSTORAGE_UPLOAD_COMPLETE,或确认业务可以接受 TICLOUDSTORAGE_UPLOAD_PARTIAL 后,客户端可以查询和播放这段录像,也可以直接下载为 MP4。

Ti 云存开发文档