一、背景
最近在做一个 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 语义,
最后收缩视频下游策略。
具体目标包括:
-
视频分析时优先真实抽帧;
-
记录视频帧来源诊断信息;
-
视频 embedding 不再写入 imageEmbedding;
-
embedding 索引中持久化最小元数据;
-
视频不再强行套图片标签和垃圾过滤;
-
后台 spool 分析路径和前台统一分析路径保持一致;
-
修复 ObjectBox 生成文件不同步;
-
修复 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 索引行上。
这样有几个好处:
-
不污染照片主表;
-
不影响原始媒体信息;
-
向量和向量元数据放在一起;
-
以后搜索和 debug 可以读取;
-
字段扩展更灵活。
元数据保持很窄,只存稳定性排查真正需要的信息:
{
"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 只写入向量索引,并携带自己的 modelVersion 和 embeddingMetaJson。
这样可以避免:
-
视频进入图片主题聚类;
-
视频被图片垃圾过滤误伤;
-
图片搜索和视频搜索空间混乱;
-
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 EXCEPTION、SIGSEGV、Unhandled Exception,暂时不作为主问题处理。
十三、commit 拆分
最后这轮改动拆成了 4 个 commit:
4136927 fix(album): 添加本地缓存清理操作
5bed857 fix(android): 编译 app 模块的 Kotlin MainActivity
2f89b88 fix(video): 稳定视频 embedding 并持久化元数据
4aecfbf chore(objectbox): 重新生成 embedding 元数据绑定
拆分原则是:
-
ObjectBox 生成同步单独提交;
-
视频 embedding 稳定化单独提交;
-
Android MainActivity 编译问题单独提交;
-
相册页本地缓存按钮修复单独提交。
这样后续如果线上发现某个模块有问题,可以更容易回滚或定位。
十四、这次工程修复的经验
这次排查有几个很典型的工程经验。
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
但这些都应该放在稳定链路之后。
工程上最重要的顺序永远是:
先别错,
再变强。