【共创季稿事节】动图魔方技术拆解 02:HarmonyOS 6.1 Media Kit 实战:从视频抽帧到 GIF89a 编码

SEO 信息

  • **SEO 标题**:【共创季稿事节】动图魔方技术拆解 02:HarmonyOS 6.1 Media Kit 实战:AVImageGenerator 视频抽帧到 GIF89a 编码
  • **SEO 摘要**:本文基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,拆解视频转 GIF 的真实落地链路:安全访问图库选视频、用 Media Kit `AVImageGenerator` 按时间戳抽帧、把 `PixelMap[]` 交给 `FrameProcessor` 做比例裁切和量化,再通过 `GifEncoderService` / `GifEncodeTask` 输出 GIF89a 文件。
  • **关键词**:HarmonyOS, Media Kit, AVImageGenerator, PixelMap, GIF89a, ArkTS, 视频转GIF, FrameProcessor
  • **文章封面**:`doc/csdn-series/covers/cover-02-media-kit.jpg`
  • **投稿方向**:HarmonyOS 6.1 创新特性适配实战
  • **项目环境**:HarmonyOS SDK `6.1.0(23)`,ArkTS,DevEco Studio,GIFRubiksCube 项目

“动图魔方”不是只做图片拼 GIF,它的核心入口其实是视频转 GIF。真正落地时,最难的不是写一个导出按钮,而是把 HarmonyOS 的视频帧能力、像素处理链和 GIF89a 编码链稳定接起来。本文只拆这一条链路:从视频素材进入,到最终作品页出现导出结果。

一、真实问题背景

做 GIF 工具时,用户最常见的素材不是一组图片,而是手机里的短视频、录屏和教程片段。项目里一开始就有“视频转 GIF”入口,但如果没有稳定的视频抽帧能力,后面的滤镜、字幕、帧率、导出体积全都无从谈起。

这条链路里我遇到的不是单点问题,而是一串工程约束:

  1. HarmonyOS 选择器默认走安全访问图库,应用并不能无边界访问整个相册;
  2. 视频帧如果按原尺寸全部读入,`PixelMap` 很容易把内存顶高;
  3. 抽帧的时间步长、最大帧数和导出 fps 需要统一,否则用户参数和导出结果会对不上;
  4. 抽出来的不是 GIF 直接可写的数据,而是一组 `PixelMap`,还要继续走裁切、缩放、滤镜、量化和 LZW 编码;
  5. 编码如果全塞在 UI 线程,导出按钮点下去以后界面会卡住。

所以第 02 篇不是讲“Media Kit 怎么调用一个 API”,而是讲怎么把 AVImageGenerator -> PixelMap[] -> FrameProcessor -> GIF89a 这一整条链路做成一个能跑的工程实现。

二、目标与边界

这一版实现的目标是:

  1. 支持本地视频导入并按导出 fps 取样;
  2. 抽出的帧进入统一的 GIF 处理链,而不是单独写一套视频导出逻辑;
  3. 控制内存和耗时,避免一次导出把应用拖死;
  4. 导出结果能真正落到作品页,而不是只做内存里的 demo;
  5. 整条链路在 HarmonyOS 6.1 工程里可编译、可截图、可复盘。

边界也很明确:

  • 当前首期只处理短视频场景,时长限制在 5 秒以内;
  • 抽帧上限控制在 75 帧,不追求“任意长视频都能导”;
  • 本文聚焦视频来源链路,不展开 GIF 文件结构和 LZW 位打包细节;
  • 音频轨、转场分析、关键帧优化等增强能力留到后续系列再拆。

三、链路拆分:从视频 URI 到作品页

项目里的视频转 GIF 不是一个大函数一把梭,而是拆成了四层:

层级 责任 对应文件
素材入口层 负责从图库或测试素材拿到视频 URI `entry/src/main/ets/pages/Index.ets`
视频抽帧层 负责 `fd -> AVImageGenerator -> PixelMap[]` `entry/src/main/ets/services/media/VideoFrameExtractor.ets`
帧处理层 负责裁切、缩放、滤镜、字幕、量化 `entry/src/main/ets/services/media/FrameProcessor.ets`
导出编码层 负责 GIF89a 编码、落盘、作品列表记录 `entry/src/main/ets/services/ExportService.ets`、`entry/src/main/ets/services/gif/GifEncodeTask.ets`

这层拆分解决了两个现实问题。

第一,视频来源只是“帧的生产方式”不同,后续编码链不应该重复实现。图片序列、视频帧、GIF 再编辑,最终都可以统一成 PixelMap[] 或 RGB 帧。

第二,用户看到的是“导出一个作品”,而不是“执行一个 Media Kit API”。因此最终落点必须是 ExportService.exportGif(),由它负责拼完整个导出流程并写入作品记录。

四、关键实现

4.1 用 AVImageGenerator 按时间戳抽帧,而不是整段视频解码

视频链路的入口在 VideoFrameExtractor.extract()。核心思路很直接:先把文件 URI 转成 fd,再用 AVImageGenerator.fetchFrameByTime() 按时间戳抓帧。

static async extract(filePath: string, durationSec: number, fps: number, signal: ExportSignal): Promise<image.PixelMap[]> {
  const safeFps = Math.max(1, Math.min(30, Math.round(fps)));
  const safeDuration = Math.max(1, Math.min(5, durationSec));
  const frameCount = Math.max(1, Math.min(MAX_FRAMES, Math.round(safeDuration * safeFps)));
  const stepUs = Math.round(1000000 / safeFps);

  const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
  const generator = await media.createAVImageGenerator();
  const pixelMaps: image.PixelMap[] = [];
  try {
    generator.fdSrc = { fd: file.fd };
    // ...
  } finally {
    await generator.release();
    fs.closeSync(file);
  }
}

这里没有直接把视频整段解出来,而是用“导出 fps -> 微秒步长 -> 逐帧抓取”的方式做时间采样。这样做的好处有两个:

  • 用户选的是 `10fps / 15fps / 24fps`,导出结果和 UI 参数天然一致;
  • 内存压力可控,不需要先解码出整段原始视频帧。

项目里还做了三重保护:

  • `fps` 限制在 `1..30`;
  • `durationSec` 限制在 `1..5`;
  • `frameCount` 最多 `75` 帧。

这不是“能力不够”,而是工程上必须先把稳定性守住。短视频做 GIF 的核心目标是可交付,而不是无限上探规格。

4.2 先探测首帧尺寸,再统一降采样

如果每一帧都按原始视频尺寸输出,后面传给 FrameProcessorPixelMap[] 很容易撑爆内存。因此抽帧前先做了一次首帧探测,决定统一的目标尺寸。

const probe = await generator.fetchFrameByTime(0, media.AVImageQueryOptions.AV_IMAGE_QUERY_CLOSEST, {
  width: -1,
  height: -1
});
const probeInfo = await probe.getImageInfo();
const target = VideoFrameExtractor.downscale(probeInfo.size.width, probeInfo.size.height);
await probe.release();

for (let index = 0; index < frameCount; index++) {
  const timeUs = index * stepUs;
  const frame = await generator.fetchFrameByTime(timeUs, media.AVImageQueryOptions.AV_IMAGE_QUERY_CLOSEST, {
    width: target.width,
    height: target.height
  });
  pixelMaps.push(frame);
}

downscale() 里把长边控制在 480 像素以内:

private static downscale(srcWidth: number, srcHeight: number): image.Size {
  const maxSide = Math.max(srcWidth, srcHeight);
  if (maxSide <= DOWNSCALE_EDGE || maxSide <= 0) {
    return { width: srcWidth, height: srcHeight };
  }
  const scale = DOWNSCALE_EDGE / maxSide;
  return {
    width: Math.max(1, Math.round(srcWidth * scale)),
    height: Math.max(1, Math.round(srcHeight * scale))
  };
}

这一步非常关键。因为后面的比例裁切、滤镜和量化都基于像素数组做处理,首帧统一降采样可以把内存、CPU 和最终文件体积同时压住。

4.3 视频帧不单独走分支,而是并入统一 FrameProcessor

很多项目做到这里会写成“视频专用编码器”,但这个项目刻意没有这样做。视频帧抽出来后,直接走和图片序列、GIF 再编辑共用的处理链:

private static async buildFromVideo(preset: ExportPreset, signal: ExportSignal): Promise<GifBuildOutput> {
  const fps = ExportService.parseFps(preset.fps);
  const delayCs = Math.max(1, Math.round(100 / fps));
  const pixelMaps = await VideoFrameExtractor.extract(preset.sourceUris[0], preset.duration, fps, signal);
  try {
    signal.checkCancelled();
    const result = await FrameProcessor.buildFramesFromPixelMaps(
      pixelMaps,
      [delayCs],
      ExportService.editOptions(preset),
      signal
    );
    return await ExportService.encodeResult(result, preset);
  } finally {
    await ExportService.releasePixelMaps(pixelMaps);
  }
}

这里最值得保留的是两个设计点。

第一个是 delayCs。GIF 延迟单位是厘秒,不是毫秒。导出 fps 先换算成每帧厘秒延迟,后面整个编码链都统一用这个单位,避免视频模式和图片模式时间语义不一致。

第二个是 finally 里的 releasePixelMaps()。视频帧是这一链路里最容易泄漏的对象,如果不显式释放,连着导几次短视频就能把应用状态拖垮。

4.4 统一做裁切、缩放、滤镜、字幕和量化

FrameProcessor 的定位不是“视频工具类”,而是“来源无关的帧处理核心”。视频来源和图片来源的共性,全都在这一层被吃掉。

static async buildFramesFromPixelMaps(
  pixelMaps: image.PixelMap[],
  delaysCs: number[],
  options: FrameBuildOptions,
  signal: ExportSignal
): Promise<GifFrameBuildResult> {
  const maxEdge = FrameProcessor.qualityMaxEdge(options.quality);
  const rgbFrames: RgbFrame[] = [];

  for (let index = 0; index < pixelMaps.length; index++) {
    const delayCs = index < delaysCs.length ? delaysCs[index] : delaysCs[delaysCs.length - 1];
    const frame = await FrameProcessor.toRgbFrame(pixelMaps[index], options.ratio, maxEdge, targetWidth, targetHeight, delayCs);
    rgbFrames.push(frame);
    signal.report(index + 1, pixelMaps.length, '处理帧');
  }

  return await FrameProcessor.framesToResult(rgbFrames, options, signal);
}

framesToResult() 里再继续做:

  • 帧区间裁剪;
  • 亮度 / 对比度调整;
  • 黑白、复古、冷暖色等滤镜;
  • 字幕叠加;
  • 全局调色板量化;
  • 索引帧输出。
const palette = FrameProcessor.buildPalette(working);
const cache = new Map<number, number>();
const frames: IndexedGifFrame[] = [];
for (let index = 0; index < working.length; index++) {
  frames.push(FrameProcessor.toIndexedFrame(working[index], palette, cache));
}
return { frames: frames, palette: palette };

这样设计的结果是:视频链路只负责“拿帧”,真正影响导出质量和风格的逻辑都沉到统一处理层,后续维护成本会低很多。

4.5 编码放到 TaskPool,失败再回退主线程

当视频帧经过量化以后,最后一步才是 GIF89a 编码。这里如果还在 UI 线程同步做,导出体验会非常差。因此项目里把编码单独包成了 TaskPool 任务:

@Concurrent
function encodeGifConcurrently(frames: IndexedGifFrame[], palette: number[], loopCount: number): ArrayBuffer {
  return GifEncoderService.encodeIndexedFrames(frames, palette, loopCount);
}

static async run(frames: IndexedGifFrame[], palette: number[], loopCount: number): Promise<ArrayBuffer> {
  const task: taskpool.Task = new taskpool.Task(encodeGifConcurrently, frames, palette, loopCount);
  const result = await taskpool.execute(task, taskpool.Priority.HIGH);
  return result as ArrayBuffer;
}

ExportService.encodeResult() 里还留了同步回退:

let bytes: ArrayBuffer;
try {
  bytes = await GifEncodeTask.run(frames, result.palette, 0);
} catch (err) {
  bytes = GifEncoderService.encodeIndexedFrames(frames, result.palette, 0);
}

这意味着即使后台线程执行失败,用户也不会直接得到一个“什么都没发生”的空导出,而是还能走一次保底路径。这种回退机制在工具类 App 里很重要,因为用户只关心最终有没有文件。

五、踩坑与修正

5.1 安全访问图库拿到的是受限文件视图,不是全相册权限

第一个容易误判的问题,是把素材选择当成“已经拿到了相册访问权”。实际上 HarmonyOS 的安全访问图库会明确提示:应用只能访问用户选中的图片和视频。

正文证据图如下:

安全访问图库提示

这意味着项目链路设计必须从“选中的 URI / 文件”出发,而不是假设应用自己能遍历整库。也正因为如此,VideoFrameExtractor 直接基于文件 fd 工作,而不是写成依赖全局媒体库扫描的实现。

5.2 视频抽帧和导出 fps 必须共用一个节拍

如果抽帧步长和导出 fps 各算各的,最终导出的动图速度会和页面上选的参数不一致。项目里的做法是:

  • 抽帧端:`stepUs = 1000000 / fps`
  • GIF 端:`delayCs = 100 / fps`

一个控制“取哪些帧”,一个控制“每帧显示多久”,两边都从同一个 fps 推出来,结果才能稳定。

5.3 不释放 PixelMap,视频导出很快就会变成内存问题

视频路径里最重的对象不是字符串、不是配置,而是 PixelMap。因此项目里有两层释放:

  • `VideoFrameExtractor` 释放 `generator` 和 `fd`
  • `ExportService` 在 `finally` 里统一释放 `pixelMaps`

这类资源释放代码看起来不起眼,但它决定了导出链能不能连续使用。

六、调试与验收

这一篇的正文证据不是概念图,而是项目里真实留下来的截图和构建输出。

6.1 素材选择器与安全访问提示

安全访问图库与视频入口

这个状态证明视频链路确实从系统选择器进入,而不是写死在本地 demo 数据上。

6.2 视频转 GIF 编辑页已加载测试素材

视频转GIF编辑页

这个界面能看到导出比例、fps、画质三个核心参数,和后文源码里的抽帧节拍、延迟换算是一一对应的。

6.3 导出完成后作品页出现真实结果

作品页中的导出结果

这张图说明链路不是停在“能抽帧”层面,而是真的走到了文件落盘和作品记录生成。

6.4 最新构建结果

本次在项目根目录执行:

$env:DEVECO_SDK_HOME='D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk'
$env:Path='D:\HuaweiDevelopFormalStudy\DevEco Studio\jbr\bin;D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk\default\openharmony\toolchains;D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\node;' + $env:Path
& 'D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\hvigor\bin\hvigorw.bat' assembleHap --mode module -p product=default --no-daemon

输出结论为:

BUILD SUCCESSFUL in 4 s 372 ms
WARN: Will skip sign 'hos_hap'. No signingConfigs profile is configured in current project.

这个结果足够支撑本文“当前视频导出链路在 HarmonyOS 6.1 工程内可编译”的判断。签名警告属于当前工程配置现状,不影响本次技术拆解。

七、工程复盘

复盘这条链路,我觉得最关键的不是某个 API,而是三条工程原则。

第一,视频只是帧来源,不应该绑死后面的处理链。AVImageGenerator 抓出的帧进入 FrameProcessor,这让图片、视频、GIF 再编辑三个入口真正共用了同一套导出核心。

第二,Media Kit 的能力必须和产品参数对齐。用户在 UI 上选 15fps,代码里就应该用它同时控制抽帧步长和 GIF 延迟,不能让“显示参数”和“真实导出”各玩各的。

第三,资源释放和后台编码不是可选优化,而是工具类 App 的基础稳定性。能导出一次不算完成,能连续导出、不中断 UI、作品页能看到结果,才算工程上真正可用。

八、验收清单

验收项 结果 说明
视频素材可从系统入口进入 通过 有安全访问图库真实截图
`AVImageGenerator` 按时间戳抽帧 通过 `VideoFrameExtractor.extract()` 已落地
帧数、fps、时长有硬限制 通过 `1..30fps`、`1..5s`、最多 `75` 帧
视频帧走统一 `FrameProcessor` 链路 通过 无视频专用重复编码逻辑
GIF 延迟与导出 fps 一致 通过 `delayCs = 100 / fps`
后台线程负责 GIF 编码 通过 `GifEncodeTask.run()` 使用 TaskPool
`PixelMap` 有显式释放 通过 `releasePixelMaps()` 在 `finally` 中调用
HarmonyOS 工程可构建 通过 Hvigor 输出 `BUILD SUCCESSFUL`
作品页出现导出结果 通过 有作品页真实截图

九、小结

HarmonyOS 6.1 做视频转 GIF,真正的难点并不是“能不能拿到一帧图”,而是怎么把抽帧节拍、像素处理、GIF89a 编码和资源释放串成一条稳定的工程链。这个项目的做法并不追求一步到位支持超长视频,而是先把 5 秒内、最多 75 帧、可导出、可回退、可复盘的基础能力做稳。

对“动图魔方”来说,第 02 篇的价值也在这里:它把项目里最核心的生产链路讲清楚了,后面无论继续拆 GIF 编码细节,还是拆本地素材链路,都会有一个清晰的上游起点。

十、下一篇衔接

下一篇会继续沿着导出链往前和往后各走一步,切到“本地优先素材链路”:为什么这个项目坚持不做登录态、不申请网络权限,而是围绕文件 URI、相册保存和系统分享来设计整个 GIF 工具的素材闭环。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐