Skip to content

下载录像 ​

客户端 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 模式。

go
cloudStorage, err := storage.NewClient(storage.ClientOptions{
	AppID:           appID,
	AccessKeyID:     accessKeyID,
	AccessKeySecret: accessKeySecret,
	CacheDir:        "/absolute/path/to/cache",
})
if err != nil {
	return err
}
java
ClientOptions options = ClientOptions.builder()
    .appId(appId)
    .accessKeyId(accessKeyId)
    .accessKeySecret(accessKeySecret)
    .cacheDir(Paths.get("/absolute/path/to/cache"))
    .build();
CloudStorageClient cloudStorage = new CloudStorageClient(options);
python
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)
dart
final cloudStorage = TiCloudStorage(token: token);
typescript
const cloudStorage = new TiCloudStorage(token);
kotlin
val cloudStorage = TiCloudStorage(token)
swift
let cloudStorage = TiCloudStorage(token: token)
typescript
const cloudStorage = new TiCloudStorage(token);

使用 app_access_token 的平台在同一设备的 Token 续签后调用 updateToken;Python 对应 update_token()。Go、Java 和采用 Access Key 模式的 Python SDK 由 Runtime 签发和刷新设备 Token。已经失败的操作都需要重新发起。

选择下载时间段 ​

listRecordings 返回查询范围内有录像的时间段。查询结果按开始时间排序,并裁剪到请求的左闭右开区间。成功但没有录像时,结果为空列表。

go
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.EndTime
java
List<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();
python
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_time
dart
final 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;
typescript
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;
kotlin
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.endTimeMs
swift
let 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.endTimeMs
typescript
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;

单次查询最长 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 已经生成。

go
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)
java
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());
python
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)
dart
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);
typescript
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);
kotlin
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.task
swift
let 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 }
typescript
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 路径当作持久地址。

go
if err := copyToApplicationDirectory(result.File.Path); err != nil {
	return err
}
if err := result.File.Delete(); err != nil {
	return err
}
java
try (RecordingFile file = result.file()) {
    Files.copy(file.path(), destination);
}
python
with file:
    shutil.copyfile(file.path, destination)
dart
final saved = await file.moveToGallery(
  fileName: '客厅摄像头-2026-08-20-14-30-05.mp4',
);
if (!saved.success || saved.data == null) return;

// 使用 saved.data!.uri 展示或持久化系统媒体资产。
typescript
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 云存服务。

go
if err := cloudStorage.Close(); err != nil {
	return err
}
java
task.close();
cloudStorage.close();
python
cloudStorage.close()
cloudClient.close()
dart
cloudStorage.dispose();
TiCloudStorage.shutdown();
typescript
cloudStorage.dispose();
TiCloudStorage.shutdown();
kotlin
cloudStorage.dispose()
TiCloudStorage.shutdown()
swift
cloudStorage.dispose()
TiCloudStorage.shutdown()
typescript
cloudStorage.dispose();
TiCloudStorage.shutdown();

释放失败时,对象仍然有效。确认没有查询、ExportTask 或回调仍在使用该实例,再次释放即可。

Ti 云存开发文档