Fix/padding #96

Merged
kdletters merged 8 commits from fix/padding into master 2026-07-21 16:11:22 +08:00
5 changed files with 266 additions and 22 deletions
@@ -16,6 +16,18 @@
---
## 2026-07-20 角色动作抠图前禁止透明 padding
- 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。
- 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。
- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。
- 边界:不改变最终帧的 RGBA/padding 语义与透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。
- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。
- 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试
- 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。
@@ -31,6 +31,11 @@
- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片``生成规范``生成角色形象``生成图标素材``生成视频``生成UI设计图``生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。
### 角色动作帧抠图像素边界
- 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。
- 转 RGB8 时若解码帧携带 Alpha 通道,必须先按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha:全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘。共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只发生在 BgFilter 输入准备阶段。
## 交互规则
- `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`
File diff suppressed because one or more lines are too long
@@ -2,6 +2,8 @@
日期:`2026-06-15`
更新时间:`2026-07-21`
## 背景
图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色规范与常规参考图。
@@ -162,8 +164,10 @@
- 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32``40``48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。
- 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URLmultipart 仅传 `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` 次。
- BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
@@ -2320,6 +2320,7 @@ async fn extract_and_persist_editor_character_animation_frames(
let plan = AnimationFrameExtractionPlan {
frame_count: request.frame_count,
apply_chroma_key: false,
prepare_for_bgfilter_input: true,
sample_start_ratio: 0.0,
sample_end_ratio: 1.0,
};
@@ -4077,6 +4078,7 @@ fn normalize_animation_frame_extraction_plan(
AnimationFrameExtractionPlan {
frame_count,
apply_chroma_key,
prepare_for_bgfilter_input: false,
sample_start_ratio,
sample_end_ratio,
}
@@ -4155,13 +4157,23 @@ async fn extract_animation_frames_from_preview_video(
"message": format!("读取动作抽帧结果失败:{error}"),
}))
})?;
finalized_frames.push(finalize_animation_frame_payload(
frame_bytes.as_slice(),
"image/png",
frame_width,
frame_height,
plan.apply_chroma_key,
)?);
let finalized_frame = if plan.prepare_for_bgfilter_input {
prepare_editor_character_animation_bgfilter_input(
frame_bytes.as_slice(),
"image/png",
frame_width,
frame_height,
)?
} else {
finalize_animation_frame_payload(
frame_bytes.as_slice(),
"image/png",
frame_width,
frame_height,
plan.apply_chroma_key,
)?
};
finalized_frames.push(finalized_frame);
}
Ok::<_, AppError>(finalized_frames)
@@ -4457,6 +4469,74 @@ fn run_process_with_timeout(
}
}
/// BgFilter 只接受不透明 RGB 输入;直接丢弃 alpha 会让全透明像素下未定义的
/// RGB 值以杂色进入抠图,这里先按白底合成再转 RGB。
fn flatten_alpha_onto_white_rgb(image: image::DynamicImage) -> image::RgbImage {
if !image.color().has_alpha() {
return image.to_rgb8();
}
let rgba = image.to_rgba8();
let mut flattened = image::RgbImage::new(rgba.width(), rgba.height());
for (source, target) in rgba.pixels().zip(flattened.pixels_mut()) {
let [red, green, blue, alpha] = source.0;
let opacity = f32::from(alpha) / 255.0;
let blend = |channel: u8| -> u8 {
(f32::from(channel) * opacity + 255.0 * (1.0 - opacity)).round() as u8
};
target.0 = [blend(red), blend(green), blend(blue)];
}
flattened
}
fn prepare_editor_character_animation_bgfilter_input(
source: &[u8],
mime_type: &str,
target_width: u32,
target_height: u32,
) -> Result<FinalizedAnimationFrame, AppError> {
let image = match image_format_from_mime(mime_type) {
Some(format) => image::load_from_memory_with_format(source, format),
None => image::load_from_memory(source),
}
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("解析 BgFilter 输入动作帧图片失败:{error}"),
}))
})?;
let image = flatten_alpha_onto_white_rgb(image);
let (draw_width, draw_height) =
compute_contain_dimensions(image.width(), image.height(), target_width, target_height);
let resized = if (draw_width, draw_height) == (image.width(), image.height()) {
image
} else {
image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle)
};
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(
resized.as_raw(),
resized.width(),
resized.height(),
ColorType::Rgb8.into(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("编码 BgFilter 输入动作帧 PNG 失败:{error}"),
}))
})?;
Ok(FinalizedAnimationFrame {
bytes: encoded,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn finalize_animation_frame_payload(
source: &[u8],
mime_type: &str,
@@ -4464,13 +4544,7 @@ fn finalize_animation_frame_payload(
frame_height: u32,
apply_chroma_key: bool,
) -> Result<FinalizedAnimationFrame, AppError> {
let image_format = match mime_type {
"image/png" => Some(ImageFormat::Png),
"image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg),
"image/webp" => Some(ImageFormat::WebP),
_ => None,
};
let mut image = match image_format {
let mut image = match image_format_from_mime(mime_type) {
Some(format) => image::load_from_memory_with_format(source, format),
None => image::load_from_memory(source),
}
@@ -4517,8 +4591,39 @@ fn finalize_animation_frame_payload(
fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0]));
let source_width = source.width().max(1);
let source_height = source.height().max(1);
let (draw_width, draw_height) =
compute_contain_dimensions(source.width(), source.height(), target_width, target_height);
let offset_x = ((target_width - draw_width) / 2) as i64;
let offset_y = ((target_height - draw_height) / 2) as i64;
if (draw_width, draw_height) == (source.width(), source.height()) {
image::imageops::overlay(&mut canvas, source, offset_x, offset_y);
} else {
let resized =
image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle);
image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y);
}
canvas
}
fn image_format_from_mime(mime_type: &str) -> Option<ImageFormat> {
match mime_type {
"image/png" => Some(ImageFormat::Png),
"image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg),
"image/webp" => Some(ImageFormat::WebP),
_ => None,
}
}
fn compute_contain_dimensions(
source_width: u32,
source_height: u32,
target_width: u32,
target_height: u32,
) -> (u32, u32) {
let target_width = target_width.max(1);
let target_height = target_height.max(1);
let source_width = source_width.max(1);
let source_height = source_height.max(1);
let scale = (target_width as f32 / source_width as f32)
.min(target_height as f32 / source_height as f32);
let draw_width = ((source_width as f32 * scale).round() as u32)
@@ -4527,11 +4632,7 @@ fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32)
let draw_height = ((source_height as f32 * scale).round() as u32)
.max(1)
.min(target_height);
let resized = image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle);
let offset_x = ((target_width - draw_width) / 2) as i64;
let offset_y = ((target_height - draw_height) / 2) as i64;
image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y);
canvas
(draw_width, draw_height)
}
async fn load_media_source_payload(
@@ -5848,6 +5949,7 @@ struct BackendFrameExtractionSettings {
struct AnimationFrameExtractionPlan {
frame_count: u32,
apply_chroma_key: bool,
prepare_for_bgfilter_input: bool,
sample_start_ratio: f32,
sample_end_ratio: f32,
}
@@ -6356,6 +6458,109 @@ mod tests {
);
}
#[test]
fn editor_character_animation_bgfilter_input_is_rgb_without_padding() {
let source = image::RgbImage::from_pixel(560, 752, image::Rgb([17, 99, 201]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgb8.into(),
)
.expect("source RGB frame should encode");
let prepared = prepare_editor_character_animation_bgfilter_input(
source_png.as_slice(),
"image/png",
323,
480,
)
.expect("BgFilter input should be prepared");
let decoded =
image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png)
.expect("prepared BgFilter input should decode");
assert_eq!(prepared.mime_type, "image/png");
assert_eq!(prepared.extension, "png");
assert_eq!(decoded.width(), 323);
assert_eq!(decoded.height(), 434);
assert_eq!(decoded.color(), ColorType::Rgb8);
assert!(
decoded
.to_rgb8()
.pixels()
.all(|pixel| pixel.0 == [17, 99, 201])
);
}
#[test]
fn editor_character_animation_bgfilter_input_flattens_alpha_onto_white() {
let mut source = RgbaImage::from_pixel(8, 8, Rgba([17, 99, 201, 255]));
source.put_pixel(0, 0, Rgba([255, 0, 0, 0]));
source.put_pixel(1, 0, Rgba([0, 0, 0, 127]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgba8.into(),
)
.expect("source RGBA frame should encode");
let prepared = prepare_editor_character_animation_bgfilter_input(
source_png.as_slice(),
"image/png",
8,
8,
)
.expect("BgFilter input should be prepared");
let decoded =
image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png)
.expect("prepared BgFilter input should decode")
.to_rgb8();
assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255]);
assert_eq!(decoded.get_pixel(1, 0).0, [128, 128, 128]);
assert_eq!(decoded.get_pixel(2, 0).0, [17, 99, 201]);
}
#[test]
fn editor_character_animation_final_frame_adds_transparent_vertical_padding() {
let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgba8.into(),
)
.expect("source RGBA frame should encode");
let finalized =
finalize_animation_frame_payload(source_png.as_slice(), "image/png", 323, 480, false)
.expect("final transparent frame should be finalized");
let decoded =
image::load_from_memory_with_format(finalized.bytes.as_slice(), ImageFormat::Png)
.expect("final transparent frame should decode");
assert_eq!(decoded.width(), 323);
assert_eq!(decoded.height(), 480);
assert_eq!(decoded.color(), ColorType::Rgba8);
let output = decoded.to_rgba8();
for (x, y, pixel) in output.enumerate_pixels() {
let expected = if (23..457).contains(&y) {
[31, 127, 223, 191]
} else {
[0, 0, 0, 0]
};
assert_eq!(pixel.0, expected, "unexpected pixel at ({x}, {y})");
}
}
#[test]
fn editor_character_animation_frames_use_three_stage_matting_fallback() {
let source = include_str!("character_animation_assets.rs");
@@ -6365,6 +6570,7 @@ mod tests {
"async fn publish_animation_set",
&[
"apply_chroma_key: false",
"prepare_for_bgfilter_input: true",
"editor_character_animation_bgfilter_request_timeout_ms",
"state.config.editor_bgfilter_request_timeout_ms",
"process_and_persist_editor_character_animation_frame",
@@ -6375,6 +6581,22 @@ mod tests {
"frame_errors.sort_by_key",
],
);
assert_function_contains(
source,
"fn normalize_animation_frame_extraction_plan",
"fn normalize_sample_ratio",
&["apply_chroma_key", "prepare_for_bgfilter_input: false"],
);
assert_function_contains_in_order(
source,
"async fn extract_animation_frames_from_preview_video",
"fn create_animation_temp_dir",
&[
"plan.prepare_for_bgfilter_input",
"prepare_editor_character_animation_bgfilter_input",
"finalize_animation_frame_payload",
],
);
assert_function_contains_in_order(
source,
"async fn process_and_persist_editor_character_animation_frame",