Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复

一、背景

最近在做一个 Flutter 相册类 APP,核心功能是对本地图片和视频进行 AI 分析,包括:

  • 图片 / 视频入库;

  • 本地 embedding 生成;

  • 语义搜索;

  • 标签生成;

  • 主题聚类;

  • 本地缓存和分析结果管理。

项目最初的图片链路比较成熟:图片可以通过 MobileCLIP 生成图像向量,进入向量索引,并参与标签、垃圾照片过滤、主题聚类等下游功能。

但是视频链路存在一个典型问题:媒体层看起来已经支持视频,但语义分析层并不够稳定。

换句话说,视频可以被扫描、展示、播放,但在 AI 分析层面,视频是否真的被当成"视频"处理,而不是被当成"一张封面图"处理,就需要进一步排查。

这篇文章记录一次完整的工程修复过程,目标不是做算法创新,而是让 APP 的视频模态更稳定、更可追溯、更不容易污染图片链路。


二、最初发现的问题

排查后发现,项目的视频链路主要有三个风险。

1. 视频输入不可信

视频模型使用的是类似 MobileViCLIP 的视频 embedding 服务,理论上输入应该是多帧视频,例如 8 帧。

但是主分析链路中,视频帧来源并不总是可靠的。如果只能拿到缩略图,那么模型实际吃到的可能不是 8 张真实视频帧,而是 8 张重复封面图。

这会导致一个很隐蔽的问题:

复制代码
模型是视频模型,
但输入退化成了图片。

这种情况下,视频 embedding 语义质量会明显下降,而且后续很难定位问题。


2. 视频 embedding 和图片 embedding 混用

原来的数据结构中,向量字段叫做:

复制代码
imageEmbedding

这个名字本身就说明它最初是给图片向量设计的。

但是如果把视频 embedding 也写进这个字段,就会带来很多下游问题:

  • 图片主题聚类可能误读视频向量;

  • 图片垃圾过滤可能错误处理视频;

  • 图片标签体系可能套到视频上;

  • 后续调试时无法区分向量来自图片模型还是视频模型。

对于一个稳定的 APP 来说,这种"字段语义污染"风险很大。


3. 视频下游策略过于激进

图片链路可以做:

  • 图片标签;

  • 垃圾照片过滤;

  • 主题聚类;

  • 视觉相似度检索。

但是视频不应该一开始就完全复用图片策略。

尤其是:

复制代码
图片标签原型 ≠ 视频标签原型
图片垃圾过滤规则 ≠ 视频垃圾过滤规则
图片 embedding 空间 ≠ 视频 embedding 空间

所以更稳妥的策略是:

复制代码
视频先参与基础检索和分析,
但不要强行进入图片标签、图片 junk filter 和图片主题聚类。

三、修复目标

这次修复的原则非常明确:

复制代码
先让视频输入可信,
再整理 embedding 语义,
最后收缩视频下游策略。

具体目标包括:

  1. 视频分析时优先真实抽帧;

  2. 记录视频帧来源诊断信息;

  3. 视频 embedding 不再写入 imageEmbedding;

  4. embedding 索引中持久化最小元数据;

  5. 视频不再强行套图片标签和垃圾过滤;

  6. 后台 spool 分析路径和前台统一分析路径保持一致;

  7. 修复 ObjectBox 生成文件不同步;

  8. 修复 Android 真机启动问题。


四、第一步:让视频输入可信

原先的视频输入链路中,存在退化成缩略图重复帧的风险。

因此这次给视频抽帧增加了明确诊断信息,例如:

复制代码
frameSource
frameCount
isRepeatedFrame

其中 frameSource 用于描述视频帧来源,例如:

复制代码
ffmpeg_fps_sample
android_thumbnail
ios_thumbnail
fallback_thumbnail_repeat

特别注意,这里把 FFmpeg 抽帧标记为:

复制代码
ffmpeg_fps_sample

而不是写成精确 timestamp 抽帧。

原因是当前 FFmpeg 命令更接近:

复制代码
-vf fps=1

这表示按 FPS 粗采样,而不是严格按照指定时间戳抽帧。如果诊断信息写成精确 timestamp,会误导后续调试。

所以这次选择保守表达:

复制代码
frameSource = ffmpeg_fps_sample
frameTimestampsUs = 不写

这比"看起来精确但实际不精确"更稳定。


五、第二步:增加 MediaEmbeddingRecord

原来的 embedding 结果主要关注向量本身,但对于视频来说,仅有向量是不够的。

我们还需要知道:

  • 这是图片向量还是视频向量;

  • 来自哪个模型;

  • 属于哪个 embedding family;

  • 使用哪个 text space;

  • 视频帧来源是什么;

  • 是否是重复帧;

  • 帧数是多少。

因此新增了一个更明确的代码层结构,可以理解为:

复制代码
class MediaEmbeddingRecord {
  final String mediaKind;
  final String modelFamily;
  final String modelVersion;
  final String textSpace;
  final String? frameSource;
  final int? frameCount;
  final bool? isRepeatedFrame;
}

这个结构的意义是:

复制代码
不要再把所有向量都当成图片向量。

对于图片,它可以是:

复制代码
{
  "mediaKind": "image",
  "modelFamily": "mobileclip_image",
  "modelVersion": "mobileclip_image_xxx",
  "textSpace": "mobileclip_text"
}

对于视频,它可以是:

复制代码
{
  "mediaKind": "video",
  "modelFamily": "mobileviclip_video",
  "modelVersion": "mobileviclip_video_small_onnx_v1",
  "textSpace": "mobileclip_ncnn_text_assumed",
  "frameSource": "ffmpeg_fps_sample",
  "frameCount": 8,
  "isRepeatedFrame": false
}

六、第三步:embedding 元数据持久化

只在运行时保存诊断信息是不够的。

如果用户之后反馈:

复制代码
为什么这个视频搜不到?
为什么这个视频语义不准?
为什么这个视频像图片一样被处理了?

如果没有持久化元数据,就很难追溯。

因此这次在 embedding 索引实体中增加了一个最小字段:

复制代码
embeddingMetaJson

它不是放在 PhotoEntity 主表中,而是放在 embedding 索引行上。

这样有几个好处:

  1. 不污染照片主表;

  2. 不影响原始媒体信息;

  3. 向量和向量元数据放在一起;

  4. 以后搜索和 debug 可以读取;

  5. 字段扩展更灵活。

元数据保持很窄,只存稳定性排查真正需要的信息:

复制代码
{
  "mediaKind": "video",
  "modelFamily": "mobileviclip_video",
  "modelVersion": "mobileviclip_video_small_onnx_v1",
  "textSpace": "mobileclip_ncnn_text_assumed",
  "frameSource": "ffmpeg_fps_sample",
  "frameCount": 8,
  "isRepeatedFrame": false
}

七、第四步:视频不再污染 imageEmbedding

这是这轮修复中非常关键的一点。

对于图片:

复制代码
PhotoEntity.imageEmbedding 可以继续写入图片向量。

但对于视频:

复制代码
不再写 PhotoEntity.imageEmbedding。

视频 embedding 只写入向量索引,并携带自己的 modelVersionembeddingMetaJson

这样可以避免:

  • 视频进入图片主题聚类;

  • 视频被图片垃圾过滤误伤;

  • 图片搜索和视频搜索空间混乱;

  • legacy 代码误读视频向量。

这属于工程稳定性中很重要的"语义隔离"。


八、第五步:收缩视频标签和垃圾过滤策略

原来的图片链路里有标签服务和 junk filter。

但是视频不应该直接套图片标签原型。

所以这次做了保守策略:

复制代码
视频先固定打基础标签:"视频"

不再直接调用图片标签 taxonomy 去解释视频 embedding。

同时,视频也不进入图片垃圾过滤逻辑。

这不是说视频永远不需要标签或质量过滤,而是当前阶段先保证稳定:

复制代码
先不误伤,
再谈智能。

对于 APP 来说,稳定优先级高于"看起来很智能"。


九、第六步:spool 后台路径也要一致

项目中不仅有统一分析管线,还有后台 spool 分析路径。

如果只改前台统一分析管线,而后台仍然拿视频缩略图重复 8 次送进视频模型,就会出现前后台结果不一致:

复制代码
前台分析的视频语义较准;
后台续跑的视频语义退化。

因此这次也修了 spool worker:

  • manifest 中带上 mediaKind

  • worker 优先尊重 manifest 中的 mediaKind;

  • 视频 / Live Photo 优先调用统一 reader;

  • 优先使用真实 videoFrameBytes

  • 如果真实帧不可用,才 fallback 到缩略图重复帧;

  • spool 结果文件也携带 embeddingMetaJson

  • 回放写索引时写入同一套 meta。

这样前台和后台的视频 embedding 语义基本统一。


十、第七步:ObjectBox 生成文件同步

改了 ObjectBox entity 后,必须重新生成绑定文件。

这次遇到的问题是:

复制代码
PhotoEntity_.isAiAnalysisCandidate 不存在

实体中字段已经存在,但生成代码没有同步,导致测试和 analyze 报错。

解决方式是重新生成 ObjectBox:

复制代码
dart run build_runner build --delete-conflicting-outputs

生成后确认:

复制代码
PhotoEntity_.isAiAnalysisCandidate 已存在
PhotoEmbeddingIndexEntity.embeddingMetaJson 已存在

这里还有一个小细节:

lib/objectbox.g.dart 原本被 .gitignore 忽略且未跟踪,但为了保证构建稳定,这次强制加入版本控制。

否则别人拉代码后可能遇到实体和 generated binding 不一致的问题。


十一、第八步:修复 Android 真机启动 ClassNotFoundException

视频链路修完后,真机运行时又遇到一个 Android 原生启动错误:

复制代码
java.lang.ClassNotFoundException:
Didn't find class "com.example.photo_album.MainActivity"

Manifest 中声明的是:

复制代码
<activity android:name=".MainActivity" />

在当前包名下会解析成:

复制代码
com.example.photo_album.MainActivity

而源码路径和包名也是对的:

复制代码
package com.example.photo_album

class MainActivity : FlutterActivity()

所以问题不是 Manifest,也不是包名,而是:

复制代码
MainActivity.kt 没有被编译进 APK。

进一步排查发现,android/app/build.gradle.kts 中没有应用 Kotlin Android 插件。

修复方式是在 app module 中加入:

复制代码
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("dev.flutter.flutter-gradle-plugin")
}

同时不恢复之前那个在当前插件组合下不可解析的顶层:

复制代码
kotlin {
    compilerOptions {
        ...
    }
}

修复后,构建产物中已经能看到:

复制代码
build/app/tmp/kotlin-classes/debug/com/example/photo_album/MainActivity.class

再次运行后,APP 成功进入 Flutter 运行态,日志中出现:

复制代码
FlutterJNI: flutter was loaded normally
A Dart VM Service on V2338A is available
[objectbox] init: path=... isOpen=true

这说明:

复制代码
APK 构建成功
APK 安装成功
MainActivity 启动成功
Flutter Engine 启动成功
ObjectBox 初始化成功

十二、测试与验证

这次修复后执行了以下验证:

复制代码
flutter test test/service/media_embedding_record_test.dart
flutter test test/service/theme_cluster_service_test.dart
flutter test test/view/album_page_split_compile_test.dart

结果均通过。

dart analyze 仍然有 warning/info,但没有 error。也就是说当前没有阻塞构建的 Dart 编译错误。

真机运行方面,APP 已经成功启动到 Flutter 层,并且 ObjectBox、OCR policy、MobileCLIP text 模型等模块都开始初始化。

日志中有一些 vivo / Android 图形栈相关 warning,例如:

复制代码
qdgralloc
SELinux avc denied
vendor display property access denied

这些更像是系统级 warning,不是当前 APP 代码崩溃。只要没有 FATAL EXCEPTIONSIGSEGVUnhandled Exception,暂时不作为主问题处理。


十三、commit 拆分

最后这轮改动拆成了 4 个 commit:

复制代码
4136927 fix(album): 添加本地缓存清理操作
5bed857 fix(android): 编译 app 模块的 Kotlin MainActivity
2f89b88 fix(video): 稳定视频 embedding 并持久化元数据
4aecfbf chore(objectbox): 重新生成 embedding 元数据绑定

拆分原则是:

  1. ObjectBox 生成同步单独提交;

  2. 视频 embedding 稳定化单独提交;

  3. Android MainActivity 编译问题单独提交;

  4. 相册页本地缓存按钮修复单独提交。

这样后续如果线上发现某个模块有问题,可以更容易回滚或定位。


十四、这次工程修复的经验

这次排查有几个很典型的工程经验。

1. 支持视频文件,不等于支持视频语义

很多项目在媒体层支持视频,但 AI 分析层仍然是图片思路。

真正的视频支持至少要回答几个问题:

复制代码
视频帧从哪里来?
是否是真实多帧?
是否退化成封面图?
视频 embedding 和图片 embedding 是否同空间?
视频是否进入图片下游?
出了问题能不能追溯?

2. 字段命名会影响系统边界

imageEmbedding 这个字段如果继续塞视频向量,就很容易污染系统边界。

工程中,字段名不是小事。

字段名背后代表的是数据契约。

如果一个字段叫 imageEmbedding,最好就只放 image embedding。


3. APP 稳定性优先于模型能力

这次没有追求更复杂的视频标签、动作识别或视频质量过滤,而是先做:

复制代码
输入可信
向量归位
下游保守
问题可查

对于产品工程来说,这比盲目加功能更重要。


4. 真机问题要分层看

这次真机排查经历了几层:

复制代码
安装被手机拒绝
↓
APK 能安装但 MainActivity 找不到
↓
Kotlin 插件缺失导致 MainActivity.kt 没编译
↓
修复后进入 Flutter 运行态

每一层问题的性质都不一样。

不能看到 flutter run 失败就直接认为是 Dart 代码错了。

Android 原生层、Gradle 层、Flutter 层、Dart 层要分开看。


十五、总结

这次修复的核心不是"让视频模型更强",而是让 APP 的视频链路更稳定。

最终完成了:

复制代码
视频真实抽帧
视频帧诊断
视频 embedding 元数据持久化
视频不污染 imageEmbedding
视频跳过图片标签和 junk filter
spool 与统一分析管线一致
ObjectBox 生成同步
Android MainActivity 真机启动修复

这套修复让视频模态从"看起来接入了"变成了"工程上可追溯、可维护、相对稳定"。

下一步如果继续做,可以考虑:

复制代码
视频搜索分数校准
视频专属标签体系
视频质量过滤
故事生成中播放真实视频片段
用户可见的视频诊断 UI

但这些都应该放在稳定链路之后。

工程上最重要的顺序永远是:

复制代码
先别错,
再变强。
相关推荐
EasyDSS1 小时前
视频直播点播/高清点播/音视频点播/云点播/云直播EasyDSS一站式音视频平台助力智慧校园智能化建设
音视频
百珏1 小时前
流量没暴涨,网关却挂了:Spring Cloud Gateway 从 500 QPS 优化到 4200 QPS
后端·spring cloud·架构
nice先生的狂想曲1 小时前
flutter页面滚动TabBar+TabBarView
flutter·客户端
SilentSamsara1 小时前
特征工程系统方法论:编码、分箱、交互特征与特征选择
开发语言·人工智能·python·机器学习·青少年编程·信息可视化·pandas
lichenyang4531 小时前
ArkUI 票根卡片:PathShape 真挖洞,shadow 沿凹陷外发光
前端
morning_judger1 小时前
Agent开发系列(十)-知识库建设(架构总览)
开发语言·人工智能
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
ai产品老杨1 小时前
【架构深评】基于 Docker 与 边缘计算,如何打通 GB28181/RTSP 与 X86/ARM 异构算力的企业级 AI 视频流网关?(附源码交付)
人工智能·docker·架构
ch.ju1 小时前
Java程序设计(第3版)第四章——继承的特点
java·开发语言