鸿蒙 NEXT 下 RTSP/RTMP 播放器如何调用录像与快照?

本文基于大牛直播 SDK(SmartMediaKit)鸿蒙 NEXT 版本,结合真实 ArkTS 工程代码,完整介绍录像的参数配置、启停控制、文件切片事件处理,以及快照的异步回调机制和目录管理实践。


背景

在安防预览、工业巡检、远程直播等实时流媒体场景中,"边看边录"和"一键抓图"是使用频率极高的两个功能。鸿蒙 NEXT(纯血鸿蒙)版 SmartPlayer SDK 将这两个能力完整透出到 ArkTS 层,整个调用链如下:

TypeScript 复制代码
ArkTS UI(SmartPlayerPage)
      ↓  doStartRecorder / captureImage
PlayerRecorderHelper / PlayerSnapshotHelper(业务封装)
      ↓  startRecorder / captureImage
SmartPlayerWrapper(ArkTS 封装层)
      ↓  NT 命名空间
SmartPlayerNative(libSmartPlayer.so NAPI 映射)
      ↓
C++ 原生层(文件 I/O / JPEG 编码)

录像和快照都是异步操作------启动命令立即返回,实际文件落盘的结果通过事件回调通知 ArkTS 层,必须正确处理事件才能拿到真实的文件路径。


一、录像功能

1.1 接口总览

Native 层(SmartPlayerNative.ets)

TypeScript 复制代码
// 目录与文件管理
SmartPlayerCreateFileDirectory(path: string): number
SmartPlayerSetRecorderDirectory(handle, path: string): number
SmartPlayerSetRecorderFileMaxSize(handle, sizeMB: number): number

// 轨道开关
SmartPlayerSetRecorderVideo(handle, enable: number): number   // 1=录视频
SmartPlayerSetRecorderAudio(handle, enable: number): number   // 1=录音频
SmartPlayerSetRecorderAudioTranscodeAAC(handle, enable: number): number  // 1=转码为AAC

// 录像控制
SmartPlayerStartRecorder(handle): number
SmartPlayerStopRecorder(handle): number

Wrapper 层(SmartPlayerWrapper.ets) ,在 Native 外包了 isOpened() 保护并同步 Session 缓存:

TypeScript 复制代码
createFileDirectory(path: string): boolean
setRecorderDirectory(path: string): boolean
setRecorderFileMaxSize(sizeMB: number): boolean
setRecorderAudioTranscodeAAC(enable: boolean): boolean
setRecorderVideo(enable: boolean): boolean
setRecorderAudio(enable: boolean): boolean
startRecorder(): boolean
stopRecorder(): boolean

1.2 录像参数配置

录像参数通过 PlayerRecorderConfig 接口统一传递,在 Page 层构建:

TypeScript 复制代码
export interface PlayerRecorderConfig {
  recorderDir: string;              // 录像保存目录(绝对路径)
  recorderFileMaxSizeMB: number;    // 单文件最大体积(MB),超出自动切片
  recorderAudioTranscodeAAC: boolean; // 音频是否转码为 AAC
  recorderVideoEnabled: boolean;    // 是否录视频轨
  recorderAudioEnabled: boolean;    // 是否录音频轨
}

Page 层构建默认录像配置:

TypeScript 复制代码
private buildRecorderConfig(): PlayerRecorderConfig {
  return {
    recorderDir: this.recordDir,
    recorderFileMaxSizeMB: this.recordFileMaxSizeMB,    // 默认 200MB
    recorderAudioTranscodeAAC: this.recordAudioTranscodeAAC, // 默认 true
    recorderVideoEnabled: this.recordVideoEnabled,           // 默认 true
    recorderAudioEnabled: this.recordAudioEnabled            // 默认 true
  };
}

Wrapper 中的 applyRecorderConfig() 负责将配置逐一下发到 Native:

TypeScript 复制代码
applyRecorderConfig(config: PlayerRecorderConfig): boolean {
  if (!this.isOpened()) return false;
  if (!config.recorderDir || config.recorderDir.trim().length === 0) return false;

  let ok = true;
  ok = this.createFileDirectory(config.recorderDir) && ok;      // 1. 确保目录存在
  ok = this.setRecorderDirectory(config.recorderDir) && ok;     // 2. 设置输出目录
  ok = this.setRecorderFileMaxSize(config.recorderFileMaxSizeMB) && ok; // 3. 切片大小
  ok = this.setRecorderAudioTranscodeAAC(config.recorderAudioTranscodeAAC) && ok; // 4. 音频转码
  ok = this.setRecorderVideo(config.recorderVideoEnabled) && ok;        // 5. 视频轨
  ok = this.setRecorderAudio(config.recorderAudioEnabled) && ok;        // 6. 音频轨
  return ok;
}

1.3 获取录像目录

鸿蒙 NEXT 的沙箱路径在 aboutToAppear() 阶段通过 UIAbilityContext 获取:

TypeScript 复制代码
aboutToAppear(): void {
  const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
  // 录像目录挂在沙箱 filesDir 下
  const dir = ctx.filesDir + '/records';
  if (dir) {
    this.recordDir = dir;
  } else {
    this.statusText = '获取录像目录失败';
  }
}

ctx.filesDir 是应用沙箱根目录,每个应用独享,无需申请额外权限。如果需要保存到相册或外部存储,需要申请 ohos.permission.WRITE_MEDIA 并通过 photoAccessHelper 操作。

1.4 开始录像

录像可以在两种场景下启动:纯录像模式 (不播放,只录)和边播边录模式 。Page 层的 doStartRecorder() 对两种场景都做了处理:

TypeScript 复制代码
private doStartRecorder(): void {
  // 防止重复启动
  if (this.player.isRecordingState()) {
    this.setLastEvent('已在录像中,忽略重复开始');
    return;
  }

  const wasPlaying = this.player.isPlayingState();

  // 如果播放器还没 open,先 open
  if (!this.player.isOpened()) {
    const openRet = this.player.open();
    if (!openRet) {
      this.statusText = '打开失败(native 未加载?)';
      return;
    }
    this.bindPlayerCallbacks(); // 注册事件回调
  }

  // 如果当前没在播放,需要先下发基础网络配置
  if (!wasPlaying) {
    if (!this.player.applyBaseConfig(this.buildCommonConfig())) {
      this.player.close();
      return;
    }
  }
  // 如果已在播放,跳过重复参数下发,直接追加录像配置

  // 下发录像参数并启动
  const recConfig = this.buildRecorderConfig();
  this.player.applyRecorderConfig(recConfig);
  const ok = this.player.startRecorder();

  if (ok) {
    this.isRecording = true;
    this.setLastEvent('录像已启动');
  } else {
    this.statusText = '录像启动失败';
    if (!this.player.isPlayingState()) {
      this.player.close(); // 失败时释放资源
    }
  }
}

关键点:播放和录像共用同一个 handle,中间件层(SmartPlayerWrapper)的 Session 会同时维护 isPlayingisRecording 两个状态,两者可以独立为 true。

1.5 停止录像

TypeScript 复制代码
private doStopRecorder(): void {
  if (!this.player.isOpened()) return;

  const ok = this.player.stopRecorder();
  if (ok) {
    this.isRecording = false;
    this.setLastEvent('录像已停止');
  }

  // 如果停录后既没在播放也没在录像,可以释放 handle
  if (!this.player.isRunningState()) {
    this.player.tryRelease();
  }
  this.syncUiFromPlayer();
}

tryRelease() 内部会检查 isRunningState()(播放或录像任一为 true 时不会释放),所以这里调用是安全的。

1.6 文件切片事件处理

当单文件体积超过 recorderFileMaxSizeMB 时,SDK 会自动切片并产生两个事件:

事件 触发时机 strParam 含义
RECORDER_START_NEW_FILE 新文件开始写入 新文件的完整路径
RECORDER_FILE_FINISHED 旧文件写入完成并关闭 已完成文件的完整路径

onPlayerEvent() 中处理这两个事件:

TypeScript 复制代码
case SmartPlayerEvent.RECORDER_START_NEW_FILE: {
  // strParam 是新文件路径,更新当前录像文件记录
  this.recorderCurrentFile = strParam ?? '';
  this.statusText = this.recordingStatusText(); // 更新状态栏
  this.setLastEvent(`新录像文件: ${strParam}`);
  break;
}

case SmartPlayerEvent.RECORDER_FILE_FINISHED: {
  // strParam 是已完成文件路径,备份后清空当前文件记录
  this.recorderLastFile = (strParam && strParam.length > 0)
    ? strParam
    : this.recorderCurrentFile;
  this.recorderCurrentFile = '';
  this.setLastEvent(`文件已完成: ${this.recorderLastFile}`);
  break;
}

典型录像文件切片时序如下:

TypeScript 复制代码
startRecorder()
    ↓
RECORDER_START_NEW_FILE  → strParam = "/data/.../records/20240415_143201.mp4"
    ↓  (录满 200MB)
RECORDER_START_NEW_FILE  → strParam = "/data/.../records/20240415_143501.mp4"  ← 新文件
RECORDER_FILE_FINISHED   → strParam = "/data/.../records/20240415_143201.mp4"  ← 旧文件封包完成
    ↓
stopRecorder()
    ↓
RECORDER_FILE_FINISHED   → strParam = "/data/.../records/20240415_143501.mp4"  ← 最后一个文件封包

二、快照功能

2.1 接口总览

Native 层

TypeScript 复制代码
SmartPlayerSaveImageFlag(handle, enable: number): number // enable=1 开启抓图功能
SmartPlayerSaveCurImage(handle, imagePath: string): number  // 同步保存当前帧
SmartPlayerCaptureImage(
  handle,
  compressFormat: number,   // 0=JPEG,1=PNG
  quality: number,          // 压缩质量 0~100
  fileName: string,         // 保存文件完整路径(含扩展名)
  userData: string          // 自定义标记,原样返回到回调
): number

Wrapper 层

TypeScript 复制代码
setSaveImageFlag(enable: boolean): boolean
saveCurImage(imagePath: string): boolean
captureImage(compressFormat: number, quality: number, fileName: string, userData: string): number

2.2 快照前提:SaveImageFlag

setSaveImageFlag(true) 是抓图功能的总开关。SDK 在收到这个指令后才会在解码管线中预留抓图通道,如果漏掉这一步,captureImage() 会直接失败。

applyPlaybackConfig()reapplySavedConfig() 中,这个开关都被无条件打开:

TypeScript 复制代码
// applyPlaybackConfig 里
ok = this.setSaveImageFlag(true) && ok;

// reapplySavedConfig(Surface 重建后恢复)里
ok = this.setSaveImageFlag(true) && ok;

也就是说,只要走正常播放启动流程,这个开关就已经开好了,业务层不需要单独调用。

2.3 异步抓图:captureImage

captureImage() 是异步的,调用后立即返回,实际的文件写入结果通过 CAPTURE_IMAGE 事件回调通知:

TypeScript 复制代码
// 触发快照
private captureImage(): void {
  if (!this.player.isOpened()) return;

  // 构造文件路径:目录 + 时间戳命名
  const ts = new Date().getTime();
  const filePath = `${this.recordDir}/snapshot_${ts}.jpg`;

  // captureImage(格式, 质量, 路径, 自定义标记)
  // compressFormat: 0=JPEG,1=PNG
  const ret = this.player.captureImage(0, 90, filePath, 'snapshot');
  if (ret !== SmartPlayerResult.OK) {
    this.statusText = '快照请求失败';
    this.setLastEvent('captureImage 返回错误');
  } else {
    this.setLastEvent('快照请求已发出,等待回调...');
  }
}

canCaptureImage() 是 Wrapper 内部的守卫------只有播放中或录像中(视频帧有数据流入)才允许抓图:

TypeScript 复制代码
canCaptureImage(): boolean {
  return this.isPlayingState() || this.isRecordingState();
}

2.4 处理快照回调事件

快照结果通过 CAPTURE_IMAGE 事件异步返回:

TypeScript 复制代码
case SmartPlayerEvent.CAPTURE_IMAGE: {
  const captureOk = param1 === 0; // param1=0 表示成功,非0表示失败
  // strParam 是实际保存的文件路径

  if (captureOk) {
    this.statusText = this.isRecording
      ? this.recordingStatusText()
      : (this.isPlaying ? this.playingStatusText() : this.stoppedStatusText());
    this.setLastEvent(`快照已保存: ${strParam}`);

    // 如果需要展示预览,可以在这里通知 UI
    // this.lastSnapshotPath = strParam;
  } else {
    this.statusText = '快照保存失败';
    this.setLastEvent(`快照失败, code=${param1}`);
  }
  break;
}

注意事件处理后要把状态栏恢复到正确的播放/录像/停止文本,不要让"快照保存失败"这类提示长期停留在状态栏上。


三、录像与快照的目录管理

录像和快照共用同一个输出目录(实际项目中可以分开),目录在 aboutToAppear() 阶段初始化,确保在任何操作前已经就绪:

TypeScript 复制代码
aboutToAppear(): void {
  const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;

  // 沙箱目录:/data/storage/el2/base/files/records
  const dir = ctx.filesDir + '/records';

  // 目录可能不存在,提前创建
  this.player.open();
  this.player.createFileDirectory(dir);
  this.player.close(); // 仅用于创建目录,立即关闭

  this.recordDir = dir;
}

更稳妥的做法是把目录创建独立出来,不依赖 player 实例。实际工程中可以直接调用 @ohos.file.fsmkdir() 方法,不必通过 SDK 创建目录。


四、完整调用时序

录像

TypeScript 复制代码
player.open()
    ↓
player.applyBaseConfig(config)          // 下发 URL 等网络参数
    ↓
player.applyRecorderConfig(recConfig)   // 下发录像参数
    ├── createFileDirectory(dir)
    ├── setRecorderDirectory(dir)
    ├── setRecorderFileMaxSize(200)
    ├── setRecorderAudioTranscodeAAC(true)
    ├── setRecorderVideo(true)
    └── setRecorderAudio(true)
    ↓
player.startRecorder()
    ↓
── 录像中 ─────────────────────────────────────────────
回调:RECORDER_START_NEW_FILE → 拿到新文件路径
回调:RECORDER_FILE_FINISHED  → 拿到完整文件路径(可上传/入库)
── 文件切片自动循环 ───────────────────────────────────
    ↓
player.stopRecorder()
    ↓
回调:RECORDER_FILE_FINISHED  → 最后一个文件封包完成
    ↓
player.tryRelease()            // 不在播放则释放

快照

TypeScript 复制代码
player.open()
    ↓
player.applyPlaybackConfig(config)    // 内部已包含 setSaveImageFlag(true)
    ↓
player.startPlayback()
    ↓
── 播放中 ──────────────────────────────────────────────
player.captureImage(0, 90, filePath, 'snapshot')
    ↓
回调:CAPTURE_IMAGE
    ├── param1 === 0  → 成功,strParam = 文件路径
    └── param1 !== 0  → 失败,记录错误码
── 可连续多次调用,每次异步通知 ──────────────────────
    ↓
player.stopPlayback()
player.tryRelease()

五、常见问题

Q:录像启动成功但文件始终是空的?

最常见原因是录像目录不存在。setRecorderDirectory() 不会自动创建目录,必须在调用前先通过 createFileDirectory()@ohos.file.fs 确保目录存在。另一个原因是视频轨和音频轨都被关闭------setRecorderVideo(false)setRecorderAudio(false) 的情况下,录出来的是空文件。

Q:RECORDER_FILE_FINISHED 一直没有回调?

这个事件只在文件被正常封包时触发。如果调用了 stopRecorder() 但没有收到这个回调,通常是因为 stopRecorder() 之前 player.close() 已经被调用,Native 层来不及触发封包事件。正确顺序是先 stopRecorder(),等到 RECORDER_FILE_FINISHED 回调后再做资源释放。

Q:captureImage() 返回 OK 但没有收到 CAPTURE_IMAGE 事件?

首先确认 setSaveImageFlag(true) 是否已调用(走正常的 applyPlaybackConfig() 流程会自动调用)。其次确认调用 captureImage() 时播放器处于 isPlayingState()isRecordingState()------没有数据流的状态下即使方法返回 OK,底层也无法抓取到帧。

Q:录像和播放可以独立控制吗?

可以。isPlayingisRecording 是 Session 里相互独立的两个布尔值。可以单独录像(不播放),也可以先播放再追加录像,还可以只停录像而继续播放,Wrapper 层对每种组合都有正确的状态维护。

Q:快照是否支持 PNG 格式?

支持,captureImage() 的第一个参数 compressFormat1 即为 PNG,传 0 为 JPEG。PNG 无损但文件更大,在监控抓图场景一般用 JPEG足够。

HarmonyOS NEXT纯血鸿蒙RTSP|RTMP播放器


小结

录像和快照两个功能的核心差异在于:录像是状态驱动的(start/stop 切换,结果通过文件切片事件持续通知),而快照是请求驱动的(每次调用对应一个异步回调)。前者需要重点处理 RECORDER_FILE_FINISHED 来拿到可用的文件路径;后者需要确保 setSaveImageFlag 总开关已开,并在回调里检查 param1 来判断是否成功。

借助 SmartPlayerWrapper 的 Session 机制,录像文件路径(recorderCurrentFile / recorderLastFile)在页面重建后同样可以自动恢复,不会因为页面切换而丢失正在录制的状态。


大牛直播 SDK(SmartMediaKit) 的鸿蒙 NEXT 模块(libSmartPlayer.so)是目前业内少有的、针对纯血鸿蒙系统完整移植并持续维护的商用直播播放器 SDK。在架构设计上,它延续了 Android 端成熟的 Native C++ 内核,通过 NAPI 桥接层将音视频能力原生透出到 ArkTS,避免了 JS 跨层调用的性能损耗,在 RTSP/RTMP 低延迟播放场景下端到端时延可控制在 100-200ms。

纯血鸿蒙(HarmonyOS )RTSP直播播放器时延测试

录像和快照模块同样体现了这套架构的优势:文件 I/O 和 JPEG 编码全部在 C++ 层完成,不经过 ArkTS 的 GC 堆,大文件切片写入不会引发界面卡顿;RECORDER_FILE_FINISHED 的异步回调机制保证了每一段切片文件在封包完成后才通知上层,从根本上杜绝了"文件未写完就被读取"的数据损坏风险。

在行业覆盖上,大牛直播 SDK 已广泛应用于安防监控、工业巡检、远程医疗、智慧零售等对实时性和稳定性要求极高的场景。鸿蒙 NEXT 版本的推出,使这些行业的终端设备可以在不依赖 Android 兼容层的前提下,直接以原生鸿蒙应用的形式完成从拉流、解码、渲染到录像存档、关键帧抓图的完整闭环,为国产操作系统在专业音视频领域的落地提供了坚实的底层支撑。


📎 CSDN官方博客:音视频牛哥-CSDN博客

相关推荐
音视频牛哥2 小时前
鸿蒙 NEXT 下 RTSP/RTMP 播放器如何实时调节音量、亮度、对比度与饱和度?
harmonyos·音视频开发·直播
音视频牛哥3 小时前
鸿蒙 NEXT RTSP/RTMP 播放器如何回调 RGB 数据并实现 AI 视觉算法分析
人工智能·算法·harmonyos·鸿蒙rtmp播放器·鸿蒙rtsp播放器·鸿蒙next rtsp播放器·鸿蒙next rtmp播放器
音视频牛哥4 小时前
HarmonyOS鸿蒙 Next 中如何实现低延迟 RTSP 流媒体播放?
华为·harmonyos·鸿蒙next·鸿蒙rtmp播放器·鸿蒙rtsp播放器·鸿蒙next rtsp播放器·鸿蒙next rtmp播放器
key_3_feng4 小时前
HarmonyOS 6.0 开发组件深度详解
华为·harmonyos
以太浮标5 小时前
华为eNSP综合实验之- 交换机组播VLAN(Multicast-VLAN)详细解析
运维·网络·网络协议·网络安全·华为·自动化·信息与通信
2601_949593655 小时前
小白入门ReactNative for OpenHarmony项目鸿蒙化三方库:react-native-fast-image
react native·react.js·harmonyos
Surplusx5 小时前
HCIP-vlan-华为专属Hybrid链路实验
华为
Swift社区6 小时前
鸿蒙游戏的资源加载与管理
游戏·华为·harmonyos
前端不太难6 小时前
鸿蒙游戏如何避免“巨型页面文件”?
游戏·华为·harmonyos