一、背景
最近在维护一个 Flutter 相册类 APP,项目目标是做一个本地智能影像管理应用,支持:
-
本地图片 / 视频扫描;
-
AI 向量分析;
-
语义搜索;
-
标签聚类;
-
本地缓存管理;
-
离线模型权重管理。
这一轮主要围绕"视频模态是否真的能稳定工作"展开。
最开始的问题是:项目虽然在媒体层支持图片和视频,但视频在 AI 分析链路里并不够稳。尤其是视频 embedding、抽帧来源、模型权重、ObjectBox 元数据、Android 真机启动之间有很多细节问题。
这篇文章记录一次完整的工程排查和修复过程。
核心不是做算法创新,而是把 APP 的视频模态从"看起来接入了"推进到"工程上可验证、可追溯、可稳定运行"。
二、最初的问题:视频不是简单多一个 mediaKind
在相册 APP 里,支持视频并不只是把媒体类型从 image 扩展成 video。
真正稳定的视频 AI 链路至少要回答几个问题:
1. 视频是否真的进入扫描队列?
2. 视频是否能抽到多帧,而不是只拿到封面图?
3. 视频 embedding 是否和图片 embedding 混用?
4. 视频是否误写 imageEmbedding 字段?
5. 视频是否被图片标签 taxonomy 错误处理?
6. 视频是否被图片 junk filter 误伤?
7. 视频分析结果是否能落库?
8. 出问题时能不能知道 frameSource / frameCount / isRepeatedFrame?
一开始视频链路的最大风险是:
模型看起来是视频模型,
但输入可能退化成一张缩略图重复 8 次。
如果没有元数据记录,后续用户说"为什么这个视频搜不到",开发者很难判断到底是模型能力问题、抽帧问题,还是索引写入问题。
三、第一阶段:给视频 embedding 增加可追溯元数据
我首先做的是把视频 embedding 从"裸向量"升级成"带语义记录的向量"。
新增的核心思想是:
embedding 不只是 vector,
还应该知道它来自什么媒体、什么模型、什么帧来源。
因此给 embedding 索引增加了最小元数据字段:
String? embeddingMetaJson;
元数据里记录:
{
"mediaKind": "video",
"modelFamily": "mobileclip_image",
"modelVersion": "mobileclip2_litert_xxx",
"textSpace": "mobileclip2_text",
"frameSource": "android_thumbnail",
"frameCount": 8,
"isRepeatedFrame": false
}
其中几个字段非常关键:
mediaKind
用于区分:
image
video
dynamicImage
frameSource
用于标记帧来源,例如:
android_thumbnail
ffmpeg_fps_sample
fallback_thumbnail_repeat
none
isRepeatedFrame
用于判断视频是否退化成重复帧输入。
如果:
"isRepeatedFrame": true
说明这条视频可能只拿到了重复缩略图。它不是崩溃,但语义质量可能比较弱。
这就是一种"可解释降级"。
四、第二阶段:视频不要污染图片链路
这轮修复里非常重要的一点是:
视频 embedding 不再写入 PhotoEntity.imageEmbedding。
原因很简单:字段名叫 imageEmbedding,就应该主要表示图片向量。
如果把视频向量塞进去,会带来很多隐患:
1. 图片主题聚类可能读到视频向量;
2. 图片 junk filter 可能误伤视频;
3. 图片标签 taxonomy 可能错误解释视频;
4. 搜索服务无法明确区分图片空间和视频空间;
5. 后续 debug 会非常困难。
所以现在的策略是:
图片:
写 PhotoEntity.imageEmbedding
写图片向量索引
可以参与图片标签、图片 junk filter、图片主题聚类
视频:
不写 PhotoEntity.imageEmbedding
只写向量索引 + embeddingMetaJson
标签先保守写"视频"
不跑图片 junk filter
这个策略不是为了让视频更"聪明",而是为了让系统先稳定。
工程里很多时候要先做到:
先别错,再变强。
五、第三阶段:远端 master 已重构,不能机械 cherry-pick
本地做完几个修复 commit 后,准备 push 时发现远端 master 已经前进了 22 个提交。
本地状态大概是:
本地 master:ahead 5, behind 22
工作区:还有未提交改动
远端:已经重构视频 / AI 相关路线
这种情况下不能直接:
git pull --rebase
因为当前工作区还有很多未提交内容,直接 rebase 容易把工作区搅乱。
最终采用了一个更稳的方案:
1. git fetch origin
2. 新建临时 worktree 到最新 origin/master
3. 在临时 worktree 中 cherry-pick 本地 5 个提交
4. 在临时目录里解决冲突
5. 确认能 fast-forward push
6. 推送到 origin/master
7. 清理临时 worktree
命令类似:
git worktree add --detach D:\StudioProjects\Memoria_push_tmp origin/master
cd D:\StudioProjects\Memoria_push_tmp
git cherry-pick <commit1> <commit2> <commit3> ...
这个过程里发现一个重要事实:
远端已经删除了旧 MobileViCLIP / NCNN / spool 路线,
改成了 MobileCLIP2 帧聚合路线。
所以冲突不能简单地选 ours 或 theirs。
如果机械保留本地旧代码,就会把远端重构推翻,甚至恢复一堆已经删除的旧文件。
最终的处理原则是:
保留远端新架构,
只把我们需要的视频稳定性语义迁移过去。
也就是说,不恢复旧 MobileViCLIP 路线,而是在远端新的 MobileCLIP2 帧聚合架构上补:
1. embeddingMetaJson;
2. mediaKind/video/dynamicImage 元数据;
3. 视频不写 imageEmbedding;
4. 视频标签保守为"视频";
5. 视频跳过图片 junk filter;
6. 测试适配新路线。
这一步很关键。
很多工程问题不是"代码冲突",而是"架构语义冲突"。
六、第四阶段:ObjectBox 生成同步
由于新增了 embeddingMetaJson 字段,ObjectBox 的 entity 和 generated bindings 必须同步。
执行:
dart run build_runner build --delete-conflicting-outputs
生成后确认:
PhotoEmbeddingIndexEntity.embeddingMetaJson 已存在
ObjectBox generated code 已同步
这里有一个经验:如果项目依赖 ObjectBox generated code,而 generated code 未跟踪或被忽略,团队协作时很容易出现:
实体字段存在,
但 generated binding 不存在,
导致测试和构建失败。
所以这次把相关 generated binding 也纳入提交,保证别人拉代码后不会因为生成文件不同步直接炸。
七、第五阶段:Android MainActivity 找不到
修完视频链路后,真机运行又遇到一个 Android 原生错误:
java.lang.ClassNotFoundException:
Didn't find class "com.example.photo_album.MainActivity"
Manifest 里写的是:
<activity android:name=".MainActivity" />
源码路径和包名也是对的:
package com.example.photo_album
class MainActivity : FlutterActivity()
所以问题不是包名,也不是 Manifest,而是:
MainActivity.kt 没有被编译进 APK。
继续查 android/app/build.gradle.kts,发现 app module 没有应用 Kotlin Android 插件。
修复方式是在 plugins 里加入:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.flutter.flutter-gradle-plugin")
}
注意 Flutter Gradle Plugin 仍然放在最后,符合 Flutter 模板要求。
修复后可以在构建目录里看到:
build/app/tmp/kotlin-classes/debug/com/example/photo_album/MainActivity.class
这说明 Kotlin Activity 已经被编译进 APK。
八、第六阶段:AI 权重页误报缺少模型
后来打开"AI 模型权重"页,发现模型显示缺少权重,并且 MobileViCLIP 远程下载 404。
问题本质不是模型推理失败,而是:
权重管理层没有识别 Flutter assets 中已经内置的模型。
项目里模型有两种来源:
1. Flutter assets 内置;
2. 远程下载。
当模型已经打包进 assets 时,权重页不应该继续强制远程下载。
最开始的错误逻辑类似:
检查下载目录
发现没有
尝试远程 preflight
远程 404
判定缺少权重
但实际上本地 assets 里已经有模型。
因此改成:
内置 asset 优先;
远程下载只是补充。
并且判断内置模型时不能直接:
rootBundle.load(bigModelPath)
因为大模型可能 100MB+,打开权重页只是检查状态,不应该把模型本体读进内存。
最终改成检查 AssetManifest:
AssetManifest 中存在模型路径
=> 认为模型已内置
=> 权重页显示"已内置"
=> ensureWeightsAvailableForInference 不再被远程 404 拦住
这次修完后,权重页里 MobileCLIP2 LiteRT 正确显示:
已内置
九、第七阶段:同步远端后,本地环境也要清理
远端推送成功后,本地原工作区仍然是旧状态,而且还有未提交改动。
这里不能继续直接开发,必须先同步到最新 origin/master。
为了安全,先做备份分支和 stash:
git branch backup-before-sync
git stash push -u -m "wip before syncing origin master"
git fetch origin
git reset --hard origin/master
之后还需要补 submodule:
git submodule update --init --recursive
因为远端最新 master 依赖:
third_party/music_feat_analyzer
如果 submodule 没补齐,flutter pub get 会被路径依赖卡住。
十、第八阶段:当前 Flutter SDK 兼容问题
同步远端后又遇到一个和视频无关、但会阻塞验证的问题:
scrollCacheExtent: ScrollCacheExtent.pixels(700)
当前 Flutter SDK 不支持这个 API,导致 album_page 和 create_page 编译失败。
为了兼容当前 SDK,改成老版本支持的:
cacheExtent: 700,
同时发现 dart analyze 会扫进 third_party/** 子模块,导致分析器被 vendored example/test 拖死。
于是把 third_party 加入 analyzer exclude:
analyzer:
exclude:
- third_party/**
这一步属于验证环境兼容修复,不改变视频逻辑。
提交为:
fix(build): 兼容当前 Flutter 验证环境
十一、第九阶段:NDK 安装不完整
构建 debug APK 时又遇到 Android NDK 问题:
NDK 27.0.12077973 缺 source.properties
这不是 Dart 或业务代码错误,而是本机 Android SDK 安装损坏。
处理方式:
1. 把坏的 NDK 目录改名备份;
2. 用 sdkmanager 重新安装同版本 NDK;
3. 重新 flutter build apk。
修复后:
flutter build apk --debug --dart-define-from-file=config/profiles/dev.json
构建通过。
十二、第十阶段:真机安装和启动
一开始安装被 vivo 手机拒绝:
INSTALL_FAILED_ABORTED: User rejected permissions
这不是代码问题,而是手机侧 USB 安装授权问题。
后来使用机主用户安装:
adb install --user 0 -r -d -g build/app/outputs/flutter-apk/app-debug.apk
安装成功。
启动后检查:
adb shell am start -n com.example.photo_album/.MainActivity
adb shell pidof com.example.photo_album
APP 成功前台运行,PID 存在。
logcat 检查没有:
FATAL EXCEPTION
ClassNotFoundException
MissingPluginException
模型权重 404
说明 Android 原生层、Flutter 层、模型权重检查层都过了。
十三、第十一阶段:真机视频 Smoke Test
接下来开始真正验证视频模态。
操作流程:
1. 打开 APP;
2. 进入 AI 模型权重页;
3. 确认 MobileCLIP2 LiteRT 显示"已内置";
4. 回到相册页;
5. 选择导入已授权范围内的新图片和视频;
6. 观察后台扫描和 AI 分析进度;
7. 查看 ObjectBox 中 embedding meta;
8. 打开"视频"聚类页。
真机结果如下:
APP 前台进程正常;
没有 FATAL;
没有模型权重 404;
scan=4402/4402;
AI 分析后台继续消费;
失败数=0;
ObjectBox 中已经写入 embedding meta:
imageMeta=702
videoMeta=15
dynamicImage=6
视频 meta 抽样:
{
"mediaKind": "video",
"modelFamily": "mobileclip_image",
"textSpace": "mobileclip2_text",
"frameSource": "android_thumbnail",
"frameCount": 8,
"isRepeatedFrame": false
}
并且同时观察到了:
isRepeatedFrame=true
isRepeatedFrame=false
这说明当前视频链路支持两种情况:
1. 正常多帧视频输入;
2. 退化成重复帧 fallback,但能被 meta 记录。
这正是我们想要的"可解释退化路径"。
另外,相册页已经出现"视频 63"的聚类,并且视频聚类页可以正常打开。
这说明视频标签保守策略也生效了。
十四、如何理解 frameSource=android_thumbnail
这次 meta 中很多视频帧来源是:
frameSource=android_thumbnail
这说明当前 Android 设备主要通过系统 thumbnail API 获取视频帧,而不是 FFmpeg。
这不是坏事。
对于移动端 APP 来说,Android thumbnail 有优点:
速度快;
功耗低;
不需要额外解码链路;
权限路径更稳定。
但也有缺点:
不同厂商实现不一致;
某些视频可能拿到重复帧;
复杂视频抽帧质量不一定稳定。
所以 isRepeatedFrame 这个字段非常重要。
如果未来统计发现:
isRepeatedFrame=true 比例很高
就说明需要考虑 FFmpeg fallback 或更可靠的真实文件抽帧。
但当前 smoke test 里 true/false 都出现,说明至少不是全部退化。
十五、当前视频架构的重新理解
经过远端重构后,现在项目的视频路线已经不是旧的 MobileViCLIP 路线,而是:
MobileCLIP2 LiteRT
+
视频/GIF 帧聚合
+
textSpace = mobileclip2_text
+
mediaKind/meta 区分 image/video/dynamicImage
因此后续验证不要再盯:
MobileViCLIP Small
mobileviclip_video_small_onnx_v1
NCNN text space
而应该看:
MobileCLIP2 LiteRT 是否已内置;
视频是否写入 video meta;
frameSource 是否可信;
frameCount 是否正常;
isRepeatedFrame 是否可解释;
视频是否能被聚类和搜索召回。
这个认知转变很重要。
否则很容易拿旧架构标准误判新架构。
十六、这轮修改的 commit 记录
这轮最终推到远端的关键提交包括:
34a8bb9 fix(ai): 优先使用内置模型资源
9751472 fix(album): 添加本地缓存清理操作
5b82d08 fix(android): 编译 app 模块的 Kotlin MainActivity
7055150 fix(video): 稳定视频 embedding 并持久化元数据
f1d9d7e chore(objectbox): 重新生成 embedding 元数据绑定
43b47fd fix(build): 兼容当前 Flutter 验证环境
这些 commit 分工比较清楚:
ObjectBox 生成同步;
视频 embedding 稳定化;
Android Kotlin Activity 编译;
AI 内置模型权重识别;
相册本地缓存清理;
当前 Flutter SDK 构建兼容。
拆开提交的好处是:后续如果某一层出问题,可以单独回滚或定位。
十七、这次工程排查的经验总结
1. 支持视频文件不等于支持视频语义
媒体层能播放视频,只能说明 ExoPlayer/MediaCodec 链路正常。
视频模态真正可用,还要看:
能不能抽帧;
能不能生成 embedding;
能不能落库;
能不能搜索;
能不能不污染图片链路;
能不能 debug。
2. embedding 必须有元数据
只保存 vector 不够。
至少要知道:
mediaKind
modelFamily
modelVersion
textSpace
frameSource
frameCount
isRepeatedFrame
否则后续搜索不准时,无法判断根因。
3. 不要让视频冒充图片
视频不应该随便写入 imageEmbedding。
字段名背后是数据契约。
如果字段叫 imageEmbedding,就不要轻易把 video embedding 塞进去。
4. 远端重构后不要机械 cherry-pick
这次远端已经把视频路线改成 MobileCLIP2 帧聚合。
本地旧提交如果原样搬上去,会恢复一堆旧架构文件。
正确做法是:
保留远端新架构,
迁移稳定性语义。
5. 权重检查不要读大模型本体
判断模型是否内置,不应该直接 rootBundle.load 几百 MB 的模型。
更合理的是检查 AssetManifest。
6. 真机问题要分层看
一次完整真机启动可能经过这些层:
USB 安装授权
APK 安装
Android Activity 启动
Flutter Engine 启动
ObjectBox 初始化
模型权重检查
AI 分析任务
视频解码 / 抽帧
embedding 落库
每一层错误都不一样,不能看到 flutter run 失败就直接怪 Dart 代码。
十八、下一步计划
当前已经证明:
最新 origin/master 可安装启动;
权重页能识别内置模型;
视频 / dynamicImage meta 已落库;
视频聚类页可以打开;
后台扫描分析稳定推进;
失败数目前为 0。
下一步不建议马上继续大改代码,而应该做更系统的测试:
1. 等后台 AI 分析跑完
记录最终:
scan 总数
AI analyzed 总数
失败数
imageMeta 数量
videoMeta 数量
dynamicImage 数量
2. 做 30 条视频黄金集
记录:
视频类型
编码格式
时长
frameSource
frameCount
isRepeatedFrame
是否出现在视频聚类
搜索 query
是否能搜到
3. 统计 repeated frame 比例
如果 repeated frame 比例太高,再考虑优化抽帧。
4. 增加内部 debug 页面
点开任意媒体,显示:
mediaKind
aiTags
embeddingMetaJson
hasImageEmbedding
isAiAnalyzed
是否 junk candidate
这会让后续排查效率大幅提高。
十九、结语
这次修复不是一个单点 bug,而是一串典型的移动端 AI 工程问题:
模型权重管理
视频抽帧
embedding 语义
ObjectBox 落库
Git 远端重构冲突
Flutter SDK 兼容
Android Kotlin Activity 编译
NDK 安装
真机权限
最后比较重要的结论是:
视频模态不是简单加一个 video 类型,
而是要给它完整的数据契约和可观测性。
当前视频链路已经从"能不能跑"推进到"可以验证、可以解释、可以继续迭代"。
对于 APP 工程来说,这一步比盲目追求更大的模型更重要。
一句话总结:
先让链路可信,再谈模型变强。