Spine序列帧动画多模态生成
This commit is contained in:
@@ -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。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Generated
+1
@@ -2934,6 +2934,7 @@ dependencies = [
|
||||
"platform-oss",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user