Spine序列帧动画多模态生成

This commit is contained in:
2026-08-10 20:14:09 +08:00
parent 3c457feb4c
commit 1575afc92c
55 changed files with 7398 additions and 795 deletions
+1
View File
@@ -20,6 +20,7 @@
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md)
- [Art Agent 多模态 Spine 序列帧动画生成](./technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md)
- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)
- [SFX 生成优化 V2.0 任务拆解](./project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md)
- [SFX 生成优化 V2.0 T6 测试与发布门禁](./【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md)
@@ -472,6 +472,8 @@
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理。
- 原因:后端仍需要先生成 `previewVideoPath` 再抽帧、绿幕去背和落 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载。
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只作为来源信息保留。生成端确认每帧对象后必须把该帧 `objectKey``assetObjectId` 一起写入正式 payload 和 `generation_inputs_json.characterAnimation.frames`。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
- 拆帧:拆帧只把正式帧对象登记成独立项目资源和账号素材,必须复用每帧已有的 `assetObjectId/objectKey`,禁止重新下载再上传 PNG。批次继续复用现有 spritesheet slice 批量事务,原子写入全部帧并完成 cohort;拆帧 task/cohort 必须按 owner + `sourceResourceId` + 规范化目标目录与标签稳定隔离,不能直接复用原生成 task,否则同一素材进入多个项目后会把多套帧归入一个错误 cohort。原帧 `asset_object.source_job_id` 继续保留原生成来源,不能为了匹配拆帧 task 伪造血缘。结果只进入素材库,不自动创建几十个画布图层。
- 多模态参考:`inputMode/referenceMediaType` 只是客户端意图,不是媒体事实。图片或视频参考必须先按 owner 解析到权威 objectKey,再在扣费和 provider 调用前使用 OSS HEAD、权威 `asset_object.content_type` 或实际媒体探测验证 `image/*` / `video/*`;不能把上游报错和退款当作后端媒体校验。
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录;生成测试还应断言首帧与非首帧的稳定引用都被保留。
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts``server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
@@ -4129,6 +4131,13 @@
- 处理:资源 / 素材只保存 `image_sequence_frames_json``image_sequence_duration_ms`,精选审核快照只冻结复制这两个正式字段。角色动作要求至少两帧且毫秒时长大于 0;播放器按 `时长毫秒 / 数组长度` 计算间隔,Spine 导出时再换算秒数并推导 FPS。音频 / 视频 `durationSeconds` 不映射到这两个字段。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts`
## 多模态角色动作参考必须绑定上传上下文并由后端读取权威规格
- 现象:普通视频与 Spine 序列帧面板共用参考上传控件时,用户在异步探测或上传期间切换面板,完成结果可能串入新面板;`same` 比例若直接采用客户端宽高,还会把省略或伪造尺寸的视频错误归一化为 `1:1`
- 处理:图片、视频、音频参考上传必须以 generation dialog 身份和分媒体容量建立 reservation,在探测、OSS 上传、项目资源 / 素材持久化和回填各边界复验;序列帧单参考限制按 dialog 语义判断,不依赖 `activeTool`。后端在扣费和 Provider 调用前按 owner 解析稳定对象,视频用带总超时的同一 HTTP client 做 HEAD 与 50MB 有界流式 GET,再由 FFprobe 读取 2–15 秒时长、显示方向宽高;图片按 EXIF 方向后的实际解码尺寸处理。`same` 只使用这些权威尺寸重新归一化。
- 拆帧:省略素材目录时先转换为 owner 的正式默认文件夹 ID 并做 project/folder preflight;决定 resource、asset 与 cohort 的稳定 operation ID 属于资产领域规则,放在 `module-assets`API 只编排调用。
- 关联:`src/components/image-editor/useImageCanvasUploadWorkflow.ts``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/module-assets/src/character_animation.rs`
## 精选角色动作显示首帧还要检查前端 renderer 与逐帧授权
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
@@ -0,0 +1,12 @@
export type EditorCharacterAnimationFrameSplitInput = {
projectId: string;
sourceLayerId: string;
sourceResourceId: string;
assetFolderId?: string | null;
assetLabel?: string | null;
};
export type EditorCharacterAnimationFrameSplitResult<TAsset> = {
frameCount: number;
assets: TAsset[];
};
+1
View File
@@ -6,6 +6,7 @@ export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/editorAudio';
export type * from './contracts/editorCharacterAnimation';
export * from './contracts/editorScene';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
+1
View File
@@ -2934,6 +2934,7 @@ dependencies = [
"platform-oss",
"reqwest",
"serde",
"sha2",
"shared-kernel",
"spacetimedb",
]
+25
View File
@@ -1823,6 +1823,31 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_character_animation_frame_split_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"projectId": "project-1",
"sourceLayerId": "layer-character-animation",
"sourceResourceId": "resource-character-animation"
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/character-animations/frame-splits")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ use platform_oss::{
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
pub use shared_contracts::assets::EditorAssetPayload;
#[cfg(test)]
use shared_contracts::assets::EditorAudioGenerateResponse;
use shared_contracts::assets::{
@@ -1813,43 +1814,6 @@ pub struct EditorAssetFolderPayload {
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetPayload {
asset_id: String,
folder_id: String,
label: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
task_id: Option<String>,
asset_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
image_sequence_frames: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
image_sequence_duration_ms: Option<u64>,
generation_inputs: Option<Value>,
source_resource_id: Option<String>,
public_showcase_enabled: Option<bool>,
thumbnail_src: Option<String>,
generation_cost_mud_points: u64,
showcase_id: Option<String>,
showcase_review_status: Option<String>,
showcase_display_enabled: Option<bool>,
showcase_like_count: Option<u64>,
created_at: String,
updated_at: String,
}
pub async fn load_recent_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
File diff suppressed because it is too large Load Diff
@@ -118,6 +118,58 @@ pub struct ExternalEditorAssetFolderUpdateRequest {
collapsed: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExternalEditorCharacterAnimationGenerateRequest {
source_layer_id: String,
source_image_src: String,
source_width: u32,
source_height: u32,
prompt_text: String,
screen_color: Option<String>,
resolution: String,
ratio: String,
frame_count: u32,
duration_seconds: u32,
model: String,
project_id: Option<String>,
canvas_completion: Option<shared_contracts::assets::EditorCanvasGenerationCompletionPayload>,
generation_inputs: Option<Value>,
source_resource_id: Option<String>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
}
impl From<ExternalEditorCharacterAnimationGenerateRequest>
for shared_contracts::assets::EditorCharacterAnimationGenerateRequest
{
fn from(payload: ExternalEditorCharacterAnimationGenerateRequest) -> Self {
Self {
source_layer_id: payload.source_layer_id,
source_image_src: payload.source_image_src,
source_width: payload.source_width,
source_height: payload.source_height,
input_mode: None,
reference_media_type: None,
reference_src: None,
prompt_text: payload.prompt_text,
background_color: None,
screen_color: payload.screen_color,
resolution: payload.resolution,
ratio: payload.ratio,
frame_count: payload.frame_count,
duration_seconds: payload.duration_seconds,
model: payload.model,
project_id: payload.project_id,
canvas_completion: payload.canvas_completion,
generation_inputs: payload.generation_inputs,
source_resource_id: payload.source_resource_id,
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetCreateRequest {
@@ -821,10 +873,7 @@ pub async fn generate_external_editor_character_animation(
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<
Json<shared_contracts::assets::EditorCharacterAnimationGenerateRequest>,
JsonRejection,
>,
payload: Result<Json<ExternalEditorCharacterAnimationGenerateRequest>, JsonRejection>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
@@ -834,7 +883,7 @@ pub async fn generate_external_editor_character_animation(
&state,
&request_context,
principal.owner_user_id(),
payload,
payload.into(),
Some(idempotency_key),
)
.await?;
@@ -1458,6 +1507,70 @@ mod tests {
assert_eq!(request.expected_revision, 7);
}
#[test]
fn external_editor_character_animation_request_keeps_legacy_image_contract() {
let request =
serde_json::from_value::<ExternalEditorCharacterAnimationGenerateRequest>(json!({
"sourceLayerId": "layer-character",
"sourceImageSrc": "editor/uploads/character.png",
"sourceWidth": 512,
"sourceHeight": 512,
"promptText": "原地待机",
"resolution": "480p",
"ratio": "same",
"frameCount": 32,
"durationSeconds": 4,
"model": "seedance2.0-fast"
}))
.expect("External v1 旧图片模式请求应继续通过反序列化");
let internal: shared_contracts::assets::EditorCharacterAnimationGenerateRequest =
request.into();
assert_eq!(internal.source_layer_id, "layer-character");
assert_eq!(internal.source_image_src, "editor/uploads/character.png");
assert_eq!(internal.input_mode, None);
assert_eq!(internal.reference_media_type, None);
assert_eq!(internal.reference_src, None);
assert_eq!(internal.background_color, None);
}
#[test]
fn external_editor_character_animation_request_rejects_internal_multimodal_fields() {
let error =
serde_json::from_value::<ExternalEditorCharacterAnimationGenerateRequest>(json!({
"sourceLayerId": "layer-character",
"sourceImageSrc": "editor/uploads/character.png",
"sourceWidth": 512,
"sourceHeight": 512,
"inputMode": "video",
"promptText": "原地待机",
"resolution": "480p",
"ratio": "same",
"frameCount": 32,
"durationSeconds": 4,
"model": "seedance2.0-fast"
}))
.expect_err("External v1 不应接收内部三模态扩展字段");
assert!(error.to_string().contains("inputMode"));
}
#[test]
fn external_editor_character_animation_request_requires_legacy_source_fields() {
let error =
serde_json::from_value::<ExternalEditorCharacterAnimationGenerateRequest>(json!({
"promptText": "原地待机",
"resolution": "480p",
"ratio": "1:1",
"frameCount": 32,
"durationSeconds": 4,
"model": "seedance2.0-fast"
}))
.expect_err("External v1 文本模式不得绕过旧图片模式必填字段");
assert!(error.to_string().contains("sourceLayerId"));
}
#[test]
fn external_editor_generation_inputs_sanitize_audit_fields_for_regular_assets() {
let serialized = serialize_external_editor_generation_inputs(
+1
View File
@@ -30,6 +30,7 @@ mod editor_project;
mod editor_project_icon;
mod editor_screen_background_decision;
mod editor_screen_background_filter;
mod editor_sequence_animation;
mod editor_sound_effect_prompt_assist;
mod error_middleware;
mod external_api_audit;
@@ -17,6 +17,7 @@ use crate::{
get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
publish_character_animation, save_character_workflow_cache,
split_editor_character_animation_frames,
},
character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual,
@@ -128,6 +129,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/character-animations/frame-splits",
post(split_editor_character_animation_frames).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/editor/videos/generations",
post(generate_editor_video).route_layer(middleware::from_fn_with_state(
@@ -11,6 +11,7 @@ spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
sha2 = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"], optional = true }
spacetimedb = { workspace = true, optional = true }
platform-oss = { workspace = true, optional = true }
@@ -0,0 +1,95 @@
//! 角色动画资产的稳定身份规则。
//!
//! 本模块只承载可纯测的资产领域规则;HTTP 编排与 SpacetimeDB 事务适配留在外层。
use sha2::{Digest as _, Sha256};
pub const CHARACTER_ANIMATION_FRAME_SPLIT_OPERATION_ID_PREFIX: &str =
"editor-character-animation-frame-split-";
/// 为一次角色动画拆帧命令生成稳定 operation ID。
///
/// 输入顺序、版本盐、逐字段 NUL 分隔和摘要截断长度均属于已发布兼容合同,不能随意调整。
pub fn character_animation_frame_split_operation_id(
owner_user_id: &str,
source_resource_id: &str,
folder_id: &str,
base_label: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(b"editor-character-animation-frame-split-v2\0");
for value in [owner_user_id, source_resource_id, folder_id, base_label] {
hasher.update(value.trim().as_bytes());
hasher.update([0]);
}
let digest = format!("{:x}", hasher.finalize());
format!(
"{CHARACTER_ANIMATION_FRAME_SPLIT_OPERATION_ID_PREFIX}{}",
&digest[..32]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_split_operation_id_preserves_api_server_v2_compatibility_fixture() {
assert_eq!(
character_animation_frame_split_operation_id(
"owner-1",
"source-resource-project-a",
"project",
"角色动作",
),
"editor-character-animation-frame-split-c836606cab3125ba1984176af35f8956"
);
}
#[test]
fn frame_split_operation_id_is_trimmed_stable_and_target_scoped() {
let first = character_animation_frame_split_operation_id(
"owner-1",
"source-resource-project-a",
"project",
"角色动作",
);
assert_eq!(
first,
character_animation_frame_split_operation_id(
" owner-1 ",
" source-resource-project-a ",
" project ",
" 角色动作 ",
)
);
assert_ne!(
first,
character_animation_frame_split_operation_id(
"owner-1",
"source-resource-project-b",
"project",
"角色动作",
)
);
assert_ne!(
first,
character_animation_frame_split_operation_id(
"owner-1",
"source-resource-project-a",
"folder-actions",
"角色动作",
)
);
assert_ne!(
first,
character_animation_frame_split_operation_id(
"owner-1",
"source-resource-project-a",
"project",
"奔跑动作",
)
);
assert!(first.starts_with(CHARACTER_ANIMATION_FRAME_SPLIT_OPERATION_ID_PREFIX));
}
}
@@ -1,4 +1,5 @@
mod application;
mod character_animation;
mod commands;
mod domain;
mod errors;
@@ -17,6 +18,10 @@ pub use application::{
pub use asset_object_service::{
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
};
pub use character_animation::{
CHARACTER_ANIMATION_FRAME_SPLIT_OPERATION_ID_PREFIX,
character_animation_frame_split_operation_id,
};
pub use commands::{
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectLocationInput,
AssetObjectUpsertInput, ConfirmAssetObjectInput,
@@ -311,11 +311,23 @@ pub struct CharacterAnimationGenerateResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationGenerateRequest {
#[serde(default)]
pub source_layer_id: String,
#[serde(default)]
pub source_image_src: String,
#[serde(default)]
pub source_width: u32,
#[serde(default)]
pub source_height: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_src: Option<String>,
pub prompt_text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_color: Option<String>,
/// 纯色抠像背景色(hex)。None/auto 走自动决策;通常继承源角色图的背景色。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_color: Option<String>,
@@ -375,6 +387,62 @@ pub struct EditorCharacterAnimationGenerateResponse {
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationFrameSplitRequest {
pub project_id: String,
pub source_layer_id: String,
pub source_resource_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationFrameSplitResponse {
pub frame_count: u32,
pub assets: Vec<EditorAssetPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetPayload {
pub asset_id: String,
pub folder_id: String,
pub label: String,
pub image_src: String,
pub object_key: Option<String>,
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_sequence_frames: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_sequence_duration_ms: Option<u64>,
pub generation_inputs: Option<Value>,
pub source_resource_id: Option<String>,
pub public_showcase_enabled: Option<bool>,
pub thumbnail_src: Option<String>,
pub generation_cost_mud_points: u64,
pub showcase_id: Option<String>,
pub showcase_review_status: Option<String>,
pub showcase_display_enabled: Option<bool>,
pub showcase_like_count: Option<u64>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSceneGenerateRequest {
@@ -1305,7 +1373,11 @@ mod tests {
source_image_src: "/generated-characters/hero/master.png".to_string(),
source_width: 768,
source_height: 1024,
input_mode: Some("video".to_string()),
reference_media_type: Some("video".to_string()),
reference_src: Some("generated/editor/reference.mp4".to_string()),
prompt_text: "待机呼吸".to_string(),
background_color: Some("purple".to_string()),
screen_color: None,
resolution: "720p".to_string(),
ratio: "same".to_string(),
@@ -1333,6 +1405,13 @@ mod tests {
.expect("request should serialize");
assert_eq!(payload["sourceLayerId"], json!("layer-1"));
assert_eq!(payload["inputMode"], json!("video"));
assert_eq!(payload["referenceMediaType"], json!("video"));
assert_eq!(
payload["referenceSrc"],
json!("generated/editor/reference.mp4")
);
assert_eq!(payload["backgroundColor"], json!("purple"));
assert_eq!(
payload["sourceImageSrc"],
json!("/generated-characters/hero/master.png")
@@ -1346,6 +1425,24 @@ mod tests {
assert_eq!(payload["assetLabel"], json!("勇者奔跑"));
}
#[test]
fn editor_character_animation_frame_split_request_uses_shared_contract_shape() {
let payload = serde_json::to_value(EditorCharacterAnimationFrameSplitRequest {
project_id: "project-1".to_string(),
source_layer_id: "layer-animation".to_string(),
source_resource_id: "resource-animation".to_string(),
asset_folder_id: Some("folder-actions".to_string()),
asset_label: Some("奔跑动作".to_string()),
})
.expect("frame split request should serialize");
assert_eq!(payload["projectId"], json!("project-1"));
assert_eq!(payload["sourceLayerId"], json!("layer-animation"));
assert_eq!(payload["sourceResourceId"], json!("resource-animation"));
assert_eq!(payload["assetFolderId"], json!("folder-actions"));
assert_eq!(payload["assetLabel"], json!("奔跑动作"));
}
#[test]
fn editor_character_animation_response_includes_frames_and_preview_video() {
let payload = serde_json::to_value(EditorCharacterAnimationGenerateResponse {
@@ -5114,6 +5114,7 @@ fn validate_editor_spritesheet_slice_batch(
let mut object_keys = BTreeSet::new();
let mut resource_ids = BTreeSet::new();
let mut asset_ids = BTreeSet::new();
let mut source_job_id = None::<String>;
let mut asset_count = 0usize;
for item in &input.items {
let asset_object_id = normalize_required(
@@ -5130,11 +5131,23 @@ fn validate_editor_spritesheet_slice_batch(
if !object_keys.insert(object_key.clone()) {
return Err("图集切片批次包含重复 object_key".to_string());
}
if normalize_optional_ref(item.asset_object.owner_user_id.as_ref()) != Some(owner_user_id)
|| normalize_optional_ref(item.asset_object.source_job_id.as_ref()) != Some(task_id)
{
return Err("图集切片对象的 owner 或 task 归属不一致".to_string());
if normalize_optional_ref(item.asset_object.owner_user_id.as_ref()) != Some(owner_user_id) {
return Err("图集切片对象的 owner 归属不一致".to_string());
}
let item_source_job_id = normalize_optional_ref(item.asset_object.source_job_id.as_ref())
.ok_or_else(|| "图集切片对象缺少来源任务".to_string())?;
if item_source_job_id != task_id
&& item.asset_object.asset_kind.trim() != EDITOR_CHARACTER_ANIMATION_OBJECT_KIND
{
return Err("只有角色序列帧拆分可以复用原生成任务的资产对象".to_string());
}
if source_job_id
.as_deref()
.is_some_and(|source_job_id| source_job_id != item_source_job_id)
{
return Err("图集切片对象的来源任务不一致".to_string());
}
source_job_id.get_or_insert_with(|| item_source_job_id.to_string());
let resource_identity = item
.project_resource
@@ -15672,6 +15685,64 @@ mod tests {
.expect("complete batch should pass before transaction writes");
}
#[test]
fn spritesheet_slice_batch_accepts_reused_source_objects_under_split_operation() {
let mut batch = spritesheet_slice_batch();
batch.task_id = "character-split-source-resource-a".to_string();
batch.group_task_id = Some(batch.task_id.clone());
for item in &mut batch.items {
item.asset_object.source_job_id = Some("character-generation-task-1".to_string());
item.asset_object.asset_kind = EDITOR_CHARACTER_ANIMATION_OBJECT_KIND.to_string();
item.project_resource.as_mut().expect("resource").task_id = Some(batch.task_id.clone());
let asset = item.asset.as_mut().expect("asset");
asset.task_id = Some(batch.task_id.clone());
asset.group_task_id = Some(batch.task_id.clone());
}
validate_editor_spritesheet_slice_batch(
&batch,
"user-1",
"character-split-source-resource-a",
Some("character-split-source-resource-a"),
)
.expect("split operation may reuse objects produced by the original generation task");
}
#[test]
fn spritesheet_slice_batch_keeps_icon_objects_bound_to_split_task() {
let mut batch = spritesheet_slice_batch();
batch.task_id = "another-split-task".to_string();
let error = validate_editor_spritesheet_slice_batch(
&batch,
"user-1",
"another-split-task",
Some("group-task-1"),
)
.expect_err("icon slices must keep their existing source_job_id equals task invariant");
assert_eq!(error, "只有角色序列帧拆分可以复用原生成任务的资产对象");
}
#[test]
fn spritesheet_slice_batch_rejects_mixed_source_object_jobs() {
let mut batch = spritesheet_slice_batch();
for item in &mut batch.items {
item.asset_object.asset_kind = EDITOR_CHARACTER_ANIMATION_OBJECT_KIND.to_string();
}
batch.items[1].asset_object.source_job_id = Some("other-source-job".to_string());
let error = validate_editor_spritesheet_slice_batch(
&batch,
"user-1",
"task-1",
Some("group-task-1"),
)
.expect_err("a batch must not mix objects from unrelated source jobs");
assert_eq!(error, "图集切片对象的来源任务不一致");
}
#[test]
fn spritesheet_slice_batch_validation_rejects_duplicate_object_key() {
let mut batch = spritesheet_slice_batch();
@@ -73,6 +73,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['上传工具', 'upload'],
['生成图片', 'generate'],
['生成视频', 'video'],
['Spine序列帧动画', 'character-animation'],
['生成音乐', 'music'],
['生成规范', 'spec'],
['生成角色形象', 'character'],
@@ -90,6 +91,42 @@ describe('ImageCanvasBottomToolbarView', () => {
expect(switchTool).toHaveBeenCalledTimes(toolExpectations.length);
});
it('keeps the sequence animation entry right after the video tool', () => {
const { toolbar } = renderToolbar();
const sequenceEntry = within(toolbar).getByRole('button', {
name: 'Spine序列帧动画',
});
const videoEntry = within(toolbar).getByRole('button', {
name: '生成视频',
});
const musicEntry = within(toolbar).getByRole('button', {
name: '生成音乐',
});
const toolbarButtons = within(toolbar).getAllByRole('button');
expect(toolbarButtons.indexOf(sequenceEntry)).toBe(
toolbarButtons.indexOf(videoEntry) + 1,
);
expect(toolbarButtons.indexOf(musicEntry)).toBe(
toolbarButtons.indexOf(sequenceEntry) + 1,
);
expect(
sequenceEntry.parentElement?.querySelector(
'.image-canvas-editor__bottom-toolbar-divider',
),
).toBeNull();
expect(
sequenceEntry.querySelector(
'.image-canvas-editor__sequence-animation-icon',
),
).toBeTruthy();
expect(
sequenceEntry.classList.contains(
'image-canvas-editor__sequence-animation-entry',
),
).toBe(true);
});
it('only exposes persistent pressed state for navigation tools', () => {
const { toolbar, rerender, refs } = renderToolbar({
effectiveTool: 'select',
@@ -12,10 +12,11 @@ import {
Upload,
UserRound,
} from 'lucide-react';
import type { RefObject } from 'react';
import type { ComponentType, RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
import { ImageCanvasSequenceAnimationIcon } from './ImageCanvasSequenceAnimationIcon';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
@@ -33,7 +34,7 @@ type ImageCanvasBottomToolbarViewProps = {
const canvasTools: Array<{
id: CanvasTool;
label: string;
icon: typeof MousePointer2;
icon: ComponentType<{ className?: string }>;
separatorBefore?: boolean;
}> = [
{ id: 'select', label: '选择工具', icon: MousePointer2 },
@@ -46,6 +47,11 @@ const canvasTools: Array<{
separatorBefore: true,
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{
id: 'character-animation',
label: 'Spine序列帧动画',
icon: ImageCanvasSequenceAnimationIcon,
},
{ id: 'music', label: '生成音乐', icon: Music },
{
id: 'spec',
@@ -126,7 +132,9 @@ export function ImageCanvasBottomToolbarView({
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
pressed={
hasPersistentPressedState(id) && effectiveTool === id
}
onClick={() => onSwitchTool(id)}
/>
</span>
@@ -143,6 +151,11 @@ export function ImageCanvasBottomToolbarView({
/>
) : null}
<EditorIconButton
className={
id === 'character-animation'
? 'image-canvas-editor__sequence-animation-entry'
: undefined
}
label={label}
title={label}
icon={Icon}
@@ -3387,8 +3387,8 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const animationFrames = Array.from({ length: 48 }, (_, index) => ({
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
width: 720,
height: 405,
}));
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
@@ -3403,8 +3403,8 @@ describe('ImageCanvasEditorView generation integration', () => {
resourceId: 'resource-character-animation-final',
projectId: 'editor-project-character-animation',
imageSrc: animationFrames[0]!.imageSrc,
width: 1024,
height: 1024,
width: 720,
height: 405,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
@@ -3455,7 +3455,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: '角色动画生成面板' }),
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
).toBeNull();
fireEvent.contextMenu(characterLayer.closest('button')!, {
clientX: 260,
@@ -3470,19 +3470,36 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByRole('button', { name: '角色动作生成占位图' }),
).toBeTruthy();
expect(screen.getByText('Action Generator')).toBeTruthy();
expect(screen.getByText('动作')).toBeTruthy();
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
expect(screen.getByText('Spine序列帧动画')).toBeTruthy();
const panel = screen.getByRole('dialog', {
name: 'Spine序列帧动画生成',
});
expect(panel.className).toContain(
'image-canvas-editor__generation-composer--character-animation',
);
expect(
fireEvent.click(
within(panel).getByRole('button', {
name: '动画参数 同图尺寸 · 4秒 · 480p',
name: '序列帧参数 同图尺寸 · 4秒 · 480p',
}),
);
const animationParameterMenu = screen.getByRole('group', {
name: '序列帧参数选项',
});
expect(
within(animationParameterMenu).getByRole('button', { name: '同图尺寸' }),
).toBeTruthy();
expect(
within(animationParameterMenu).getByRole('button', {
name: '32帧·4秒',
}),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '生成40泥点' }),
within(animationParameterMenu).getByRole('button', { name: '480p' }),
).toBeTruthy();
const submitAnimationButton = within(panel).getByRole('button', {
name: '生成Spine序列帧动画',
});
expect(submitAnimationButton.textContent).toBe('生成40泥点');
for (const actionLabel of [
'待机',
'行走',
@@ -3514,31 +3531,39 @@ describe('ImageCanvasEditorView generation integration', () => {
within(panel).getByLabelText('动画描述'),
precisePrompt,
);
expect(
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
).toBeTruthy();
expect(within(panel).getByLabelText('动画描述').textContent).toBe(
precisePrompt,
);
fireEvent.click(
within(panel).getByRole('button', {
name: '动画参数 同图尺寸 · 4秒 · 480p',
name: '序列帧参数 同图尺寸 · 4秒 · 480p',
}),
);
const animationMenu = screen.getByRole('menu', { name: '动画参数选项' });
fireEvent.click(
within(animationMenu).getByRole('button', { name: '清晰度 720p' }),
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', { name: '720p' }),
);
fireEvent.click(
within(animationMenu).getByRole('button', { name: '比例 16:9' }),
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', { name: '16:9' }),
);
fireEvent.click(
within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }),
);
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
expect(
within(panel).getByRole('button', {
name: '动画参数 16:9 · 6秒 · 720p',
within(
screen.getByRole('group', { name: '序列帧参数选项' }),
).getByRole('button', {
name: '48帧·6秒',
}),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
);
const resizedPlaceholder = screen.getByRole('button', {
name: '角色动作生成占位图',
});
expect(resizedPlaceholder.style.width).toBe('720px');
expect(resizedPlaceholder.style.height).toBe('405px');
expect(within(resizedPlaceholder).getByText('720 x 405')).toBeTruthy();
expect(submitAnimationButton.textContent).toBe('生成120泥点');
fireEvent.click(submitAnimationButton);
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
@@ -3553,6 +3578,14 @@ describe('ImageCanvasEditorView generation integration', () => {
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
canvasCompletion: expect.objectContaining({
placeholder: expect.objectContaining({
width: 720,
height: 405,
originalWidth: 720,
originalHeight: 405,
}),
}),
}),
);
});
@@ -3564,6 +3597,11 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const actionFrame = screen.getByAltText('画布序列帧:角色动作');
expect(actionFrame.tagName.toLowerCase()).toBe('img');
const actionLayerButton = screen.getByRole('button', {
name: '选择角色动作',
});
expect(actionLayerButton.style.width).toBe('720px');
expect(actionLayerButton.style.height).toBe('405px');
await waitFor(() => {
expect(actionFrame.getAttribute('src')).toBe(
'https://assets.test/generated-character-drafts/editor/frame1.png',
@@ -3575,13 +3613,13 @@ describe('ImageCanvasEditorView generation integration', () => {
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
expect(screen.getByText('动作')).toBeTruthy();
expect(screen.getByText('Spine序列帧动画')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '选择角色动作' }));
fireEvent.click(actionLayerButton);
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const remodelPanel = screen.getByRole('dialog', {
name: '角色动画生成面板',
name: 'Spine序列帧动画生成',
});
expect(within(remodelPanel).getByLabelText('动画描述').textContent).toBe(
precisePrompt,
@@ -1546,6 +1546,8 @@ describe('ImageCanvasEditorModel', () => {
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-reference-1',
width: 600,
height: 800,
},
],
};
@@ -1605,6 +1607,8 @@ describe('ImageCanvasEditorModel', () => {
src: '/read/reference-1.png',
resourceId: 'resource-reference-1',
objectKey: 'generated/reference-1.png',
width: 600,
height: 800,
});
});
@@ -677,6 +677,8 @@ function serializeGenerationReference(
mimeType: reference.mimeType,
sizeBytes: reference.sizeBytes,
durationSeconds: reference.durationSeconds,
width: reference.width,
height: reference.height,
resourceId: pointer.resourceId,
sourceAssetId: pointer.sourceAssetId,
};
@@ -2328,6 +2330,10 @@ function hydrateCharacterReference(
const assetObjectId =
stringOrUndefined(snapshot.assetObjectId) ??
stringOrUndefined(resource?.assetObjectId);
const width =
numberFromSnapshot(snapshot.width, 0) || resource?.width || undefined;
const height =
numberFromSnapshot(snapshot.height, 0) || resource?.height || undefined;
const resolvedSrc = resource?.imageSrc || objectKey || src;
return id && label && resolvedSrc
? {
@@ -2338,6 +2344,8 @@ function hydrateCharacterReference(
mimeType,
sizeBytes,
durationSeconds,
width,
height,
objectKey,
assetObjectId,
resourceId,
@@ -220,6 +220,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
onSplitIconSpritesheet: vi.fn(),
onSplitCharacterAnimation: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionModelChange: vi.fn(),
@@ -6,6 +6,7 @@ import type {
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationBackgroundColor,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
@@ -183,6 +184,7 @@ export type CanvasTool =
| 'upload'
| 'generate'
| 'video'
| 'character-animation'
| 'music'
| 'spec'
| 'character'
@@ -219,6 +221,8 @@ export type CharacterReferenceImage = {
mimeType?: string;
sizeBytes?: number;
durationSeconds?: number;
width?: number;
height?: number;
objectKey?: string | null;
assetObjectId?: string | null;
resourceId?: string | null;
@@ -295,6 +299,7 @@ export type GenerateDialogState = {
characterAnimationRatio?: EditorCharacterAnimationRatio;
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
characterAnimationDurationSeconds?: 4 | 5 | 6;
characterAnimationBackgroundColor?: EditorCharacterAnimationBackgroundColor;
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
soundModel?: EditorSoundEffectModel | 'audio1.0';
soundDurationMode?: EditorSoundEffectDurationMode;
@@ -488,9 +493,11 @@ export type CropExpandResizeHandle =
| 'bottom-right';
export type CharacterAnimationPanelState = {
sourceLayerId: string;
sourceLayerId?: string;
references?: CharacterReferenceImage[];
promptText: string;
assetLabel?: string;
backgroundColor?: EditorCharacterAnimationBackgroundColor;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;

Some files were not shown because too many files have changed in this diff Show More