上传录像
设备取得身份和短期 device_access_token 后,通过 C SDK 创建上传请求并写入已经编码的音视频帧。请求正常结束时,on_result 回调返回唯一的最终结果。
音视频格式与帧要求
C SDK 接收已经编码的完整帧。写入前先确认格式是客户端能够播放的。
选择客户端能够播放的格式
| 类型 | 支持的格式 | C SDK 媒体常量 |
|---|---|---|
| 视频 | H.264 | TISTORE_VIDEO_H264 |
| 视频 | H.265 | TISTORE_VIDEO_H265 |
| 视频 | JPEG 帧(作为 MJPEG 视频播放) | TISTORE_VIDEO_JPEG |
| 音频 | 16 位 PCM | TISTORE_AUDIO_PCM |
| 音频 | G.711 A-law | TISTORE_AUDIO_ALAW |
| 音频 | AAC | TISTORE_AUDIO_AAC |
| 音频 | Opus | TISTORE_AUDIO_OPUS |
TISTORE_AUDIO_ULAW 和 TISTORE_AUDIO_G726 虽然是 C SDK 已定义的媒体编号,但客户端 SDK 无法播放这两种音频。TiStoreQueueWriteFrame() 返回成功只表示队列接收了媒体编号和帧数据,不表示客户端能够解码。
组织视频帧
- 每次调用
TiStoreQueueWriteFrame()写入一帧完整的编码数据,不要把一帧拆成多次调用。 - H.264 和 H.265 使用带起始码的 Annex B 码流。H.264 关键帧应包含 SPS、PPS 和 IDR;H.265 关键帧应包含 VPS、SPS、PPS 和可独立解码的帧。
- H.264 和 H.265 的关键帧必须携带完整参数集,并把
flags设为TISTORE_FRAME_FLAG_KEY_FRAME。C SDK 会从关键帧开始上传,并自动完成后续分片;设备应用不需要管理分片边界。 - JPEG 写入完整的 JPEG/JFIF 图片,从
FF D8开始、以FF D9结束;每帧都设置TISTORE_FRAME_FLAG_KEY_FRAME。 - 同一通道不要在录像过程中切换视频编码格式。
组织音频帧
| 格式 | 每次写入的数据 |
|---|---|
| PCM | S16LE(16 位小端有符号整数)交错采样数据,不包含 WAV 文件头 |
| G.711 A-law | 一包原始 A-law 数据,不包含 WAV 文件头 |
| AAC | 一个不带封装头的 AAC 访问单元(Raw Access Unit),不包含 ADTS 头 |
| Opus | 一包 Opus 编码数据,不包含 Ogg 封装 |
音频 flags 必须与数据实际采用的采样率和声道数一致。客户端 SDK 支持 8 kHz 或 16 kHz、16 位、单声道或双声道。
上传流程
上传请求是异步任务。TiStoreUploadRequest() 返回正数只表示请求已经受理。每当一段媒体上传成功或确认失败时,on_progress 返回这段范围的结果;一个请求可能收到多次。每个请求只有一次 on_result,它才是正常结束时的最终结果回调。TiStoreServiceStop() 会中止活动请求,不补发 on_result。当 duration_ms 为 0 时,请求会一直保持开启;必须成功调用 TiStoreUploadSetEnd() 设置结束时间,它才会正常结束。
准备设备身份和 Token
- 与设备绑定的
device_id和device_secret_key; - 业务服务端签发的短期
device_access_token; - 与目标系统、CPU 架构和工具链匹配的 C SDK;
- 编码器输出的音视频帧,以及可靠的 UTC 毫秒时间戳。
设备应用先用产品已有的设备鉴权方式请求业务服务端,再取得 device_access_token。签发方式见云端签发 Token,设备侧的取得方式见 C SDK 接入。AccessKeySecret 只保存在业务服务端,不能写入设备固件。
1. 初始化 SDK
#include <stdint.h>
#include <stdio.h>
#include <stdatomic.h>
#include <string.h>
#include "tistore.h"
static void on_log(int level, const char *message) {
fprintf(stderr, "[tistore][%d] %s\n", level, message);
}
static int init_tistore(void) {
struct TiStoreOptions options = TISTORE_OPTIONS_INITIALIZER;
options.log_level = TISTORE_LOG_INFO;
options.on_log = on_log;
int rc = TiStoreInit(&options);
if (rc != TISTORE_OK) {
fprintf(stderr, "TiStoreInit failed: %s\n",
TiStoreGetErrorString(rc));
}
return rc;
}TiStoreInit() 是进程级初始化,只调用一次。上线前记录 TiStoreGetVersion() 和 TiStoreGetBuildInfo(),便于定位二进制和头文件是否匹配。
2. 启动设备上传服务
设备每启动一条独立的媒体时间线,就创建一个 Service。创建 Service 后设置短期 Token,再调用 TiStoreServiceStart() 启动上传服务。回调由 SDK 分发线程执行,必须快速返回;不要在回调中调用 Stop、Destroy 或 Uninit。
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 后,
// 由业务线程调用 TiStoreServiceUpdateToken(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 TiStoreServiceOptions options =
TISTORE_SERVICE_OPTIONS_INITIALIZER;
options.device_secret_key = device_secret_key;
options.buffer_size_bytes = 8 * 1024 * 1024;
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 = TiStoreServiceCreate(device_id, &options);
if (service_id < 0) {
fprintf(stderr, "create service failed: %s\n",
TiStoreGetErrorString(service_id));
return service_id;
}
int rc = TiStoreServiceUpdateToken(
service_id, device_access_token);
if (rc != TISTORE_OK) {
TiStoreServiceDestroy(service_id);
return rc;
}
rc = TiStoreServiceStart(service_id);
if (rc != TISTORE_OK) {
TiStoreServiceDestroy(service_id);
return rc;
}
return service_id;
}SDK 会复制配置字符串和回调函数指针,但不会管理 user_data。上例中的 ctx 必须保持有效,直到 Service 销毁完成。
3. 准备上传回调
先准备进度回调和最终结果回调。设备写帧与上传回调并发执行;回调只记录结果或投递事件,不要在回调线程中停止 Service。
static void on_upload_progress(
int service_id,
const struct TiStoreUploadRange *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 TiStoreUploadResult *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;同一 Channel、同一媒体类型的时间戳必须单调不减。
Service 启动后应立即启动送帧线程,并在没有上传请求时也持续写入。最近帧队列只有先收到事件前的媒体帧,事件请求才可能回溯到预录范围。下面的写帧函数在送帧线程中持续调用,与后续的请求创建和回调并发执行。
H.264 视频帧:
static int write_h264(
int service_id,
const uint8_t *data,
uint32_t length,
uint64_t utc_time_ms,
int is_key_frame
) {
struct TiStoreFrameInfo frame;
memset(&frame, 0, sizeof(frame));
frame.channel_id = 0;
frame.media = TISTORE_VIDEO_H264;
frame.flags = is_key_frame
? TISTORE_FRAME_FLAG_KEY_FRAME : 0;
frame.timestamp_ms = utc_time_ms;
frame.length = length;
return TiStoreQueueWriteFrame(service_id, &frame, data);
}AAC 音频帧:
static int write_aac(
int service_id,
const uint8_t *data,
uint32_t length,
uint64_t utc_time_ms
) {
struct TiStoreFrameInfo frame;
memset(&frame, 0, sizeof(frame));
frame.channel_id = 0;
frame.media = TISTORE_AUDIO_AAC;
frame.flags = TISTORE_AUDIO_SAMPLE_16K16B1C;
frame.timestamp_ms = utc_time_ms;
frame.length = length;
return TiStoreQueueWriteFrame(service_id, &frame, data);
}SDK 在函数返回前复制 FrameInfo 和帧数据,因此调用返回后可以复用编码器缓冲区。返回 TISTORE_OK 只表示帧进入环形队列,不表示已经上传。
写帧失败时按具体错误码处理:
TISTORE_E_TIMESTAMP_OUT_OF_ORDER:修正同一 Channel、同一媒体类型的时间戳;TISTORE_E_QUEUE_FULL:检查网络、鉴权和队列压力,不要无界重试;TISTORE_E_UNSUPPORTED_MEDIA:media不在音频或视频的预留编号范围内,改用TISTORE_AUDIO_*或TISTORE_VIDEO_*常量;TISTORE_E_SERVICE_INVALID_STATE:确认 Service 已启动且尚未停止。
5. 创建录像请求
上传请求只选择时间范围,不拥有媒体帧。请求之间可以重叠;同一段底层媒体只上传一次。
事件录像
事件发生时,用“事件时刻减去预录时长”作为开始时间。把 duration_ms 设为 0,可以继续写入事件后的媒体;事件结束后再按下一节设置结束边界。
static int start_event_upload(
int service_id,
uint64_t event_time_ms,
uint64_t preroll_ms,
struct UploadContext *ctx
) {
struct TiStoreUploadRequestOptions request =
TISTORE_UPLOAD_REQUEST_OPTIONS_INITIALIZER;
request.start_time_ms = event_time_ms >= preroll_ms
? event_time_ms - preroll_ms : TISTORE_TIME_EARLIEST;
request.duration_ms = 0;
request.on_progress = on_upload_progress;
request.on_result = on_upload_result;
request.user_data = ctx;
return TiStoreUploadRequest(service_id, &request);
}预录范围受最近帧队列容量和第一个合法视频关键帧限制。请求的实际起点可能晚于 start_time_ms;不要在事件触发后才开始送帧。
连续录像
连续录像同样使用开放请求。开始时间可以指向队列中的一段预录,也可以使用 TISTORE_TIME_EARLIEST 从当前可用的最早关键帧开始。录像计划结束或应用准备滚动到下一请求时,再设置结束边界。
struct UploadContext upload_context = {0};
struct TiStoreUploadRequestOptions request =
TISTORE_UPLOAD_REQUEST_OPTIONS_INITIALIZER;
request.start_time_ms = TISTORE_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 = TiStoreUploadRequest(service_id, &request);
if (upload_id < 0) {
fprintf(stderr, "request upload failed: %s\n",
TiStoreGetErrorString(upload_id));
return upload_id;
}正数 upload_id 只表示请求已受理,不表示已经找到起始关键帧或上传成功。应用必须等待这个请求的 on_result。
回调与业务线程之间必须使用互斥量、条件变量或 C11 原子操作同步。上例通过 atomic_load_explicit(&upload_context.completed, memory_order_acquire) 判断回调是否完成。不要用普通变量或 volatile 变量跨线程传递完成状态。
6. 设置录像结束时间并等待结果
录像事件结束时,先把事件结束前的最后一帧写入队列,再设置录像结束时间。end_time_ms 是 UTC 毫秒时间戳,也是录像范围的右开边界:
int rc = TiStoreUploadSetEnd(
service_id, upload_id, event_end_time_ms);
if (rc != TISTORE_OK) {
fprintf(stderr, "set end failed: %s\n",
TiStoreGetErrorString(rc));
// 请求尚未取得结束时间,不能等待 on_result。
// 修正 end_time_ms 后可以重试;这里选择放弃整个 Service。
TiStoreServiceStop(service_id);
TiStoreServiceDestroy(service_id);
TiStoreUninit();
return rc;
}只有设置成功后才能等待最终结果。设置失败不会改变请求边界:先修正 end_time_ms 并重试;如果无法恢复,必须在业务线程停止并销毁 Service 来放弃请求,不能继续等待 on_result。设置成功只表示结束边界被接受,仍要保持 Service、请求上下文和回调有效,直到 on_result 恰好返回一次。
on_result 的结果分为三种:
TISTORE_UPLOAD_COMPLETE:所选媒体范围连续、完整地上传;TISTORE_UPLOAD_PARTIAL:至少一段媒体范围上传成功,但成功范围存在缺口;TISTORE_UPLOAD_FAILED:没有媒体范围上传成功,结合error处理。
只有收到 COMPLETE 或业务明确接受的 PARTIAL 后,再由客户端查询并验证录像。写帧成功、进度回调成功或结束边界设置成功,都不能单独作为上传完成信号。
7. 检查上传队列
struct TiStoreQueueInfo info;
int rc = TiStoreQueueGetInfo(service_id, &info);
if (rc == TISTORE_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. 停止并释放资源
先停止所有送帧线程,并确保需要保留结果的请求都已经收到 on_result,再释放资源:
int rc = TiStoreServiceStop(service_id);
if (rc == TISTORE_OK) {
rc = TiStoreServiceDestroy(service_id);
}
if (rc == TISTORE_OK) {
rc = TiStoreUninit();
}TiStoreServiceStop() 会立即中止活动任务并丢弃尚未分发的回调。它不是等待上传完成的接口。
确认录像已经上传
完成接入后,应能从日志和回调确认:
- SDK 与 Service 初始化成功;
- 上传请求取得正数
upload_id; - 第一个合法视频关键帧进入后产生上传进度;
- 设置结束边界后收到唯一
on_result; - 客户端用对应设备的
app_access_token查询到该时间段并能播放。
COMPLETE 和 PARTIAL 说明云端已经保存相应时间范围,不代表音视频数据已经通过客户端解码验证。若客户端能够查到录像,但播放或下载返回“录像不可读”,回到前面的格式、关键帧和音频帧要求,修正设备编码后重新上传。
若请求以 TISTORE_E_UPLOAD_START_TIMEOUT 结束,检查是否持续写入合法视频关键帧。也要确认 max_key_frame_interval_ms 覆盖编码器真实关键帧间隔。
收到 TISTORE_UPLOAD_COMPLETE,或确认业务可以接受 TISTORE_UPLOAD_PARTIAL 后,客户端可以查询、播放或下载这段录像。