diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0463c496f..428d096bc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -79,6 +79,14 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +## 图片编辑器角色动画抽帧不要采到视频尾点 + +- 现象:画板角色图点击 `生成动画` 后,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 帧回归。 +- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 + ## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 748855f58..03f3239b5 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -160,6 +160,7 @@ ### 抽帧与 OSS 存储 - 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。 +- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 - 每帧必须执行绿幕去背,输出透明背景 PNG。 - 抽帧结果写入 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 af4f24e05..b258a2bb1 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -3235,9 +3235,7 @@ fn probe_video_duration_seconds_or_known( ) -> Result { match probe_video_duration_seconds(input_path, extraction_settings) { Ok(duration_seconds) => Ok(duration_seconds), - Err(error) - if is_ffprobe_start_error(&error, extraction_settings.ffprobe_path.as_str()) => - { + Err(error) if is_ffprobe_start_error(&error, extraction_settings.ffprobe_path.as_str()) => { if let Some(duration_seconds) = known_duration_seconds .filter(|duration_seconds| duration_seconds.is_finite() && *duration_seconds > 0.0) { @@ -3267,13 +3265,20 @@ fn compute_sample_time_seconds( let sample_start = duration_seconds * sample_start_ratio as f64; let sample_end = duration_seconds * sample_end_ratio as f64; let sample_window = (sample_end - sample_start).max(0.001); - let progress = if loop_mode { - frame_index as f64 / frame_count.max(1) as f64 + let frame_count_f64 = frame_count.max(1) as f64; + let progress = if loop_mode || frame_count <= 1 || sample_end_ratio >= 1.0 { + frame_index as f64 / frame_count_f64 } else { frame_index as f64 / frame_count.saturating_sub(1).max(1) as f64 }; + let frame_margin_seconds = (sample_window / frame_count_f64) + .max(0.05) + .min(sample_window); + let safe_sample_end = (sample_end - frame_margin_seconds) + .max(sample_start) + .min((duration_seconds - 0.001).max(0.0)); - (sample_start + sample_window * progress).min((duration_seconds - 0.001).max(0.0)) + (sample_start + sample_window * progress).min(safe_sample_end) } fn extract_video_frame_to_png( @@ -3282,7 +3287,7 @@ fn extract_video_frame_to_png( target_seconds: f64, extraction_settings: &BackendFrameExtractionSettings, ) -> Result<(), AppError> { - run_process_with_timeout( + let output = run_process_with_timeout( &extraction_settings.ffmpeg_path, &[ "-y", @@ -3301,10 +3306,16 @@ fn extract_video_frame_to_png( )?; if !output_path.is_file() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); 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(), + "stderr": stderr, + "stdout": stdout, })), ); } @@ -4945,6 +4956,56 @@ mod tests { ); } + #[test] + fn editor_character_animation_samples_last_frame_before_decode_tail() { + let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false); + + assert!((last_frame_seconds - 3.875).abs() < f64::EPSILON); + } + + #[test] + fn editor_character_animation_extracts_final_sample_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", + "-pix_fmt", + "yuv420p", + input_path.to_string_lossy().as_ref(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("ffmpeg should start"); + if !make_video_status.success() { + 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 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 _ = fs::remove_dir_all(&temp_dir); + result.expect("safe final sample should produce a frame"); + } + #[test] fn editor_character_animation_uses_known_duration_when_ffprobe_is_missing() { let settings = BackendFrameExtractionSettings { @@ -4953,12 +5014,9 @@ mod tests { timeout_ms: 1_000, }; - let duration = probe_video_duration_seconds_or_known( - Path::new("preview.mp4"), - &settings, - Some(6.0), - ) - .expect("known duration should cover a missing ffprobe binary"); + let duration = + probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, Some(6.0)) + .expect("known duration should cover a missing ffprobe binary"); assert_eq!(duration, 6.0); } @@ -4971,12 +5029,9 @@ mod tests { timeout_ms: 1_000, }; - let error = probe_video_duration_seconds_or_known( - Path::new("preview.mp4"), - &settings, - None, - ) - .expect_err("missing ffprobe should still fail without a trusted duration"); + let error = + probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, None) + .expect_err("missing ffprobe should still fail without a trusted duration"); assert!(error.body_text().contains("无法启动进程")); }