优化角色动画批量抽帧
使用单个FFmpeg filter graph批量输出32、40或48帧 保留精确采样时刻和尾帧安全步长 补充整批缺帧诊断与真实短视频回归测试 同步角色动画设计、决策记录和排障说明
This commit is contained in:
@@ -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\,<target>)` 输出一帧。不得改用会漂移现有采样时刻的粗粒度 `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 与模型实际生成清晰度不一致。
|
||||
|
||||
@@ -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 子进程环境
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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::<String>();
|
||||
let selectors = target_seconds
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, target_seconds)| {
|
||||
format!("[s{index}]select=gte(t\\,{target_seconds:.3})[f{index}]")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
let output_paths = (0..48)
|
||||
.map(|frame_index| temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1)))
|
||||
.collect::<Vec<_>>();
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user