@@ -83,8 +83,10 @@ pub fn run_video_import<F: Fn(&ImportProgress)>(
8383 engines : & EnginePool ,
8484 resolver : & FfmpegResolver ,
8585 video_path : & str ,
86- // REQ-117:UI 垃圾黑名单(导入画面要点源头过滤 ——与实时链路同口径)
86+ // REQ-117:UI 垃圾黑名单(导入字幕源头过滤 ——与实时链路同口径)
8787 ui_junk : & crate :: ui_junk:: UiJunkList ,
88+ // v0.12.0 M5 补完成:数据目录(session-images 图片库根)——关键帧纯图归档
89+ data_dir : & Path ,
8890 progress : F ,
8991) -> Result < i64 > {
9092 let video = Path :: new ( video_path) ;
@@ -105,11 +107,20 @@ pub fn run_video_import<F: Fn(&ImportProgress)>(
105107 kind : None ,
106108 } ) ?;
107109 let session_id = session. id ;
110+ // v0.12.0 M5 补完成:视频导入关键帧纯图归档(存图不识别——与实时链路同口径;
111+ // 创建失败不阻断——降级为无图集会话,日志可观测)
112+ let mut image_store = crate :: image_store:: SessionImageStore :: new (
113+ data_dir. join ( "session-images" ) . join ( session_id. to_string ( ) ) ,
114+ )
115+ . ok ( ) ;
108116 // 中间文件限定系统临时目录(导入后即清理,不污染应用数据目录)
109117 let work_dir = std:: env:: temp_dir ( ) . join ( format ! ( "entropy-import-{}" , session_id) ) ;
110118 let _ = std:: fs:: create_dir_all ( & work_dir) ;
111119
112- let result = run_import_inner ( db, engines, resolver, video, session_id, & work_dir, ui_junk, & progress) ;
120+ let result = run_import_inner (
121+ db, engines, resolver, video, session_id, & work_dir, ui_junk,
122+ & mut image_store, & progress,
123+ ) ;
113124 // 清理中间文件(无论成败);失败标记会话 failed(与实时链路同口径)
114125 let _ = std:: fs:: remove_dir_all ( & work_dir) ;
115126 result. inspect_err ( |_| {
@@ -119,7 +130,7 @@ pub fn run_video_import<F: Fn(&ImportProgress)>(
119130}
120131
121132/// 管线主体(与清理/失败标记分离,保证清理必然执行)。
122- /// @ai-context: 参数为管线上下文传递(db/engines/resolver/路径/会话/黑名单),
133+ /// @ai-context: 参数为管线上下文传递(db/engines/resolver/路径/会话/黑名单/图片库 ),
123134/// 聚合会破坏内聚——登记 clippy 豁免(与 persist_final 同模式)。
124135#[ allow( clippy:: too_many_arguments) ]
125136fn run_import_inner < F : Fn ( & ImportProgress ) > (
@@ -130,6 +141,7 @@ fn run_import_inner<F: Fn(&ImportProgress)>(
130141 session_id : i64 ,
131142 work_dir : & Path ,
132143 ui_junk : & crate :: ui_junk:: UiJunkList ,
144+ image_store : & mut Option < crate :: image_store:: SessionImageStore > ,
133145 progress : & F ,
134146) -> Result < ( ) > {
135147 // 1) 字幕决策(L1 零依赖;L2 需 ffmpeg)
@@ -150,10 +162,12 @@ fn run_import_inner<F: Fn(&ImportProgress)>(
150162 crate :: import_transcribe:: transcribe_audio ( db, engines, resolver, video, session_id, work_dir, progress) ?;
151163 }
152164
153- // 3) 关键帧 OCR(画面要点,独立于转写路径;ffmpeg 缺失跳过不阻断)
154- // REQ-130(v0.7.0 M3):P4 无图短路——会话档案 disable_ocr 时跳过画面识别。
155- // 导入路径当前 profile=None(默认档案,OCR 不跳过——零回归);播客类档案
156- // 接入导入链路(前端传 profile)后自动生效(ASR-only 快速路径)。
165+ // 3) 关键帧处理(v0.12.0 M5 补完成:纯图归档 + 字幕 OCR;独立于转写路径;
166+ // ffmpeg 缺失跳过不阻断)
167+ // REQ-130(v0.7.0 M3):P4 无图短路——会话档案 disable_ocr 时跳过**字幕 OCR**
168+ // (画面要点 OCR 已随 ADR-023 下线,本门控只作用于字幕)。导入路径当前
169+ // profile=None(默认档案,OCR 不跳过——零回归);关键帧纯图归档恒执行
170+ // (存图不识别——与 disable_ocr 语义解耦)。
157171 let ocr_enabled = db
158172 . get_session ( session_id)
159173 . ok ( )
@@ -164,17 +178,8 @@ fn run_import_inner<F: Fn(&ImportProgress)>(
164178 !crate :: video_profile:: profile_by_kind ( kind) . disable_ocr
165179 } )
166180 . unwrap_or ( true ) ;
167- if ocr_enabled {
168- // REQ-117:导入画面要点过 UI 垃圾黑名单(与实时链路同口径)
169- ocr_keyframes ( engines, db, resolver, video, session_id, work_dir, ui_junk, progress) ;
170- } else {
171- progress ( & ImportProgress {
172- stage : "ocr" . into ( ) ,
173- message : "档案禁用画面识别(disable_ocr),跳过关键帧 OCR" . into ( ) ,
174- done : 1 ,
175- total : 1 ,
176- } ) ;
177- }
181+ // REQ-117:字幕过 UI 垃圾黑名单(与实时链路同口径)
182+ ocr_keyframes ( engines, db, resolver, video, session_id, work_dir, ui_junk, image_store, ocr_enabled, progress) ;
178183
179184 // 4) 完成
180185 db. finish_session ( session_id) ?;
@@ -202,8 +207,12 @@ fn write_subtitle_segments(db: &Db, session_id: i64, segments: &[crate::fusion::
202207 Ok ( ( ) )
203208}
204209
205- /// 关键帧提取 + OCR(失败跳过不阻断;帧号按固定间隔换算时间戳)。
206- /// @ai-context: 参数为管线上下文传递(引擎/DB/解析器/路径/会话/黑名单),
210+ /// 关键帧纯图归档 + 字幕 OCR(失败跳过不阻断;帧号按固定间隔换算时间戳)。
211+ /// @ai-context: v0.12.0 M5 补完成(ADR-023):关键帧**存图不识别**——不再做
212+ /// 画面要点 OCR(中部 region=full 下线);只做底部字幕带 OCR
213+ /// (region=subtitle,本地 OCR 只做字幕)。关键帧全量纯图归档
214+ /// 到 session-images/<id>(参考图集/精修图片理解同一数据源)。
215+ /// @ai-context: 参数为管线上下文传递(引擎/DB/解析器/路径/会话/黑名单/图片库),
207216/// 登记 clippy 豁免(与 persist_final 同模式)。
208217#[ allow( clippy:: too_many_arguments) ]
209218fn ocr_keyframes < F : Fn ( & ImportProgress ) > (
@@ -214,10 +223,13 @@ fn ocr_keyframes<F: Fn(&ImportProgress)>(
214223 session_id : i64 ,
215224 work_dir : & Path ,
216225 ui_junk : & crate :: ui_junk:: UiJunkList ,
226+ image_store : & mut Option < crate :: image_store:: SessionImageStore > ,
227+ // REQ-130:disable_ocr 档案 → 跳过字幕 OCR(关键帧归档恒执行)
228+ ocr_enabled : bool ,
217229 progress : & F ,
218230) {
219231 let Ok ( paths) = resolver. resolve ( ) else {
220- progress ( & ImportProgress { stage : "ocr" . into ( ) , message : "ffmpeg 缺失,跳过关键帧 OCR " . into ( ) , done : 1 , total : 1 } ) ;
232+ progress ( & ImportProgress { stage : "ocr" . into ( ) , message : "ffmpeg 缺失,跳过关键帧处理 " . into ( ) , done : 1 , total : 1 } ) ;
221233 return ;
222234 } ;
223235 let frames_dir = work_dir. join ( "frames" ) ;
@@ -228,7 +240,7 @@ fn ocr_keyframes<F: Fn(&ImportProgress)>(
228240 & ffmpeg:: extract_keyframes_args ( video, & frames_dir, ffmpeg:: KEYFRAME_FPS , ffmpeg:: KEYFRAME_MAX_FRAMES ) ,
229241 ffmpeg:: default_timeout ( ) ,
230242 ) {
231- eprintln ! ( "[Import] 关键帧提取失败(跳过画面识别 ): {}" , e) ;
243+ eprintln ! ( "[Import] 关键帧提取失败(跳过 ): {}" , e) ;
232244 return ;
233245 }
234246 let mut files: Vec < PathBuf > = std:: fs:: read_dir ( & frames_dir)
@@ -245,23 +257,41 @@ fn ocr_keyframes<F: Fn(&ImportProgress)>(
245257 progress ( & ImportProgress { stage : "ocr" . into ( ) , message : "无关键帧产出" . into ( ) , done : 1 , total : 1 } ) ;
246258 return ;
247259 }
248- // TD-037:区域裁剪 → 识别 → 信息整合——中部(full)+ 底部(subtitle)两路,
249- // 各区域帧间文本去重(静态画面不重复落库)
250- let mut last_full: Vec < String > = Vec :: new ( ) ;
260+ // v0.12.0 M5 补完成:字幕区帧间文本去重(静态画面不重复落库);全帧文本
261+ // 去重随画面要点 OCR 一并下线(无 full 块再落库)
251262 let mut last_subtitle: Vec < String > = Vec :: new ( ) ;
252263 // v0.11.7:累计识别块数(进度消息「已识别 N 块文字」)
253264 let mut total_blocks = 0usize ;
254265 for ( i, path) in files. iter ( ) . enumerate ( ) {
255266 let timestamp_ms = ( i as u64 ) * FRAME_INTERVAL_MS ;
256267 match image:: open ( path) . map ( |d| d. into_rgb8 ( ) ) {
257- Ok ( img) => total_blocks += crate :: import_frame:: ocr_keyframe (
258- db, engines, session_id, timestamp_ms, & img, & mut last_full, & mut last_subtitle, ui_junk,
259- ) ,
268+ Ok ( img) => {
269+ // v0.12.0 M5 补完成:关键帧纯图归档(存图不识别——预算超限/去重
270+ // 由 image_store 管理;归档失败不阻断,日志可观测)
271+ if let Some ( store) = image_store. as_mut ( ) {
272+ let ( w, h) = img. dimensions ( ) ;
273+ let bgra = crate :: structure_capture:: rgb_to_bgra ( & img) ;
274+ if let Err ( e) = store. save_frame ( timestamp_ms, & bgra, w, h) {
275+ eprintln ! ( "[Import] 关键帧归档失败: {}" , e) ;
276+ }
277+ }
278+ if ocr_enabled {
279+ total_blocks += crate :: import_frame:: ocr_keyframe_subtitles (
280+ db, engines, session_id, timestamp_ms, & img, & mut last_subtitle, ui_junk,
281+ ) ;
282+ }
283+ }
260284 Err ( e) => eprintln ! ( "[Import] 关键帧解码失败(跳过): {}" , e) ,
261285 }
262286 progress ( & ImportProgress {
263287 stage : "ocr" . into ( ) ,
264- message : format ! ( "画面识别 {}/{} · 已识别 {} 块文字" , i + 1 , total, total_blocks) ,
288+ message : format ! (
289+ "关键帧归档 {}/{} · 已识别字幕 {} 块{}" ,
290+ i + 1 ,
291+ total,
292+ total_blocks,
293+ if ocr_enabled { "" } else { "(档案禁用字幕识别)" }
294+ ) ,
265295 done : ( i + 1 ) as u32 ,
266296 total,
267297 } ) ;
@@ -331,12 +361,14 @@ mod tests {
331361 // Arrange & Act:不存在的文件 → 可操作错误(不建会话)
332362 let db = crate :: db:: Db :: open ( ":memory:" ) . expect ( "mem db" ) ;
333363 let resolver = FfmpegResolver :: dev ( ) ;
364+ let tmp = std:: env:: temp_dir ( ) ;
334365 let result = run_video_import (
335366 & db,
336367 & crate :: engine:: EnginePool :: dummy ( ) ,
337368 & resolver,
338369 "不存在.mp4" ,
339370 & crate :: ui_junk:: UiJunkList :: defaults ( ) ,
371+ & tmp,
340372 |_| { } ,
341373 ) ;
342374 // Assert
0 commit comments