修复角色动画尾帧抽帧失败
调整角色动画抽帧采样,避免采到视频尾点 补充 FFmpeg 无输出文件时的诊断字段 新增角色动画抽帧回归测试 更新角色动画抽帧文档和踩坑记录
This commit is contained in:
@@ -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,...`,后续实现很容易照抄。
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
### 抽帧与 OSS 存储
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须执行绿幕去背,输出透明背景 PNG。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端首版只展示生成完成结果摘要,不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。
|
||||
|
||||
@@ -3235,9 +3235,7 @@ fn probe_video_duration_seconds_or_known(
|
||||
) -> Result<f64, AppError> {
|
||||
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("无法启动进程"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user