React Native for OpenHarmony 实战:三方库 expo-video-thumbnails 的鸿蒙化适配指南
视频列表页需要封面图,但服务端没有提供——这是做视频类应用时特别常见的一件事。expo-video-thumbnails 解决的就是它:给一个视频地址和一个时间点,返回一张 JPEG 缩略图。它是 Expo 体系里的小而实用的库,图片选择器、视频编辑、媒体库这类应用经常会用到。
这个库在鸿蒙上没有官方实现,所以我做了一版适配。它的公开 API 只有一个函数,链路却比想象中长——光是"请求头"这一个选项,就逼出了两条完全不同的取帧通路。本文把整条链路写清楚,并把验证过程中一次"看起来像抽帧坏了"的误判也完整记录下来。
环境准备:本文不重复环境搭建步骤。RNOH(React Native for OpenHarmony)开发环境的完整配置见官方开发者指南:
https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md

一、版本配套:四件套必须对齐
RNOH 项目有个硬约束:RN 版本、RNOH 的 npm 包、RNOH 的 ohpm 包、DevEco SDK 四者必须对齐,错一个就是编译报错或者白屏。而且版本矩阵只是通用参考,具体库验证过的组合才算数。
我这次锁定的组合:
| 项 | 版本 |
|---|---|
expo-video-thumbnails | 57.0.2(与 npm 上游 latest 一致) |
| React Native | 0.84.1 |
| React | 19.2.3 |
@react-native-oh/react-native-harmony(npm) | 0.84.3 |
@rnoh/react-native-openharmony(ohpm) | 0.84.3 |
| Compile SDK | 26.0.0 |
| DevEco Studio | 26.0.0 Release |
| 实现方式 | TurboModule + CAPI 架构 |
写文章前我用
npm view expo-video-thumbnails version核对过,上游 latest 就是57.0.2,和适配 TAG 的上游部分一致。这一步别省——每个库都单独查一次。
二、适配步骤
第一步:上游同步到 AtomGit
expo-video-thumbnails 不是独立仓库,它是 expo/expo monorepo 里的一个包:packages/expo-video-thumbnails。
我的做法是在 oh-react-native 组织下建一个独立仓库,把上游这个包的源码同步过来,并锁死基线 commit:
upstreamCommit: 9e5319c0f821a27b7924841903abae50e2b41790
锁 commit 这一步不能省。上游是 monorepo,包目录会跟着主仓一起动;不锁基线的话,以后想复现"这版适配对应上游哪份代码"就说不清了。这条信息我写进了仓库的 spec.json。
第二步:本地克隆
git clone https://atomgit.com/oh-react-native/expo-video-thumbnails.git
cd expo-video-thumbnails
第三步:确定交付分支与版本号
适配包和普通库不一样,它是要被别的主程按版本引用的,所以版本号必须能一眼看出"上游版本 + 鸿蒙实现版本"。
我用 main 作开发分支,完成后打 TAG 交付:
git tag 57.0.2-ohos-1.0.0
命名规则是 <上游版本>-ohos-<适配版本>。调用方按 TAG 引用,就不会被后续改动影响到:
"expo-video-thumbnails": "git+https://atomgit.com/oh-react-native/expo-video-thumbnails.git#57.0.2-ohos-1.0.0"
第四步:适配实现——新增了什么、为什么
新增的第一块是 HAR 工程 harmony/video_thumbnails/:
harmony/video_thumbnails/
├── Index.ets # 导出 ExpoVideoThumbnailsPackage
├── oh-package.json5 # 声明包名 @react-native-ohos/expo-video-thumbnails
├── build-profile.json5
└── src/main/
├── module.json5
├── cpp/ # CAPI 架构下的 C++ 侧
│ ├── CMakeLists.txt
│ ├── ExpoVideoThumbnailsPackage.h # Package + TurboModule 工厂
│ └── ExpoVideoThumbnailsPackage.cpp
└── ets/
├── ExpoVideoThumbnailsPackage.ets # 把 TurboModule 交给 RNOH
└── ExpoVideoThumbnailsTurboModule.ts # ★ 真正的实现
第二块是 TurboModule 的实现。上游 JS 侧只有一个方法,但它是四参数的异步调用:
methodMap_ = {
ARK_ASYNC_METHOD_METADATA(getThumbnail, 4),
};
四个参数分别是:source(视频 URI)、time(毫秒)、quality(0~1)、headers(序列化成 JSON 字符串的请求头对象)。
第三块是 package.json 里的 autolinking 声明:
"harmony": {
"alias": "expo-video-thumbnails",
"autolinking": {
"ohPackageName": "@react-native-ohos/expo-video-thumbnails",
"etsPackageClassName": "ExpoVideoThumbnailsPackage",
"cppPackageClassName": "ExpoVideoThumbnailsPackage",
"cmakeLibraryTargetName": "rnoh_video_thumbnails"
}
}
这四个名字是 RNOH 找到这个包的凭据。少一个或者拼错,表现都是"编译过了但模块没注册",运行时才发现,很难查。
第五步:补全适配仓库所需的额外文件
上游 README 原文我没动,适配相关的东西单独成文件:
| 文件 | 作用 |
|---|---|
README.OpenHarmony.md / README.OpenHarmony_CN.md | 适配说明:能力对照、版本配套、接入方式、已知限制 |
spec.json | 机器可读的适配规格:包名、模块名、方法清单、版本配套、基线 commit、验证结论 |
RN_expo-video-thumbnails+代码检查报告.md | 代码检查结论、六个真机场景、限制说明 |
harmony/video_thumbnails.har | 预编译产物(4.9 KB),随包分发,装依赖即可拿到 |
__tests__/video-thumbnails.test.cjs | 十组契约测试(node --test) |
那份契约测试覆盖得挺细:默认值、毫秒→微秒换算、零质量不被覆盖、请求头到达 HTTP、流式下载要等 dataEnd 才结束、HTTP 失败与空响应、非法输入不分配解码器、取帧失败可重试、部分写入不留残图、并发独立 FD、销毁拒绝在途请求。里面几条断言后面直接成了我验证时的对照依据:
await api.getThumbnailAsync('file:///cache/video.mp4', {time: 1500, quality: 0});
assert.equal(extractors[0].timeUs, 1500000); // 毫秒 → 微秒
assert.equal(packed[0].options.quality, 0); // 0 没有被默认值覆盖
第六步:代码推送
git push origin main
git push origin 57.0.2-ohos-1.0.0
三、这个适配包长什么样
expo-video-thumbnails/
├── package.json # 含 harmony.autolinking
├── spec.json # 适配规格
├── src/
│ ├── index.ts # 公开 API + 入参校验
│ └── NativeVideoThumbnails.ts
├── harmony/
│ ├── video_thumbnails.har # 预编译产物(4.9 KB)
│ └── video_thumbnails/ # HAR 源码
├── __tests__/
└── README.OpenHarmony*.md
要点:
- 它是"带原生实现的适配包",必须编译原生代码,不能只
npm install就完事,还要走 ohpm 和 hvigor。 files字段里包含harmony,所以从 git 装依赖时能直接拿到 HAR。- 它没有依赖
expo-modules-core,是按 RNOH 的 TurboModule + autolinking 规范直接实现的。
JS 侧那一层很薄,但校验写得很扎实——而且注解里点明了每个上界的来由:
export async function getThumbnailAsync(sourceFilename: string, options: VideoThumbnailsOptions = {}) {
if (typeof sourceFilename !== 'string' || !/^(file|https?):\/\/\S+$/i.test(sourceFilename)) {
throw new TypeError('Expected a file://, http:// or https:// video URI');
}
const time = options.time ?? 0, quality = options.quality ?? 1, headers = options.headers ?? {};
if (typeof time !== 'number' || !Number.isFinite(time) || time < 0 || time > Number.MAX_SAFE_INTEGER / 1000) {
throw new TypeError('Expected a finite non-negative time in milliseconds');
}
if (typeof quality !== 'number' || !Number.isFinite(quality) || quality < 0 || quality > 1) {
throw new TypeError('Expected quality between 0 and 1');
}
for (const [name, value] of Object.entries(headers)) {
if (!name || /[\r\n]/.test(name) || typeof value !== 'string' || /[\r\n]/.test(value)) {
throw new TypeError('Expected string request headers');
}
}
return NativeVideoThumbnails.getThumbnail(sourceFilename, time, quality, JSON.stringify(headers));
}
三个细节值得单独说:
time的上界是MAX_SAFE_INTEGER / 1000,不是随便定的——因为原生侧要把毫秒 ×1000 转成微秒(见第五节点二),上界正好卡住这次换算不溢出;quality用??而不是||:quality: 0是合法值,用||会被默认值 1 覆盖掉;- 请求头名和值都拒绝
\r/\n:这是防 HTTP 头注入,很到位的细节。
四、接入宿主:三处改动面(外加一处自动生成的)
库本身不能独立运行,必须有一个 RNOH 宿主 App。社区已有现成的——oh-react-native/RNOH084Demo 是 RNOH 0.84.3 的多库验证宿主,版本和我这版适配完全一致,而且自带一个很实用的机制:
// harmony/entry/src/main/ets/entryability/EntryAbility.ets
const rnAppKey = want.parameters?.['rnAppKey'] as string | undefined;
AppStorage.setOrCreate('rnAppKey', rnAppKey ?? 'RNOH084Demo');
Index.ets 里 RNApp 的 appKey 取自它,于是一个宿主可以挂很多独立测试页,用命令行参数切换:
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey VideoThumbnailsTestApp
接入要改的地方
第一处:package.json。
"expo-video-thumbnails": "file:../expo-video-thumbnails"
第二处:两级 oh-package.json5 都要写 HAR。
"@react-native-ohos/expo-video-thumbnails":
"file:../node_modules/expo-video-thumbnails/harmony/video_thumbnails.har",
harmony/oh-package.json5 管工程级、harmony/entry/oh-package.json5 管模块级,两处都要加。只加一处会出现"能找到包但链接不上"。
这里有个很容易漏的点:跑 link-harmony 时,它只会自动更新工程级那一份,模块级那份要你自己加。详见第七节坑五。
第三处:在 ETS 侧注册 Package。
// harmony/entry/src/main/ets/RNOHPackagesFactory.ets
import type { RNPackageContext, RNOHPackage } from '@rnoh/react-native-openharmony';
import ExpoVideoThumbnailsPackage from '@react-native-ohos/expo-video-thumbnails';
export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
return [
new ExpoVideoThumbnailsPackage(ctx),
];
}
代码写在哪,这里说清楚:手工改动面就是这三个文件(外加 metro.config.js 的 watchFolders)。C++ 侧不用手改——CAPI 架构下 PackageProvider.cpp 会自动消费 autolinking 生成的 RNOHPackagesFactory.h。
那"自动生成的一处"是什么? 执行 link-harmony 时,它会一次性重写这四个文件:
• harmony/entry/src/main/cpp/RNOHPackagesFactory.h # C++ 侧注册
• harmony/entry/src/main/cpp/autolinking.cmake # add_subdirectory + 链接
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets # ETS 侧注册
• harmony/oh-package.json5 # 工程级 HAR 依赖
这四个文件头部都写着 DO NOT modify it manually, your changes WILL be overwritten.——别手改。
本库特有:视频源从哪来
其他库接完就能测,这个库不行——它需要一个真实可读的视频。这一步比接入本身还费事,值得单列。
本地视频:只能由宿主自己复制进沙箱。 库要求传入"实际可读的 file:// 视频地址",但 hdc 的身份是 uid=2000(shell),而应用缓存目录是 drwxrwx--- 20020070——外部推不进去(实测 touch 直接 Permission denied)。
所以我在宿主 EntryAbility.onCreate 里加了一个复制动作,把 rawfile 里的测试视频搬进缓存目录:
private prepareLocalVideo(): void {
const name = 'trailer.mp4';
try {
const target = this.context.cacheDir + '/' + name;
if (!fs.accessSync(target)) {
const content = this.context.resourceManager.getRawFileContentSync(name);
const file = fs.openSync(target, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE | fs.OpenMode.TRUNC);
const bytes: ArrayBuffer = content.buffer.slice(
content.byteOffset, content.byteOffset + content.byteLength);
fs.writeSync(file.fd, bytes);
fs.closeSync(file);
}
hilog.info(LOG_DOMAIN, LOG_TAG, 'local video uri=%{public}s size=%{public}d',
fileUri.getUriFromPath(target), fs.statSync(target).size);
} catch (error) {
hilog.error(LOG_DOMAIN, LOG_TAG, 'prepare local video failed: %{public}s', String(error));
}
}
实测打出来的路径很关键——fileUri.getUriFromPath 给的是 file://<包名>/<完整沙箱路径> 形式,不是 file:///data/...:
local video sandboxPath=/data/storage/el2/base/haps/entry/cache/trailer.mp4
local video uri=file://com.rnoh084.demo/data/storage/el2/base/haps/entry/cache/trailer.mp4 size=4372373
远程视频:一个本地 HTTP 服务 + 反向端口就够了。 为了验证"请求头到底有没有到服务器",我写了个小服务器,两个路径刻意做出差别:
GET /public.mp4 无需请求头,支持 Range → 验证 setUrlSource 通路
GET /auth.mp4 必须带 X-Test-Token,否则 403 → 验证流式下载通路
设备侧靠 hdc rport 反连主机:
node E:\rnoh-work\video-thumb-server.mjs # 主机上跑
hdc rport tcp:18080 tcp:18080 # 设备 127.0.0.1:18080 → 主机 18080
于是测试页可以直接请求 http://127.0.0.1:18080/public.mp4。服务器会把每个请求的方法、路径、收到的请求头、Range 全部记进日志——这份日志后面成了本库最有说服力的一份证据(见第八节)。
五、实现上的设计点
点一:为什么带头请求要自己下载整个视频
这是本库最值得讲的一处取舍。先看实现里的分支:
if (/^https?:\/\//i.test(source) && Object.keys(headers).length === 0) {
extractor.setUrlSource(source, headers); // 无头:交给系统媒体栈
} else {
let local = source;
if (/^https?:\/\//i.test(source)) {
// This ROM drops setUrlSource headers; stream authenticated media to an owned temporary file.
downloaded = this.cacheDirectory() + '/' + util.generateRandomUUID() + '.download';
await this.downloadSource(source, headers, downloaded);
local = downloaded;
}
input = fs.openSync(local, fs.OpenMode.READ_ONLY);
extractor.fdSrc = {fd: input.fd, offset: 0, length: fs.statSync(input.fd).size};
}
也就是说:
- 无请求头 → 直接把 URL 交给
AVMetadataExtractor.setUrlSource,由系统媒体栈按需拉流(实测走 Range 分段); - 带请求头 → 系统的取帧接口拿不到自定义头,所以先用库自己的
@ohos.net.http把整个视频下载到临时文件,再用fdSrc取帧。
这条分支不是凭感觉加的。适配方在真机上用独立原生探针复现过:setUrlSource 传进去的自定义请求头根本不会到达服务器(大小写两种形式都试了)。所以带头视频只能另起一条路。
这条路的代价写在库的 README 里,我觉得写得很诚实:
带头视频会完整下载到应用缓存后取帧,因此耗时、流量及临时磁盘占用与视频大小有关。连接超时 15 秒、读取超时 30 秒、下载总等待上限 120 秒。
而收益是:请求头真的能用了。第八节的服务器日志会直接证明这一点。
顺带说,下载那段的状态机写得很小心——它必须同时等两件事:HTTP 状态码返回 2xx,以及 dataEnd 事件到达。只等状态码会在流还没写完时就返回:
const finish = (): void => {
if (settled || !ended || status === 0) return; // 两者缺一不可
if (received === 0) { fail(new Error('ERR_VIDEO_THUMBNAILS_EMPTY_DOWNLOAD')); return; }
settled = true; resolve();
};
契约测试里专门有一条盯这个行为:streamed downloads wait for dataEnd even if the status promise has resolved。
点二:time 是毫秒,原生侧 ×1000 转微秒
上游的 time 单位是毫秒,而鸿蒙取帧接口要的是微秒:
pixels = await extractor.fetchFrameByTimeWithTimeout(
Math.round(time * 1000), // ms → µs
media.AVImageQueryOptions.AV_IMAGE_QUERY_CLOSEST_SYNC,
{},
15000,
);
契约测试把这条换算固定住了:time: 1500 → timeUs === 1500000。
这也解释了 JS 侧那个看起来奇怪的上界 time > Number.MAX_SAFE_INTEGER / 1000——它就是为了给这次 ×1000 留出安全空间。写适配时这类"单位不同、边界要跟着挪"的地方很容易漏。
点三:最近关键帧,而不是精确帧
第三个参数 AV_IMAGE_QUERY_CLOSEST_SYNC 决定了取帧策略:取离目标时间最近的同步帧(关键帧),不是精确那一帧。
这个选择是对齐上游 Android 的行为,而不是 iOS:
time| 毫秒,默认 0;采用最近关键帧策略,与上游 Android 路径一致。iOS 的精确帧策略不作为本实现保证。
它带来的实践后果是:时间精度受 GOP 长度限制。这不是缺陷,但会直接影响你怎么用——比如做一个"拖动进度条预览缩略图"的功能,如果视频关键帧间隔是 10 秒,那你的预览图在 10 秒内都是同一张。
我用测试视频把这件事量化出来了(第八节有完整对照表):视频关键帧在 0 / 9.33 / 13.92 / … 秒,于是 time=0、time=1000、time=2000 全部落到 0.00 秒那一帧,输出字节数一模一样(3188)。而 time=10000 开始就落到不同关键帧、输出 4 张大小各异的图。
点四:每个请求独立资源,临时文件原子改名
取帧要开一堆系统资源:解码器、PixelMap、输入 FD、输出 FD、编码器。实现把它们全部放在每次调用的局部变量里,finally 里统一释放:
} finally {
// Each request owns its decoder, PixelMap and FDs, including concurrent calls and failure paths.
if (packer) { try { await packer.release(); } catch (error) { this.warnCleanup(error); } }
if (pixels) { try { await pixels.release(); } catch (error) { this.warnCleanup(error); } }
if (extractor) { try { await extractor.release(); } catch (error) { this.warnCleanup(error); } }
if (input) { try { fs.closeSync(input); } catch (error) { this.warnCleanup(error); } }
if (output) { try { fs.closeSync(output); } catch (error) { this.warnCleanup(error); } }
if (temporary) { try { fs.unlinkSync(temporary); } catch (error) { this.warnCleanup(error); } }
if (downloaded){ try { if (fs.accessSync(downloaded)) fs.unlinkSync(downloaded); } catch (error) { this.warnCleanup(error); } }
...
}
两个细节:
- 输出先写
.part再原子改名,所以业务侧永远看不到半张图:temporary = destination + '.part'; // ... packToFile 写入 temporary ... fs.renameSync(temporary, destination); temporary = ''; committed = destination; - 下载的临时文件无论如何都删,成功失败都删。实测跑完全部场景后,目录里
.part/.download残留是 0。
清理本身失败也不掩盖主流程——只记一条 warn:
private warnCleanup(error: Error): void { this.ctx.logger.warn('VideoThumbnails cleanup failed', String(error)); }
契约测试里有一条专门验证这点:cleanup failure does not mask a successful image。
点五:销毁时拒绝在途请求,并且回收已经产出但没交付的图
override __onDestroy__(): void {
this.destroyed = true;
for (const cancel of this.downloads.values()) cancel();
}
destroyed 标记在关键节点都会被检查(ensureActive()),销毁后:
- 在途请求被拒绝(
ERR_VIDEO_THUMBNAILS_DESTROYED); - 正在下载的 HTTP 请求被 cancel;
- 已经写盘但还没返回给调用方的图也会被删掉,不留垃圾。
if (this.destroyed && committed) {
try { fs.unlinkSync(committed); } catch (error) { this.warnCleanup(error); }
this.ensureActive();
}
六、构建与运行
# 1) 装 JS 依赖 + 自动链接
npm install
./node_modules/.bin/react-native link-harmony
# 2) 生成调试签名 + 装 ohpm 依赖
cd harmony
devecocli signature generate
ohpm install --all
# 3) 打包 JS bundle(输出到 harmony/entry/src/main/resources/rawfile/)
cd ..
npm run dev
# 4) 编译 HAP
cd harmony
hvigorw --mode module -p product=default -p module=entry@default assembleHap --no-daemon
# 5) 安装 + 启动测试页
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
# 换页参数只在「冷启动」时生效:先 force-stop 再起(见坑四)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey VideoThumbnailsTestApp
耗时:在已有原生缓存的宿主上增量加入这个原生库,assembleHap 用了 6 分 50 秒;后续只改 JS 重编也要 6 分 37 秒(hvigor 照样把整套流水线走一遍)。HAP 从 79.5 MB 涨到 79.7 MB,新库本身约 169 KB。
如果宿主是全新 clone(没有原生编译缓存),首次构建会到 30–40 分钟量级。
看日志:
hdc shell "hilog -x | grep -i 'ExpoVideoThumbnails'"
hdc shell "hilog -x | grep -i 'TM created'"
hdc shell "hilog -x | grep -i 'video-thumb-test'"
看界面(读无障碍树,不用截图就能拿到文本):
devecocli ui layout
点击 / 滚动 / 截图:
hdc shell "uinput -T -c <x> <y>" # 点击
hdc shell "uinput -T -m <x1> <y1> <x2> <y2> <ms>" # 滚动
hdc shell snapshot_display -f /data/local/tmp/s.jpeg # 截图
hdc file recv /data/local/tmp/s.jpeg .\s.jpeg
七、踩坑记录
坑一:本地视频根本推不进应用沙箱
库要求传入"实际可读的 file:// 视频地址",但想从 PC 把视频送进去会撞墙:
$ hdc shell id
uid=2000(shell) gid=2000(shell) groups=2000(shell),1006(file_manager),...
$ hdc shell "touch /data/app/el2/100/base/com.rnoh084.demo/haps/entry/cache/.probe"
touch: '...': Permission denied
应用缓存目录是 drwxrwx--- 20020070 20020070,shell 既不是 owner 也不在组里。只能由宿主自己复制(见第四节)。
顺带一个有用的发现:hdc 在 file_manager 组里,所以它能读应用缓存目录——ls -la 和 hdc file recv 都可以,只是不能写。这让我能把生成的 JPEG 拉回本机肉眼核对(见第八节)。
坑二:fileUri.getUriFromPath 的形式和你想的不一样
我一开始按直觉准备了两个候选路径,都是错的:
file:///data/storage/el2/base/haps/entry/cache/trailer.mp4 ← 错
file://com.rnoh084.demo/cache/trailer.mp4 ← 错
实际是 file://<包名>/<完整沙箱路径>:
file://com.rnoh084.demo/data/storage/el2/base/haps/entry/cache/trailer.mp4
所以测试页干脆不硬编码,而是从一次成功取帧的输出 uri 反推缓存根——库把结果写在自己的 VideoThumbnails 子目录下,前缀形态必然一致:
function deriveCacheBase(uri: string): string | null {
const matched = /^(.*)\/VideoThumbnails\/[^/]+$/.exec(uri);
return matched ? matched[1] : null; // file://包名/…/cache
}
// 本地视频 = `${cacheBase}/trailer.mp4`
坑三:取样点选错,会以为"抽帧全坏了"
这是本次最容易误判的一处,值得完整记录。
第一版测试页我取了 time = 0 / 1000 / 2000 三个点,结果生成的文件全是 3188 字节、而且全是纯黑图。看起来像抽帧功能根本没工作。
真实原因是两件事叠在一起:
- 实现用的是最近同步帧策略(点三),
time落在 GOP 中间时会回到该 GOP 的关键帧; - 这个测试视频的关键帧间隔约 2–10 秒,第一个关键帧在 0.00 秒,而它恰好是一帧全黑(视频开头)。
于是 0 / 1000 / 2000 ms 全部映射到同一个 t=0 关键帧,输出自然一模一样、而且全黑。
确认方法:与其反复猜时间点,不如把视频的关键帧分布读出来。我写了个极简 MP4 解析(mp4-probe.mjs),读 stss(sync sample table):
trak[0] handler=vide codec=avc1 mdhd timescale=24 duration=52.208s samples=1253
keyframes=11 indices=[1,225,335,397,527,572,622,659,773,1023,1141]
keyframe times(s)= [0.00, 9.33, 13.92, 16.50, 21.92, 23.79, 25.88, 27.42, 32.17, 42.58, 47.50]
把取样点放到真实关键帧上(10/20/30/45 秒),输出立刻变成 4 张大小各异、有真实画面的图。
这里还有一个坑中坑:算关键帧时间必须用该 trak 自己的
mdhdtimescale,不能用mvhd的 movie timescale。这个视频的mvhdtimescale 是 1000,但视频轨的mdhdtimescale 是 24(帧率)。我第一版探针用了mvhd的 timescale,把关键帧时间全算成了 0–1.14 秒,看起来像"52 秒视频只有前 1 秒有关键帧"这种荒唐结论。
坑四:.ps1 带中文注释,Windows PowerShell 会解析失败
为了方便点按钮,我写了个按文本定位的辅助脚本。第一次运行时直接报:
At E:\rnoh-work\click-label.ps1:16 char:1
+ }
+ ~
Unexpected token '}' in expression or statement.
脚本本身没问题——问题是文件是无 BOM 的 UTF-8 且带中文注释。Windows PowerShell 按 GBK 解码,中文注释的字节把行尾换行"吃掉",于是后面的代码被并进注释里,语法结构直接崩掉。
解法:这类辅助脚本只写 ASCII 注释(或存成 UTF-8 with BOM)。
顺带第二个小坑:Windows PowerShell 5.1 不支持 ?? 运算符,写了会整段解析失败:
Unexpected token '??' in expression or statement.
坑五:link-harmony 不写模块级 oh-package.json5
执行 link-harmony 后日志写得很清楚:
• harmony/entry/src/main/cpp/RNOHPackagesFactory.h
• harmony/entry/src/main/cpp/autolinking.cmake
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets
• harmony/oh-package.json5
info updated 4 file(s), linked 4 libraries, skipped 1 libraries
四个文件里没有 harmony/entry/oh-package.json5——它的 --oh-package-path-relative-to-harmony 参数默认只指向工程级那一份。而前面第四节说过,两级都要写 HAR,缺模块级那处就是"能找到包但链接不上"这种不好查的症状。
同一个坑还有个小兄弟:metro.config.js 的 watchFolders 要加新库的真实目录。file: 依赖装进 node_modules 之后是个链接(Windows 上是 Junction),不是真目录,不加就解析不到源码。
顺带一个可以自检的好信号:bundle 打包成功时,Metro 会打印它重定向到鸿蒙实现的三方包清单——
[INFO] Redirected imports to 4 harmony-specific third-party package(s):
[INFO] • expo-keep-awake → expo-keep-awake
[INFO] • expo-localization → expo-localization
[INFO] • expo-system-ui → expo-system-ui
[INFO] • expo-video-thumbnails → expo-video-thumbnails
这里没有你的库,就说明 autolinking 没认出来。
坑六:rnAppKey 换页只在冷启动生效
RNOH084Demo 的换页机制是靠 EntryAbility.onCreate 里读 want.parameters['rnAppKey']。onCreate 只在 ability 冷启动时走一次,所以应用已经在运行时,再 aa start ... --ps rnAppKey <另一个页名> 只会把已有实例拉到前台,页面不会切——命令还报 start ability successfully,很容易被误导。
对策:换页前先强停,再带参数冷启动:
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey VideoThumbnailsTestApp
坑七:页面高度会变,固定坐标点击很脆
这个测试页的「最新结果」卡片会随取到的帧长高(多了预览图和几行文字),而「取样带」也会在第一次取帧后出现——按钮位置一直在动。用固定坐标点击,第一次能中、第二次就落到别的按钮上了。
对策:按无障碍树里的文本定位,每次点击前重新 dump、目标不在可视区就自动滚动。这样的辅助脚本一次写好,后面所有场景都能稳定复现。
八、模拟器验证
验证环境:Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,API 26,ohos-x64。
测试视频:trailer.mp4(4,372,373 字节,H.264,854×480,24fps,52.2 秒)。
TurboModule 注册
这个库的 TurboModule 不是启动时创建的,而是首次实际调用才创建——这点和有些库不同,抓日志时要注意:
RNInstance::TurboModuleProvider TM created: ExpoVideoThumbnails

两条远程通路(本库最核心的验证)
先看服务器侧的实际记录:
[05:52:25] GET /public.mp4 token=(none) range=bytes=0-8191 -> 206 bytes 0-8191/4372373
[05:52:25] GET /public.mp4 token=(none) range=bytes=8192-2105343 -> 206 ...
[05:55:46] GET /auth.mp4 token=dsh-rn-04 range=(none) -> 200 full 4372373
[05:55:56] GET /auth.mp4 token=wrong-token range=(none) -> 403
两条通路的指纹完全不同:
| 无请求头 | 带请求头 | |
|---|---|---|
| 走的代码路径 | extractor.setUrlSource | 库自己的 @ohos.net.http → 临时文件 → fdSrc |
| 服务器看到的请求 | Range 分段,206 | 无 Range,整包 200 |
| 自定义头 | — | token=dsh-rn-04 确实到达服务器 |
| 耗时 | 1721 ms(按需拉流) | 233–319 ms(本地副本取帧) |
token=dsh-rn-04 这一行就是关键证据:它证明了适配方那条"绕开系统取帧接口、自己下载"的设计是必要且有效的——如果直接用 setUrlSource 传头,服务器这一列会是 token=(none),然后返回 403。
错误路径也符合预期:错 token 时 JS 侧收到的是明确的 ERR_VIDEO_THUMBNAILS_HTTP_403,而不是一个空结果。

time 与关键帧的对应
用 mp4-probe.mjs 解析出这个视频的关键帧分布,再和实测输出对照:
| 请求 time | 最近的同步帧 | 输出字节数 |
|---|---|---|
| 0 ms | 0.00 s | 3188 |
| 1000 ms | 0.00 s | 3188(与 time=0 是同一帧) |
| 2000 ms | 0.00 s | 3188(同上) |
| 10000 ms | 9.33 s | 8013 |
| 20000 ms | 21.92 s | 31844 |
| 30000 ms | 32.17 s | 28612 |
| 45000 ms | 42.58 s | 7322 |
这张表同时说明了两件事:
- 抽帧是真的、且随时间变化——4 个不同关键帧给出 4 个不同大小;
- "最近关键帧"的粒度后果是可见的——1000 ms 和 2000 ms 拿到的是同一张图。

抽帧内容真实性(把图拉回来看)
无障碍树读不出画面内容,所以我用 hdc file recv 把生成的 JPEG 拉回本机肉眼核对(hdc 在 file_manager 组,能读应用缓存):
| 时间点 | 画面 |
|---|---|
| 0.00 s | 纯黑 |
| 9.33 s | 黑底白字 “THE BLENDER FOUNDATION presents” 字幕卡 |
| 21.92 s | 暗场景,可见真实画面结构 |
| 32.17 s | 暗场景,可见人物轮廓 |
结论:抽帧产出的是真实视频帧。这个测试视频(一部 Blender 开放电影预告片)整体偏暗,加上 t=0 那帧全黑,才让最初的几次取样看起来像"全黑故障"。
quality 0 与 1
在 10 秒处(字幕卡,文字细节丰富)做受控单跑:
quality=0 @10s -> 8a9ea8a2-….jpg 3444 字节
quality=1 @10s -> a0b77b0b-….jpg 8013 字节
quality: 0 确实传到了编码器(没有被默认值覆盖),输出差异明显。
非法输入与并发
八种非法输入全部在 JS 侧被拦下并抛 TypeError,一个都没触达原生:
非法输入「URI 不是 file/http/https」-> TypeError(JS 侧拦下,未调原生)
非法输入「URI 用了 ftp 协议」-> TypeError(JS 侧拦下,未调原生)
非法输入「空 URI」-> TypeError(JS 侧拦下,未调原生)
非法输入「time 为负」-> TypeError(JS 侧拦下,未调原生)
非法输入「time 为 NaN」-> TypeError(JS 侧拦下,未调原生)
非法输入「quality 大于 1」-> TypeError(JS 侧拦下,未调原生)
非法输入「quality 为负」-> TypeError(JS 侧拦下,未调原生)
非法输入「请求头名含换行」-> TypeError(JS 侧拦下,未调原生)
并发三路(本地 file:// 10s / 远程无头 30s / 远程带头 45s)全部成功:
并发 3 路取帧(本地 10s / 远程无头 30s / 远程带头 45s)
并发[0] -> 854 × 480
并发[1] -> 854 × 480
并发[2] -> 854 × 480
资源清理
跑完全部场景(含失败与并发)之后:
ls …/cache/VideoThumbnails | grep -c -E '\.part|\.download' -> 0
临时文件残留 0,与库声明一致。
能力对照
| 能力 | 结果 |
|---|---|
getThumbnailAsync(本地 file://) | ✅ 854 × 480,238 ms |
getThumbnailAsync(远程,无请求头) | ✅ 854 × 480,1721 ms,服务器记录 Range 206 |
getThumbnailAsync(远程,带请求头) | ✅ 854 × 480,233–319 ms,服务器收到自定义头 |
| 错误路径 | ✅ 错 token → ERR_VIDEO_THUMBNAILS_HTTP_403;失败不用空 URI 冒充成功 |
time | ✅ 毫秒;最近同步帧策略,实测与关键帧分布完全吻合 |
quality | ✅ 0~1 → JPEG 0~100;0 不被默认值覆盖(3444 vs 8013 字节) |
| 非法输入 | ✅ 八种全部 JS 侧 TypeError,未触达原生 |
| 并发 | ✅ 三路混合并发全部成功,每请求独立资源 |
| 临时文件清理 | ✅ 残留 0 |
| TurboModule 注册 | ✅ TM created: ExpoVideoThumbnails(首次调用时创建) |
九、已知限制
一、time 的精度受关键帧间隔限制。 实现用最近同步帧策略(与上游 Android 一致),GOP 内的任意时间点都会回到该 GOP 的关键帧。实测中 time=1000 与 time=2000 拿到的是同一张图。如果业务需要精确帧,这个库在鸿蒙上不满足;需要自己做插值或改用其他方案。
二、带请求头的视频会被完整下载一遍。 因为系统取帧接口拿不到自定义头,带头视频走"先整包下载到临时文件再取帧"。代价是耗时、流量、临时磁盘占用都与视频大小成正比,且有 120 秒总等待上限。大视频要注意这个开销。
三、只支持 file:// / http:// / https:// 三种来源。 上游 Android 的 content:// 不映射为鸿蒙 URI:需要宿主先通过文件选择/授权流程拿到可读取的 file:// URI。这一条在 JS 侧和原生侧各校验一次。
四、只返回一张图和实际像素宽高,不返回码率、时长等元信息。旋转由 SDK 按元数据自动应用,实现不再手工旋转(避免重复变换)。
五、编解码器与远程容器由系统决定。 本次只在真机/模拟器上验证了 H.264 MP4 和 loopback HTTP;其他编解码器、TLS 服务、HLS/DASH 与直播未测(后两者不在媒体 URL 数据源支持范围内)。
六、进程销毁、磁盘写失败、流式事件顺序由 mock 覆盖,本次未在设备上刻意复现这些异常路径。
七、其他 ROM / 真机未验证。 适配方记录的是 OpenHarmony-7.0.0.105;本次在 HarmonyOS 7.0.0(26.0.0) Beta2 模拟器上通过。
十、常见问题
Q:为什么不能直接 npm install expo-video-thumbnails?
A:npm 上那个包只有 iOS/Android 实现,没有鸿蒙原生代码。本仓库是独立的鸿蒙实现,要按 git+...#57.0.2-ohos-1.0.0 或者本地 file: 的方式装。README 里也写明了这一点。
Q:需要额外依赖 expo-modules-core 吗?
A:不需要。这版是按 RNOH 的 TurboModule + autolinking 规范实现的,package.json 里的 harmony 字段就是它接入 RNOH 的全部凭据。
Q:为什么我传了 time: 1000,拿到的却是开头那一帧?
A:因为实现取的是离目标时间最近的关键帧,不是精确帧。如果视频第一秒内没有关键帧,time=1000 就会回到 t=0 那一帧。想确认你手里视频的关键帧在哪,可以解析 MP4 的 stss 表(注意用视频轨自己的 mdhd timescale,不是 mvhd 的)。
Q:远程视频明明能播,为什么取帧却 403?
A:检查 headers 有没有真的传进去。没有请求头时走系统媒体栈(setUrlSource),带请求头时才走库自己的 HTTP 下载。如果你把鉴权头放在别处(比如 URL query),那走的是无头路径。另外明文 HTTP 是否允许由宿主与系统网络策略决定,库不修改网络策略。
Q:我设了 quality: 0,会不会被当成"没传"而用默认值?
A:不会。JS 侧用的是 options.quality ?? 1,0 是合法值;契约测试也专门断言了 packed.options.quality === 0。实测 quality 0 与 1 的输出字节数是 3444 与 8013,差异明显。
Q:本地视频路径怎么写才对?
A:必须是 file:// 开头。鸿蒙上 fileUri.getUriFromPath(沙箱绝对路径) 给出的是 file://<包名>/<完整沙箱路径> 形式(例如 file://com.rnoh084.demo/data/storage/el2/base/haps/entry/cache/trailer.mp4),不是 file:///data/...。另外外部进程(包括 hdc)写不进应用沙箱,本地文件要么由宿主复制,要么由应用自己下载。
Q:并发调用安全吗?
A:安全。每个请求持有独立的解码器、PixelMap、输入/输出 FD 和输出文件名,finally 里各自释放。实测三路混合并发(本地 + 无头 + 带头)全部成功。
Q:生成的图会自己清理吗?
A:不会。成功返回的图留在宿主缓存的 VideoThumbnails 目录里,由业务自行管理和复用,可能被系统缓存清理。需要长期保存就自己复制到持久目录。库只保证临时文件(.part / .download)不留残留——实测跑完全部场景后残留为 0。
Q:为什么我在模拟器上编译要这么久?
A:看是不是首次编译。已有原生缓存的宿主增量加一个库是 6–8 分钟;全新 clone 的宿主首次编译要 30–40 分钟。而且只改 JS 重新打包也要 6–7 分钟(hvigor 会把整套流水线走一遍),所以别把 UI 微调留到最后做。
Q:怎么确认库真的生效了,而不是只是没报错?
A:本库有四层证据:① TM created: ExpoVideoThumbnails(模块真的注册了);② 服务器日志里自定义请求头到达了服务器、无头请求走了 Range 206(两条通路都对);③ 输出文件字节数随 time 变化(说明真在读视频而不是返回固定图);④ 把 JPEG 拉回本机肉眼核对,确认是真实画面。第 ③④ 两层最关键——只调 API 不报错,是看不出"取到的是不是同一帧"的。
小结
这个库只有一个公开函数,但它把三件事讲得很清楚:
- 平台能力有缺口时,"绕路"是设计的一部分。 系统取帧接口拿不到自定义请求头,于是带头视频只能整包下载到临时文件再取帧——代价是流量和耗时,收益是鉴权视频真的能用了。验证这条设计的唯一方法是看服务器收到了什么,而不是看 JS 侧有没有报错。
- 单位换算是适配里最容易出错的地方。
time对外是毫秒、对内是微秒(×1000),JS 侧的上界就是为这次换算服务的;quality对外是 0~1、对内是 0~100,而0必须用??而不是||才不会被默认值吃掉。 - "最近关键帧"是一个必须让调用方知道的行为。 它不是缺陷,但会改变业务预期——时间精度受 GOP 限制,10 秒 GOP 就意味着 10 秒内拿到同一张图。
验证方法上,这次最大的收获是一条教训:取样点选错,会把"正常"看成"坏了"。最初三次取样全部落在同一个全黑关键帧上,输出一模一样、全是黑的,看起来就像抽帧功能完全没工作。解法不是继续猜时间点,而是把视频的关键帧分布读出来,再把取样点放到真实关键帧上。更进一步——把生成的图拉回本机看一眼,才知道"黑"到底是抽帧坏了还是视频本身就黑。
适配链路本身,还是那几条老规律在起作用:
- autolinking 的四个身份名(ohpm 包名、ETS 包类、C++ 包类、CMake 目标),少一个都是"编译过了但模块没注册";
- HAR 的工程级与模块级双重声明,而且
link-harmony只会自动写工程级那一份,模块级要自己加; - 版本四件套必须对齐,且以实测组合为准。
最后是两个和"能不能复现"直接相关的细节:hdc 是 shell 身份,写不进应用沙箱,但能读出来——写不进去意味着本地测试资产得靠宿主自己搬,能读出来意味着生成物可以拉回本机核对;.ps1 里写中文注释会踩 Windows PowerShell 的编码坑,辅助脚本要么存 UTF-8 with BOM,要么干脆只写 ASCII。
本篇用到的库
| 项 | 内容 |
|---|---|
| 三方库 | expo-video-thumbnails(上游 57.0.2 的鸿蒙适配版) |
| 适配仓库 | https://atomgit.com/oh-react-native/expo-video-thumbnails |
| 适配 TAG | 57.0.2-ohos-1.0.0 |
| ohpm 包名 | @react-native-ohos/expo-video-thumbnails |
| HAR | harmony/video_thumbnails.har(4.9 KB) |
| 基线 commit | 9e5319c0f821a27b7924841903abae50e2b41790 |
| 宿主工程 | RNOH084Demo(测试页 rnAppKey = VideoThumbnailsTestApp) |
"expo-video-thumbnails": "git+https://atomgit.com/oh-react-native/expo-video-thumbnails.git#57.0.2-ohos-1.0.0"
// harmony/oh-package.json5 与 harmony/entry/oh-package.json5 都要加
"@react-native-ohos/expo-video-thumbnails":
"file:../node_modules/expo-video-thumbnails/harmony/video_thumbnails.har",
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey VideoThumbnailsTestApp
// 公开 API(唯一一个函数)
import { getThumbnailAsync } from 'expo-video-thumbnails';
const thumbnail = await getThumbnailAsync(videoUri, {
time: 10000, // 毫秒;实际落到最近的关键帧
quality: 0.8, // 0~1
headers: {'Authorization': 'Bearer …'}, // 有头则走整包下载路径
});
// thumbnail = { uri, width, height }
验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3 |
| Node.js | v24.14.0 |
| DevEco Studio | 26.0.0.621 |
| HarmonyOS SDK | API 26(26.0.0.32) |
| 设备 | HarmonyOS 7.0.0(26.0.0) Beta2 模拟器 Pura X View(ohos-x64) |
| 测试视频 | trailer.mp4,H.264,854 × 480,24 fps,52.2 s,4,372,373 字节 |
| 宿主 HAP 产物 | entry-default-signed.hap(79.7 MB) |
| 本次增量构建 | assembleHap 6 分 50 秒(改 JS 后重编 6 分 37 秒) |
欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN
React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native
RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview
更多推荐

所有评论(0)