Skip to content

查询与播放录像 ​

先查询目标设备的录像,再播放或截取需要的内容。需要把指定时间段直接保存为 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 模式。

dart
final cloudStorage = TiCloudStorage(token: token);
kotlin
val cloudStorage = TiCloudStorage(token)
swift
let cloudStorage = TiCloudStorage(token: token)
typescript
const cloudStorage = new TiCloudStorage(token);
typescript
const cloudStorage = new TiCloudStorage(token);
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)

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

查询哪些日期有录像 ​

月历或日期列表不需要逐日查询录像时间段。使用 listRecordingDays 一次取得包含式日期范围内的完整逐日结果,再保留 hasRecording 为 true 的日期。

dart
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();
kotlin
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 }
}
swift
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)
typescript
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);
typescript
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);
go
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)
	}
}
java
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());
python
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 参数:

  1. 在查询使用的 IANA 时区中,把所选日期当天 00:00 作为开始边界。
  2. 在同一时区中,把下一个自然日的 00:00 作为结束边界。
  3. 分别把两个边界转换为 UTC Unix 毫秒,得到左闭右开区间 [startTimeMs, endTimeMs)。

不要用 startTimeMs + 86400000 推导结束边界。采用夏令时的地区可能存在 23 或 25 小时的自然日。

查询可回放的时间段 ​

listRecordings 返回设备在查询范围内有录像的时间段。没有录像时,结果为空列表。

dart
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 打开播放页。
kotlin
lifecycleScope.launch {
    val result = cloudStorage.listRecordings(
        startTimeMs,
        endTimeMs,
    )
    if (result.code != TiCloudStorageErrorCode.OK) return@launch

    val selectedRecording = result.recordings.firstOrNull() ?: return@launch
    // 使用 selectedRecording 打开播放页。
}
swift
let result = await cloudStorage.listRecordings(
    startTimeMs: startTimeMs,
    endTimeMs: endTimeMs
)
guard result.code == TiCloudStorageErrorCode.ok,
      let selectedRecording = result.recordings.first else { return }

// 使用 selectedRecording 打开播放页。
typescript
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];
typescript
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];
go
recordings, err := cloudStorage.ListRecordings(ctx, deviceID, startTime, endTime)
if err != nil {
	return err
}
if len(recordings) == 0 {
	return nil
}

selectedRecording := recordings[0]
java
List<RecordingRange> recordings = cloudStorage
    .listRecordings(deviceId, startTime, endTime)
    .get();
if (recordings.isEmpty()) return;

RecordingRange selectedRecording = recordings.get(0);
python
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 绑定和播放控制的时序不变。

dart
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());
kotlin
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,
)
swift
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)
)
typescript
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});
}
tsx
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});
}
go
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
}
java
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);
python
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 开始的已播放时长。

进度条使用相对时间时,按下面的方式换算:

text
已播放时长 = 当前回放位置 - 本次播放范围的开始时间
总时长 = 本次播放范围的结束时间 - 本次播放范围的开始时间

调用 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 / OutputCompletedReplay 来源已经耗尽,并且这个 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 都是整数,但含义不同:

dart
videoOutput.attach(replay: replay, channelId: uploadedVideoChannelId);
dart
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 驱动:

go
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
}
java
EncodedVideoOutput encodedVideo = new EncodedVideoOutput(
    replay, videoChannelId, frame -> consumeEncodedVideo(frame));
EncodedAudioOutput encodedAudio = new EncodedAudioOutput(
    replay, audioChannelId, frame -> consumeEncodedAudio(frame));
replay.play(startTime, endTime);
python
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 的当前位置和时间变化通知会更新。

dart
replay.pause();
replay.resume();

replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音

replay.stop();
kotlin
replay.pause()
replay.resume()

replay.seek(targetTimeMs)
replay.setSpeed(TiCloudStorageReplaySpeed.X0_5)
audioOutput.setVolume(0) // 静音

replay.stop()
swift
replay.pause()
replay.resume()

replay.seek(toTimeMs: targetTimeMs)
replay.setSpeed(.x0_5)
audioOutput.setVolume(0) // 静音

replay.stop()
typescript
replay.pause();
replay.resume();

replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音

replay.stop();
typescript
replay.pause();
replay.resume();

replay.seek(targetTimeMs);
replay.setSpeed(TiCloudStorageReplaySpeed.x0_5);
audioOutput.setVolume(0); // 静音

replay.stop();
go
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
}
java
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。

dart
final result = await videoOutput.takeSnapshot();
if (!result.success || result.data == null) return;
final snapshot = result.data!;
kotlin
lifecycleScope.launch {
    val result = videoOutput.takeSnapshot()
    if (result.code != TiCloudStorageErrorCode.OK || result.file == null) return@launch
    val snapshot = result.file
}
swift
let result = await videoOutput.takeSnapshot()
guard result.code == TiCloudStorageErrorCode.ok,
      let snapshot = result.file else { return }
typescript
const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;
typescript
const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;
go
snapshot, err := videoOutput.TakeSnapshot()
if err != nil {
	return err
}
java
SnapshotFile snapshot = videoOutput.takeSnapshot();

Video Output 尚未收到画面时,截图返回 no-frame;等待首帧后再试。截图文件写入失败时,先检查缓存目录和磁盘空间。

JPEG 先生成在 SDK 私有 cache。Flutter 与 React Native 可以对返回的文件对象调用 moveToGallery(),并为保存的 JPEG 指定文件名。Android、iOS / macOS 与 HarmonyOS 由应用复制或移动普通文件,并在目标文件操作中设置名称。

直接读取、上传或分享完成后调用 delete()。需要长期保存时及时移动,不能把 cache 路径当作持久地址。

dart
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 展示或持久化系统媒体资产。
}
typescript
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 展示或持久化系统媒体资产。
}
go
if err := copyToApplicationDirectory(snapshotFile.Path); err != nil {
	return err
}
if err := snapshotFile.Delete(); err != nil {
	return err
}
java
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 路,实际能力还取决于终端的解码、渲染、内存和网络资源。

dart
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();
kotlin
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,
)
swift
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)
)
typescript
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 });
  }
}
tsx
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})}
    </>
  );
}
go
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
}
java
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 的格式限制和临时文件处理方式见下载录像。

dart
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);
kotlin
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)
}
swift
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)
typescript
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);
typescript
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);
go
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)
java
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,等不再访问该设备时再释放。

dart
replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();
kotlin
replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
cloudStorage.dispose()
swift
replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
cloudStorage.dispose()
typescript
replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();
typescript
replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
cloudStorage.dispose();
go
if err := errors.Join(
	replay.Stop(),
	videoOutput.Detach(),
	audioOutput.Detach(),
	videoOutput.Close(),
	audioOutput.Close(),
	replay.Close(),
	cloudStorage.Close(),
); err != nil {
	return err
}
java
replay.stop();
videoOutput.close();
audioOutput.close();
replay.close();
cloudStorage.close();
python
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 实例。

Ti 云存开发文档