一、42 秒视频并不等于 86 张可用照片

这次给 3DGS 端侧重建补“视频导入”时,产品给的验收话术很简单:用户围着物体走一圈,App 每 500 ms 抽一帧,然后开始重建。真正跑起来却很快露馅。测试素材 lobby_walk_42s.mp4 时长 42.6 秒,按时间轴应请求 86 帧;首版把 86 个时间点同时交给解码器,峰值内存立刻抬高,最终得到的画面里还有大量连续近重复帧。它们不但没有补充视角,反而让特征匹配被同一块纹理占满。

更麻烦的是,“请求时间”不等于“实际 PTS”。视频是长 GOP,fetchFrameByTime() 为了命中可解码帧,可能返回相邻同步帧。首版只按请求序号命名,结果同一实际帧被写成三个文件;用户第二次换视频后,第一次任务迟到的 PixelMap 又覆盖了新任务进度。于是我把 Demo CaptureFrameLab 的目标改成了一条可证明的流水线:有限并发解码、记录实际 PTS、淘汰近重复和模糊帧,并且让每一个 PixelMap 都能在日志里对账。

二、先把任务口径固定下来

页面是 KeyframeExtractPage,本轮任务 ID 为 KF-0810,输入文件固定为 lobby_walk_42s.mp4。候选间隔 500 ms,PTS 近邻窗口 120 ms,dHash 汉明距离阈值 5,清晰度阈值 82。状态机只有六个业务状态:IDLE → OPENING → SAMPLING → FILTERING → READY_FOR_RECON,取消或异常统一进入 CANCELLED、FAILED。页面上不直接展示解码器内部状态,避免 UI 把一次回调当成一次业务提交。

这几个阈值不是为了凑“算法感”。120 ms 用来挡住多个请求命中同一同步帧;dHash 距离 5 用来识别相机缓慢移动时几乎没有视差的画面;清晰度 82 则是从本项目 18 段手持视频里取的保守线,低于它的帧通常已有明显运动模糊。它们都进入任务快照,任务开始后不再从滑块实时读取,否则运行到一半改阈值会让前后帧使用不同规则。

三、AVImageGenerator 只负责解码,不负责业务时序

第一段代码解决“源何时打开、时间点如何生成、资源由谁释放”。AVImageGenerator 绑定的是文件描述符区间,因此 fd、生成器和任务代次必须放在同一个会话里。示例省略了文件选择器,只保留真正参与 Demo 的部分。

import { media } from '@kit.MediaKit'
import { fileIo } from '@kit.CoreFileKit'

class FrameSession {
  private generator?: media.AVImageGenerator
  private sourceFd: number = -1
  readonly generation: number

  constructor(generation: number) {
    this.generation = generation
  }

  async open(path: string): Promise<void> {
    const file = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY)
    this.sourceFd = file.fd
    this.generator = await media.createAVImageGenerator()
    this.generator.fdSrc = { fd: file.fd, offset: 0, length: fileIo.statSync(path).size }
  }

  buildRequestedPts(durationUs: number): number[] {
    const points: number[] = []
    for (let pts = 0; pts <= durationUs; pts += 500_000) points.push(pts)
    return points
  }

  async close(): Promise<void> {
    await this.generator?.release()
    this.generator = undefined
    if (this.sourceFd >= 0) fileIo.closeSync(this.sourceFd)
    this.sourceFd = -1
  }
}

这里最重要的不是循环,而是所有权。生成器释放前 fd 必须仍然有效;close() 要能被取消、异常和页面退出重复调用,所以每次释放后立即清空句柄。项目里还给 close() 外层加了互斥 Promise,避免两个 finally 同时释放 Native 对象。durationUs 使用微秒,500 ms 写成 500_000,如果误把毫秒直接传入,抽帧点会全挤在视频开头,这类错误仅看“成功回调”很难发现。

四、把近重复判断移出 UI 线程

单纯比较 PTS 不够。手持相机在转角前会短暂停顿,即便实际 PTS 不同,画面也可能几乎一样。第二段代码把缩小后的灰度数据送进 TaskPool,计算 dHash 和拉普拉斯方差;主线程只接收几十字节的特征,不传 PixelMap 本体。

import { taskpool } from '@kit.ArkTS'

@Concurrent
function buildFeature(gray: Uint8Array, width: number, height: number): FrameFeature {
  let hash = 0n
  for (let y = 0; y < 8; y++) {
    for (let x = 0; x < 8; x++) {
      const left = gray[y * width + x]
      const right = gray[y * width + x + 1]
      hash = (hash << 1n) | (left > right ? 1n : 0n)
    }
  }
  let lapSum = 0
  let lapSq = 0
  let count = 0
  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      const i = y * width + x
      const lap = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - width] - gray[i + width]
      lapSum += lap; lapSq += lap * lap; count++
    }
  }
  return { hash, sharpness: lapSq / count - (lapSum / count) ** 2 }
}

async function analyze(gray: Uint8Array): Promise<FrameFeature> {
  return taskpool.execute(buildFeature, gray, 9, 8) as Promise<FrameFeature>
}

这里只传 9×8 的灰度缩略图,是刻意限制数据体积。dHash 需要横向相邻像素,宽度必须比目标位数多 1;清晰度分数只用于粗筛,不承担最终图像质量评价。TaskPool 返回后还要检查 generation,因为取消只能阻止后续提交,不能假设已经运行的任务瞬间消失。另一个易错点是 Uint8Array 的底层缓冲区转移:若业务还要复用原数组,就不能把它作为可转移对象交出。

五、一次只握住三张 PixelMap

第三段代码是流水线的核心。首版把所有 Promise 放进数组,表面上写得简洁,实际上 4K PixelMap 会在等待过滤时一起存活。现在使用容量为 3 的租约池;无论保留、淘汰还是抛错,当前帧都在 finally 中释放,最终只把文件路径与特征写进清单。

async function extractOne(session: FrameSession, requestUs: number,
  previous?: KeptFrame): Promise<Decision> {
  let pixelMap: image.PixelMap | undefined
  try {
    assertCurrent(session.generation)
    pixelMap = await this.generator.fetchFrameByTime(
      requestUs, media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC,
      { width: 960, height: 540 })

    const actualUs = await readActualPts(pixelMap)
    const gray = await downsampleGray(pixelMap, 9, 8)
    const feature = await analyze(gray)
    assertCurrent(session.generation)

    if (previous && Math.abs(actualUs - previous.actualUs) <= 120_000) {
      return { kind: 'DUPLICATE_PTS', actualUs }
    }
    if (previous && hamming(feature.hash, previous.hash) <= 5) {
      return { kind: 'DUPLICATE_VIEW', actualUs }
    }
    if (feature.sharpness < 82) return { kind: 'BLURRY', actualUs }
    const path = await writeAtomicJpeg(pixelMap, actualUs, session.generation)
    return { kind: 'KEPT', actualUs, path, feature }
  } finally {
    pixelMap?.release()
    pixelMap = undefined
  }
}

实际工程不能假设 PixelMap 自带业务需要的 PTS,因此 readActualPts() 是我们在解码适配层记录回调实际时间的封装;它和文件名都用 actualUs,不能退回请求时间。writeAtomicJpeg() 先写 .part,校验 JPEG 头与长度后再改名。取消发生在编码阶段时,finally 只负责释放内存,临时文件由同一代次的清理器删除。这样“资源释放”和“文件晋级”各有明确负责人,不会互相漏账。

六、目录、日志与状态必须能互相证明

Demo 的工程目录没有做成万能工具箱:pages/KeyframeExtractPage.ets 负责交互,pipeline/FrameSession.ets 管生成器与 fd,workers/FrameFeatureTask.ets 放 TaskPool 函数,store/FrameManifest.ets 管原子清单,model/FrameTypes.ets 只放数据类型。HiLog 统一使用标签 FramePipe,关键日志固定为 task=KF-0810 generation=14 requested=86 decoded=84 kept=58 duplicate=17 blurry=9 failed=2 peakLease=3。

这张调试现场里,模拟器必须在右侧,因为我要同时看中间的资源释放路径和右侧页面计数是否一致。圆环停在 68% 是故障复盘用的时间轴游标,右侧字段与顶部 READY_FOR_RECON 则来自同一任务的终态快照;两者并排是为了回看 68% 时的 PixelMap 峰值,不代表终态仍在处理。若日志 decoded 增长而页面没有变化,不一定是 UI 丢回调,也可能是同一帧被规则淘汰;只有 kept + duplicate + blurry = decoded 时才算账目闭合。两次解码失败单独计入 failed,所以 58 + 17 + 9 = 84,而 84 + 2 = 86。

七、结果不是“抽完了”,而是可交给重建

在 4K 原始视频上,最终任务耗时 2841 ms,保留 58 帧,输出清单 kf_0810_manifest.json。峰值 PixelMap 租约始终为 3,没有出现上一版本那种任务结束后内存仍缓慢下降的尾巴。页面状态只有在清单落盘、临时文件为 0、生成器释放完成后才进入 READY_FOR_RECON。这个顺序稍显保守,却保证重建模块拿到的目录不会继续变化。

手机页显示的 86/84/58、17 个近重复、9 个模糊、2 个失败以及 2841 ms,都来自同一份会话快照,不由多个组件各自拼数。用户点击“开始重建”时还会再次读取清单中的 generation=14;若后台清理或重新导入视频让代次改变,按钮会回到待确认状态,而不是带着旧路径继续跑。

八、这条管线刻意没有做的事

当前 Demo 不做光流选帧,也不承诺“58 帧一定能重建成功”。近重复、模糊和 PTS 规则只是进入 3DGS 前的工程闸门,真正的视角覆盖仍要由重建模块给出反馈。VFR 视频、损坏 GOP、HDR 色彩转换和带旋转矩阵的素材也应作为独立用例测试,不能把一段普通 SDR 视频的阈值直接推广到所有来源。

还有一个边界是页面生命周期:onPageHide 不立即销毁任务,因为用户可能只是打开系统选择器;只有明确取消、Ability 进入后台超过预算或页面被移除时才提升 generation 并收口资源。这个区别让正常的短暂切换不会白做工,也让真正离场后不会再有迟到提交。最终我保留的判断很朴素:视频抽帧不是一个返回 PixelMap 的循环,而是一笔必须闭合的资源账、时序账和数据质量账。

Logo

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

更多推荐