diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8276f29eb..835a434a4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-19 角色动作视频使用单进程批量抽帧 + +- 背景:角色动作生成在拿到预览视频后,原实现会为 `32 / 40 / 48` 个采样点分别启动一次 FFmpeg、重复解码同一视频。release 的 2 vCPU 主机在一次 32 帧任务中因此出现约 10 秒的 CPU 尖刺,且进程启动和重复解码都不是业务必需开销。 +- 决策:角色动作抽帧必须先沿用 `compute_sample_time_seconds()` 计算全部采样点,再通过一个 FFmpeg filter graph 对输入统一 `setpts`、`split`,各分支按精确 `select=gte(t\,)` 输出一帧。不得改用会漂移现有采样时刻的粗粒度 `fps` 抽帧。单次命令完成后逐一确认全部目标文件存在,任一缺帧继续使用原有用户错误文案,并在 details 中保留首个缺帧的 `targetSeconds / outputPath`、整批 `missingFrames` 和 stdout/stderr。视频封面等单帧调用保留兼容 helper,但内部复用同一批量实现。 +- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs` 的角色动作视频本地抽帧和单帧视频封面抽取;不改变尾帧安全步长、BgFilter 并发、OSS 路径、帧编号、透明化后处理或前后端结果契约。 +- 验证方式:运行 `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,真实短视频回归必须由一次 FFmpeg 命令产出整批帧,并继续断言 `32帧·4秒` 最后一帧为 `3.875s`;追加 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、Rust 格式、编码和 diff 门禁。 +- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + ## 2026-07-18 图片生成 K 档由 provider 直接生成 - 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5146ff880..449575fe5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -272,12 +272,12 @@ - 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`。 - 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 -## 图片编辑器角色动画抽帧不要采到视频尾点 +## 图片编辑器角色动画抽帧不要采到视频尾点或逐帧重启 FFmpeg - 现象:画板角色图点击 `生成动画` 后,Ark 视频已生成并上传 OSS,但后端返回 `ffmpeg 已执行但未产出动作帧文件(requestId:...)`。 -- 原因:FFmpeg 在 `-ss` 采样时间落到视频尾点附近时可能退出码仍为 `0`,但实际输出 `0` 帧;如果后端按 `duration - 0.001` 抽最后一帧,低帧率或短视频很容易踩到不可解码尾点。 -- 处理:角色动画抽帧按目标帧数预留一个采样步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,不要采 `3.999s`;`ffmpeg` 返回成功但无输出文件时,错误 details 保留 `targetSeconds`、`stdout`、`stderr` 和输出路径,用户主文案保持简短。 -- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,其中 `editor_character_animation_extracts_final_sample_from_short_video` 应覆盖本机 FFmpeg 8 的 0 帧回归。 +- 原因:FFmpeg 在采样时间落到视频尾点附近时可能退出码仍为 `0`,但实际输出 `0` 帧;如果后端按 `duration - 0.001` 抽最后一帧,低帧率或短视频很容易踩到不可解码尾点。旧实现还会为 `32 / 40 / 48` 个采样点分别启动 FFmpeg、重复解码同一视频,在低配 worker 上形成不必要的多秒 CPU 尖刺。 +- 处理:角色动画先按目标帧数计算全部安全采样时刻,例如 `32帧·4秒` 最后一帧采 `3.875s`,不要采 `3.999s`;随后使用单个 `setpts + split + select` filter graph 批量输出全部帧,不改用粗粒度 `fps` 抽帧。命令返回后逐一检查输出,缺帧时用户主文案保持简短,details 保留首个缺帧的 `targetSeconds / outputPath`、整批 `missingFrames` 和 stdout/stderr。 +- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`;`editor_character_animation_batch_extracts_all_samples_from_short_video` 必须用一次 FFmpeg 产出整批短视频帧,尾帧测试继续锁定 `3.875s`。 - 关联:`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 ## Windows 本地角色动画抽帧找不到 ffmpeg 先查 dev 子进程环境 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 057f22910..f2371fc67 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -162,6 +162,7 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 +- 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 - 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b925e07d3..8ba5a2f44 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4050,23 +4050,29 @@ async fn extract_animation_frames_from_preview_video( known_duration_seconds, )? .max(0.001); - let mut finalized_frames = Vec::with_capacity(plan.frame_count as usize); + let mut target_seconds = Vec::with_capacity(plan.frame_count as usize); + let mut raw_frame_paths = Vec::with_capacity(plan.frame_count as usize); for frame_index in 0..plan.frame_count { - let target_seconds = compute_sample_time_seconds( + target_seconds.push(compute_sample_time_seconds( duration_seconds, frame_index, plan.frame_count, plan.sample_start_ratio, plan.sample_end_ratio, plan.frame_count > 1 && plan.sample_end_ratio < 1.0, - ); - let raw_frame_path = temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1)); - extract_video_frame_to_png( - &input_path, - &raw_frame_path, - target_seconds, - extraction_settings, - )?; + )); + raw_frame_paths.push(temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1))); + } + // 中文注释:整批采样点只进入一个 FFmpeg filter graph,避免逐帧重启进程并重复解码同一视频。 + extract_video_frames_to_png( + &input_path, + raw_frame_paths.as_slice(), + target_seconds.as_slice(), + extraction_settings, + )?; + + let mut finalized_frames = Vec::with_capacity(raw_frame_paths.len()); + for raw_frame_path in raw_frame_paths { let frame_bytes = fs::read(&raw_frame_path).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "character-animation", @@ -4183,39 +4189,106 @@ fn compute_sample_time_seconds( (sample_start + sample_window * progress).min(safe_sample_end) } -fn extract_video_frame_to_png( +fn build_batch_frame_filter_graph(target_seconds: &[f64]) -> String { + let split_outputs = (0..target_seconds.len()) + .map(|index| format!("[s{index}]")) + .collect::(); + let selectors = target_seconds + .iter() + .enumerate() + .map(|(index, target_seconds)| { + format!("[s{index}]select=gte(t\\,{target_seconds:.3})[f{index}]") + }) + .collect::>() + .join(";"); + format!( + "[0:v]setpts=PTS-STARTPTS,split={}{split_outputs};{selectors}", + target_seconds.len() + ) +} + +fn extract_video_frames_to_png( input_path: &Path, - output_path: &Path, - target_seconds: f64, + output_paths: &[PathBuf], + target_seconds: &[f64], extraction_settings: &BackendFrameExtractionSettings, ) -> Result<(), AppError> { + if output_paths.is_empty() + || output_paths.len() != target_seconds.len() + || target_seconds + .iter() + .any(|target_seconds| !target_seconds.is_finite() || *target_seconds < 0.0) + { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": "动作视频批量抽帧计划无效。", + "outputCount": output_paths.len(), + "targetCount": target_seconds.len(), + })), + ); + } + + let filter_graph = build_batch_frame_filter_graph(target_seconds); + let mut owned_args = vec![ + "-hide_banner".to_string(), + "-loglevel".to_string(), + "error".to_string(), + "-y".to_string(), + "-i".to_string(), + input_path.to_string_lossy().into_owned(), + "-filter_complex".to_string(), + filter_graph, + ]; + for (frame_index, output_path) in output_paths.iter().enumerate() { + owned_args.extend([ + "-map".to_string(), + format!("[f{frame_index}]"), + "-frames:v".to_string(), + "1".to_string(), + "-f".to_string(), + "image2".to_string(), + output_path.to_string_lossy().into_owned(), + ]); + } + let args = owned_args.iter().map(String::as_str).collect::>(); + let fallback_message = if output_paths.len() == 1 { + "抽取动作视频帧失败" + } else { + "批量抽取动作视频帧失败" + }; let output = run_process_with_timeout( &extraction_settings.ffmpeg_path, - &[ - "-y", - "-ss", - &format!("{target_seconds:.3}"), - "-i", - input_path.to_string_lossy().as_ref(), - "-frames:v", - "1", - "-f", - "image2", - output_path.to_string_lossy().as_ref(), - ], + args.as_slice(), extraction_settings.timeout_ms, - "抽取动作视频帧失败", + fallback_message, )?; - if !output_path.is_file() { + let missing_frames = output_paths + .iter() + .enumerate() + .filter(|(_, output_path)| !output_path.is_file()) + .collect::>(); + if let Some((first_missing_index, first_missing_path)) = missing_frames.first().copied() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let missing_frame_details = missing_frames + .into_iter() + .map(|(frame_index, output_path)| { + json!({ + "frameIndex": frame_index + 1, + "targetSeconds": target_seconds[frame_index], + "outputPath": output_path.to_string_lossy(), + }) + }) + .collect::>(); return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "character-animation", "message": "ffmpeg 已执行但未产出动作帧文件。", - "targetSeconds": target_seconds, - "outputPath": output_path.to_string_lossy(), + "targetSeconds": target_seconds[first_missing_index], + "outputPath": first_missing_path.to_string_lossy(), + "missingFrames": missing_frame_details, "stderr": stderr, "stdout": stdout, })), @@ -4224,6 +4297,22 @@ fn extract_video_frame_to_png( Ok(()) } +fn extract_video_frame_to_png( + input_path: &Path, + output_path: &Path, + target_seconds: f64, + extraction_settings: &BackendFrameExtractionSettings, +) -> Result<(), AppError> { + let output_paths = [output_path.to_path_buf()]; + let target_seconds = [target_seconds]; + extract_video_frames_to_png( + input_path, + output_paths.as_slice(), + target_seconds.as_slice(), + extraction_settings, + ) +} + fn run_process_with_timeout( program: &str, args: &[&str], @@ -6380,21 +6469,30 @@ mod tests { } #[test] - fn editor_character_animation_extracts_final_sample_from_short_video() { + fn editor_character_animation_builds_one_filter_graph_for_all_samples() { + let filter_graph = build_batch_frame_filter_graph(&[0.0, 1.0, 2.0, 3.875]); + + assert_eq!( + filter_graph, + "[0:v]setpts=PTS-STARTPTS,split=4[s0][s1][s2][s3];[s0]select=gte(t\\,0.000)[f0];[s1]select=gte(t\\,1.000)[f1];[s2]select=gte(t\\,2.000)[f2];[s3]select=gte(t\\,3.875)[f3]" + ); + } + + #[test] + fn editor_character_animation_batch_extracts_all_samples_from_short_video() { if Command::new("ffmpeg").arg("-version").output().is_err() { return; } let temp_dir = create_animation_temp_dir().expect("temp dir should be created"); let input_path = temp_dir.join("short-preview.mp4"); - let output_path = temp_dir.join("raw-frame-32.png"); let make_video_status = Command::new("ffmpeg") .args([ "-y", "-f", "lavfi", "-i", - "testsrc=size=160x120:rate=8:duration=4", + "testsrc=size=160x120:rate=8:duration=6", "-pix_fmt", "yuv420p", input_path.to_string_lossy().as_ref(), @@ -6408,18 +6506,55 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); return; } - let target_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false); + let target_seconds = (0..48) + .map(|frame_index| compute_sample_time_seconds(6.0, frame_index, 48, 0.0, 1.0, false)) + .collect::>(); + let output_paths = (0..48) + .map(|frame_index| temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1))) + .collect::>(); let settings = BackendFrameExtractionSettings { ffmpeg_path: "ffmpeg".to_string(), ffprobe_path: "ffprobe".to_string(), timeout_ms: 10_000, }; - let result = - extract_video_frame_to_png(&input_path, &output_path, target_seconds, &settings); + let result = extract_video_frames_to_png( + &input_path, + output_paths.as_slice(), + target_seconds.as_slice(), + &settings, + ); + let output_count = output_paths + .iter() + .filter(|output_path| output_path.is_file()) + .count(); + let missing_output_paths = [ + temp_dir.join("missing-check-frame-01.png"), + temp_dir.join("missing-check-frame-02.png"), + ]; + let missing_target_seconds = [0.0, 60.0]; + let missing_error = extract_video_frames_to_png( + &input_path, + missing_output_paths.as_slice(), + missing_target_seconds.as_slice(), + &settings, + ) + .expect_err("a successful ffmpeg exit with a missing frame should still fail the batch"); + let missing_details = missing_error + .details() + .cloned() + .expect("missing frame error should keep diagnostics"); let _ = fs::remove_dir_all(&temp_dir); - result.expect("safe final sample should produce a frame"); + result.expect("one ffmpeg process should extract every safe sample"); + assert_eq!(output_count, 48); + assert_eq!( + missing_error.body_text(), + "ffmpeg 已执行但未产出动作帧文件。" + ); + assert_eq!(missing_details["targetSeconds"], 60.0); + assert_eq!(missing_details["missingFrames"][0]["frameIndex"], 2); + assert_eq!(missing_details["missingFrames"][0]["targetSeconds"], 60.0); } #[test]