下载录像
客户端 SDK 可以把指定时间段内的一路视频和可选的一路音频导出为本地 MP4。下载不需要创建 Replay 或 Output,也不需要先播放录像。
Go、Java、Python、Flutter、React Native、Android、iOS / macOS 和 HarmonyOS SDK 都支持下载录像。下面的代码组优先展示 Go,切换标签可以查看其他平台。
开始前
- 先选择客户端 SDK或 Go、Java 和 Python SDK,并按对应接入指南完成 SDK 引用和初始化;
- Go 和 Java SDK,以及采用 Access Key 模式的 Python SDK,准备 App ID、Access Key ID、Access Key Secret 和目标
device_id; - 其他平台和采用 External Token 模式的 Python SDK 从业务服务端取得目标设备的
app_access_token,见云端签发 Token; - 从设备上传配置或业务服务取得视频和音频使用的
channel_id,并确认要下载的 UTC 时间范围。
本文从 SDK 已经初始化成功开始。已经知道下载范围时,可以直接创建目标设备实例并开始下载;需要先确认哪些时间段有录像时,按下一节查询。
仅有应用凭据和 device_id 还不能开始下载。录像查询返回设备整体的录像范围,不返回其中包含的 Channel,也不能根据 device_id 推导 Channel。视频 Channel 是必填项;音频 Channel 可以省略。单通道设备可以把音视频都配置为 0,但 0 不是 SDK 默认值,只有设备上传时确实使用该编号才能传入。无法取得设备的媒体通道映射时,先让设备侧或业务服务补充这项配置。
创建目标设备实例
Flutter、React Native、Android、iOS / macOS 和 HarmonyOS 使用目标设备对应的 app_access_token 创建 TiCloudStorage 实例。Go 和 Java 使用托管 Access Key。Python 可以使用 External Token,也可以使用 Access Key;下面展示 Access Key 模式。
cloudStorage, err := storage.NewClient(storage.ClientOptions{
AppID: appID,
AccessKeyID: accessKeyID,
AccessKeySecret: accessKeySecret,
CacheDir: "/absolute/path/to/cache",
})
if err != nil {
return err
}ClientOptions options = ClientOptions.builder()
.appId(appId)
.accessKeyId(accessKeyId)
.accessKeySecret(accessKeySecret)
.cacheDir(Paths.get("/absolute/path/to/cache"))
.build();
CloudStorageClient cloudStorage = new CloudStorageClient(options);options = tirtc.ClientOptions(
app_id=app_id,
cache_dir=Path("/absolute/path/to/cache"),
)
cloudClient = storage.Client(
options,
access_key_id=access_key_id,
access_key_secret=access_key_secret,
)
cloudStorage = cloudClient.open_device(device_id)final cloudStorage = TiCloudStorage(token: token);const cloudStorage = new TiCloudStorage(token);val cloudStorage = TiCloudStorage(token)let cloudStorage = TiCloudStorage(token: token)const cloudStorage = new TiCloudStorage(token);使用 app_access_token 的平台在同一设备的 Token 续签后调用 updateToken;Python 对应 update_token()。Go、Java 和采用 Access Key 模式的 Python SDK 由 Runtime 签发和刷新设备 Token。已经失败的操作都需要重新发起。
选择下载时间段
listRecordings 返回查询范围内有录像的时间段。查询结果按开始时间排序,并裁剪到请求的左闭右开区间。成功但没有录像时,结果为空列表。
recordings, err := cloudStorage.ListRecordings(ctx, deviceID, queryStartTime, queryEndTime)
if err != nil {
return err
}
if len(recordings) == 0 {
return nil
}
selectedRecording := recordings[0]
exportStartTime := selectedRecording.StartTime
exportEndTime := selectedRecording.EndTimeList<RecordingRange> recordings = cloudStorage
.listRecordings(deviceId, queryStartTime, queryEndTime)
.get();
if (recordings.isEmpty()) return;
RecordingRange selectedRecording = recordings.get(0);
Instant exportStartTime = selectedRecording.startTime();
Instant exportEndTime = selectedRecording.endTime();recordings = cloudStorage.list_recordings(
query_start_time,
query_end_time,
timeout=30,
)
if not recordings:
return
selected_recording = recordings[0]
export_start_time = selected_recording.start_time
export_end_time = selected_recording.end_timefinal resp = await cloudStorage.listRecordings(
startTimeMs: queryStartTimeMs,
endTimeMs: queryEndTimeMs,
);
if (!resp.success || resp.data == null || resp.data!.isEmpty) return;
final selectedRecording = resp.data!.first;
final exportStartTimeMs = selectedRecording.startTimeMs;
final exportEndTimeMs = selectedRecording.endTimeMs;const result = await cloudStorage.listRecordings(
queryStartTimeMs,
queryEndTimeMs,
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];
const exportStartTimeMs = selectedRecording.startTimeMs;
const exportEndTimeMs = selectedRecording.endTimeMs;val result = cloudStorage.listRecordings(
queryStartTimeMs,
queryEndTimeMs,
)
if (result.code != TiCloudStorageErrorCode.OK) return
val selectedRecording = result.recordings.firstOrNull() ?: return
val exportStartTimeMs = selectedRecording.startTimeMs
val exportEndTimeMs = selectedRecording.endTimeMslet result = await cloudStorage.listRecordings(
startTimeMs: queryStartTimeMs,
endTimeMs: queryEndTimeMs
)
guard result.code == TiCloudStorageErrorCode.ok,
let selectedRecording = result.recordings.first else { return }
let exportStartTimeMs = selectedRecording.startTimeMs
let exportEndTimeMs = selectedRecording.endTimeMsconst result = await cloudStorage.listRecordings(
queryStartTimeMs,
queryEndTimeMs,
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];
const exportStartTimeMs = selectedRecording.startTimeMs;
const exportEndTimeMs = selectedRecording.endTimeMs;单次查询最长 10 天。返回结果表示设备整体有录像,不保证要下载的 Channel 在每个时间段内都有媒体。Channel ID 来自设备的媒体通道配置,不来自录像列表。
当前时间范围没有录像时,先按查询哪些日期有录像调用 listRecordingDays 定位历史日期,再把选中日期换算成 UTC 范围调用 listRecordings。日期查询单次最多覆盖 31 天;需要查找更早的录像时按月或不超过 31 天的窗口向前查询。
可以下载整个查询结果,也可以把起止时间缩小到其中一段。开始时间必须早于结束时间。
确认录像可以生成 MP4
视频可以使用 H.264、H.265 或 JPEG(MJPEG);JPEG 会转为 H.264。H.264 和 H.265 应使用不含 B 帧或其他重排序依赖的低延迟码流,否则下载任务返回“格式不支持”。
SDK 可以把 AAC 直接写入 MP4,并把云录像读取链路输出的 PCM、G.711 A-law 或 Opus 转为 AAC-LC。设备上传 AAC-LC 时,每帧必须包含完整 ADTS 头,并且 ADTS 头中的采样率、声道数和帧长度必须与实际音频一致。不确定音频是否兼容时,可以省略 audioChannelId,先下载只包含视频的 MP4。
一个 ExportTask 只生成一个 MP4,输出音视频轨道的格式保持稳定。SDK 遇到不兼容的编码或分辨率片段时,会把已确认的问题写入导出报告;如果后续重新取得与输出轨道兼容的独立可解码片段,可以越过缺口继续生成可播放文件。它不会把不同分辨率或编码格式转码、拼接成同一条输出轨道。业务必须取得完整录像时,仍应按格式稳定的时间范围分段创建任务,并检查最终报告的 complete、gaps 和 unprocessedRanges。
音视频帧还必须符合上传录像中的帧要求。
开始下载
使用目标设备实例创建 ExportTask。视频 Channel 必填,音频 Channel 可选。任务创建成功只表示下载已经开始,不表示 MP4 已经生成。
task, err := cloudStorage.ExportRecording(ctx, deviceID, storage.ExportOptions{
StartTime: exportStartTime,
EndTime: exportEndTime,
VideoChannelID: videoChannelID,
AudioChannelID: &audioChannelID,
OnProgress: func(progress storage.ExportProgress) {
updateExportProgress(progress.Fraction)
},
})
if err != nil {
return err
}
result, err := task.Wait()
if err != nil {
handleFailedExport(err)
return err
}
showSavedFile(result.File.Path)ExportTask task = cloudStorage.exportRecording(
deviceId,
new ExportOptions(
exportStartTime,
exportEndTime,
videoChannelId,
audioChannelId),
new ExportTask.Listener() {
@Override public void onProgress(ExportProgress progress) {
updateExportProgress(progress.fraction());
}
});
ExportResult result = task.completion().toCompletableFuture().get();
showSavedFile(result.file().path());task = cloudStorage.export_recording(
export_start_time,
export_end_time,
video_channel_id=video_channel_id,
audio_channel_id=audio_channel_id,
on_progress=lambda progress: update_export_progress(progress.fraction),
)
file = task.wait(timeout=900)
show_saved_file(file.path)final start = cloudStorage.exportRecording(
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId: videoChannelId,
audioChannelId: audioChannelId,
onProgress: (progress) => exportProgress = progress,
);
if (!start.success || start.data == null) return;
final task = start.data!;
final completed = await task.result;
if (!completed.success || completed.data == null) return;
final file = completed.data!;
showSavedFile(file.path);const start = cloudStorage.exportRecording({
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId,
audioChannelId,
}, (progress) => {
exportProgress = progress;
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.result;
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);val start = cloudStorage.exportRecording(
TiCloudStorageExportRequest(
startTimeMs = exportStartTimeMs,
endTimeMs = exportEndTimeMs,
videoChannelId = videoChannelId,
audioChannelId = audioChannelId,
),
TiCloudStorageExportProgressListener { exportProgress = it },
) { result ->
if (result.code == TiCloudStorageErrorCode.OK && result.file != null) {
showSavedFile(result.file.path)
}
}
if (start.code != TiCloudStorageErrorCode.OK || start.task == null) return
val task = start.tasklet start = cloudStorage.exportRecording(
TiCloudStorageExportRequest(
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId: videoChannelId,
audioChannelId: NSNumber(value: audioChannelId)
),
progress: { exportProgress = $0 },
completion: { result in
guard result.code == TiCloudStorageErrorCode.ok,
let file = result.file else { return }
showSavedFile(file.path)
}
)
guard start.code == TiCloudStorageErrorCode.ok, let task = start.task else { return }const start = cloudStorage.exportRecording({
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId,
audioChannelId,
}, (progress) => {
exportProgress = progress;
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.result;
if (!completed.success || completed.data === null) return;
showSavedFile(completed.data.path);进度值的范围是 0.0..1.0。进度回调不能替代最终结果;Go 等待 Wait,Java 等待 completion(),Python 等待 wait(),Flutter、React Native 和 HarmonyOS 等待 result,Android 和 iOS / macOS 等待创建任务时传入的 callback 或 completion。对应结果成功返回后才能使用文件。
如果业务只需要一份可播放 MP4,按最终结果是否成功以及文件是否存在处理即可。只有业务要求录像完整覆盖指定时间范围、需要展示缺口或统计实际覆盖时长时,才需要读取详细进度、录像缺口和最终报告中的 complete、gaps、unprocessedRanges;进度达到 1.0 只表示请求范围扫描完成。入口和字段见当前平台的 API 说明。
保存并清理 MP4
成功结果中的路径指向 SDK 私有 cache。需要长期保存时,先把 MP4 移到应用自己的目录或系统媒体库;直接读取、上传或分享完成后,调用文件对象的 delete() 清理临时文件。不能把 cache 路径当作持久地址。
if err := copyToApplicationDirectory(result.File.Path); err != nil {
return err
}
if err := result.File.Delete(); err != nil {
return err
}try (RecordingFile file = result.file()) {
Files.copy(file.path(), destination);
}with file:
shutil.copyfile(file.path, destination)final saved = await file.moveToGallery(
fileName: '客厅摄像头-2026-08-20-14-30-05.mp4',
);
if (!saved.success || saved.data == null) return;
// 使用 saved.data!.uri 展示或持久化系统媒体资产。const saved = await file.moveToGallery(
'客厅摄像头-2026-08-20-14-30-05.mp4',
);
if (!saved.success || saved.data === null) return;
// 使用 saved.data.uri 展示或持久化系统媒体资产。Android、iOS / macOS 与 HarmonyOS 由应用使用平台文件 API 复制或移动普通文件,再调用文件对象的 delete()。成功文件对象在 Ti 云存服务关闭后仍然可以读取和删除,但 cache 路径仍不是持久地址。Flutter 和 React Native 的 moveToGallery() 成功后会删除 cache 源文件。
Flutter 和 React Native 的文件名可以省略 .mp4,SDK 会自动补全。传入扩展名时只接受 .mp4;非法名称返回 6000,并保留 cache 源文件。省略文件名时,SDK 使用 Unix 毫秒时间戳生成名称。
下载多路录像
一个 ExportTask 只选择一路视频。要得到“视频 A + 音频”和“视频 B + 音频”两个文件,分别创建两个 ExportTask,并为两个任务传入同一个音频 Channel。SDK 不会把多路视频合成画中画,也不会生成包含多条视频轨的单个 MP4。
不同任务相互独立,可以分别等待、取消和处理结果。
需要取消下载时
只在应用提供取消操作时需要处理本节。Go 使用 Cancel() 发出非阻塞取消请求,再通过 Wait() 取得终局;传给 ExportRecording 的 Context 也可以请求取消。Java 使用 cancel() 后等待 completion(),Python 使用 cancel() 后等待 wait()。Flutter 和 React Native 使用 cancel() 后继续等待 result 或 completion;Android、iOS / macOS 和 HarmonyOS 分别等待 callback、completion 或 task.completion。取消请求与自然完成可能竞争,以最终结果为准。
stop() 与 cancel() 的语义不同:对仍在运行的 ExportTask 调用 stop() 会停止任务并清理未完成文件,不会把当前内容封装成成功文件。Flutter 和 React Native 的 stop() 返回并缓存基本结果;Android 和 iOS / macOS 的 stop() 只同步返回停止请求是否受理,最终结果仍由 callback 或 completion 返回;HarmonyOS 等待 stop() 返回,但仍应以 task.completion 判断任务终局。
处理下载失败
- 使用
app_access_token的平台在 Token 过期时取得同一设备的新 Token,调用updateToken或 Python 的update_token()后重新创建 ExportTask;Go、Java 和采用 Access Key 模式的 Python SDK 检查托管凭据、设备归属和系统时间; - 权限不足时停止重试,确认当前用户、
device_id和 Token 的授权关系; - 返回
6119(没有可导出的媒体)时,先确认范围来自最新查询结果,再核对设备上传与下载请求使用的 Channel ID;重复查询设备级录像范围不能代替核对 Channel; - 返回“格式不支持”时,核对编码格式、关键帧和分辨率变化,并按格式稳定的时间范围拆分任务;
- 无法写入文件时,检查 cache 目录权限、磁盘空间和本地存储状态。
持续失败时,记录平台、SDK 版本、目标 device_id、时间范围、Channel ID 和公开错误码,不要记录 Token。日志取得方式见问题排查。
释放资源
无论 ExportTask 自然完成、被取消还是被停止,都要等任务最终结果返回后再释放 Client 或目标设备实例:Go 等待 Wait,Java 等待 completion(),Python 等待 wait(),Flutter 和 React Native 等待 result、completion 或 stop(),HarmonyOS 等待 task.completion,Android 和 iOS / macOS 等待创建任务时传入的 callback 或 completion。不再使用 Ti 云存时,最后关闭对应平台的 Ti 云存服务。
if err := cloudStorage.Close(); err != nil {
return err
}task.close();
cloudStorage.close();cloudStorage.close()
cloudClient.close()cloudStorage.dispose();
TiCloudStorage.shutdown();cloudStorage.dispose();
TiCloudStorage.shutdown();cloudStorage.dispose()
TiCloudStorage.shutdown()cloudStorage.dispose()
TiCloudStorage.shutdown()cloudStorage.dispose();
TiCloudStorage.shutdown();释放失败时,对象仍然有效。确认没有查询、ExportTask 或回调仍在使用该实例,再次释放即可。