diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd8566e07..872c1f78e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14179,3 +14179,9 @@ - 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。 - 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。 - 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-08-17 Spine 视频模式外观参考图容量门禁 + +- 背景:视频模式的 `appearanceReferenceSrcs` 原先会在 Provider 调用前逐项下载并完整解码,但该实际执行路径没有复用通用视频参考的 9 张数量上限,也没有单图、累计字节、解码边长和像素预算,可能让第 10 张或压缩炸弹图片持续占用 durable worker 资源。 +- 决策:角色外观图最多 9 张,单图最大 30MB、合计最大 64MB,编码边长最大 8192、单图总像素最大 33554432。前端上传追加与草稿恢复收口到 9 张;HTTP 入队和 worker 共用请求归一化数量门禁;worker 在 Provider 调用前按 OSS HEAD、流式下载字节上限、解码器资源限制及显式像素计算失败关闭。既有 owner、稳定 objectKey、MIME 和来源资源校验继续保留。 +- 验证:覆盖 9/10 张边界、30MB 单图字节边界、8192 边长及总像素边界,并运行角色动作 Rust 定向测试、相关前端 Vitest、类型检查、编码与 diff 门禁。 diff --git a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md index 759e39a93..76134a7f9 100644 --- a/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md +++ b/docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md @@ -574,7 +574,8 @@ flowchart TD - 从画布选择现有 `character-animation` 时,只有确认“序列帧改造”的语义后才允许; - 上传图片是用户在该入口内显式指定的角色参考,可直接按角色参考处理,不要求先创建一个 `character` 图层; - 视频必须是已登记的 `video` 媒体; -- 继续复用现有 Seedance 文件规则:图片最大 30MB,视频 mp4/mov 最大 50MB、2–15 秒;最终上限仍需结合“全部帧”决策重新评估; +- 继续复用现有 Seedance 文件规则:图片最大 30MB,视频 mp4/mov 最大 50MB、2–15 秒; +- 视频模式的角色外观图最多 9 张,单图最大 30MB、合计最大 64MB;前端追加与草稿恢复、HTTP 入队和 durable worker 执行使用同一数量边界。worker 在 Provider 调用前逐图执行有界下载和解码校验,编码边长不得超过 8192,单图总像素不得超过 33554432,拒绝大尺寸或压缩炸弹输入; - 服务端再次验证 owner、媒体类型和稳定对象,前端标签不是安全边界。 ### 10.4 生成后图层 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 a272c808a..07744c438 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -187,6 +187,12 @@ const EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT: usize = 3; const EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT: usize = 3; const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 256 * 1024; const EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS: u64 = 60 * 60; +const EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES: u64 = 30 * 1024 * 1024; +const EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES: u64 = 64 * 1024 * 1024; +const EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION: u32 = 8_192; +const EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_PIXELS: u64 = 32 * 1024 * 1024; +const EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_ALLOC_BYTES: u64 = + EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_PIXELS * 8; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ MotionTemplate { @@ -1816,6 +1822,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( } }; let mut appearance_object_keys = Vec::new(); + let mut appearance_total_bytes = 0_u64; if !payload.appearance_reference_srcs.is_empty() { if provisional_normalized.input_mode != EditorCharacterAnimationInputMode::Video { return Err(character_animation_error_response( @@ -1837,7 +1844,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( .map_err(|error| character_animation_error_response(&request_context, error))?; // 先下载并解码图片完成权威 MIME、尺寸和内容校验;Provider 阶段必须继续使用 // 稳定 objectKey,由统一视频参考解析器签发可访问 URL,不能把内部 data URL 传下去。 - resolve_editor_character_animation_reference_image( + let resolved = resolve_editor_character_animation_reference_image( &state, &source_resolve_client, object_key.as_str(), @@ -1845,6 +1852,11 @@ pub(crate) async fn generate_editor_character_animation_for_owner( ) .await .map_err(|error| character_animation_error_response(&request_context, error))?; + appearance_total_bytes = accumulate_editor_character_animation_appearance_bytes( + appearance_total_bytes, + resolved.source_size_bytes, + ) + .map_err(|error| character_animation_error_response(&request_context, error))?; appearance_object_keys.push(object_key); } } @@ -3518,7 +3530,14 @@ async fn resolve_editor_character_animation_reference_image( source: &str, field: &str, ) -> Result { - let payload = load_media_source_payload_with_client(state, http_client, source, None).await?; + let payload = load_media_source_payload_with_client( + state, + http_client, + source, + Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES), + ) + .await?; + let source_size_bytes = u64::try_from(payload.bytes.len()).unwrap_or(u64::MAX); validate_editor_character_animation_reference_content_type( payload.mime_type.as_str(), field, @@ -3540,6 +3559,23 @@ async fn resolve_editor_character_animation_reference_image( "message": format!("参考图片无法解码:{error}"), })) })?; + let (encoded_width, encoded_height) = decoder.dimensions(); + validate_editor_character_animation_reference_image_dimensions( + encoded_width, + encoded_height, + field, + )?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION); + limits.max_image_height = Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION); + limits.max_alloc = Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_ALLOC_BYTES); + decoder.set_limits(limits).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-character-animation", + "field": field, + "message": format!("参考图片超过解码资源上限:{error}"), + })) + })?; let orientation = decoder.orientation().map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-character-animation", @@ -3569,9 +3605,56 @@ async fn resolve_editor_character_animation_reference_image( data_url, width: decoded.width(), height: decoded.height(), + source_size_bytes, }) } +fn accumulate_editor_character_animation_appearance_bytes( + current_bytes: u64, + next_bytes: u64, +) -> Result { + let total_bytes = current_bytes + .checked_add(next_bytes) + .ok_or_else(|| editor_character_animation_bad_request("角色外观图总大小超过允许范围。"))?; + if total_bytes > EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES { + return Err(editor_character_animation_bad_request(format!( + "角色外观图总大小不能超过{}MB。", + EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES / 1024 / 1024 + ))); + } + Ok(total_bytes) +} + +fn validate_editor_character_animation_reference_image_dimensions( + width: u32, + height: u32, + field: &str, +) -> Result<(), AppError> { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 || height == 0 { + return Err(editor_character_animation_bad_request( + "参考图片缺少可识别的媒体宽高。", + )); + } + if width > EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION + || height > EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION + || pixels > EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_PIXELS + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-character-animation", + "field": field, + "message": format!( + "参考图片尺寸超过{}像素边长或{}总像素上限。", + EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_DIMENSION, + EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_PIXELS + ), + })), + ); + } + Ok(()) +} + async fn validate_editor_character_animation_reference_video_object( state: &AppState, http_client: &reqwest::Client, @@ -9238,6 +9321,7 @@ struct ResolvedEditorCharacterAnimationReferenceImage { data_url: String, width: u32, height: u32, + source_size_bytes: u64, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -10143,6 +10227,65 @@ mod tests { assert!(first.ends_with("-task-1")); } + #[test] + fn editor_character_animation_appearance_reference_enforces_download_and_pixel_limits() { + assert!( + validate_media_payload_download_size( + EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES as usize, + Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES), + ) + .is_ok() + ); + assert!( + validate_media_payload_download_size( + EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES as usize + 1, + Some(EDITOR_CHARACTER_ANIMATION_REFERENCE_IMAGE_MAX_BYTES), + ) + .is_err() + ); + + assert_eq!( + accumulate_editor_character_animation_appearance_bytes( + EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES - 1, + 1, + ) + .expect("64MB total should be allowed"), + EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES + ); + assert!( + accumulate_editor_character_animation_appearance_bytes( + EDITOR_CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES, + 1, + ) + .is_err() + ); + + assert!( + validate_editor_character_animation_reference_image_dimensions( + 8_192, + 4_096, + "appearanceReferenceSrcs", + ) + .is_ok() + ); + assert!( + validate_editor_character_animation_reference_image_dimensions( + 8_192, + 4_097, + "appearanceReferenceSrcs", + ) + .is_err() + ); + assert!( + validate_editor_character_animation_reference_image_dimensions( + 8_193, + 1, + "appearanceReferenceSrcs", + ) + .is_err() + ); + } + #[test] fn editor_character_animation_background_removal_validates_each_source_frame() { let source = include_str!("character_animation_assets.rs"); diff --git a/server-rs/crates/api-server/src/editor_sequence_animation.rs b/server-rs/crates/api-server/src/editor_sequence_animation.rs index 0a8715d22..a7a3383eb 100644 --- a/server-rs/crates/api-server/src/editor_sequence_animation.rs +++ b/server-rs/crates/api-server/src/editor_sequence_animation.rs @@ -5,6 +5,8 @@ use shared_contracts::assets::{ EditorCharacterAnimationVideoConversionRequest, }; +pub(crate) const EDITOR_CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT: usize = 9; + use crate::{ editor_generation_config::EditorGenerationPricingConfig, editor_green_screen::{ @@ -207,10 +209,17 @@ pub(crate) fn normalize_editor_character_animation_request_with_pricing( ); let reference_src = trim_optional_text(payload.reference_src.as_deref()) .or_else(|| trim_optional_text(Some(payload.source_image_src.as_str()))); - let has_appearance_references = payload + let appearance_reference_count = payload .appearance_reference_srcs .iter() - .any(|src| !src.trim().is_empty()); + .filter(|src| !src.trim().is_empty()) + .count(); + if appearance_reference_count > EDITOR_CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT { + return Err(editor_character_animation_bad_request(format!( + "角色外观图最多支持{EDITOR_CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT}张。" + ))); + } + let has_appearance_references = appearance_reference_count > 0; if input_mode == EditorCharacterAnimationInputMode::Text && reference_src.is_some() { return Err(editor_character_animation_bad_request( "文本模式不能携带参考素材。", @@ -944,6 +953,33 @@ mod tests { .and_then(|field| field.get("value")) } + #[test] + fn character_animation_video_appearance_references_accept_nine_and_reject_ten() { + let appearance_reference = |index: usize| format!("users/user-1/appearance-{index}.png"); + let accepted = + normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest { + input_mode: Some("video".to_string()), + reference_media_type: Some("video".to_string()), + reference_src: Some("users/user-1/motion.mp4".to_string()), + source_image_src: String::new(), + appearance_reference_srcs: (0..9).map(appearance_reference).collect(), + ..test_request() + }); + assert!(accepted.is_ok()); + + let rejected = + normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest { + input_mode: Some("video".to_string()), + reference_media_type: Some("video".to_string()), + reference_src: Some("users/user-1/motion.mp4".to_string()), + source_image_src: String::new(), + appearance_reference_srcs: (0..10).map(appearance_reference).collect(), + ..test_request() + }) + .expect_err("the tenth appearance reference must be rejected"); + assert!(rejected.body_text().contains("最多支持9张")); + } + #[test] fn character_animation_normalizes_seedance_request_contract_and_labels() { let normalized = diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 259731a6f..d73fe6273 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1872,6 +1872,26 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('drops character animation appearance references beyond the ninth during hydration', () => { + const snapshot = { + id: 'dialog-character-animation-over-limit', + mode: 'character-animation', + prompt: '奔跑', + status: 'idle', + characterAnimationAppearanceReferences: Array.from( + { length: 10 }, + (_, index) => ({ + id: `appearance-${index}`, + label: `角色外观${index + 1}`, + src: `users/user-1/appearance-${index}.png`, + }), + ), + }; + + const hydrated = hydrateCanvasGenerationDialog(snapshot); + expect(hydrated?.characterAnimationAppearanceReferences).toHaveLength(9); + }); + it('keeps the operation ledger out of the layout and restores it from the local ledger', () => { const dialogId = 'dialog-perfect-pixel-round-trip'; const operation = buildPerfectPixelOperation(dialogId); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 1bc8412df..a59ee2343 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -32,6 +32,7 @@ import type { EditorAssetFolder, PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; +import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel'; import { hydrateCanvasGenerationInputs, hydrateCanvasGenerationInputsResult, @@ -1282,7 +1283,7 @@ export function hydrateCanvasGenerationDialog( snapshot.characterAnimationAppearanceReferences, resourcesById, currentUserId, - ), + )?.slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT), characterAnimationConversionVideoReference: hydrateCharacterReference( snapshot.characterAnimationConversionVideoReference, resourcesById, diff --git a/src/components/image-editor/ImageCanvasFileModel.ts b/src/components/image-editor/ImageCanvasFileModel.ts index 335802380..709f85f0f 100644 --- a/src/components/image-editor/ImageCanvasFileModel.ts +++ b/src/components/image-editor/ImageCanvasFileModel.ts @@ -25,6 +25,9 @@ export function getEditorUploadAccept(target: string) { return EDITOR_ASSET_UPLOAD_ACCEPT; } +export const CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT = 9; +export const CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES = 64 * 1024 * 1024; + export const SEEDANCE_REFERENCE_FILE_LIMITS = { imageBytes: 30 * 1024 * 1024, videoBytes: 50 * 1024 * 1024, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 3771a4ab2..7366958a5 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -19,6 +19,7 @@ import type { SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; +import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel'; import { appendLimitedImageReferences, appendLimitedQuickEditReferences, @@ -2404,7 +2405,7 @@ export function appendGenerationReference( characterAnimationAppearanceReferences: [ ...(dialog.characterAnimationAppearanceReferences ?? []), createCanvasLayerReference(layer), - ], + ].slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT), } : { sourceLayerId: layer.id, diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 410795565..2d66b875f 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -537,6 +537,39 @@ describe('ImageCanvasUploadModel', () => { }); }); + it('caps character animation appearance uploads at nine references', () => { + const motionReference = { + id: 'motion', + label: '动作视频', + src: 'motion.mp4', + mediaType: 'video' as const, + }; + const existingAppearances = Array.from({ length: 9 }, (_, index) => ({ + id: `appearance-${index}`, + label: `角色外观${index + 1}`, + src: `appearance-${index}.png`, + mediaType: 'image' as const, + })); + const nextAppearance = { + id: 'appearance-10', + label: '第十张角色外观', + src: 'appearance-10.png', + mediaType: 'image' as const, + }; + + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ + mode: 'character-animation', + generationReferences: [motionReference], + characterAnimationAppearanceReferences: existingAppearances, + }), + target: 'video-reference-image', + references: [nextAppearance], + })?.characterAnimationAppearanceReferences, + ).toEqual(existingAppearances); + }); + it('resizes a same-ratio character animation placeholder for an uploaded reference', () => { const dialog = createDialog({ mode: 'character-animation', diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index ac7711803..71c30c3d7 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -11,6 +11,7 @@ import type { GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; +import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel'; import { appendLimitedImageReferences, QUICK_EDIT_REFERENCE_LIMIT, @@ -279,7 +280,7 @@ export function applyGenerationReferenceUpload({ characterAnimationAppearanceReferences: [ ...(dialog.characterAnimationAppearanceReferences ?? []), firstReference, - ], + ].slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT), composerOpen: true, } : { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 3fcfab9e8..887845f17 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -51,6 +51,10 @@ import type { QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; +import { + CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT, + CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES, +} from './ImageCanvasFileModel'; import { applyImageEditResultToSourceLayer, createAudioResultLayer, @@ -2703,6 +2707,23 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const selectedReference = characterAnimationPanel.references?.[0]; const appearanceReferences = characterAnimationPanel.appearanceReferences ?? []; + if ( + appearanceReferences.length > + CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT + ) { + throw new Error( + `角色外观图最多支持${CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT}张`, + ); + } + const knownAppearanceBytes = appearanceReferences.reduce( + (total, reference) => total + (reference.sizeBytes ?? 0), + 0, + ); + if ( + knownAppearanceBytes > CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES + ) { + throw new Error('角色外观图总大小不能超过64MB'); + } const referenceMediaType = selectedReference?.mediaType === 'video' ? 'video' : 'image'; const stableReferenceSrc = selectedReference