查询与播放录像
先查询目标设备的录像,再播放或截取需要的内容。需要把指定时间段直接保存为 MP4 时,使用下载录像,不需要创建 Replay。
需要自行转码、分析或转发媒体时,创建 Replay 并通过 Output 接收解码帧或编码帧。
确认录像格式可以播放
录像的音视频格式由设备上传的编码帧决定。客户端 SDK 只播放这些已经上传的格式。
| 类型 | 客户端 SDK 支持的格式 |
|---|---|
| 视频 | H.264、H.265、JPEG(MJPEG) |
| 音频 | G.711 A-law、AAC-LC(每帧一个完整 ADTS 帧)、16 位 PCM、Opus |
音视频帧还必须符合上传录像中的帧要求。播放返回格式不支持或录像不可读时,应核对设备使用的编码格式和送帧方式,修正后重新上传录像。
开始前
- 先选择客户端 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。
本文从 SDK 已经初始化成功开始,不重复各平台的依赖配置和初始化代码。
创建用于访问目标设备的 TiCloudStorage 实例
Flutter、React Native、Android、iOS / macOS 和 HarmonyOS 使用目标设备对应的 app_access_token 创建 TiCloudStorage 实例。Go 和 Java 使用托管 Access Key。Python 可以使用 External Token,也可以使用 Access Key;下面展示 Access Key 模式。
final cloudStorage = TiCloudStorage(token: token);val cloudStorage = TiCloudStorage(token)let cloudStorage = TiCloudStorage(token: token)const cloudStorage = new TiCloudStorage(token);const cloudStorage = new TiCloudStorage(token);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)使用 app_access_token 的平台在同一设备的 Token 续签后调用 updateToken;Python 对应 update_token()。Go、Java 和采用 Access Key 模式的 Python SDK 由 Runtime 签发和刷新设备 Token。已经失败的操作都需要重新发起。
查询哪些日期有录像
月历或日期列表不需要逐日查询录像时间段。使用 listRecordingDays 一次取得包含式日期范围内的完整逐日结果,再保留 hasRecording 为 true 的日期。
final resp = await cloudStorage.listRecordingDays(
startDate: '2026-08-01',
endDate: '2026-08-31',
timeZoneId: 'Asia/Shanghai',
);
if (!resp.success || resp.data == null) return;
final availableDays = resp.data!
.where((day) => day.hasRecording)
.map((day) => day.date)
.toList();lifecycleScope.launch {
val result = cloudStorage.listRecordingDays(
startDate = "2026-08-01",
endDate = "2026-08-31",
timeZoneId = "Asia/Shanghai",
)
if (result.code != TiCloudStorageErrorCode.OK) return@launch
val availableDays = result.days
.filter { it.hasRecording }
.map { it.date }
}let result = await cloudStorage.listRecordingDays(
startDate: "2026-08-01",
endDate: "2026-08-31",
timeZoneId: "Asia/Shanghai"
)
guard result.code == TiCloudStorageErrorCode.ok else { return }
let availableDays = result.days
.filter(\.hasRecording)
.map(\.date)const result = await cloudStorage.listRecordingDays(
'2026-08-01',
'2026-08-31',
'Asia/Shanghai',
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK) return;
const availableDays = result.days
.filter((day) => day.hasRecording)
.map((day) => day.date);const result = await cloudStorage.listRecordingDays(
'2026-08-01',
'2026-08-31',
'Asia/Shanghai',
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK) return;
const availableDays = result.days
.filter((day) => day.hasRecording)
.map((day) => day.date);days, err := cloudStorage.ListRecordingDaysInTimeZone(
ctx,
deviceID,
"2026-08-01",
"2026-08-31",
"Asia/Shanghai",
)
if err != nil {
return err
}
availableDays := make([]string, 0, len(days))
for _, day := range days {
if day.HasRecording {
availableDays = append(availableDays, day.Date)
}
}List<RecordingDay> days = cloudStorage.listRecordingDays(
deviceId,
LocalDate.parse("2026-08-01"),
LocalDate.parse("2026-08-31"),
ZoneId.of("Asia/Shanghai")
).get();
List<LocalDate> availableDays = days.stream()
.filter(RecordingDay::hasRecording)
.map(RecordingDay::date)
.collect(Collectors.toList());from datetime import date
from zoneinfo import ZoneInfo
days = cloudStorage.list_recording_days(
date(2026, 8, 1),
date(2026, 8, 31),
timezone=ZoneInfo("Asia/Shanghai"),
timeout=30,
)
available_days = [day.date for day in days if day.has_recording]startDate 和 endDate 都使用严格的 YYYY-MM-DD 格式,并且均包含在结果中。timeZoneId 使用 IANA 时区 ID;省略时默认为 Asia/Shanghai。单次最多查询 31 天,成功结果按日期升序返回范围内每一天,包括 hasRecording == false 的日期。
选择某一天后,按下面的边界计算 listRecordings 参数:
- 在查询使用的 IANA 时区中,把所选日期当天
00:00作为开始边界。 - 在同一时区中,把下一个自然日的
00:00作为结束边界。 - 分别把两个边界转换为 UTC Unix 毫秒,得到左闭右开区间
[startTimeMs, endTimeMs)。
不要用 startTimeMs + 86400000 推导结束边界。采用夏令时的地区可能存在 23 或 25 小时的自然日。
查询可回放的时间段
listRecordings 返回设备在查询范围内有录像的时间段。没有录像时,结果为空列表。
final resp = await cloudStorage.listRecordings(
startTimeMs: startTimeMs,
endTimeMs: endTimeMs,
);
if (!resp.success || resp.data == null) return;
final recordings = resp.data!;
if (recordings.isEmpty) return;
final selectedRecording = recordings.first;
// 使用 selectedRecording 打开播放页。lifecycleScope.launch {
val result = cloudStorage.listRecordings(
startTimeMs,
endTimeMs,
)
if (result.code != TiCloudStorageErrorCode.OK) return@launch
val selectedRecording = result.recordings.firstOrNull() ?: return@launch
// 使用 selectedRecording 打开播放页。
}let result = await cloudStorage.listRecordings(
startTimeMs: startTimeMs,
endTimeMs: endTimeMs
)
guard result.code == TiCloudStorageErrorCode.ok,
let selectedRecording = result.recordings.first else { return }
// 使用 selectedRecording 打开播放页。const result = await cloudStorage.listRecordings(
startTimeMs,
endTimeMs,
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];const result = await cloudStorage.listRecordings(
startTimeMs,
endTimeMs,
);
if (result.code !== TI_CLOUD_STORAGE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];recordings, err := cloudStorage.ListRecordings(ctx, deviceID, startTime, endTime)
if err != nil {
return err
}
if len(recordings) == 0 {
return nil
}
selectedRecording := recordings[0]List<RecordingRange> recordings = cloudStorage
.listRecordings(deviceId, startTime, endTime)
.get();
if (recordings.isEmpty()) return;
RecordingRange selectedRecording = recordings.get(0);recordings = cloudStorage.list_recordings(
start_time,
end_time,
timeout=30,
)
if not recordings:
return
selected_recording = recordings[0]查询结果按开始时间排序,并裁剪到请求范围。单次查询最长 10 天。返回的是设备整体有录像的时间段,不保证每个 Channel 都有媒体。播放使用的 Channel ID 由设备配置或业务服务提供。
使用 app_access_token 的平台在 Token 过期后,从业务服务端取得同一设备的新 Token,调用 updateToken 或 Python 的 update_token() 后重新查询。Go、Java 和采用 Access Key 模式的 Python SDK 由 Runtime 签发和刷新设备 Token;鉴权失败时检查应用凭据、设备归属和系统时间。权限不足时停止重试。查询跨度超过 10 天时,缩短时间范围后再查。
开始播放
创建录像播放对象(Replay)和需要的音视频输出对象(Output),绑定 Channel 后调用 play。initialTimeMs 是播放页选定的起播时间;没有指定时省略它,Replay 会从录像范围开头播放。
Flutter、Android、iOS / macOS、HarmonyOS 和 React Native 可以通过 Video Output 提供的 View 显示画面。Go、Java 和 Python SDK 通过 Output 回调或 listener 交付音视频帧,由应用接入自己的渲染、播放、分析或转发链路;Replay、Channel 绑定和播放控制的时序不变。
final replay = cloudStorage.createReplay();
final videoOutput = TiCloudStorageVideoOutput();
final audioOutput = TiCloudStorageAudioOutput();
replay.onError = (code) { /* 处理来源错误 */ };
replay.onTimeChanged = updatePlaybackProgress;
replay.onCompleted = handleReplaySourceCompleted;
videoOutput.onStateChanged = handleVideoState;
audioOutput.onStateChanged = handleAudioState;
videoOutput.onError = (code) { /* 处理视频 Output 错误 */ };
audioOutput.onError = (code) { /* 处理音频 Output 错误 */ };
videoOutput.attach(replay: replay, channelId: videoChannelId);
audioOutput.attach(replay: replay, channelId: audioChannelId);
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: initialTimeMs,
);
final video = AspectRatio(child: videoOutput.view());val replay = cloudStorage.createReplay()
val videoOutput = TiCloudStorageVideoOutput()
val audioOutput = TiCloudStorageAudioOutput()
replay.onError = TiCloudStorageReplayErrorListener { /* 处理来源错误 */ }
replay.onTimeChanged = TiCloudStorageTimeChangedListener { timeMs -> updatePlaybackProgress(timeMs) }
replay.onCompleted = TiCloudStorageReplayCompletedListener { handleReplaySourceCompleted() }
videoOutput.onStateChanged = TiCloudStorageVideoOutputStateListener { state -> handleVideoState(state) }
audioOutput.onStateChanged = TiCloudStorageAudioOutputStateListener { state -> handleAudioState(state) }
videoOutput.onError = TiCloudStorageOutputErrorListener { /* 处理视频 Output 错误 */ }
audioOutput.onError = TiCloudStorageOutputErrorListener { /* 处理音频 Output 错误 */ }
videoOutput.attachView(videoContainer)
videoOutput.attach(replay, videoChannelId)
audioOutput.attach(replay, audioChannelId)
replay.play(
startTimeMs = selectedRecording.startTimeMs,
endTimeMs = selectedRecording.endTimeMs,
initialTimeMs = initialTimeMs,
)let replay = cloudStorage.createReplay()
let videoOutput = TiCloudStorageVideoOutput()
let audioOutput = TiCloudStorageAudioOutput()
replay.onError = { _ in /* 处理来源错误 */ }
replay.onTimeChanged = updatePlaybackProgress
replay.onCompleted = handleReplaySourceCompleted
videoOutput.delegate = outputDelegate // 处理视频状态和错误
audioOutput.delegate = outputDelegate
videoOutput.attachView(videoView)
videoOutput.attach(replay: replay, channelId: videoChannelId)
audioOutput.attach(replay: replay, channelId: audioChannelId)
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: NSNumber(value: initialTimeMs)
)const replay = cloudStorage.createReplay();
const videoOutput = new TiCloudStorageVideoOutput();
const audioOutput = new TiCloudStorageAudioOutput();
replay.onError = (code) => { /* 处理来源错误 */ };
replay.onTimeChanged = updatePlaybackProgress;
replay.onCompleted = handleReplaySourceCompleted;
videoOutput.onStateChanged = handleVideoState;
audioOutput.onStateChanged = handleAudioState;
videoOutput.onError = (code) => { /* 处理视频 Output 错误 */ };
audioOutput.onError = (code) => { /* 处理音频 Output 错误 */ };
videoOutput.attach(replay, videoChannelId);
audioOutput.attach(replay, audioChannelId);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
@Builder
function CloudStoragePlaybackVideo(): void {
TiCloudStorageVideoOutputView({output: videoOutput, fit: TiCloudStorageVideoFit.contain});
}const replay = cloudStorage.createReplay();
const videoOutput = new TiCloudStorageVideoOutput();
const audioOutput = new TiCloudStorageAudioOutput();
replay.onError = (code) => { /* 处理来源错误 */ };
replay.onTimeChanged = updatePlaybackProgress;
replay.onCompleted = handleReplaySourceCompleted;
videoOutput.onStateChanged = handleVideoState;
audioOutput.onStateChanged = handleAudioState;
videoOutput.onError = (code) => { /* 处理视频 Output 错误 */ };
audioOutput.onError = (code) => { /* 处理音频 Output 错误 */ };
videoOutput.attach(replay, videoChannelId);
audioOutput.attach(replay, audioChannelId);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
function CloudStoragePlaybackVideo(): ReactElement {
return videoOutput.view({style: styles.video});
}replay, err := cloudStorage.NewReplay(deviceID, storage.ReplayOptions{
OnTimeChanged: updatePlaybackProgress,
OnCompleted: handleReplaySourceCompleted,
OnError: func(err error) { /* 处理来源错误 */ },
})
if err != nil {
return err
}
videoOutput, err := storage.NewVideoOutput(storage.VideoOutputOptions{
OnFrame: func(frame storage.VideoFrame) { render(frame) },
OnStateChanged: handleVideoState,
OnError: func(err error) { /* 处理 Output 错误 */ },
})
if err != nil {
return err
}
audioOutput, err := storage.NewAudioOutput(storage.AudioOutputOptions{
OnFrame: func(frame storage.AudioFrame) { playPCM(frame) },
OnStateChanged: handleAudioState,
OnError: func(err error) { /* 处理 Output 错误 */ },
})
if err != nil {
return err
}
if err := videoOutput.Attach(replay, videoChannelID); err != nil {
return err
}
if err := audioOutput.Attach(replay, audioChannelID); err != nil {
return err
}
if err := replay.PlayAt(
selectedRecording.StartTime,
selectedRecording.EndTime,
initialTime,
); err != nil {
return err
}Replay.Listener replayListener = new Replay.Listener() {
@Override public void onTimeChanged(Instant time) {
updatePlaybackProgress(time);
}
@Override public void onCompleted() {
handleReplaySourceCompleted();
}
@Override public void onError(TiRtcException error) {
handleReplayError(error);
}
};
Replay replay = cloudStorage.newReplay(deviceId, replayListener);
VideoOutput videoOutput = new VideoOutput(
replay, videoChannelId, frame -> render(frame));
AudioOutput audioOutput = new AudioOutput(
replay, audioChannelId, frame -> playPcm(frame));
replay.playAt(
selectedRecording.startTime(),
selectedRecording.endTime(),
initialTime);replay = cloudStorage.create_replay(
on_time_changed=update_playback_progress,
on_completed=handle_replay_source_completed,
on_error=handle_replay_error,
)
video_output = storage.VideoOutput(
render,
on_state_changed=handle_video_state,
on_error=handle_output_error,
)
audio_output = storage.AudioOutput(
play_pcm,
on_state_changed=handle_audio_state,
on_error=handle_output_error,
)
video_output.attach(replay, video_channel_id)
audio_output.attach(replay, audio_channel_id)
replay.play(
selected_recording.start_time,
selected_recording.end_time,
initial_time=initial_time,
)updatePlaybackProgress、handleReplaySourceCompleted、handleVideoState、handleAudioState、render 和 playPCM 是应用实现的处理函数,不是 SDK API。上面的 iOS / macOS 示例使用 delegate 接收状态和错误;outputDelegate 由应用持有,并同时遵循 TiCloudStorageAudioOutputDelegate 和 TiCloudStorageVideoOutputDelegate。
鉴权、权限、网络或录像读取错误由 Replay 返回。解码、播放或渲染错误由对应 Output 返回。
attach 或 play 同步返回失败时,本次操作没有开始。先按错误码修正 Channel、时间范围或对象状态。使用 app_access_token 的平台在 Replay 报告 Token 过期后更新 Token,再重新调用 play;Go、Java 和采用 Access Key 模式的 Python SDK 检查托管凭据和设备归属。录像不可读时不要持续重试同一段录像。网络或服务暂时不可用时,可以采用有上限的退避重试。
监听播放进度
通过 Replay 的 onTimeChanged(Go 为 OnTimeChanged)监听当前回放位置,并用回调值更新进度条或时间标签。Flutter、Android、iOS / macOS、HarmonyOS 和 React Native 的回调值是录像时间轴上的 UTC Unix 毫秒;Go 使用 time.Time,Java 使用 Instant。这个值不是从 0 开始的已播放时长。
进度条使用相对时间时,按下面的方式换算:
已播放时长 = 当前回放位置 - 本次播放范围的开始时间
总时长 = 本次播放范围的结束时间 - 本次播放范围的开始时间调用 Seek 后,继续使用 onTimeChanged / OnTimeChanged 返回的新位置更新 UI。
识别录像缺口
Replay 确认并越过录像缺口时,会通过 onRecordingGap(Go 为 OnRecordingGap,Python 为 on_recording_gap)返回缺口的时间范围、受影响的音视频 Channel 和原因。应用可以据此提示用户录像不连续;缺口之后仍有可解码媒体时,Replay 会继续播放。
该回调只表示 SDK 已经确认并越过的缺口,不表示所有异常都会被自动跳过。下载失败、解密失败或媒体无法继续解析时,Replay 仍可能通过 onError 结束。Android 和 iOS / macOS 可以在创建 Replay 时选择主线程或 SDK 后台串行线程接收 Replay 回调;回调内应快速返回,不要同步停止当前 Replay。
判断播放是否开始和结束
播放请求被接受、媒体已经开始输出、Replay 来源耗尽和 Output 排空是四个不同阶段:
| 要判断什么 | 使用的信号 | 信号的含义 |
|---|---|---|
| 播放请求是否被接受 | play 同步返回成功 | SDK 已接受本次请求,不表示已经出声、出画或播放完成 |
| 目标媒体是否开始输出 | 看到视频首帧或 Video Output 进入 rendering;听到音频或 Audio Output 进入 playing;Go 或 Java Output 进入 OutputDelivering / DELIVERING,或收到对应帧 | 当前 Output 已经交付目标媒体 |
| Replay 来源是否耗尽 | Replay onCompleted / OnCompleted | 本次 play 的来源范围不再产生新数据,Output 可能仍在消费已经接收的媒体 |
| 对应媒体是否输出结束 | Output 进入 completed / COMPLETED / OutputCompleted | Replay 来源已经耗尽,并且这个 Output 已排空 |
Replay 可以领先于声音和画面的实际输出。收到 Replay 完成回调后,继续保持 Replay、Output 和渲染 View 有效;不要仅根据这个回调停止播放、解除绑定、移除 View 或切换录像。
如果播放页只要求画面自然播放到结尾,先确认 Video Output 曾经开始出画且没有失败,再等待它进入完成状态。如果同时要求声音和画面,则分别确认业务需要的 Audio、Video Output 已经开始输出且没有失败,再等待相应 Output 都进入完成状态。所选范围内没有某类媒体时,对应 Output 可能没有出声或出画便直接完成,因此不能只凭 completed 判断播放成功。
主动退出页面或切换录像时可以立即调用 stop;这会取消尚未输出的媒体,不属于自然播放完成。需要在自然完成后自动清理或切换时,先满足上述 Output 条件,再按当前平台的资源释放顺序处理。
使用正确的 Channel ID
attach 的 channelId 必须与设备写入同类媒体时使用的 channel_id 一致。录像查询只返回设备整体的可用时间范围,不提供 Channel ID;音频和视频的值可以相同,也可以不同。不要把设备 ID、录像列表下标或播放器轨道序号当作 Channel ID。
以 Flutter 为例,录像列表下标和设备上传时的 Channel ID 都是整数,但含义不同:
videoOutput.attach(replay: replay, channelId: uploadedVideoChannelId);videoOutput.attach(replay: replay, channelId: selectedRecordingIndex);selectedRecordingIndex 和 uploadedVideoChannelId 是应用变量,不是 SDK API;Audio Output 同样使用设备写入音频帧时的 Channel ID。
调用 play 前注册 Replay 和各 Output 的错误回调。没有声音或画面时,先比较设备上传配置与 attach 的值;不一致就重新绑定并播放同一时间范围。所选范围内没有该媒体类型时,Output 也可能在未输出媒体的情况下完成,因此还要结合状态和错误回调判断。具体步骤见问题排查。
在 Go、Java 和 Python 中处理媒体帧
Go、Java 和 Python SDK 都提供解码和编码两类 Output。
需要修改画面、识别内容或重新编码时,使用 AudioOutput 和 VideoOutput 取得 PCM 与像素帧。需要保留录像中的压缩数据并自行封装或转发时,使用 EncodedAudioOutput 和 EncodedVideoOutput。编码 Output 可以交付 G.711 A-law、AAC、Opus、H.264、H.265 等编码帧,并通过 Codec 和 BitstreamFormat 标明实际格式。它不会把媒体转换成调用方指定的编码。
下面的示例读取一路编码视频和一路编码音频。每个 Output 分别绑定对应媒体的 Channel,再由同一个 Replay 驱动:
encodedVideo, err := storage.NewEncodedVideoOutput(
storage.EncodedVideoOutputOptions{
OnFrame: func(frame storage.EncodedVideoFrame) {
consumeEncodedVideo(frame)
},
OnError: func(err error) { /* 处理视频输出错误 */ },
},
)
if err != nil {
return err
}
encodedAudio, err := storage.NewEncodedAudioOutput(
storage.EncodedAudioOutputOptions{
OnFrame: func(frame storage.EncodedAudioFrame) {
consumeEncodedAudio(frame)
},
OnError: func(err error) { /* 处理音频输出错误 */ },
},
)
if err != nil {
return err
}
if err := encodedVideo.Attach(replay, videoChannelID); err != nil {
return err
}
if err := encodedAudio.Attach(replay, audioChannelID); err != nil {
return err
}
if err := replay.Play(startTime, endTime); err != nil {
return err
}EncodedVideoOutput encodedVideo = new EncodedVideoOutput(
replay, videoChannelId, frame -> consumeEncodedVideo(frame));
EncodedAudioOutput encodedAudio = new EncodedAudioOutput(
replay, audioChannelId, frame -> consumeEncodedAudio(frame));
replay.play(startTime, endTime);encoded_video = storage.EncodedVideoOutput(consume_encoded_video)
encoded_audio = storage.EncodedAudioOutput(consume_encoded_audio)
encoded_video.attach(replay, video_channel_id)
encoded_audio.attach(replay, audio_channel_id)
replay.play(start_time, end_time)帧数据在回调返回后仍然有效,但回调应尽快返回,不要在其中执行耗时的编码或文件写入。Output 的回调队列有界。处理持续跟不上时会丢帧,下一次成功交付的帧以 Discontinuity=true 标记不连续。需要完整、连续的 MP4 时,优先使用 exportRecording。
控制播放
Seek 使用当前播放范围内的绝对 UTC 时间。它是同步控制命令,不返回独立完成回调。定位生效后,Replay 的当前位置和时间变化通知会更新。
replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音
replay.stop();replay.pause()
replay.resume()
replay.seek(targetTimeMs)
replay.setSpeed(TiCloudStorageReplaySpeed.X0_5)
audioOutput.setVolume(0) // 静音
replay.stop()replay.pause()
replay.resume()
replay.seek(toTimeMs: targetTimeMs)
replay.setSpeed(.x0_5)
audioOutput.setVolume(0) // 静音
replay.stop()replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音
replay.stop();replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音
replay.stop();if err := replay.Pause(); err != nil {
return err
}
if err := replay.Resume(); err != nil {
return err
}
if err := replay.Seek(targetTime); err != nil {
return err
}
if err := replay.SetSpeed(storage.ReplaySpeed0_5x); err != nil {
return err
}
if err := replay.Stop(); err != nil {
return err
}replay.pause();
replay.resume();
replay.seek(targetTime);
replay.setSpeed(ReplaySpeed.X0_5);
replay.stop();Seek 只能在当前播放仍在运行或暂停时调用,目标必须位于当前播放范围。越界时同步返回错误,当前播放不受影响。
目标落在录像空洞中时,Replay 会定位到其后的第一个可播放位置。直到范围末尾都没有录像时按自然结束处理。播放已经结束时,从指定位置重新调用 play。
setVolume(0) 表示静音,恢复到 100 即可取消静音。Go、Java 和 Python SDK 通过媒体帧接入应用自己的播放链路,音量由应用控制。Replay 支持 1/8x、1/4x、1/2x、1x、2x、4x 和 8x 七档速度;三档慢速通过持帧实现。非 1x 倍速下,Audio Output 自动静音。切换到另一段录像时,直接在同一个 Replay 上再次调用 play;已有 Output 绑定、音量和倍速会保留。
截取当前画面
Video Output 出画后,可以把当前画面保存为 JPEG。截图操作(Snapshot)不包含字幕、按钮等页面 UI。
final result = await videoOutput.takeSnapshot();
if (!result.success || result.data == null) return;
final snapshot = result.data!;lifecycleScope.launch {
val result = videoOutput.takeSnapshot()
if (result.code != TiCloudStorageErrorCode.OK || result.file == null) return@launch
val snapshot = result.file
}let result = await videoOutput.takeSnapshot()
guard result.code == TiCloudStorageErrorCode.ok,
let snapshot = result.file else { return }const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;snapshot, err := videoOutput.TakeSnapshot()
if err != nil {
return err
}SnapshotFile snapshot = videoOutput.takeSnapshot();Video Output 尚未收到画面时,截图返回 no-frame;等待首帧后再试。截图文件写入失败时,先检查缓存目录和磁盘空间。
JPEG 先生成在 SDK 私有 cache。Flutter 与 React Native 可以对返回的文件对象调用 moveToGallery(),并为保存的 JPEG 指定文件名。Android、iOS / macOS 与 HarmonyOS 由应用复制或移动普通文件,并在目标文件操作中设置名称。
直接读取、上传或分享完成后调用 delete()。需要长期保存时及时移动,不能把 cache 路径当作持久地址。
Future<void> saveSnapshot(
TiCloudStorageSnapshotFile snapshotFile,
) async {
final snapshotSave = await snapshotFile.moveToGallery(
fileName: '客厅摄像头-2026-08-20-14-30-05.jpg',
);
if (!snapshotSave.success || snapshotSave.data == null) return;
final snapshotAsset = snapshotSave.data!;
// 使用 snapshotAsset.uri 展示或持久化系统媒体资产。
}import type { TiCloudStorageSnapshotFile } from 'tirtc-react-native';
async function saveSnapshot(
snapshotFile: TiCloudStorageSnapshotFile,
): Promise<void> {
const snapshotSave = await snapshotFile.moveToGallery(
'客厅摄像头-2026-08-20-14-30-05.jpg',
);
if (!snapshotSave.success || snapshotSave.data === null) return;
// 使用 snapshotSave.data.uri 展示或持久化系统媒体资产。
}if err := copyToApplicationDirectory(snapshotFile.Path); err != nil {
return err
}
if err := snapshotFile.Delete(); err != nil {
return err
}try (SnapshotFile snapshotFile = snapshot) {
Files.copy(snapshotFile.path(), destination);
}上例的参数来自截图接口的成功结果。文件名可以省略 .jpg,SDK 会自动补全。传入扩展名时只接受 .jpg;非法名称返回 6000,并保留 cache 源文件。省略文件名时,SDK 使用 Unix 毫秒时间戳生成名称。
播放多路录像
同一个 Replay 可以连接多个 Video Output,并按 channel_id 分别播放多路视频。Channel ID 来自设备的媒体通道配置,不来自录像列表。以下示例与上传多路录像中的约定一致:摄像头 A 视频和麦克风音频使用 Channel 0,摄像头 B 视频使用 Channel 1。
Flutter、Android、iOS / macOS、HarmonyOS、React Native、Go、Java 和 Python SDK 都支持通过同一个 Replay 播放多路视频。每一路视频创建一个 Video Output,并绑定对应的 Channel;界面平台为每个 Video Output 提供一个渲染视图,Go、Java 和 Python SDK 通过不同 Output 的回调或 listener 分别交付视频帧。
下面使用摄像头 A(Channel 0)和摄像头 B(Channel 1)演示调用关系。两路只是示例,不是 SDK 固定上限。需要播放更多路时,继续为其他有效 Channel 创建并绑定 Video Output。Channel ID 的范围是 0..255,每个 Channel 最多包含一路视频和一路音频;这不表示每台终端都保证同时解码 256 路,实际能力还取决于终端的解码、渲染、内存和网络资源。
final replay = cloudStorage.createReplay();
final audioOutput = TiCloudStorageAudioOutput();
final videoAOutput = TiCloudStorageVideoOutput();
final videoBOutput = TiCloudStorageVideoOutput();
audioOutput.attach(replay: replay, channelId: 0);
videoAOutput.attach(replay: replay, channelId: 0);
videoBOutput.attach(replay: replay, channelId: 1);
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: initialTimeMs,
);
final videoAView = videoAOutput.view();
final videoBView = videoBOutput.view();val replay = cloudStorage.createReplay()
val audioOutput = TiCloudStorageAudioOutput()
val videoAOutput = TiCloudStorageVideoOutput()
val videoBOutput = TiCloudStorageVideoOutput()
videoAOutput.attachView(videoContainerA)
videoBOutput.attachView(videoContainerB)
audioOutput.attach(replay, 0)
videoAOutput.attach(replay, 0)
videoBOutput.attach(replay, 1)
replay.play(
startTimeMs = selectedRecording.startTimeMs,
endTimeMs = selectedRecording.endTimeMs,
initialTimeMs = initialTimeMs,
)let replay = cloudStorage.createReplay()
let audioOutput = TiCloudStorageAudioOutput()
let videoAOutput = TiCloudStorageVideoOutput()
let videoBOutput = TiCloudStorageVideoOutput()
videoAOutput.attachView(videoViewA)
videoBOutput.attachView(videoViewB)
audioOutput.attach(replay: replay, channelId: 0)
videoAOutput.attach(replay: replay, channelId: 0)
videoBOutput.attach(replay: replay, channelId: 1)
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: NSNumber(value: initialTimeMs)
)const replay = cloudStorage.createReplay();
const audioOutput = new TiCloudStorageAudioOutput();
const videoAOutput = new TiCloudStorageVideoOutput();
const videoBOutput = new TiCloudStorageVideoOutput();
audioOutput.attach(replay, 0);
videoAOutput.attach(replay, 0);
videoBOutput.attach(replay, 1);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
@Builder
function MultiChannelVideo(): void {
Column() {
TiCloudStorageVideoOutputView({ output: videoAOutput });
TiCloudStorageVideoOutputView({ output: videoBOutput });
}
}const replay = cloudStorage.createReplay();
const audioOutput = new TiCloudStorageAudioOutput();
const videoAOutput = new TiCloudStorageVideoOutput();
const videoBOutput = new TiCloudStorageVideoOutput();
audioOutput.attach(replay, 0);
videoAOutput.attach(replay, 0);
videoBOutput.attach(replay, 1);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
function MultiChannelVideo(): ReactElement {
return (
<>
{videoAOutput.view({style: styles.video})}
{videoBOutput.view({style: styles.video})}
</>
);
}replay, err := cloudStorage.NewReplay(deviceID, storage.ReplayOptions{})
if err != nil {
return err
}
audioOutput, err := storage.NewAudioOutput(storage.AudioOutputOptions{
OnFrame: func(frame storage.AudioFrame) { playPCM(frame) },
})
if err != nil {
return err
}
videoAOutput, err := storage.NewVideoOutput(storage.VideoOutputOptions{
OnFrame: func(frame storage.VideoFrame) { renderA(frame) },
})
if err != nil {
return err
}
videoBOutput, err := storage.NewVideoOutput(storage.VideoOutputOptions{
OnFrame: func(frame storage.VideoFrame) { renderB(frame) },
})
if err != nil {
return err
}
if err := audioOutput.Attach(replay, 0); err != nil {
return err
}
if err := videoAOutput.Attach(replay, 0); err != nil {
return err
}
if err := videoBOutput.Attach(replay, 1); err != nil {
return err
}
if err := replay.PlayAt(
selectedRecording.StartTime,
selectedRecording.EndTime,
initialTime,
); err != nil {
return err
}Replay replay = cloudStorage.newReplay(deviceId);
AudioOutput audioOutput = new AudioOutput(
replay, 0, frame -> playPcm(frame));
VideoOutput videoAOutput = new VideoOutput(
replay, 0, frame -> renderA(frame));
VideoOutput videoBOutput = new VideoOutput(
replay, 1, frame -> renderB(frame));
replay.playAt(
selectedRecording.startTime(),
selectedRecording.endTime(),
initialTime);一个 Audio Output 播放 Channel 0 的音频即可,不要为两路画面重复创建相同的音频输出。两个 Video Output 由同一个 Replay 驱动,因此暂停、继续、Seek 和倍速控制作用于同一播放时间线。所选范围内完全没有视频帧时,对应 Video Output 可以在没有出画的情况下完成;存在视频帧但 Channel ID 与绑定值不一致时,Output 无法交付目标画面,并可能报告错误。应用需要结合 Output 状态和错误回调提示实际结果,不能仅凭设备存在该 Channel 就假定这段时间一定有录像。
需要直接下载多路录像时,为每路视频分别创建 ExportTask,具体方式见下载多路录像。
保存正在播放的片段
等待所选视频 Channel 开始交付后,从当前 Replay 创建 RecordingTask。结束保存时调用 Task 的 stop。成功结果包含 MP4 路径和实际媒体时长。MP4 的格式限制和临时文件处理方式见下载录像。
final start = replay.startRecording(
videoChannelId: videoChannelId,
audioChannelId: audioChannelId,
);
if (!start.success || start.data == null) return;
final task = start.data!;
final completed = await task.stop();
if (!completed.success || completed.data == null) return;
final file = completed.data!;
showSavedFile(file.path);val start = replay.startRecording(
videoChannelId = videoChannelId,
audioChannelId = audioChannelId,
)
if (start.code != TiCloudStorageErrorCode.OK || start.task == null) return
val task = start.task
lifecycleScope.launch {
val completed = task.stop()
if (completed.code != TiCloudStorageErrorCode.OK || completed.file == null) return@launch
val file = completed.file
showSavedFile(file.path)
}let start = replay.startRecording(
videoChannelId: videoChannelId,
audioChannelId: NSNumber(value: audioChannelId)
)
guard start.code == TiCloudStorageErrorCode.ok, let task = start.task else { return }
let completed = await task.stop()
guard completed.code == TiCloudStorageErrorCode.ok,
let file = completed.file else { return }
showSavedFile(file.path)const start = replay.startRecording({
videoChannelId,
audioChannelId,
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.stop();
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);const start = replay.startRecording({
videoChannelId,
audioChannelId,
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.stop();
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);task, err := replay.StartRecording(storage.StartRecordingOptions{
VideoChannelID: videoChannelID,
AudioChannelID: &audioChannelID,
})
if err != nil {
return err
}
// 用户或业务流程决定结束保存时,再调用 Stop。
file, err := task.Stop()
if err != nil {
return err
}
showSavedFile(file.Path)try (RecordingTask task = replay.startRecording(
new StartRecordingOptions(videoChannelId, audioChannelId));
RecordingFile file = task.stop()) {
Files.copy(file.path(), destination);
}RecordingTask 保存 Replay 交付的媒体,不包含页面 UI。Replay 自然结束时 Task 会自动完成;之后调用 stop 仍会返回同一文件结果。
startRecording 失败时不会创建 Task。stop 返回成功并交付文件后,MP4 才可以读取或展示。文件写入失败时,检查缓存目录权限、磁盘剩余空间和本地写入性能,再重新保存。
一个 Task 只保存一个视频 Channel。两个摄像头分别启动两个 Task,SDK 会生成两个独立文件;需要时可以给两个 Task 传入同一个音频 Channel。
成功结果中的 MP4 位于 SDK 私有 cache。需要长期保存、上传或分享时,按保存并清理 MP4处理文件。
确认播放结果
释放页面资源前,确认本次操作已经得到预期结果:
- 查询返回至少一个录像时间段;
play返回成功后,业务需要的音频或视频 Output 确实开始输出;- 需要自然播放到结尾时,按判断播放是否开始和结束等待相应 Output 排空,不把 Replay 完成回调当作声音或画面已经结束;
- 暂停、继续和 Seek 后,状态与画面符合预期;
- 保存正在播放的片段时返回可读取的 MP4,截图时返回可读取的 JPEG。
若任一步失败,记录平台、SDK 版本、目标 device_id、时间范围和公开错误码,不要记录 Token。
释放资源
主动离开播放页面时可以停止 Replay,这会取消尚未输出的媒体。如果页面要在自然播放到结尾后自动退出或切换录像,应先等待业务需要的 Output 进入完成状态。准备释放页面资源时,先结束页面持有且不再继续的 RecordingTask,并等待已经接受的 Snapshot。使用 Video Output View 的平台先移除 View,然后解除并释放 Output。
播放页持有 cloudStorage 且离开后不再访问这台设备时,一并释放它。如果应用在更高层复用同一个 cloudStorage,等不再访问该设备时再释放。
replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
cloudStorage.dispose()replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
cloudStorage.dispose()replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();if err := errors.Join(
replay.Stop(),
videoOutput.Detach(),
audioOutput.Detach(),
videoOutput.Close(),
audioOutput.Close(),
replay.Close(),
cloudStorage.Close(),
); err != nil {
return err
}replay.stop();
videoOutput.close();
audioOutput.close();
replay.close();
cloudStorage.close();replay.stop()
video_output.detach()
audio_output.detach()
video_output.close()
audio_output.close()
replay.close()
cloudStorage.close()
cloudClient.close()释放失败时,对象仍然有效。等待任务和回调结束、解除绑定或移除 View 后,再次释放即可。离开页面后,确认没有仍被占用的 Replay、Output、RecordingTask 或 TiCloudStorage 实例。