From e0319e80f09eb6a1b1ae3f8e343aa0912b58419f Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Sun, 23 Aug 2026 21:14:35 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E8=A1=A5=E9=BD=90AGC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=83=BD=E5=8A=9B=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=8A=A8=E7=94=BB=E4=B8=8E=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源编辑命令新增create生成模式,支持无源视频、音效和背景音乐生成 - 图片聚焦态新增角色动画生成入口,提交角色动画接口并下载预览视频登记为本地媒体 - 资源页新增生成视频、生成音效、生成背景音乐入口 - 统一资源编辑面板按生成模式区分新建媒体资源与编辑现有资源 - 补充资源生成与角色动画定向测试,并更新AI游戏创作App实施计划文档 --- .../src-tauri/src/project/resource_editor.rs | 353 ++++++++++++++++-- .../src-tauri/src/ui_editor/layout/node.rs | 2 +- .../ResourceEditSurface.tsx | 15 +- .../src/view/project-development/index.tsx | 152 ++++++-- .../project-development/resourceEditModel.ts | 32 ++ .../projectResourceLiveIntegration.test.tsx | 6 +- .../tests/resourceEditModel.test.ts | 36 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 + 8 files changed, 548 insertions(+), 56 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 91972015a..c255e681e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -80,6 +80,7 @@ fn resource_edit_project_mutation_lock(root: &Path) -> Result bool { matches!( *self, - Self::ImageReference | Self::Video | Self::SoundEffect | Self::BackgroundMusic + Self::ImageReference + | Self::CharacterAnimation + | Self::Video + | Self::SoundEffect + | Self::BackgroundMusic ) } @@ -104,6 +109,7 @@ impl LocalProjectResourceEditKind { match self { Self::ImageReference => "image-reference", Self::Svg => "svg", + Self::CharacterAnimation => "character-animation", Self::Video => "video", Self::SoundEffect => "sound-effect", Self::BackgroundMusic => "background-music", @@ -114,6 +120,14 @@ impl LocalProjectResourceEditKind { } } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum LocalProjectResourceGenerationMode { + Create, + #[default] + Derive, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct DeriveLocalProjectResourceInput { @@ -123,6 +137,8 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) operation_id: String, pub(crate) idempotency_key: String, pub(crate) edit_kind: LocalProjectResourceEditKind, + #[serde(default)] + pub(crate) generation_mode: LocalProjectResourceGenerationMode, pub(crate) source_resource_id: String, #[serde(default)] pub(crate) source_asset_id: Option, @@ -145,6 +161,8 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) struct DeriveLocalProjectResourceResult { pub(crate) operation_id: String, pub(crate) edit_kind: LocalProjectResourceEditKind, + #[serde(default)] + pub(crate) generation_mode: LocalProjectResourceGenerationMode, pub(crate) source_resource_id: String, pub(crate) committed_project_revision: u64, pub(crate) asset: Option, @@ -262,6 +280,9 @@ struct ResourceEditSourceSnapshot { asset_kind: String, source_sha256: String, bytes: Option>, + generation_mode: LocalProjectResourceGenerationMode, + source_width: Option, + source_height: Option, text: Option, source_asset: Option, source_version: Option, @@ -303,6 +324,7 @@ struct ResourceEditLedger { idempotency_key: String, request_fingerprint: String, edit_kind: LocalProjectResourceEditKind, + generation_mode: LocalProjectResourceGenerationMode, project_id: String, expected_project_revision: u64, source_resource_id: String, @@ -338,6 +360,8 @@ struct ResourceEditLedger { remote_operation_id: Option, remote_resource_id: Option, remote_object_key: Option, + #[serde(default)] + remote_legacy_public_path: Option, remote_asset_object_id: Option, remote_model: Option, #[serde(default)] @@ -694,7 +718,9 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u match edit_kind { LocalProjectResourceEditKind::BackgroundMusic => 140, LocalProjectResourceEditKind::SoundEffect => 1_900, - LocalProjectResourceEditKind::Video => 4_000, + LocalProjectResourceEditKind::Video | LocalProjectResourceEditKind::CharacterAnimation => { + 4_000 + } _ => 32_000, } } @@ -712,6 +738,7 @@ fn normalize_resource_edit_prompt( LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", LocalProjectResourceEditKind::SoundEffect => "音效", LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", _ => "", } )); @@ -952,6 +979,16 @@ fn infer_resource_edit_source_media_type( edit_kind: &LocalProjectResourceEditKind, source_path: Option<&str>, ) -> Option { + if source_path.is_none() { + return match edit_kind { + LocalProjectResourceEditKind::CharacterAnimation => Some("image/png"), + LocalProjectResourceEditKind::Video => Some("video/mp4"), + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic => Some("audio/mpeg"), + _ => None, + } + .map(str::to_string); + } let extension = source_path .and_then(|path| Path::new(path).extension()) .and_then(|value| value.to_str()) @@ -998,6 +1035,7 @@ fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKin match edit_kind { LocalProjectResourceEditKind::ImageReference => "art-image", LocalProjectResourceEditKind::Svg => "svg", + LocalProjectResourceEditKind::CharacterAnimation => "character-animation", LocalProjectResourceEditKind::Video => "video", LocalProjectResourceEditKind::SoundEffect => "sound-effect", LocalProjectResourceEditKind::BackgroundMusic => "background-music", @@ -1057,6 +1095,9 @@ fn resolve_resource_edit_source( .map_err(|error| format!("序列化源项目版本失败:{error}"))?, ), bytes: None, + generation_mode: input.generation_mode, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: Some(version), @@ -1076,12 +1117,45 @@ fn resolve_resource_edit_source( asset_kind: "agent-result-derivative".to_string(), source_sha256: sha256_hex(content.as_bytes()), bytes: None, + generation_mode: input.generation_mode, + source_width: None, + source_height: None, text: Some(content), source_asset: None, source_version: None, }); } + if input.generation_mode == LocalProjectResourceGenerationMode::Create { + if !matches!( + input.edit_kind, + LocalProjectResourceEditKind::Video + | LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic + ) { + return Err("当前资源类型不支持无源生成".to_string()); + } + let asset_kind = infer_resource_edit_source_asset_kind(&input.edit_kind); + return Ok(ResourceEditSourceSnapshot { + canonical_resource_id: input.source_resource_id.trim().to_string(), + source_path: None, + media_type: infer_resource_edit_source_media_type( + &input.edit_kind, + input.source_path.as_deref(), + ) + .unwrap_or_else(|| "application/octet-stream".to_string()), + asset_kind, + source_sha256: sha256_hex(input.prompt.trim().as_bytes()), + bytes: None, + generation_mode: input.generation_mode, + source_width: None, + source_height: None, + text: None, + source_asset: None, + source_version: None, + }); + } + let requested_path = input .source_path .as_deref() @@ -1167,6 +1241,9 @@ fn resolve_resource_edit_source( asset_kind, source_sha256: sha256_hex(text.as_bytes()), bytes: None, + generation_mode: input.generation_mode, + source_width: None, + source_height: None, text: Some(text), source_asset, source_version: None, @@ -1202,6 +1279,14 @@ fn resolve_resource_edit_source( media_read_limit(&input.edit_kind), "源媒体资源", )?; + let (source_width, source_height) = + if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?; + (Some(decoded.width()), Some(decoded.height())) + } else { + (None, None) + }; Ok(ResourceEditSourceSnapshot { canonical_resource_id, source_path: Some(path), @@ -1209,6 +1294,9 @@ fn resolve_resource_edit_source( asset_kind, source_sha256: sha256_hex(&bytes), bytes: Some(bytes), + generation_mode: input.generation_mode, + source_width, + source_height, text: None, source_asset, source_version: None, @@ -1792,7 +1880,13 @@ fn build_resource_edit_audio_prompt( .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or("现有音频"); + .unwrap_or( + if source.generation_mode == LocalProjectResourceGenerationMode::Create { + "新音频" + } else { + "现有音频" + }, + ); let original_prompt = source .source_asset .as_ref() @@ -1804,8 +1898,17 @@ fn build_resource_edit_audio_prompt( source_context.push_str(",原始描述 "); source_context.push_str(original_prompt); } - let prefix = "基于现有音频资源语义派生重制;源信息:"; - let instruction_prefix = ";编辑要求:"; + let prefix = if source.generation_mode == LocalProjectResourceGenerationMode::Create { + "生成新音频;目标:" + } else { + "基于现有音频资源语义派生重制;源信息:" + }; + let instruction_prefix = if source.generation_mode == LocalProjectResourceGenerationMode::Create + { + ";要求:" + } else { + ";编辑要求:" + }; let fixed_chars = prefix.chars().count() + instruction_prefix.chars().count() + prompt.chars().count(); if fixed_chars >= max_chars { @@ -1840,9 +1943,25 @@ fn resource_edit_remote_request( "generationInputs": generation_inputs, }), )), - LocalProjectResourceEditKind::Video => Ok(( - "/api/external/v1/editor/videos/generations", + LocalProjectResourceEditKind::CharacterAnimation => Ok(( + "/api/external/v1/editor/character-animations/generations", serde_json::json!({ + "sourceLayerId": format!("resource-{}", input.operation_id), + "sourceImageSrc": source_reference.ok_or_else(|| "角色动画缺少稳定源图片引用".to_string())?, + "sourceWidth": source.source_width.ok_or_else(|| "角色动画缺少源图片宽度".to_string())?, + "sourceHeight": source.source_height.ok_or_else(|| "角色动画缺少源图片高度".to_string())?, + "promptText": prompt, + "resolution": "720p", + "ratio": "same", + "frameCount": 32, + "durationSeconds": 4, + "model": "seedance2.0-fast", + "assetLabel": asset_name, + "generationInputs": generation_inputs, + }), + )), + LocalProjectResourceEditKind::Video => { + let mut body = serde_json::json!({ "prompt": prompt, "model": "seedance2.0-fast", "aspectRatio": "16:9", @@ -1851,12 +1970,17 @@ fn resource_edit_remote_request( "mode": "std", "sound": "on", "webSearchEnabled": false, - "referenceVideoSrcs": [source_reference.ok_or_else(|| "视频编辑缺少稳定源引用".to_string())?], "assetKind": "video", "assetLabel": asset_name, "generationInputs": generation_inputs, - }), - )), + }); + if input.generation_mode == LocalProjectResourceGenerationMode::Derive { + body["referenceVideoSrcs"] = serde_json::json!([ + source_reference.ok_or_else(|| "视频编辑缺少稳定源引用".to_string())? + ]); + } + Ok(("/api/external/v1/editor/videos/generations", body)) + } LocalProjectResourceEditKind::SoundEffect => Ok(( "/api/external/v1/editor/audios/sound-effects/generations", serde_json::json!({ @@ -1896,12 +2020,14 @@ fn resource_edit_result_has_download(payload: &serde_json::Value) -> bool { .unwrap_or(data); json_string_field(resource, "objectKey").is_some() || json_string_field(data, "objectKey").is_some() + || json_string_field(data, "previewVideoPath").is_some() } fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, "/api/external/v1/editor/images/edits" + | "/api/external/v1/editor/character-animations/generations" | "/api/external/v1/editor/videos/generations" | "/api/external/v1/editor/audios/sound-effects/generations" | "/api/external/v1/editor/audios/background-music/generations" @@ -2078,9 +2204,18 @@ async fn wait_for_resource_edit_remote( } } +struct ResourceEditRemoteIdentity { + resource_id: Option, + object_key: Option, + legacy_public_path: Option, + asset_object_id: Option, + model: Option, +} + fn extract_resource_edit_remote_identity( generated: &serde_json::Value, -) -> Result<(Option, String, Option, Option), String> { + edit_kind: LocalProjectResourceEditKind, +) -> Result { let data = external_editor_response_data(generated); let null = serde_json::Value::Null; let resource = data @@ -2093,14 +2228,37 @@ fn extract_resource_edit_remote_identity( .unwrap_or(&null); let object_key = json_string_field(resource, "objectKey") .or_else(|| json_string_field(data, "objectKey")) - .ok_or_else(|| "资源编辑结果缺少稳定 objectKey".to_string())?; + .or_else(|| extract_character_animation_object_key(generated)) + .filter(|value| !value.is_empty()); + let legacy_public_path = if edit_kind == LocalProjectResourceEditKind::CharacterAnimation { + json_string_field(data, "previewVideoPath") + } else { + None + }; + if object_key.is_none() && legacy_public_path.is_none() { + return Err("资源编辑结果缺少稳定媒体引用".to_string()); + } let resource_id = json_string_field(resource, "resourceId").or_else(|| json_string_field(data, "resourceId")); let asset_object_id = json_string_field(resource, "assetObjectId") .or_else(|| json_string_field(asset, "assetObjectId")) .or_else(|| json_string_field(data, "assetObjectId")); let model = json_string_field(data, "model"); - Ok((resource_id, object_key, asset_object_id, model)) + Ok(ResourceEditRemoteIdentity { + resource_id, + object_key, + legacy_public_path, + asset_object_id, + model, + }) +} + +fn extract_character_animation_object_key(generated: &serde_json::Value) -> Option { + let data = external_editor_response_data(generated); + data.get("frames") + .and_then(|frames| frames.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| json_string_field(frame, "objectKey")) } fn validate_downloaded_media( @@ -2140,6 +2298,15 @@ fn validate_downloaded_media( Err("派生图片不是受支持的 PNG、JPEG 或 WebP".to_string()) } } + LocalProjectResourceEditKind::CharacterAnimation => { + if is_mp4 { + Ok(("video/mp4".to_string(), "mp4".to_string())) + } else if is_webm { + Ok(("video/webm".to_string(), "webm".to_string())) + } else { + Err("角色动画预览不是受支持的 MP4 或 WebM 视频".to_string()) + } + } LocalProjectResourceEditKind::Video => { if is_mp4 { Ok(("video/mp4".to_string(), "mp4".to_string())) @@ -2348,11 +2515,17 @@ async fn prepare_remote_resource_edit( .timeout(Duration::from_secs(35 * 60)) .build() .map_err(|_| "无法创建资源编辑 HTTP 客户端".to_string())?; - let object_key = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { - ledger - .remote_object_key - .clone() - .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? + let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { + if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { + serde_json::json!({ "imageSrc": legacy_public_path }) + } else { + serde_json::json!({ + "objectKey": ledger + .remote_object_key + .clone() + .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? + }) + } } else { let generated = if ledger.remote_operation_id.is_some() { let result = @@ -2363,8 +2536,11 @@ async fn prepare_remote_resource_edit( } else { let source_reference = if matches!( input.edit_kind, - LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Video - ) { + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::CharacterAnimation + ) || (input.edit_kind == LocalProjectResourceEditKind::Video + && input.generation_mode == LocalProjectResourceGenerationMode::Derive) + { Some( ensure_resource_edit_source_reference( root, @@ -2426,16 +2602,23 @@ async fn prepare_remote_resource_edit( ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; result }; - let (resource_id, object_key, asset_object_id, model) = - extract_resource_edit_remote_identity(&generated)?; - ledger.remote_resource_id = resource_id; - ledger.remote_object_key = Some(object_key.clone()); - ledger.remote_asset_object_id = asset_object_id; - ledger.remote_model = model; + let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind)?; + ledger.remote_resource_id = identity.resource_id; + ledger.remote_object_key = identity.object_key.clone(); + ledger.remote_legacy_public_path = identity.legacy_public_path.clone(); + ledger.remote_asset_object_id = identity.asset_object_id; + ledger.remote_model = identity.model; update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted)?; - object_key + if let Some(legacy_public_path) = identity.legacy_public_path { + serde_json::json!({ "imageSrc": legacy_public_path }) + } else { + serde_json::json!({ + "objectKey": identity + .object_key + .ok_or_else(|| "资源编辑结果缺少稳定 objectKey".to_string())? + }) + } }; - let download_source = serde_json::json!({ "objectKey": object_key }); let download = resolve_canvas_resource_download_with_limit( &client, &api_base_url, @@ -2579,6 +2762,7 @@ fn committed_resource_edit_result( Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, source_resource_id: source_resource_id.to_string(), committed_project_revision: revision, asset, @@ -3116,7 +3300,7 @@ fn commit_resource_edit_asset_internal( derivative_file_stem(asset_name), staged_extension ); - let remote = ledger.remote_object_key.is_some(); + let remote = ledger.remote_object_key.is_some() || ledger.remote_legacy_public_path.is_some(); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: source.asset_kind.clone(), @@ -3419,6 +3603,7 @@ fn commit_resource_edit_asset_internal( Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, source_resource_id: source.canonical_resource_id.clone(), committed_project_revision: revision.revision, asset: Some(asset), @@ -3607,6 +3792,7 @@ fn commit_resource_edit_version( return Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, source_resource_id: source.canonical_resource_id.clone(), committed_project_revision: current_revision.revision, asset: None, @@ -3716,6 +3902,7 @@ fn commit_resource_edit_version( Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, source_resource_id: source.canonical_resource_id.clone(), committed_project_revision: current_revision.revision, asset: None, @@ -4003,6 +4190,7 @@ pub(crate) async fn resume_local_project_resource_edit_at( operation_id: ledger.operation_id, idempotency_key: ledger.idempotency_key, edit_kind: ledger.edit_kind, + generation_mode: ledger.generation_mode, source_resource_id: ledger.source_resource_id, source_asset_id: ledger .source_asset_id @@ -4069,6 +4257,7 @@ pub(crate) async fn derive_local_project_resource_at( idempotency_key: input.idempotency_key.clone(), request_fingerprint, edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, project_id: input.expected_project_id.clone(), expected_project_revision: input.expected_project_revision, source_resource_id: source.canonical_resource_id.clone(), @@ -4103,6 +4292,7 @@ pub(crate) async fn derive_local_project_resource_at( remote_operation_id: None, remote_resource_id: None, remote_object_key: None, + remote_legacy_public_path: None, remote_asset_object_id: None, remote_model: None, terminal_failure_code: None, @@ -4329,6 +4519,7 @@ mod tests { operation_id, idempotency_key: Uuid::new_v4().to_string(), edit_kind, + generation_mode: LocalProjectResourceGenerationMode::Derive, source_resource_id, source_asset_id: None, source_path: None, @@ -4352,6 +4543,7 @@ mod tests { idempotency_key: input.idempotency_key.clone(), request_fingerprint: "test-fingerprint".to_string(), edit_kind: input.edit_kind.clone(), + generation_mode: input.generation_mode, project_id: input.expected_project_id.clone(), expected_project_revision: input.expected_project_revision, source_resource_id: source.canonical_resource_id.clone(), @@ -4386,6 +4578,7 @@ mod tests { remote_operation_id: None, remote_resource_id: None, remote_object_key: None, + remote_legacy_public_path: None, remote_asset_object_id: None, remote_model: None, terminal_failure_code: None, @@ -4610,6 +4803,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(vec![1, 2, 3]), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -4724,6 +4920,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -4857,6 +5056,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -5025,6 +5227,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -5152,6 +5357,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -5320,6 +5528,9 @@ mod tests { asset_kind: "video".to_string(), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -5464,6 +5675,9 @@ mod tests { asset_kind: "background-music".to_string(), source_sha256: "a".repeat(64), bytes: Some(vec![1]), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: None, text: None, source_asset: None, source_version: None, @@ -5545,6 +5759,87 @@ mod tests { )); } + #[test] + fn resource_edit_requests_support_create_media_and_character_animation() { + let source = ResourceEditSourceSnapshot { + canonical_resource_id: "local-asset:media-1".to_string(), + source_path: Some("assets/source.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: Some(720), + source_height: Some(1280), + text: None, + source_asset: None, + source_version: None, + }; + let prompt = "全新的森林场景"; + + let mut create_video = input( + std::path::Path::new("."), + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "create:video".to_string(), + ); + create_video.generation_mode = LocalProjectResourceGenerationMode::Create; + let (create_video_endpoint, create_video_body) = + resource_edit_remote_request(&create_video, &source, prompt, "新视频", None) + .expect("build create video request"); + assert_eq!( + create_video_endpoint, + "/api/external/v1/editor/videos/generations" + ); + assert!(create_video_body.get("referenceVideoSrcs").is_none()); + + let mut animation = create_video.clone(); + animation.edit_kind = LocalProjectResourceEditKind::CharacterAnimation; + animation.generation_mode = LocalProjectResourceGenerationMode::Derive; + let (animation_endpoint, animation_body) = resource_edit_remote_request( + &animation, + &source, + "让角色自然呼吸", + "角色动画", + Some("stable-image-object-key"), + ) + .expect("build character animation request"); + assert_eq!( + animation_endpoint, + "/api/external/v1/editor/character-animations/generations" + ); + assert_eq!( + animation_body["sourceImageSrc"], + serde_json::json!("stable-image-object-key") + ); + assert_eq!(animation_body["sourceWidth"], serde_json::json!(720)); + assert_eq!(animation_body["sourceHeight"], serde_json::json!(1280)); + + let generated = serde_json::json!({ + "data": { + "previewVideoPath": "/generated-character-drafts/layer/animation/preview.mp4", + "objectKey": "generated-character-drafts/layer/animation/frame-01.png", + "resource": { + "resourceId": "editor-resource-animation", + "objectKey": "generated-character-drafts/layer/animation/frame-01.png" + } + } + }); + let identity = extract_resource_edit_remote_identity( + &generated, + LocalProjectResourceEditKind::CharacterAnimation, + ) + .expect("extract character animation identity"); + assert_eq!( + identity.legacy_public_path.as_deref(), + Some("/generated-character-drafts/layer/animation/preview.mp4") + ); + assert_eq!( + identity.object_key.as_deref(), + Some("generated-character-drafts/layer/animation/frame-01.png") + ); + } + #[tokio::test] async fn video_edit_uses_external_upload_generation_poll_and_read_url_without_mutating_source() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index bd9fc0443..9375e913f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -29,7 +29,7 @@ pub enum NodeSource { pub enum StageStatus { NoProblem, NeedReview(String), // reason inside - Blocked(String), // reason inside + Blocked(String), // reason inside } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx index d61075181..bd1a89b66 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx @@ -14,6 +14,7 @@ export type ResourceEditSubmitInput = { export function ResourceEditSurface({ resourceLabel, editKind, + generationMode = 'derive', initialAssetName, semanticNotice, onCancel, @@ -21,6 +22,7 @@ export function ResourceEditSurface({ }: { resourceLabel: string; editKind: LocalProjectResourceEditKind; + generationMode?: 'create' | 'derive'; initialAssetName: string; semanticNotice: string | null; onCancel: () => void; @@ -33,6 +35,7 @@ export function ResourceEditSurface({ const [error, setError] = useState(''); const inputLocked = attempted || submitting; const promptMaxLength = resourceEditPromptMaxLength(editKind); + const isCreate = generationMode === 'create'; async function submit(event: FormEvent) { event.preventDefault(); @@ -72,7 +75,7 @@ export function ResourceEditSurface({ 返回资源 - 编辑现有资源 + {isCreate ? '新建媒体资源' : '编辑现有资源'} {resourceLabel} @@ -96,7 +99,11 @@ export function ResourceEditSurface({ rows={7} autoFocus disabled={inputLocked} - placeholder="描述希望在现有资源基础上发生的变化" + placeholder={ + isCreate + ? '描述希望生成的视频、音效或背景音乐' + : '描述希望在现有资源基础上发生的变化' + } onChange={(event) => setPrompt(event.currentTarget.value)} /> @@ -132,7 +139,9 @@ export function ResourceEditSurface({ ? '正在派生…' : editKind === 'version' ? '创建子版本' - : '生成派生资源'} + : isCreate + ? '生成资源' + : '生成派生资源'} )} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a0ef217ef..fe1f5d3ec 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -88,7 +88,10 @@ import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; import { defaultDerivedResourceName, type ProjectResourceEditCapability, + resolveProjectCharacterAnimationCapability, resolveProjectResourceEditCapability, + type ResourceGenerationKind, + resourceGenerationOptions, } from './resourceEditModel'; import { type ResourceEditSubmitInput, @@ -175,8 +178,9 @@ type ResourceEditorRoute = { flowId: string; operationId: string; idempotencyKey: string; - resource: ProjectResource; + resource: ProjectResource | null; capability: Extract; + generationMode: 'create' | 'derive'; }; type UiEditorRoute = { @@ -2321,11 +2325,70 @@ export default function ProjectDevelopmentView({ idempotencyKey: crypto.randomUUID(), resource, capability, + generationMode: 'derive', }); }, [advanceFocusGeneration, normalizeRasterSourceAndOpen, openAssetCanvas], ); + const openCharacterAnimation = useCallback( + (resource: ProjectResource) => { + const capability = resolveProjectCharacterAnimationCapability(resource); + if (!capability) { + return; + } + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + const flowId = crypto.randomUUID(); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + setAssetCanvasNotice(''); + setResourceEditorRoute({ + flowId, + operationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + resource, + capability, + generationMode: 'derive', + }); + }, + [advanceFocusGeneration], + ); + + const openCreateResource = useCallback( + (kind: ResourceGenerationKind) => { + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + const flowId = crypto.randomUUID(); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + setAssetCanvasNotice(''); + const sourceMediaType = kind === 'video' ? 'video/mp4' : 'audio/mpeg'; + setResourceEditorRoute({ + flowId, + operationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + resource: null, + capability: { + route: 'derive', + editKind: kind, + sourceMediaType, + semanticNotice: + kind === 'background-music' + ? '将生成全新的背景音乐。' + : kind === 'sound-effect' + ? '将生成全新的音效。' + : '将生成全新的视频。', + generationMode: 'create', + }, + generationMode: 'create', + }); + }, + [advanceFocusGeneration], + ); + const cancelResourceEditor = useCallback(() => { if (resourceEditorRoute) { resourceEditorRevisionRef.current.delete(resourceEditorRoute.operationId); @@ -2687,6 +2750,7 @@ export default function ProjectDevelopmentView({ ); } const resource = route.resource; + const isCreate = route.generationMode === 'create'; const result = await withPlatformSessionRefresh(() => invoke( 'derive_local_project_resource', @@ -2698,16 +2762,27 @@ export default function ProjectDevelopmentView({ operationId: route.operationId, idempotencyKey: route.idempotencyKey, editKind: route.capability.editKind, - sourceResourceId: resource.id, - sourceAssetId: resource.manifestAssetId, + generationMode: route.generationMode, + sourceResourceId: isCreate + ? `create:${route.operationId}` + : (resource?.id ?? ''), + sourceAssetId: isCreate + ? null + : (resource?.manifestAssetId ?? null), sourcePath: - resource.version || resource.subtype === 'agent-result' + isCreate || + resource?.version || + resource?.subtype === 'agent-result' ? null - : resource.path, + : (resource?.path ?? null), sourceMediaType: route.capability.sourceMediaType, - sourceSubtype: resource.subtype, - producerTaskId: resource.producerTaskId, - sourceVersionId: resource.version?.versionId ?? null, + sourceSubtype: isCreate ? null : (resource?.subtype ?? null), + producerTaskId: isCreate + ? null + : (resource?.producerTaskId ?? null), + sourceVersionId: isCreate + ? null + : (resource?.version?.versionId ?? null), prompt, assetName, }, @@ -2756,7 +2831,11 @@ export default function ProjectDevelopmentView({ setResourceEditorRoute(null); setFocusedResourceId(null); setMode('resources'); - setAssetCanvasNotice('派生资源已保存,正在同步资源与布局…'); + setAssetCanvasNotice( + isCreate + ? '生成资源已保存,正在同步资源与布局…' + : '派生资源已保存,正在同步资源与布局…', + ); resourceEditorRevisionRef.current.delete(route.operationId); }, [manifest.projectId, onManifestChange, projectPath, resourceEditorRoute], @@ -3112,14 +3191,16 @@ export default function ProjectDevelopmentView({ : `管理未完成编辑 (${pendingResourceEdits.length})`} ) : null} - + {resourceGenerationOptions.map((option) => ( + + ))} + ) : null} - {resourceLayoutSaving ? '保存中' : resourceLayoutNotice} - + + + ) : null} @@ -4015,13 +4027,20 @@ export default function ProjectDevelopmentView({