From a86b7db9518455980078aeb23f53233ceed68649 Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 21 Aug 2026 15:03:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Game=20Agent=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=94=BB=E6=9D=BF=E4=B8=8E=E5=9B=BE=E7=89=87=E7=B2=BE?= =?UTF-8?q?=E4=BF=AE=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将资源依赖视图改为统一自由画板,并增加真实比例卡片和隐藏导航边界。 复用素材画布实现图片快速编辑、候选生成、导入、失败任务归档和正式图提交。 补齐原生事务恢复、服务身份确认、全屏失败遮罩及任务列表交互。 同步资源投影、布局、预览、前后端测试和技术文档。 --- .../scripts/check-config.mjs | 3 + .../src-tauri/src/commands.rs | 66 + .../src-tauri/src/main.rs | 3 + .../src-tauri/src/project/asset_canvas.rs | 804 ++++- .../src/project/asset_canvas/generation.rs | 1864 ++++++++++- .../src/project/asset_canvas_tests.rs | 527 ++- .../src-tauri/src/tests/project_tools.rs | 3 +- ...setCanvasSurface.before-click-refactor.tsx | 2821 ----------------- .../asset-canvas/AssetCanvasSurface.tsx | 1944 +++++++++--- ...setCanvasSurface.before-click-refactor.css | 469 --- .../asset-canvas/assetCanvasSurface.css | 495 ++- .../tauriImageCanvasHostAdapter.ts | 171 +- apps/ai-game-creator-shell/src/styles.css | 100 +- .../ResourceDependencyOverlay.tsx | 23 +- .../src/view/project-development/index.tsx | 1673 ++++++---- .../resourceCanvasLayoutModel.ts | 96 + .../useProjectResourceCanvasLayout.ts | 2 + .../useProjectResourceCardPreviews.ts | 46 +- .../tests/ResourceDependencyOverlay.test.ts | 30 + .../appSurface/project-development.suite.ts | 535 ++-- .../tests/assetCanvasSurface.test.tsx | 1032 ++++-- .../projectResourceLiveIntegration.test.tsx | 142 +- .../tests/resourceCanvasLayoutModel.test.ts | 41 + ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 44 +- .../shared-memory/decision-log.md | 28 + docs/project-memory/shared-memory/pitfalls.md | 27 + ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 101 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 4 +- packages/image-canvas-core/src/ports.ts | 71 +- 29 files changed, 8060 insertions(+), 5105 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx delete mode 100644 apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.before-click-refactor.css create mode 100644 docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c624b7aa8..9a4b3265e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -115,12 +115,15 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'archive_failed_local_project_resource_edit', + 'archive_failed_local_project_asset_canvas_generation', 'chat_with_game_creator_agent', 'commit_local_project_asset', + 'commit_local_project_asset_canvas_candidate', 'confirm_local_project_asset_canvas_generation_service_identity', 'create_local_project_asset_canvas_draft', 'discard_local_project_asset_canvas_draft', 'generate_local_project_asset_canvas_image', + 'import_local_project_asset_canvas_images', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_local_project_asset_canvas_draft', diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5e41e3cd6..aff278334 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -382,6 +382,45 @@ pub(crate) fn update_local_project_asset_canvas_draft( update_asset_canvas_draft_at(&root, &input) } +#[tauri::command] +pub(crate) async fn import_local_project_asset_canvas_images( + app: tauri::AppHandle, + input: ImportAssetCanvasImagesInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入图片") + .add_filter("图片", &["png", "jpg", "jpeg", "webp"]); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_files(move |paths| { + let _ = sender.send(paths); + }); + let Some(paths) = receiver + .await + .map_err(|_| "图片文件选择器意外关闭".to_string())? + else { + return Ok(ImportAssetCanvasImagesResult::cancelled()); + }; + if paths.is_empty() { + return Ok(ImportAssetCanvasImagesResult::cancelled()); + } + let paths = paths + .into_iter() + .map(|path| { + path.into_path() + .map_err(|error| format!("读取导入图片路径失败:{error}")) + }) + .collect::, _>>()?; + tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths)) + .await + .map_err(|error| format!("图片导入任务异常结束:{error}"))? +} + #[tauri::command] pub(crate) fn store_local_project_asset_canvas_media( input: StoreAssetCanvasMediaInput, @@ -398,6 +437,14 @@ pub(crate) fn stage_local_project_asset_canvas_image( stage_asset_canvas_image_at(&root, &input) } +#[tauri::command] +pub(crate) async fn archive_failed_local_project_asset_canvas_generation( + input: ArchiveAssetCanvasGenerationInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + archive_failed_asset_canvas_generation_at(&root, &input).await +} + #[tauri::command] pub(crate) async fn generate_local_project_asset_canvas_image( app: tauri::AppHandle, @@ -487,6 +534,25 @@ pub(crate) fn commit_local_project_asset( Ok(execution.result) } +#[tauri::command] +pub(crate) fn commit_local_project_asset_canvas_candidate( + app: tauri::AppHandle, + input: CommitAssetCanvasCandidateInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let execution = commit_asset_canvas_candidate_at(&root, &input)?; + if let Some(event) = execution.event.as_ref() { + publish_asset_canvas_event_after_commit_at(&root, event, |payload| { + app.emit( + "game-creator-local-asset-committed", + asset_canvas_committed_public_event(payload), + ) + .map_err(|error| error.to_string()) + }); + } + Ok(execution.result) +} + #[tauri::command] pub(crate) fn recover_local_project_asset_canvas_transactions( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 651654b6f..9ccf71686 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2284,8 +2284,10 @@ fn main() { read_local_project_asset_canvas_draft, discover_local_project_asset_canvas_draft, update_local_project_asset_canvas_draft, + import_local_project_asset_canvas_images, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, + archive_failed_local_project_asset_canvas_generation, generate_local_project_asset_canvas_image, recover_local_project_asset_canvas_generations, confirm_local_project_asset_canvas_generation_service_identity, @@ -2293,6 +2295,7 @@ fn main() { discard_local_project_asset_canvas_draft, recover_local_project_asset_canvas_transactions, commit_local_project_asset, + commit_local_project_asset_canvas_candidate, get_local_game_project_revision, get_local_game_manifest ]) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 28b5f56bb..4cf8207f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -23,10 +23,12 @@ const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024; const ASSET_CANVAS_MAX_LAYERS: usize = 4096; const ASSET_CANVAS_MAX_GENERATIONS: usize = 64; const ASSET_CANVAS_MAX_REFERENCES: usize = 128; +const ASSET_CANVAS_MAX_IMPORT_BATCH: usize = 64; const ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES: usize = 4096; const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384; const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456; +const ASSET_CANVAS_WORLD_SIZE: f64 = 12_000.0; const ASSET_CANVAS_STAGING_TTL_MILLIS: u64 = 24 * 60 * 60 * 1000; const ASSET_CANVAS_LOCK_WAIT_ATTEMPTS: usize = 100; const ASSET_CANVAS_LOCK_WAIT_MILLIS: u64 = 10; @@ -117,6 +119,7 @@ pub(crate) enum AssetCanvasGenerationStatus { GenerationRunning, RemoteCompleted, MediaDownloaded, + CandidateReady, AssetDurableCommitted, Failed, ReconciliationRequired, @@ -137,7 +140,16 @@ enum LegacyAssetCanvasGenerationStatus { Failed, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AssetCanvasGenerationPlaceholder { + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) width: f64, + pub(crate) height: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AssetCanvasGenerationRecord { pub(crate) generation_id: String, @@ -148,6 +160,10 @@ pub(crate) struct AssetCanvasGenerationRecord { pub(crate) reference_resource_ids: Vec, #[serde(default)] pub(crate) output_asset_id: Option, + #[serde(default)] + pub(crate) source_layer_id: Option, + #[serde(default)] + pub(crate) placeholder: Option, pub(crate) error_code: Option, pub(crate) created_at: u64, pub(crate) updated_at: u64, @@ -181,6 +197,10 @@ pub(crate) struct AssetCanvasLastCommit { pub(crate) asset_id: String, pub(crate) event_id: String, pub(crate) committed_project_revision: u64, + #[serde(default)] + pub(crate) source_layer_id: String, + #[serde(default)] + pub(crate) media_sha256: String, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -319,6 +339,51 @@ pub(crate) struct StoreAssetCanvasMediaResult { pub(crate) draft: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ImportAssetCanvasImagesInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) viewport_width: f64, + pub(crate) viewport_height: f64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImportAssetCanvasImagesStatus { + Imported, + Cancelled, + Conflict, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportAssetCanvasImagesResult { + pub(crate) status: ImportAssetCanvasImagesStatus, + pub(crate) draft: Option, + pub(crate) imported_layer_ids: Vec, +} + +impl ImportAssetCanvasImagesResult { + pub(crate) fn cancelled() -> Self { + Self { + status: ImportAssetCanvasImagesStatus::Cancelled, + draft: None, + imported_layer_ids: Vec::new(), + } + } +} + +struct ValidatedAssetCanvasImport { + title: String, + media_type: String, + bytes: Vec, + width: u32, + height: u32, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct StageAssetCanvasImageInput { @@ -570,6 +635,32 @@ fn validate_asset_canvas_state(canvas: &AssetCanvasState) -> Result<(), String> Ok(()) } +fn validate_asset_canvas_generation_placeholder( + placeholder: &AssetCanvasGenerationPlaceholder, +) -> Result<(), String> { + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || placeholder.x < 0.0 + || placeholder.y < 0.0 + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.x + placeholder.width > ASSET_CANVAS_WORLD_SIZE + || placeholder.y + placeholder.height > ASSET_CANVAS_WORLD_SIZE + { + return Err("素材画布生成占位超出安全画布边界".to_string()); + } + Ok(()) +} + +fn validate_asset_canvas_sha256(value: &str, label: &str) -> Result<(), String> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("{label} 必须是 SHA-256")); + } + Ok(()) +} + fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Result<(), String> { if records.len() > ASSET_CANVAS_MAX_GENERATIONS { return Err(format!( @@ -596,6 +687,12 @@ fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Resul { return Err("素材画布生成引用必须是规范化且去重后的稳定身份".to_string()); } + if let Some(source_layer_id) = record.source_layer_id.as_deref() { + validate_plain_component(source_layer_id, "generation.sourceLayerId", 512)?; + } + if let Some(placeholder) = record.placeholder.as_ref() { + validate_asset_canvas_generation_placeholder(placeholder)?; + } validate_safe_revision(record.created_at, "generation.createdAt")?; validate_safe_revision(record.updated_at, "generation.updatedAt")?; if record.updated_at < record.created_at { @@ -632,7 +729,24 @@ fn validate_asset_canvas_draft( _ => {} } validate_asset_canvas_state(&draft.canvas)?; - validate_generation_records(&draft.generations) + validate_generation_records(&draft.generations)?; + if let Some(last_commit) = draft.last_commit.as_ref() { + validate_safe_revision( + last_commit.committed_project_revision, + "lastCommit.committedProjectRevision", + )?; + if !last_commit.source_layer_id.is_empty() { + validate_plain_component( + &last_commit.source_layer_id, + "lastCommit.sourceLayerId", + 512, + )?; + } + if !last_commit.media_sha256.is_empty() { + validate_asset_canvas_sha256(&last_commit.media_sha256, "lastCommit.mediaSha256")?; + } + } + Ok(()) } fn read_asset_canvas_bytes( @@ -707,6 +821,7 @@ fn normalize_legacy_generation_records(records: &mut [AssetCanvasGenerationRecor if record.intent_id.is_empty() { record.intent_id = record.generation_id.clone(); } + let had_legacy_status = record.status.is_some(); if let Some(status) = record.status.take() { record.phase = match status { LegacyAssetCanvasGenerationStatus::Accepted => { @@ -721,7 +836,7 @@ fn normalize_legacy_generation_records(records: &mut [AssetCanvasGenerationRecor LegacyAssetCanvasGenerationStatus::Failed => AssetCanvasGenerationStatus::Failed, }; } - if record.output_asset_id.is_none() { + if had_legacy_status && record.output_asset_id.is_none() { record.output_asset_id = record.output_media_ids.first().cloned(); } record.reference_resource_ids = @@ -1417,6 +1532,24 @@ pub(crate) fn update_asset_canvas_draft_at( }) } +fn rollback_new_asset_canvas_files(paths: &[PathBuf], label: &str) -> Result<(), String> { + let mut failed = 0_usize; + for path in paths.iter().rev() { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => failed += 1, + } + } + if failed > 0 { + Err(format!( + "{label} 回滚失败,需要对账:{failed} 个新文件未能删除" + )) + } else { + Ok(()) + } +} + fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), String> { let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; @@ -1439,22 +1572,35 @@ fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Resu return Err(format!("写入 {label} 临时文件失败:{error}")); } drop(file); - fs::hard_link(&temp_path, path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!("原子安装 {label} 失败:{error}") - })?; - fs::remove_file(&temp_path).map_err(|error| format!("完成 {label} 安装失败:{error}"))?; - let (_, metadata) = open_project_snapshot_regular_file(path, label)?; - if metadata.len() != bytes.len() as u64 { - return Err(format!("{label} 安装后大小不一致")); + if let Err(error) = fs::hard_link(&temp_path, path) { + let install_error = format!("原子安装 {label} 失败:{error}"); + return match rollback_new_asset_canvas_files(&[temp_path], label) { + Ok(()) => Err(install_error), + Err(rollback_error) => Err(format!("{install_error};{rollback_error}")), + }; + } + + let completion = (|| -> Result<(), String> { + fs::remove_file(&temp_path).map_err(|error| format!("完成 {label} 安装失败:{error}"))?; + let (_, metadata) = open_project_snapshot_regular_file(path, label)?; + if metadata.len() != bytes.len() as u64 { + return Err(format!("{label} 安装后大小不一致")); + } + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; + Ok(()) + })(); + if let Err(error) = completion { + let rollback_paths = [path.to_path_buf(), temp_path]; + return match rollback_new_asset_canvas_files(&rollback_paths, label) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error};{rollback_error}")), + }; } - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; Ok(()) } - fn asset_canvas_draft_media_bytes(root: &Path, draft_id: &str) -> Result { let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/media/{draft_id}"))?; @@ -1482,6 +1628,319 @@ fn asset_canvas_draft_media_bytes(root: &Path, draft_id: &str) -> Result bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn asset_canvas_import_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn asset_canvas_import_media_type(bytes: &[u8]) -> Result<&'static str, String> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Ok("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Ok("image/jpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + Ok("image/webp") + } else { + Err("只支持 PNG、JPEG 和 WebP 图片".to_string()) + } +} + +fn sanitize_asset_canvas_import_title(path: &Path, index: usize) -> String { + let title = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("") + .chars() + .filter(|value| !value.is_control()) + .take(200) + .collect::(); + if title.trim().is_empty() { + format!("导入图片 {}", index + 1) + } else { + title + } +} + +fn read_asset_canvas_import( + path: &Path, + index: usize, +) -> Result { + if !path.is_absolute() { + return Err(format!("第 {} 张导入图片路径必须是绝对路径", index + 1)); + } + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取第 {} 张导入图片失败:{error}", index + 1))?; + if path_metadata.file_type().is_symlink() + || asset_canvas_import_metadata_is_reparse_point(&path_metadata) + || !path_metadata.is_file() + { + return Err(format!("第 {} 张导入图片必须是普通文件", index + 1)); + } + if path_metadata.len() == 0 || path_metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 { + return Err(format!( + "第 {} 张导入图片大小必须在 1..={ASSET_CANVAS_MAX_MEDIA_BYTES} 字节", + index + 1 + )); + } + let (mut file, metadata) = open_project_snapshot_regular_file(path, "导入图片")?; + if metadata.len() != path_metadata.len() || metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 + { + return Err(format!("第 {} 张导入图片在读取期间发生变化", index + 1)); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((ASSET_CANVAS_MAX_MEDIA_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取第 {} 张导入图片失败:{error}", index + 1))?; + let media_type = asset_canvas_import_media_type(&bytes)?; + let (media_type, width, height) = validate_image_bytes(media_type, &bytes)?; + Ok(ValidatedAssetCanvasImport { + title: sanitize_asset_canvas_import_title(path, index), + media_type, + bytes, + width, + height, + }) +} + +fn asset_canvas_import_display_size( + width: u32, + height: u32, + viewport_width: f64, + viewport_height: f64, + viewport_scale: f64, +) -> (f64, f64) { + let original_width = f64::from(width); + let original_height = f64::from(height); + let visible_width = viewport_width / viewport_scale; + let visible_height = viewport_height / viewport_scale; + let minimum_scale = if original_width.max(original_height) < 160.0 { + 160.0 / original_width.max(original_height) + } else { + 1.0 + }; + let maximum_scale = (visible_width * 0.6 / original_width) + .min(visible_height * 0.6 / original_height) + .min(1024.0 / original_width) + .min(1024.0 / original_height); + let scale = minimum_scale.min(maximum_scale).max(0.01); + (original_width * scale, original_height * scale) +} + +fn asset_canvas_layers_overlap( + x: f64, + y: f64, + width: f64, + height: f64, + layers: &[AssetCanvasLayer], +) -> bool { + layers.iter().filter(|layer| !layer.hidden).any(|layer| { + x < layer.x + layer.width + 16.0 + && x + width + 16.0 > layer.x + && y < layer.y + layer.height + 16.0 + && y + height + 16.0 > layer.y + }) +} + +pub(crate) fn import_asset_canvas_images_at( + root: &Path, + input: &ImportAssetCanvasImagesInput, + paths: &[PathBuf], +) -> Result { + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + if !input.viewport_width.is_finite() + || !input.viewport_height.is_finite() + || input.viewport_width <= 0.0 + || input.viewport_height <= 0.0 + || input.viewport_width > 16_384.0 + || input.viewport_height > 16_384.0 + { + return Err("素材画布导入 viewport 尺寸无效".to_string()); + } + if paths.is_empty() || paths.len() > ASSET_CANVAS_MAX_IMPORT_BATCH { + return Err(format!( + "单次只能导入 1..={ASSET_CANVAS_MAX_IMPORT_BATCH} 张图片" + )); + } + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let mut imports = Vec::with_capacity(paths.len()); + let mut batch_bytes = 0_u64; + for (index, path) in paths.iter().enumerate() { + let imported = read_asset_canvas_import(path, index)?; + batch_bytes = batch_bytes + .checked_add(imported.bytes.len() as u64) + .ok_or_else(|| "导入图片总量溢出".to_string())?; + if batch_bytes > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES { + return Err("单批导入图片总量超过 512 MiB".to_string()); + } + imports.push(imported); + } + + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Ok(ImportAssetCanvasImagesResult { + status: ImportAssetCanvasImagesStatus::Conflict, + draft: Some(draft), + imported_layer_ids: Vec::new(), + }); + } + if !matches!(draft.status, AssetCanvasDraftStatus::Editing) { + return Err("素材画布草稿当前状态不允许导入图片".to_string()); + } + if draft.canvas.layers.len() + imports.len() > ASSET_CANVAS_MAX_LAYERS { + return Err(format!("素材画布最多支持 {ASSET_CANVAS_MAX_LAYERS} 个图层")); + } + let existing_bytes = asset_canvas_draft_media_bytes(root, &input.draft_id)?; + if existing_bytes + .checked_add(batch_bytes) + .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) + { + return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); + } + + // 先完成所有可能失败的身份、路径和画布计算,再安装文件。 + // 避免后续层序、revision 或路径错误留下部分导入媒体。 + let mut prepared_installs = Vec::with_capacity(imports.len()); + let mut imported_layer_ids = Vec::with_capacity(imports.len()); + let mut next_z = draft + .canvas + .layers + .iter() + .map(|layer| layer.z_index) + .max() + .unwrap_or(0); + let has_existing_layer = !draft.canvas.layers.is_empty(); + let center_x = + (input.viewport_width / 2.0 - draft.canvas.viewport.x) / draft.canvas.viewport.scale; + let center_y = + (input.viewport_height / 2.0 - draft.canvas.viewport.y) / draft.canvas.viewport.scale; + for (index, imported) in imports.into_iter().enumerate() { + let media_id = new_asset_canvas_token()?; + let extension = media_extension(&imported.media_type)?; + let relative = format!( + "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", + input.draft_id + ); + let path = resolve_local_project_path(root, &relative)?; + let media_ref = AssetCanvasMediaRef::DraftMedia { + media_id: media_id.clone(), + media_type: imported.media_type, + sha256: asset_canvas_sha256(&imported.bytes), + byte_length: imported.bytes.len() as u64, + pixel_width: imported.width, + pixel_height: imported.height, + }; + let layer_id = Uuid::new_v4().to_string(); + let (display_width, display_height) = asset_canvas_import_display_size( + imported.width, + imported.height, + input.viewport_width, + input.viewport_height, + draft.canvas.viewport.scale, + ); + let offset = 36.0 / draft.canvas.viewport.scale; + let mut x = (center_x - display_width / 2.0 + index as f64 * offset) + .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_width).max(0.0)); + let mut y = (center_y - display_height / 2.0 + index as f64 * offset) + .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_height).max(0.0)); + for attempt in 0..64 { + if !asset_canvas_layers_overlap( + x, + y, + display_width, + display_height, + &draft.canvas.layers, + ) { + break; + } + let column = (attempt % 8 + 1) as f64; + let row = (attempt / 8) as f64; + x = (center_x - display_width / 2.0 + column * offset) + .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_width).max(0.0)); + y = (center_y - display_height / 2.0 + row * offset) + .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_height).max(0.0)); + } + if has_existing_layer || index > 0 { + next_z = next_z + .checked_add(1) + .ok_or_else(|| "素材画布图层层序已达到上限".to_string())?; + } + validate_safe_revision(next_z, "zIndex")?; + draft.canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.clone(), + resource_id: format!("draft-media:{media_id}"), + title: imported.title, + media_ref, + x, + y, + width: display_width, + height: display_height, + original_width: f64::from(imported.width), + original_height: f64::from(imported.height), + z_index: next_z, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + prepared_installs.push((path, imported.bytes)); + imported_layer_ids.push(layer_id); + } + draft.canvas.selected_layer_ids = imported_layer_ids.clone(); + draft.canvas.primary_selected_layer_id = imported_layer_ids.last().cloned(); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + + let mut installed_paths = Vec::with_capacity(prepared_installs.len()); + for (path, bytes) in &prepared_installs { + if let Err(error) = install_new_asset_canvas_file(path, bytes, "素材画布导入媒体") { + return match rollback_new_asset_canvas_files(&installed_paths, "素材画布批量导入") + { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error};{rollback_error}")), + }; + } + installed_paths.push(path.clone()); + } + if let Err(error) = write_asset_canvas_draft_locked(root, &draft) { + match read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id) { + Ok(Some(candidate)) if candidate == draft => {} + Ok(_) => { + return match rollback_new_asset_canvas_files(&installed_paths, "素材画布批量导入") + { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!("{error};{rollback_error}")), + }; + } + Err(read_error) => { + // 草稿提交结果未知时保留媒体,避免删除一个已落盘草稿正在引用的文件。 + return Err(format!( + "素材画布批量导入写入结果无法确认,需要对账:{error};{read_error}" + )); + } + } + } + Ok(ImportAssetCanvasImagesResult { + status: ImportAssetCanvasImagesStatus::Imported, + draft: Some(draft), + imported_layer_ids, + }) +} + pub(crate) fn store_asset_canvas_media_at( root: &Path, input: &StoreAssetCanvasMediaInput, @@ -1773,12 +2232,33 @@ pub(crate) struct CommitAssetCanvasInput { pub(crate) intent: AssetCanvasIntent, pub(crate) source_asset_id: Option, pub(crate) staged_image_token: String, + #[serde(default)] + pub(crate) source_layer_id: Option, + #[serde(default)] + pub(crate) media_sha256: Option, pub(crate) name: String, pub(crate) asset_kind: String, pub(crate) reference_resource_ids: Vec, pub(crate) generation_provenance: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CommitAssetCanvasCandidateInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_revision: u64, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) commit_id: String, + pub(crate) idempotency_key: String, + pub(crate) source_asset_id: Option, + pub(crate) source_layer_id: String, + pub(crate) name: String, + pub(crate) asset_kind: String, + pub(crate) reference_resource_ids: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum AssetCanvasConflictKind { @@ -1986,6 +2466,10 @@ struct AssetCanvasCommitLedger { committed_project_revision: Option, expected_draft_revision: u64, committed_draft_revision: Option, + #[serde(default)] + source_layer_id: String, + #[serde(default)] + media_sha256: String, asset_id: Option, event_id: String, event_payload: Option, @@ -2023,6 +2507,10 @@ struct AssetCanvasTransactionJournal { expected_draft_revision: u64, target_draft_revision: u64, staged_image: AssetCanvasStagedImage, + #[serde(default)] + source_layer_id: String, + #[serde(default)] + media_sha256: String, final_image_relative_path: String, final_image_sha256: String, final_image_existed_before: bool, @@ -2049,6 +2537,8 @@ struct NormalizedAssetCanvasCommitRequest<'a> { intent: &'a AssetCanvasIntent, source_asset_id: &'a Option, staged_image_token: &'a str, + source_layer_id: &'a Option, + media_sha256: &'a Option, name: &'a str, asset_kind: &'a str, reference_resource_ids: &'a [String], @@ -2352,10 +2842,11 @@ fn verify_committed_asset_canvas_state( .next() .cloned() .ok_or_else(|| "正式资产未进入 manifest".to_string())?; + let source_resource_id = asset_canvas_source_resource_id(&asset); if assets.next().is_some() || asset.local_path != journal.final_image_relative_path || asset.media_type != journal.staged_image.media_type - || asset.source.resource_id.as_deref() != Some(&format!("local-asset:{}", journal.asset_id)) + || asset.source.resource_id.as_deref() != Some(source_resource_id.as_str()) { return Err("正式资产 manifest 身份无效".to_string()); } @@ -2479,6 +2970,17 @@ fn commit_asset_canvas_at_internal( let name = validate_asset_canvas_name(&input.name)?; let asset_kind = validate_asset_kind(&input.asset_kind)?; let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; + match ( + input.source_layer_id.as_deref(), + input.media_sha256.as_deref(), + ) { + (Some(source_layer_id), Some(media_sha256)) => { + validate_plain_component(source_layer_id, "sourceLayerId", 512)?; + validate_asset_canvas_sha256(media_sha256, "mediaSha256")?; + } + (None, None) => {} + _ => return Err("sourceLayerId 与 mediaSha256 必须一起提供以冻结候选身份".to_string()), + } if let Some(provenance) = input.generation_provenance.as_ref() { validate_generation_provenance(provenance)?; } @@ -2551,6 +3053,8 @@ fn commit_asset_canvas_at_internal( intent: &input.intent, source_asset_id: &input.source_asset_id, staged_image_token: &input.staged_image_token, + source_layer_id: &input.source_layer_id, + media_sha256: &input.media_sha256, name: &name, asset_kind: &asset_kind, reference_resource_ids: &references, @@ -2620,6 +3124,36 @@ fn commit_asset_canvas_at_internal( { return Err("staging 图片与当前项目草稿 revision 不匹配".to_string()); } + let (committed_source_layer_id, committed_media_sha256) = match ( + input.source_layer_id.as_deref(), + input.media_sha256.as_deref(), + ) { + (Some(source_layer_id), Some(media_sha256)) => { + if staged.sha256 != media_sha256 { + return Err("候选媒体摘要与 staging 图片不一致".to_string()); + } + let layer = draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == source_layer_id) + .ok_or_else(|| "候选图层不属于当前草稿".to_string())?; + match &layer.media_ref { + AssetCanvasMediaRef::DraftMedia { + media_type, sha256, .. + } if media_type == "image/png" && sha256 == media_sha256 => {} + AssetCanvasMediaRef::DraftMedia { .. } => { + return Err("候选图层媒体身份已变化".to_string()) + } + AssetCanvasMediaRef::ProjectAsset { .. } => { + return Err("精修最终图片必须来自草稿候选媒体".to_string()) + } + } + (source_layer_id.to_string(), media_sha256.to_string()) + } + (None, None) => ("canvas-composite".to_string(), staged.sha256.clone()), + _ => unreachable!("候选身份配对已在提交预检中验证"), + }; let manifest_before = manifest.clone(); if input.intent == AssetCanvasIntent::Refine { @@ -2658,8 +3192,16 @@ fn commit_asset_canvas_at_internal( return Err("create 提交不能包含 sourceAssetId".to_string()); } - let asset_id = format!("canvas-{}", input.commit_id); - if manifest.assets.iter().any(|asset| asset.id == asset_id) { + if input.intent == AssetCanvasIntent::Refine && staged.media_type != "image/png" { + return Err("精修最终图片必须是 PNG".to_string()); + } + let asset_id = input + .source_asset_id + .clone() + .unwrap_or_else(|| format!("canvas-{}", input.commit_id)); + if input.intent == AssetCanvasIntent::Create + && manifest.assets.iter().any(|asset| asset.id == asset_id) + { return Err("目标 canvas asset 已存在但没有匹配 committed ledger".to_string()); } let extension = media_extension(&staged.media_type)?; @@ -2668,41 +3210,53 @@ fn commit_asset_canvas_at_internal( if fs::symlink_metadata(&final_path).is_ok() { return Err("正式素材目标路径已存在".to_string()); } - let source = GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: None, - resource_id: Some(format!("local-asset:{asset_id}")), - asset_object_id: None, - task_id: input - .generation_provenance - .as_ref() - .map(|value| value.task_id.clone()), - prompt: input - .generation_provenance - .as_ref() - .map(|value| value.prompt.clone()), - model: input - .generation_provenance - .as_ref() - .map(|value| value.model.clone()), - generation_route: input - .generation_provenance - .as_ref() - .map(|value| value.generation_route.clone()), - generation_kind: input - .generation_provenance - .as_ref() - .map(|value| value.generation_kind.clone()), - reference_resource_ids: references, + let asset = if input.intent == AssetCanvasIntent::Refine { + let source = manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| "refine 源资产不存在".to_string())?; + source.media_type = "image/png".to_string(); + source.local_path = final_relative_path.clone(); + source.clone() + } else { + let source = GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: Some(format!("local-asset:{asset_id}")), + asset_object_id: None, + task_id: input + .generation_provenance + .as_ref() + .map(|value| value.task_id.clone()), + prompt: input + .generation_provenance + .as_ref() + .map(|value| value.prompt.clone()), + model: input + .generation_provenance + .as_ref() + .map(|value| value.model.clone()), + generation_route: input + .generation_provenance + .as_ref() + .map(|value| value.generation_route.clone()), + generation_kind: input + .generation_provenance + .as_ref() + .map(|value| value.generation_kind.clone()), + reference_resource_ids: references, + }; + let asset = GameCreationAppAssetManifestEntry { + id: asset_id.clone(), + kind: asset_kind, + media_type: staged.media_type.clone(), + local_path: final_relative_path.clone(), + source, + }; + manifest.assets.push(asset.clone()); + asset }; - let asset = GameCreationAppAssetManifestEntry { - id: asset_id.clone(), - kind: asset_kind, - media_type: staged.media_type.clone(), - local_path: final_relative_path.clone(), - source, - }; - manifest.assets.push(asset.clone()); let mut target_revision_state = current_revision.clone(); target_revision_state.revision = target_project_revision; @@ -2737,6 +3291,8 @@ fn commit_asset_canvas_at_internal( committed_project_revision: None, expected_draft_revision: input.expected_draft_revision, committed_draft_revision: None, + source_layer_id: committed_source_layer_id.clone(), + media_sha256: committed_media_sha256.clone(), asset_id: None, event_id: event_id.clone(), event_payload: None, @@ -2757,6 +3313,8 @@ fn commit_asset_canvas_at_internal( expected_draft_revision: input.expected_draft_revision, target_draft_revision, staged_image: staged.clone(), + source_layer_id: committed_source_layer_id.clone(), + media_sha256: committed_media_sha256.clone(), final_image_relative_path: final_relative_path.clone(), final_image_sha256: staged.sha256.clone(), final_image_existed_before: false, @@ -2836,7 +3394,7 @@ fn commit_asset_canvas_at_internal( maybe_fail_asset_canvas_commit(fault, "ledger-committed")?; draft.revision = target_draft_revision; - draft.status = AssetCanvasDraftStatus::Committed; + draft.status = AssetCanvasDraftStatus::Editing; draft.pending_commit = None; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: input.commit_id.clone(), @@ -2844,6 +3402,8 @@ fn commit_asset_canvas_at_internal( asset_id: asset_id.clone(), event_id: event_id.clone(), committed_project_revision: target_project_revision, + source_layer_id: committed_source_layer_id, + media_sha256: committed_media_sha256, }); draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; @@ -2875,6 +3435,99 @@ pub(crate) fn commit_asset_canvas_at( commit_asset_canvas_at_internal(root, input, None) } +pub(crate) fn commit_asset_canvas_candidate_at( + root: &Path, + input: &CommitAssetCanvasCandidateInput, +) -> Result { + validate_plain_component(&input.source_layer_id, "sourceLayerId", 512)?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + + let (media_ref, media_sha256) = { + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.intent != AssetCanvasIntent::Refine + || draft.source_asset_id != input.source_asset_id + { + return Err("候选图提交身份与精修草稿不一致".to_string()); + } + if draft.revision != input.expected_draft_revision { + return Err("候选图提交时草稿 revision 已变化".to_string()); + } + let layer = draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == input.source_layer_id) + .ok_or_else(|| "候选图层不属于当前草稿".to_string())?; + match &layer.media_ref { + AssetCanvasMediaRef::DraftMedia { + media_type, sha256, .. + } if media_type == "image/png" => (layer.media_ref.clone(), sha256.clone()), + AssetCanvasMediaRef::DraftMedia { .. } => { + return Err("精修最终图片必须是 PNG".to_string()); + } + AssetCanvasMediaRef::ProjectAsset { .. } => { + return Err("精修最终图片必须来自草稿候选媒体".to_string()); + } + } + }; + + let media = read_asset_canvas_media_at( + root, + &ReadAssetCanvasMediaInput { + project_path: input.project_path.clone(), + expected_project_id: input.expected_project_id.clone(), + draft_id: input.draft_id.clone(), + media_ref, + }, + )?; + if media.media_type != "image/png" { + return Err("精修最终图片必须是 PNG".to_string()); + } + let staged = stage_asset_canvas_image_at( + root, + &StageAssetCanvasImageInput { + project_path: input.project_path.clone(), + expected_project_id: input.expected_project_id.clone(), + draft_id: input.draft_id.clone(), + expected_draft_revision: input.expected_draft_revision, + media_type: "image/png".to_string(), + bytes: media.bytes, + }, + )?; + if staged.status != "staged" { + return Err("候选图提交时草稿 revision 已变化".to_string()); + } + let staged_image_token = staged + .staged_image_token + .ok_or_else(|| "候选图 staging token 缺失".to_string())?; + + commit_asset_canvas_at( + root, + &CommitAssetCanvasInput { + project_path: input.project_path.clone(), + expected_project_id: input.expected_project_id.clone(), + expected_revision: input.expected_revision, + expected_draft_revision: input.expected_draft_revision, + draft_id: input.draft_id.clone(), + commit_id: input.commit_id.clone(), + idempotency_key: input.idempotency_key.clone(), + intent: AssetCanvasIntent::Refine, + source_asset_id: input.source_asset_id.clone(), + staged_image_token, + source_layer_id: Some(input.source_layer_id.clone()), + media_sha256: Some(media_sha256), + name: input.name.clone(), + asset_kind: input.asset_kind.clone(), + reference_resource_ids: input.reference_resource_ids.clone(), + generation_provenance: None, + }, + ) +} + pub(crate) fn mark_asset_canvas_event_attempted_at( root: &Path, project_id: &str, @@ -3094,21 +3747,32 @@ fn finish_recovered_asset_canvas_commit_locked( && last.asset_id == journal.asset_id && last.event_id == journal.event_id && last.committed_project_revision == journal.target_project_revision + && last.source_layer_id == journal.source_layer_id + && last.media_sha256 == journal.media_sha256 }); + let mut draft_changed = false; if !already_matches { if draft.revision != journal.expected_draft_revision { return Err("恢复提交时草稿 revision 已被其它写入推进".to_string()); } draft.revision = journal.target_draft_revision; - draft.status = AssetCanvasDraftStatus::Committed; - draft.pending_commit = None; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: journal.commit_id.clone(), idempotency_key: None, asset_id: journal.asset_id.clone(), event_id: journal.event_id.clone(), committed_project_revision: journal.target_project_revision, + source_layer_id: journal.source_layer_id.clone(), + media_sha256: journal.media_sha256.clone(), }); + draft_changed = true; + } + if draft.status != AssetCanvasDraftStatus::Editing || draft.pending_commit.is_some() { + draft.status = AssetCanvasDraftStatus::Editing; + draft.pending_commit = None; + draft_changed = true; + } + if draft_changed { draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; } @@ -3140,6 +3804,8 @@ fn recover_asset_canvas_transaction_locked( || ledger.draft_id != journal.draft_id || ledger.idempotency_key != journal.idempotency_key || ledger.request_fingerprint != journal.request_fingerprint + || ledger.source_layer_id != journal.source_layer_id + || ledger.media_sha256 != journal.media_sha256 { return Err("素材画布 journal 与 ledger 身份不一致".to_string()); } @@ -3171,24 +3837,38 @@ fn recover_asset_canvas_transaction_locked( let mut draft = read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? .ok_or_else(|| "已提交事务缺少草稿".to_string())?; - if !draft - .last_commit - .as_ref() - .is_some_and(|last| last.commit_id == journal.commit_id) - { + let last_commit_matches = draft.last_commit.as_ref().is_some_and(|last| { + last.commit_id == journal.commit_id + && last.asset_id == journal.asset_id + && last.event_id == journal.event_id + && last.committed_project_revision == journal.target_project_revision + && last.source_layer_id == journal.source_layer_id + && last.media_sha256 == journal.media_sha256 + }); + let mut draft_changed = false; + if !last_commit_matches { if draft.revision != journal.expected_draft_revision { return mark_asset_canvas_reconciliation_locked(root, journal, ledger) .map(|outcome| (outcome, None)); } draft.revision = journal.target_draft_revision; - draft.status = AssetCanvasDraftStatus::Committed; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: journal.commit_id.clone(), idempotency_key: None, asset_id: journal.asset_id.clone(), event_id: journal.event_id.clone(), committed_project_revision: journal.target_project_revision, + source_layer_id: journal.source_layer_id.clone(), + media_sha256: journal.media_sha256.clone(), }); + draft_changed = true; + } + if draft.status != AssetCanvasDraftStatus::Editing || draft.pending_commit.is_some() { + draft.status = AssetCanvasDraftStatus::Editing; + draft.pending_commit = None; + draft_changed = true; + } + if draft_changed { draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index b499934ed..6555d521e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -12,10 +12,16 @@ const ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS: u64 = 10 * 60 * 1_0 const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; const ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; const ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE: &str = "asset-canvas-references"; +const ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION: &str = + "game-creator-asset-canvas-canonical-reference.v1"; +const ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES: usize = 32 * 1024; static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< tokio::sync::Mutex>>>, > = OnceLock::new(); +static ASSET_CANVAS_CANONICAL_REFERENCE_LOCKS: OnceLock< + tokio::sync::Mutex>>>, +> = OnceLock::new(); #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -36,6 +42,10 @@ pub(crate) struct GenerateAssetCanvasImageInput { pub(crate) asset_kind: String, pub(crate) asset_name: String, pub(crate) reference_resource_ids: Vec, + #[serde(default)] + pub(crate) source_layer_id: Option, + #[serde(default)] + pub(crate) placeholder: Option, } #[derive(Clone, Deserialize)] @@ -46,6 +56,25 @@ pub(crate) struct RecoverAssetCanvasGenerationsInput { pub(crate) draft_id: String, } +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ArchiveAssetCanvasGenerationInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) generation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ArchiveAssetCanvasGenerationResult { + pub(crate) generation_id: String, + pub(crate) phase: String, + pub(crate) archived_at: u64, + pub(crate) draft: AssetCanvasDraft, +} + #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { @@ -72,6 +101,13 @@ pub(crate) struct AssetCanvasGenerationProgressEvent { pub(crate) occurred_at: u64, } +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetCanvasGeneratedImage { + pub(crate) media_ref: AssetCanvasMediaRef, + pub(crate) resource_id: String, +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssetCanvasGenerationCommitResult { @@ -91,8 +127,10 @@ pub(crate) struct AssetCanvasGenerationCommitResult { #[serde(rename_all = "camelCase")] pub(crate) struct GenerateAssetCanvasImageResult { pub(crate) generation: AssetCanvasGenerationRecord, - pub(crate) images: Vec, - pub(crate) commit: AssetCanvasGenerationCommitResult, + pub(crate) images: Vec, + pub(crate) draft: AssetCanvasDraft, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) commit: Option, } pub(crate) struct GenerateAssetCanvasImageExecution { @@ -143,8 +181,10 @@ enum GenerationLedgerPhase { Running, RemoteCompleted, MediaDownloaded, + CandidateReady, AssetDurableCommitted, Failed, + Archived, ReconciliationRequired, } @@ -158,8 +198,10 @@ impl GenerationLedgerPhase { Self::Running => "running", Self::RemoteCompleted => "remote-completed", Self::MediaDownloaded => "media-downloaded", + Self::CandidateReady => "candidate-ready", Self::AssetDurableCommitted => "asset-durable-committed", Self::Failed => "failed", + Self::Archived => "archived", Self::ReconciliationRequired => "reconciliation-required", } } @@ -203,6 +245,30 @@ struct PrivateReferenceState { upload_completed: bool, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateCanonicalReferenceIdentity { + service_identity_fingerprint: String, + platform_owner_binding_sha256: String, + editor_project_id: String, + source_sha256: String, + canonical_asset_kind: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateCanonicalReferenceCache { + schema_version: String, + cache_key_sha256: String, + identity: PrivateCanonicalReferenceIdentity, + resource_id: String, + object_key: String, + asset_object_id: String, + width: u32, + height: u32, + created_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct PrivateRemoteResult { @@ -234,7 +300,7 @@ struct PrivateServiceIdentityConfirmation { expires_at: u64, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AssetCanvasGenerationLedger { schema_version: String, @@ -244,6 +310,10 @@ struct AssetCanvasGenerationLedger { intent: AssetCanvasIntent, source_asset_id: Option, source_resource_id: Option, + #[serde(default)] + source_layer_id: Option, + #[serde(default)] + placeholder: Option, intent_id: String, generation_id: String, idempotency_key: String, @@ -262,6 +332,8 @@ struct AssetCanvasGenerationLedger { reference_states: Vec, resolved_reference_ids: Vec, #[serde(default)] + canonical_reference_repair_attempted: bool, + #[serde(default)] api_identity_scheme: Option, #[serde(default, alias = "externalConfigurationFingerprint")] api_identity_fingerprint: Option, @@ -282,8 +354,14 @@ struct AssetCanvasGenerationLedger { staged_image_token: String, #[serde(default)] staged_draft_revision: Option, + #[serde(default)] + candidate_media_ref: Option, + #[serde(default)] + candidate_layer_id: Option, commit_result: Option, error_code: Option, + #[serde(default)] + archived_at: Option, created_at: u64, updated_at: u64, } @@ -295,6 +373,8 @@ struct NormalizedGenerationRequest<'a> { draft_id: &'a str, intent: &'a AssetCanvasIntent, source_asset_id: &'a Option, + source_layer_id: &'a Option, + placeholder: &'a Option, intent_id: &'a str, generation_id: &'a str, idempotency_key: &'a str, @@ -312,6 +392,7 @@ struct NormalizedGenerationRequest<'a> { struct ReferenceMaterial { stable_reference: Option, + asset_kind: Option, bytes: Option>, media_type: Option, file_name: Option, @@ -559,6 +640,182 @@ fn generation_ledger_relative_path(generation_id: &str) -> String { format!("{ASSET_CANVAS_ROOT}/generations/{generation_id}.json") } +fn canonical_reference_cache_relative_path(cache_key_sha256: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/references/canonical/{cache_key_sha256}.json") +} + +fn canonical_reference_platform_owner_binding_sha256( + ledger: &AssetCanvasGenerationLedger, + api_mode: &CanvasGenerationApiMode, +) -> Result { + let binding = match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (Some(user_id), Some(api_base_url)) => serde_json::json!({ + "scheme": "platform-session", + "userId": user_id, + "apiBaseUrl": api_base_url, + }), + (None, None) => serde_json::json!({ + "scheme": "external-api-key", + "credentialSha256": asset_canvas_sha256(api_mode.bearer_token().as_bytes()), + }), + _ => return Err("素材画布 canonical 参考的账号绑定不完整".to_string()), + }; + let bytes = serde_json::to_vec(&binding) + .map_err(|_| "序列化素材画布 canonical 参考账号绑定失败".to_string())?; + Ok(asset_canvas_sha256(&bytes)) +} + +fn canonical_reference_identity( + ledger: &AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + material: &ReferenceMaterial, +) -> Result { + let context = ledger + .canvas_context + .as_ref() + .ok_or_else(|| "素材画布 canonical 参考缺少 External canvas context".to_string())?; + let source_sha256 = material + .sha256 + .as_deref() + .ok_or_else(|| "素材画布 canonical 参考缺少本地源摘要".to_string())?; + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + let canonical_asset_kind = editor_reference_asset_kind(source_asset_kind) + .unwrap_or("default-image") + .to_string(); + Ok(PrivateCanonicalReferenceIdentity { + service_identity_fingerprint: canvas_api_identity_fingerprint(api_base_url, api_mode), + platform_owner_binding_sha256: canonical_reference_platform_owner_binding_sha256( + ledger, api_mode, + )?, + editor_project_id: context.project_id.clone(), + source_sha256: source_sha256.to_string(), + canonical_asset_kind, + }) +} + +fn canonical_reference_cache_key_sha256( + identity: &PrivateCanonicalReferenceIdentity, +) -> Result { + let bytes = serde_json::to_vec(identity) + .map_err(|_| "序列化素材画布 canonical 参考身份失败".to_string())?; + Ok(asset_canvas_sha256(&bytes)) +} + +fn validate_canonical_reference_cache( + cache: &PrivateCanonicalReferenceCache, +) -> Result<(), String> { + let expected_key = canonical_reference_cache_key_sha256(&cache.identity)?; + if cache.schema_version != ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION + || cache.cache_key_sha256 != expected_key + || cache.identity.service_identity_fingerprint.len() != 64 + || !cache + .identity + .service_identity_fingerprint + .chars() + .all(|character| character.is_ascii_hexdigit()) + || cache.identity.platform_owner_binding_sha256.len() != 64 + || !cache + .identity + .platform_owner_binding_sha256 + .chars() + .all(|character| character.is_ascii_hexdigit()) + || cache.identity.editor_project_id.trim().is_empty() + || cache.identity.editor_project_id.len() > 512 + || cache + .identity + .editor_project_id + .chars() + .any(char::is_control) + || cache.identity.source_sha256.len() != 64 + || !cache + .identity + .source_sha256 + .chars() + .all(|character| character.is_ascii_hexdigit()) + || cache.identity.canonical_asset_kind.trim().is_empty() + || cache.identity.canonical_asset_kind.len() > 128 + || cache + .identity + .canonical_asset_kind + .chars() + .any(char::is_control) + || !is_registered_editor_reference_id(&cache.resource_id) + || cache.object_key.trim().is_empty() + || cache.object_key.len() > 2048 + || cache.object_key.chars().any(char::is_control) + || cache.asset_object_id.trim().is_empty() + || cache.asset_object_id.len() > 512 + || cache.asset_object_id.chars().any(char::is_control) + || cache.width == 0 + || cache.height == 0 + { + return Err("素材画布 canonical 参考缓存无效".to_string()); + } + validate_safe_revision(cache.created_at, "canonicalReference.createdAt") +} + +fn read_canonical_reference_cache( + root: &Path, + identity: &PrivateCanonicalReferenceIdentity, +) -> Result, String> { + let cache_key_sha256 = canonical_reference_cache_key_sha256(identity)?; + let cache = match read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &canonical_reference_cache_relative_path(&cache_key_sha256), + "素材画布 canonical 参考缓存", + ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES, + ) { + Ok(cache) => cache, + Err(_) => None, + }; + let Some(cache) = cache else { + return Ok(None); + }; + if validate_canonical_reference_cache(&cache).is_err() + || cache.cache_key_sha256 != cache_key_sha256 + || cache.identity != *identity + { + return Ok(None); + } + Ok(Some(cache)) +} + +fn write_canonical_reference_cache( + root: &Path, + cache: &PrivateCanonicalReferenceCache, +) -> Result<(), String> { + validate_canonical_reference_cache(cache)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &canonical_reference_cache_relative_path(&cache.cache_key_sha256), + "素材画布 canonical 参考缓存", + cache, + ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES, + ) +} + +async fn canonical_reference_singleflight_lock( + cache_key_sha256: &str, +) -> tokio::sync::OwnedMutexGuard<()> { + let locks = ASSET_CANVAS_CANONICAL_REFERENCE_LOCKS + .get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); + let lock = { + let mut locks = locks.lock().await; + locks + .entry(cache_key_sha256.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + lock.lock_owned().await +} + fn generation_progress_event( ledger: &AssetCanvasGenerationLedger, phase: &str, @@ -591,10 +848,12 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option { Some(AssetCanvasGenerationStatus::MediaDownloaded) } + GenerationLedgerPhase::CandidateReady => Some(AssetCanvasGenerationStatus::CandidateReady), GenerationLedgerPhase::AssetDurableCommitted => { Some(AssetCanvasGenerationStatus::AssetDurableCommitted) } GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed), + GenerationLedgerPhase::Archived => None, GenerationLedgerPhase::ReconciliationRequired => { Some(AssetCanvasGenerationStatus::ReconciliationRequired) } @@ -610,6 +869,7 @@ fn public_phase_name(phase: &AssetCanvasGenerationStatus) -> &'static str { AssetCanvasGenerationStatus::GenerationRunning => "generation-running", AssetCanvasGenerationStatus::RemoteCompleted => "remote-completed", AssetCanvasGenerationStatus::MediaDownloaded => "media-downloaded", + AssetCanvasGenerationStatus::CandidateReady => "candidate-ready", AssetCanvasGenerationStatus::AssetDurableCommitted => "asset-durable-committed", AssetCanvasGenerationStatus::Failed => "failed", AssetCanvasGenerationStatus::ReconciliationRequired => "reconciliation-required", @@ -622,6 +882,7 @@ fn public_progress_value(phase: &AssetCanvasGenerationStatus) -> Option { AssetCanvasGenerationStatus::GenerationRunning => Some(35), AssetCanvasGenerationStatus::RemoteCompleted => Some(60), AssetCanvasGenerationStatus::MediaDownloaded => Some(80), + AssetCanvasGenerationStatus::CandidateReady => Some(100), AssetCanvasGenerationStatus::AssetDurableCommitted => Some(100), AssetCanvasGenerationStatus::Failed | AssetCanvasGenerationStatus::ReconciliationRequired => None, @@ -653,6 +914,12 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() if let Some(revision) = ledger.staged_draft_revision { validate_safe_revision(revision, "stagedDraftRevision")?; } + if let Some(source_layer_id) = ledger.source_layer_id.as_deref() { + validate_plain_component(source_layer_id, "sourceLayerId", 512)?; + } + if let Some(placeholder) = ledger.placeholder.as_ref() { + validate_asset_canvas_generation_placeholder(placeholder)?; + } if ledger.prompt.is_empty() || ledger.prompt.chars().count() > 32_000 || ledger.request_fingerprint.len() != 64 @@ -709,6 +976,18 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() return Err("素材画布私有生成参考状态无效".to_string()); } } + match (&ledger.phase, ledger.archived_at) { + (GenerationLedgerPhase::Archived, Some(archived_at)) => { + validate_safe_revision(archived_at, "archivedAt")?; + } + (GenerationLedgerPhase::Archived, None) => { + return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); + } + (_, Some(_)) => { + return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string()); + } + (_, None) => {} + } if let (Some(body), Some(expected_sha)) = ( ledger.request_body_json.as_deref(), ledger.request_body_sha256.as_deref(), @@ -796,6 +1075,8 @@ fn upsert_public_generation_record( phase, reference_resource_ids: references, output_asset_id, + source_layer_id: ledger.source_layer_id.clone(), + placeholder: ledger.placeholder.clone(), error_code: ledger.error_code.clone(), created_at: index .and_then(|index| draft.generations.get(index)) @@ -822,7 +1103,10 @@ fn upsert_public_generation_record( } if ledger.phase == GenerationLedgerPhase::Failed { draft.status = AssetCanvasDraftStatus::Editing; - } else if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted { + } else if !matches!( + ledger.phase, + GenerationLedgerPhase::CandidateReady | GenerationLedgerPhase::AssetDurableCommitted + ) { draft.status = AssetCanvasDraftStatus::Generating; } draft.revision = draft @@ -900,6 +1184,226 @@ fn synchronize_staged_image_revision( Ok(true) } +fn encode_generation_candidate_png(bytes: &[u8]) -> Result<(Vec, u32, u32), String> { + let image = image::load_from_memory(bytes).map_err(|_| "生成图片无法完整解码".to_string())?; + let (width, height) = image.dimensions(); + let mut png = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|_| "生成图片转换 PNG 失败".to_string())?; + let png = png.into_inner(); + validate_image_bytes("image/png", &png)?; + Ok((png, width, height)) +} + +fn persist_generation_candidate_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result< + ( + AssetCanvasGenerationRecord, + AssetCanvasDraft, + AssetCanvasGeneratedImage, + ), + String, +> { + let (_, staged_bytes) = read_staged_image_locked(root, &ledger.staged_image_token)?; + let (png_bytes, width, height) = encode_generation_candidate_png(&staged_bytes)?; + let media_id = ledger.generation_id.clone(); + let resource_id = format!("draft-media:{media_id}"); + let layer_id = ledger + .candidate_layer_id + .clone() + .unwrap_or_else(|| ledger.generation_id.clone()); + let media_ref = AssetCanvasMediaRef::DraftMedia { + media_id: media_id.clone(), + media_type: "image/png".to_string(), + sha256: asset_canvas_sha256(&png_bytes), + byte_length: png_bytes.len() as u64, + pixel_width: width, + pixel_height: height, + }; + let relative = format!( + "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.png", + ledger.draft_id + ); + let path = resolve_local_project_path(root, &relative)?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("生成候选图片目标必须是普通文件".to_string()); + } + Ok(_) => { + open_and_validate_image_file( + &path, + "image/png", + Some(&asset_canvas_sha256(&png_bytes)), + )?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if asset_canvas_draft_media_bytes(root, &ledger.draft_id)? + .checked_add(png_bytes.len() as u64) + .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) + { + return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); + } + install_new_asset_canvas_file(&path, &png_bytes, "生成候选图片")?; + } + Err(error) => return Err(format!("读取生成候选图片失败:{error}")), + } + + let _lock = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.intent != ledger.intent || draft.source_asset_id != ledger.source_asset_id { + return Err("素材画布生成账本与草稿身份不一致".to_string()); + } + let existing_layer = draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == layer_id); + if let Some(existing) = existing_layer { + if existing.resource_id != resource_id || existing.media_ref != media_ref { + return Err("生成候选图层身份已绑定到不同媒体".to_string()); + } + if ledger.placeholder.as_ref().is_some_and(|placeholder| { + existing.x != placeholder.x + || existing.y != placeholder.y + || existing.width != placeholder.width + || existing.height != placeholder.height + }) { + return Err("生成候选图层已偏离持久化占位".to_string()); + } + } else { + let anchor = ledger + .source_layer_id + .as_deref() + .and_then(|source_layer_id| { + draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == source_layer_id) + }) + .or_else(|| { + draft.canvas.layers.iter().find(|layer| { + ledger + .source_resource_id + .as_deref() + .is_some_and(|source| layer.resource_id == source) + }) + }); + let (x, y, display_width, display_height) = ledger + .placeholder + .as_ref() + .map(|placeholder| { + ( + placeholder.x, + placeholder.y, + placeholder.width, + placeholder.height, + ) + }) + .unwrap_or_else(|| { + ( + anchor + .map(|layer| layer.x + layer.width + 80.0) + .unwrap_or(6000.0 - f64::from(width) / 2.0), + anchor + .map(|layer| layer.y) + .unwrap_or(6000.0 - f64::from(height) / 2.0), + f64::from(width), + f64::from(height), + ) + }); + let z_index = draft + .canvas + .layers + .iter() + .map(|layer| layer.z_index) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| "生成候选图层层序已达到上限".to_string())?; + draft.canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.clone(), + resource_id: resource_id.clone(), + title: format!("{} 候选", ledger.asset_name), + media_ref: media_ref.clone(), + x, + y, + width: display_width, + height: display_height, + original_width: f64::from(width), + original_height: f64::from(height), + z_index, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + } + draft.canvas.selected_layer_ids = vec![layer_id.clone()]; + draft.canvas.primary_selected_layer_id = Some(layer_id.clone()); + let now = asset_canvas_now(); + let record = AssetCanvasGenerationRecord { + generation_id: ledger.generation_id.clone(), + intent_id: ledger.intent_id.clone(), + phase: AssetCanvasGenerationStatus::CandidateReady, + reference_resource_ids: normalize_asset_canvas_references( + &ledger.requested_reference_resource_ids, + )?, + output_asset_id: None, + source_layer_id: ledger.source_layer_id.clone(), + placeholder: ledger.placeholder.clone(), + error_code: None, + created_at: draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .map(|record| record.created_at) + .unwrap_or(ledger.created_at), + updated_at: now, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }; + if let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id) + { + draft.generations[index] = record.clone(); + } else { + if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { + return Err("素材画布生成记录数量已达上限".to_string()); + } + draft.generations.push(record.clone()); + } + draft.status = AssetCanvasDraftStatus::Editing; + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + draft.updated_at = now; + write_asset_canvas_draft_locked(root, &draft)?; + ledger.current_draft_revision = Some(draft.revision); + ledger.candidate_media_ref = Some(media_ref.clone()); + ledger.candidate_layer_id = Some(layer_id); + write_generation_ledger(root, ledger)?; + Ok(( + record, + draft, + AssetCanvasGeneratedImage { + media_ref, + resource_id, + }, + )) +} + fn normalized_request_fingerprint( input: &GenerateAssetCanvasImageInput, manifest: &GameCreationAppManifest, @@ -914,6 +1418,8 @@ fn normalized_request_fingerprint( draft_id: &input.draft_id, intent: &draft.intent, source_asset_id: &draft.source_asset_id, + source_layer_id: &input.source_layer_id, + placeholder: &input.placeholder, intent_id: &input.intent_id, generation_id: &input.generation_id, idempotency_key: &input.idempotency_key, @@ -1005,11 +1511,28 @@ fn validate_and_prepare_ledger( } let asset_kind = validate_asset_kind(&input.asset_kind)?; let asset_name = validate_asset_canvas_name(&input.asset_name)?; + if let Some(source_layer_id) = input.source_layer_id.as_deref() { + validate_plain_component(source_layer_id, "sourceLayerId", 512)?; + } + if let Some(placeholder) = input.placeholder.as_ref() { + validate_asset_canvas_generation_placeholder(placeholder)?; + } let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; let _lock = acquire_asset_canvas_draft_lock(root)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if let Some(source_layer_id) = input.source_layer_id.as_deref() { + let source_layer = draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == source_layer_id) + .ok_or_else(|| "图片生成来源图层不属于当前草稿".to_string())?; + if source_layer.hidden { + return Err("图片生成来源图层不可见".to_string()); + } + } if draft.intent == AssetCanvasIntent::Refine { let source = draft .source_resource_id @@ -1070,6 +1593,8 @@ fn validate_and_prepare_ledger( intent: draft.intent, source_asset_id: draft.source_asset_id, source_resource_id: draft.source_resource_id, + source_layer_id: input.source_layer_id.clone(), + placeholder: input.placeholder.clone(), intent_id: input.intent_id.clone(), generation_id: input.generation_id.clone(), idempotency_key: input.idempotency_key.clone(), @@ -1086,6 +1611,7 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + canonical_reference_repair_attempted: false, api_identity_scheme: None, api_identity_fingerprint: None, platform_owner_user_id: None, @@ -1101,8 +1627,11 @@ fn validate_and_prepare_ledger( remote_result: None, staged_image_token: Uuid::new_v4().to_string(), staged_draft_revision: None, + candidate_media_ref: None, + candidate_layer_id: None, commit_result: None, error_code: None, + archived_at: None, created_at: now, updated_at: now, }; @@ -1143,13 +1672,14 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - if let Some(stable_reference) = stable_manifest_reference(asset).filter(|reference| { - ledger.intent != AssetCanvasIntent::Refine - || ledger.source_resource_id.as_deref() != Some(resource_id) - || is_registered_editor_reference_id(reference) - }) { + let is_refine_source = ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id); + if let Some(stable_reference) = + stable_manifest_reference(asset).filter(|_| !is_refine_source) + { return Ok(ReferenceMaterial { stable_reference: Some(stable_reference), + asset_kind: Some(asset.kind.clone()), bytes: None, media_type: None, file_name: None, @@ -1167,6 +1697,7 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); return Ok(ReferenceMaterial { stable_reference: None, + asset_kind: Some(asset.kind.clone()), bytes: Some(bytes), media_type: Some(asset.media_type.clone()), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1192,6 +1723,7 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); Ok(ReferenceMaterial { stable_reference: None, + asset_kind: None, bytes: Some(bytes), media_type: Some(media_type), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1220,7 +1752,7 @@ async fn try_confirm_uploaded_reference( "contentType": material.media_type, "contentLength": material.bytes.as_ref().map(Vec::len), "contentHash": material.sha256, - "assetKind": asset_kind, + "assetKind": editor_reference_object_asset_kind(asset_kind), "accessPolicy": "private", })), api_mode, @@ -1377,12 +1909,32 @@ async fn upload_reference( Ok(()) } +fn editor_reference_object_asset_kind(asset_kind: &str) -> &'static str { + editor_reference_asset_kind(asset_kind).unwrap_or("image") +} + +fn editor_reference_asset_kind(asset_kind: &str) -> Option<&'static str> { + match asset_kind.trim() { + "character" | "character-art" => Some("character"), + "icon" => Some("icon"), + "icon-spec" => Some("icon-spec"), + "art-spritesheet" | "icon-spritesheet" => Some("icon-spritesheet"), + "ui-prototype" | "ui-design" => Some("ui-design"), + "publication-material" => Some("publication-material"), + "game-background" | "scene" => Some("scene"), + "spec" => Some("spec"), + "illustration" | "game-art" | "image" => None, + _ => None, + } +} + async fn register_uploaded_image_reference( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, + source_asset_kind: &str, object_key: &str, asset_object_id: &str, ) -> Result { @@ -1400,6 +1952,24 @@ async fn register_uploaded_image_reference( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(&context.project_id) ); + let mut request_body = serde_json::json!({ + "imageSrc": format!("/{}", object_key.trim_start_matches('/')), + "objectKey": object_key, + "assetObjectId": asset_object_id, + "width": width, + "height": height, + "sourceType": "uploaded", + "generationInputs": { + "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, + "operationId": ledger.generation_id, + }, + }); + if let Some(asset_kind) = editor_reference_asset_kind(source_asset_kind) { + request_body + .as_object_mut() + .ok_or(ReferencePreparationError::Internal)? + .insert("assetKind".to_string(), serde_json::json!(asset_kind)); + } let payload = external_editor_json_request( authorize_canvas_request( client @@ -1407,19 +1977,7 @@ async fn register_uploaded_image_reference( "{api_base_url}{}", resolve_platform_editor_api_route(&endpoint) )) - .json(&serde_json::json!({ - "imageSrc": format!("/{}", object_key.trim_start_matches('/')), - "objectKey": object_key, - "assetObjectId": asset_object_id, - "width": width, - "height": height, - "sourceType": "uploaded", - "assetKind": ledger.asset_kind, - "generationInputs": { - "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, - "operationId": ledger.generation_id, - }, - })), + .json(&request_body), api_mode, ), "登记参考图片项目资源", @@ -1433,6 +1991,63 @@ async fn register_uploaded_image_reference( json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) } +fn cache_registered_canonical_reference( + root: &Path, + identity: PrivateCanonicalReferenceIdentity, + material: &ReferenceMaterial, + resource_id: &str, + object_key: &str, + asset_object_id: &str, +) -> Result<(), ReferencePreparationError> { + let cache_key_sha256 = canonical_reference_cache_key_sha256(&identity) + .map_err(|_| ReferencePreparationError::Internal)?; + let cache = PrivateCanonicalReferenceCache { + schema_version: ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION.to_string(), + cache_key_sha256, + identity, + resource_id: resource_id.to_string(), + object_key: object_key.to_string(), + asset_object_id: asset_object_id.to_string(), + width: material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + height: material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + created_at: asset_canvas_now(), + }; + write_canonical_reference_cache(root, &cache).map_err(|_| ReferencePreparationError::Internal) +} + +fn invalidate_canonical_reference_cache_for_ledger( + root: &Path, + ledger: &AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, +) -> Result { + if ledger.intent != AssetCanvasIntent::Refine { + return Ok(false); + } + let source_resource_id = ledger + .source_resource_id + .as_deref() + .ok_or_else(|| "精修生成缺少源资源 ID".to_string())?; + let material = reference_material_at(root, ledger, source_resource_id)?; + let identity = canonical_reference_identity(ledger, api_base_url, api_mode, &material)?; + let cache_key_sha256 = canonical_reference_cache_key_sha256(&identity)?; + let path = resolve_local_project_path( + root, + &canonical_reference_cache_relative_path(&cache_key_sha256), + )?; + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "删除失效的素材画布 canonical 参考缓存失败: {error}" + )), + } +} + async fn ensure_reference_states( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1450,6 +2065,31 @@ async fn ensure_reference_states( for resource_id in requested { let material = reference_material_at(root, ledger, &resource_id) .map_err(|_| ReferencePreparationError::MaterialInvalid)?; + let is_refine_source = ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()) + .to_string(); + let canonical_identity = if is_refine_source { + Some( + canonical_reference_identity(ledger, api_base_url, api_mode, &material) + .map_err(|_| ReferencePreparationError::Internal)?, + ) + } else { + None + }; + let canonical_cache_key = canonical_identity + .as_ref() + .map(canonical_reference_cache_key_sha256) + .transpose() + .map_err(|_| ReferencePreparationError::Internal)?; + let _canonical_guard = if let Some(cache_key) = canonical_cache_key.as_deref() { + Some(canonical_reference_singleflight_lock(cache_key).await) + } else { + None + }; let index = match ledger .reference_states .iter() @@ -1469,28 +2109,39 @@ async fn ensure_reference_states( ledger.reference_states.len() - 1 } }; - if ledger.reference_states[index].stable_reference.is_some() { - let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); - if !requires_registered_source - || ledger.reference_states[index] - .stable_reference - .as_deref() - .is_some_and(is_registered_editor_reference_id) + + if let Some(identity) = canonical_identity.as_ref() { + if let Some(cache) = read_canonical_reference_cache(root, identity) + .map_err(|_| ReferencePreparationError::Internal)? { + ledger.reference_states[index].stable_reference = Some(cache.resource_id); + ledger.reference_states[index].asset_object_id = Some(cache.asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; continue; } - // A legacy prepared refine ledger may carry an objectKey. It cannot - // be sent as sourceReferenceId, so re-register it before submission. + // A manifest resourceId or a ledger prepared by an older client does not prove + // that the remote record uses the canonical quick-edit taxonomy. Only this + // identity-bound cache may bypass local material registration for refine. ledger.reference_states[index].stable_reference = None; write_generation_ledger(root, ledger)?; + } else if ledger.reference_states[index].stable_reference.is_some() { + continue; } + if let Some(stable_reference) = material.stable_reference.clone() { ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; continue; } + let upload_asset_kind = if is_refine_source { + source_asset_kind.clone() + } else { + ledger.asset_kind.clone() + }; if let (Some(bucket), Some(object_key)) = ( ledger.reference_states[index].upload_bucket.clone(), ledger.reference_states[index].upload_object_key.clone(), @@ -1502,23 +2153,33 @@ async fn ensure_reference_states( &bucket, &object_key, &material, - &ledger.asset_kind, + &upload_asset_kind, ) .await? { - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + let stable_reference = if is_refine_source { + let stable_reference = register_uploaded_image_reference( client, api_base_url, api_mode, ledger, &material, + &source_asset_kind, &object_key, &asset_object_id, ) - .await? + .await?; + cache_registered_canonical_reference( + root, + canonical_identity + .clone() + .ok_or(ReferencePreparationError::Internal)?, + &material, + &stable_reference, + &object_key, + &asset_object_id, + )?; + stable_reference } else { object_key }; @@ -1547,23 +2208,33 @@ async fn ensure_reference_states( &ticket.bucket, &ticket.object_key, &material, - &ledger.asset_kind, + &upload_asset_kind, ) .await? .ok_or(ReferencePreparationError::ConfirmFailed)?; - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + let stable_reference = if is_refine_source { + let stable_reference = register_uploaded_image_reference( client, api_base_url, api_mode, ledger, &material, + &source_asset_kind, &ticket.object_key, &asset_object_id, ) - .await? + .await?; + cache_registered_canonical_reference( + root, + canonical_identity + .clone() + .ok_or(ReferencePreparationError::Internal)?, + &material, + &stable_reference, + &ticket.object_key, + &asset_object_id, + )?; + stable_reference } else { ticket.object_key }; @@ -1571,6 +2242,7 @@ async fn ensure_reference_states( ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; } ledger.resolved_reference_ids = ledger @@ -1624,10 +2296,6 @@ fn build_generation_request_snapshot( "imageSize".to_string(), serde_json::json!(ledger.image_size), ); - body.insert( - "assetKind".to_string(), - serde_json::json!(ledger.asset_kind), - ); body.insert( "assetLabel".to_string(), serde_json::json!(ledger.asset_name), @@ -1679,6 +2347,10 @@ fn build_generation_request_snapshot( ); "/api/external/v1/editor/images/edits" } else { + body.insert( + "assetKind".to_string(), + serde_json::json!(ledger.asset_kind), + ); body.insert( "referenceImageSrcs".to_string(), serde_json::json!(ledger.resolved_reference_ids), @@ -1750,6 +2422,8 @@ fn committed_execution_from_private_result( phase: AssetCanvasGenerationStatus::AssetDurableCommitted, reference_resource_ids: ledger.requested_reference_resource_ids.clone(), output_asset_id: Some(committed.asset_id.clone()), + source_layer_id: ledger.source_layer_id.clone(), + placeholder: ledger.placeholder.clone(), error_code: None, created_at: ledger.created_at, updated_at: ledger.updated_at, @@ -1763,7 +2437,9 @@ fn committed_execution_from_private_result( result: GenerateAssetCanvasImageResult { generation, images: Vec::new(), - commit: AssetCanvasGenerationCommitResult { + draft: read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?, + commit: Some(AssetCanvasGenerationCommitResult { resource_id: committed.resource_id.clone(), asset_id: committed.asset_id.clone(), project_id: committed.project_id.clone(), @@ -1776,7 +2452,45 @@ fn committed_execution_from_private_result( commit_status: committed.commit_status.clone(), manifest, event_id: committed.event_id.clone(), - }, + }), + }, + event: None, + }) +} + +fn candidate_execution_from_private_result( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result { + let media_ref = ledger + .candidate_media_ref + .clone() + .ok_or_else(|| "已完成生成账本缺少候选媒体引用".to_string())?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + let generation = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .cloned() + .ok_or_else(|| "已完成生成账本缺少公开候选记录".to_string())?; + let resource_id = match &media_ref { + AssetCanvasMediaRef::DraftMedia { media_id, .. } => { + format!("draft-media:{media_id}") + } + AssetCanvasMediaRef::ProjectAsset { .. } => { + return Err("生成候选媒体不能引用正式项目资产".to_string()); + } + }; + Ok(GenerateAssetCanvasImageExecution { + result: GenerateAssetCanvasImageResult { + generation, + images: vec![AssetCanvasGeneratedImage { + media_ref, + resource_id, + }], + draft, + commit: None, }, event: None, }) @@ -1821,6 +2535,11 @@ fn sanitized_generation_error(code: &str) -> String { "platform-service-configuration" => { "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() } + "invalid-generation-request" => "图片生成请求参数不符合平台合同,未创建资源".to_string(), + "source-reference-invalid" => { + "精修源图片引用无效或已失效,请重新打开资源后重试".to_string() + } + "unsupported-source-kind" => "当前资源类型不支持图片快速编辑,未创建资源".to_string(), "generation-rejected" => "图片生成请求被平台明确拒绝,未创建资源".to_string(), "generation-failed" => "图片生成失败,未创建资源".to_string(), "stable-reference-missing" => "远端生成结果缺少稳定资源引用,未创建本地资源".to_string(), @@ -1897,6 +2616,37 @@ async fn wait_for_canvas_generation_result( .await } +fn classify_canvas_bad_request(body: &str) -> &'static str { + let payload = serde_json::from_str::(body).ok(); + let details = payload + .as_ref() + .and_then(|payload| { + payload + .pointer("/error/details") + .or_else(|| payload.get("details")) + }) + .and_then(serde_json::Value::as_object); + let provider = details + .and_then(|details| details.get("provider")) + .and_then(serde_json::Value::as_str); + let message = details + .and_then(|details| details.get("message")) + .and_then(serde_json::Value::as_str); + let field = details + .and_then(|details| details.get("field")) + .and_then(serde_json::Value::as_str); + + if provider == Some("editor-image-edit") + && message.is_some_and(|message| message.contains("当前素材类型不支持图片快速编辑")) + { + return "unsupported-source-kind"; + } + if provider == Some("editor-image-edit") && field == Some("sourceReferenceId") { + return "source-reference-invalid"; + } + "invalid-generation-request" +} + async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { let status = response.status(); if matches!(status, reqwest::StatusCode::UNAUTHORIZED) { @@ -1909,6 +2659,9 @@ async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static if status == reqwest::StatusCode::CONFLICT && body.contains("泥点余额不足") { return ("insufficient-mud-points", false); } + if status == reqwest::StatusCode::BAD_REQUEST { + return (classify_canvas_bad_request(&body), false); + } if status.is_server_error() && ["配置", "API Key", "供应商", "provider"] .iter() @@ -1962,9 +2715,15 @@ async fn reconcile_generation( platform_session: Option<&PlatformSessionSnapshot>, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result { + if ledger.phase == GenerationLedgerPhase::CandidateReady { + return candidate_execution_from_private_result(root, &ledger); + } if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } + if ledger.phase == GenerationLedgerPhase::Archived { + return Err("素材画布失败生成已归档".to_string()); + } if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), @@ -2090,6 +2849,39 @@ async fn reconcile_generation( let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); + if code == "source-reference-invalid" + && ledger.intent == AssetCanvasIntent::Refine + && !ledger.canonical_reference_repair_attempted + && !reconciliation + { + ledger.canonical_reference_repair_attempted = true; + if invalidate_canonical_reference_cache_for_ledger( + root, + &ledger, + api_base_url, + api_mode, + )? { + ledger.endpoint = None; + ledger.request_body_sha256 = None; + ledger.request_body_json = None; + ledger.reference_states.clear(); + ledger.resolved_reference_ids.clear(); + ledger.phase = GenerationLedgerPhase::ContextPreparing; + ledger.error_code = None; + write_generation_ledger(root, &mut ledger)?; + // 持久化修复标记保证这里只会重入一次。Box 仅用于固定 async future 大小, + // 不改变提交与对账边界:只有明确未入队的来源引用拒绝才允许重建后再提交。 + return Box::pin(reconcile_generation( + root, + ledger, + api_base_url, + api_mode, + platform_session, + emit, + )) + .await; + } + } mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; return Err(sanitized_classified_generation_error(code, reconciliation)); } @@ -2262,6 +3054,44 @@ async fn reconcile_generation( publish_public_phase(root, &mut ledger, emit)?; } + if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + ensure_frozen_generation_platform_session(platform_session)?; + if !synchronize_staged_image_revision(root, &mut ledger)? { + mark_generation_error( + root, + &mut ledger, + true, + "candidate-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error( + "candidate-reconciliation-required", + )); + } + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::CandidateReady, + None, + )?; + let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; + emit(generation_progress_event( + &ledger, + public_phase_name(&generation.phase), + public_progress_value(&generation.phase), + None, + )); + return Ok(GenerateAssetCanvasImageExecution { + result: GenerateAssetCanvasImageResult { + generation, + images: vec![image], + draft, + commit: None, + }, + event: None, + }); + } + if ledger.phase == GenerationLedgerPhase::MediaDownloaded { ensure_frozen_generation_platform_session(platform_session)?; if !synchronize_staged_image_revision(root, &mut ledger)? { @@ -2289,6 +3119,8 @@ async fn reconcile_generation( intent: ledger.intent.clone(), source_asset_id: ledger.source_asset_id.clone(), staged_image_token: ledger.staged_image_token.clone(), + source_layer_id: None, + media_sha256: None, name: ledger.asset_name.clone(), asset_kind: ledger.asset_kind.clone(), reference_resource_ids: ledger.requested_reference_resource_ids.clone(), @@ -2393,7 +3225,9 @@ async fn reconcile_generation( result: GenerateAssetCanvasImageResult { generation, images: Vec::new(), - commit: AssetCanvasGenerationCommitResult { + draft: read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?, + commit: Some(AssetCanvasGenerationCommitResult { resource_id: committed.resource_id.clone(), asset_id: committed.asset_id.clone(), project_id: committed.project_id.clone(), @@ -2404,7 +3238,7 @@ async fn reconcile_generation( commit_status: committed.commit_status.clone(), manifest, event_id: committed.event_id.clone(), - }, + }), }, event: execution.event, }); @@ -2460,6 +3294,94 @@ pub(crate) async fn generate_asset_canvas_image_at( .await } +pub(crate) async fn archive_failed_asset_canvas_generation_at( + root: &Path, + input: &ArchiveAssetCanvasGenerationInput, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.generation_id, "generationId")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _generation_guard = + generation_singleflight_lock(&manifest.project_id, &input.generation_id).await; + let mut ledger = read_generation_ledger(root, &input.generation_id)? + .ok_or_else(|| "待归档的素材画布生成账本不存在".to_string())?; + if ledger.project_id != manifest.project_id + || ledger.draft_id != input.draft_id + || ledger.generation_id != input.generation_id + { + return Err("待归档的素材画布生成账本身份无效".to_string()); + } + + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; + if draft.project_id != ledger.project_id + || draft.draft_id != ledger.draft_id + || draft.source_asset_id != ledger.source_asset_id + || draft.source_resource_id != ledger.source_resource_id + { + return Err("待归档的素材画布生成账本与草稿身份不一致".to_string()); + } + let generation_index = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id); + if ledger.phase == GenerationLedgerPhase::Archived && generation_index.is_none() { + return Ok(ArchiveAssetCanvasGenerationResult { + generation_id: ledger.generation_id, + phase: GenerationLedgerPhase::Archived.as_str().to_string(), + archived_at: ledger + .archived_at + .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?, + draft, + }); + } + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + let generation_index = + generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; + let public_generation = &draft.generations[generation_index]; + if public_generation.intent_id != ledger.intent_id { + return Err("待归档的素材画布公开生成记录身份无效".to_string()); + } + match ledger.phase { + GenerationLedgerPhase::Failed => { + if public_generation.phase != AssetCanvasGenerationStatus::Failed { + return Err("只有公开状态明确失败的素材画布生成可以归档".to_string()); + } + ledger.phase = GenerationLedgerPhase::Archived; + ledger.archived_at = Some(asset_canvas_now()); + write_generation_ledger(root, &mut ledger)?; + } + GenerationLedgerPhase::Archived => {} + GenerationLedgerPhase::ReconciliationRequired => { + return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string()); + } + _ => return Err("只有明确失败的素材画布生成可以归档".to_string()), + } + + let archived_at = ledger + .archived_at + .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?; + draft.generations.remove(generation_index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(ArchiveAssetCanvasGenerationResult { + generation_id: ledger.generation_id, + phase: ledger.phase.as_str().to_string(), + archived_at, + draft, + }) +} + pub(crate) async fn recover_asset_canvas_generations_at( root: &Path, input: &RecoverAssetCanvasGenerationsInput, @@ -2522,7 +3444,10 @@ pub(crate) async fn recover_asset_canvas_generations_at( || initial_ledger.draft_id != input.draft_id || matches!( initial_ledger.phase, - GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::Failed + | GenerationLedgerPhase::Archived ) { continue; @@ -2535,7 +3460,10 @@ pub(crate) async fn recover_asset_canvas_generations_at( || ledger.draft_id != input.draft_id || matches!( ledger.phase, - GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::Failed + | GenerationLedgerPhase::Archived ) { continue; @@ -2803,6 +3731,15 @@ mod tests { fn create_refine_generation_fixture( project_id: &str, project_name: &str, + ) -> (tempfile::TempDir, AssetCanvasDraft, Vec) { + create_refine_generation_fixture_with_source(project_id, project_name, "illustration", None) + } + + fn create_refine_generation_fixture_with_source( + project_id: &str, + project_name: &str, + source_asset_kind: &str, + source_resource_id: Option<&str>, ) -> (tempfile::TempDir, AssetCanvasDraft, Vec) { let directory = tempfile::tempdir().expect("create refine generation fixture"); let mut initialized = @@ -2816,13 +3753,13 @@ mod tests { .assets .push(GameCreationAppAssetManifestEntry { id: "source-character".to_string(), - kind: "illustration".to_string(), + kind: source_asset_kind.to_string(), media_type: "image/png".to_string(), local_path: "assets/source.png".to_string(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, - resource_id: None, + resource_id: source_resource_id.map(str::to_string), asset_object_id: None, task_id: None, prompt: None, @@ -2875,6 +3812,13 @@ mod tests { asset_kind: "illustration".to_string(), asset_name: "阶段五生成图".to_string(), reference_resource_ids: Vec::new(), + source_layer_id: draft.canvas.primary_selected_layer_id.clone(), + placeholder: Some(AssetCanvasGenerationPlaceholder { + x: 7_000.0, + y: 6_000.0, + width: 640.0, + height: 360.0, + }), } } @@ -2899,6 +3843,13 @@ mod tests { intent: AssetCanvasIntent::Create, source_asset_id: None, source_resource_id: None, + source_layer_id: None, + placeholder: Some(AssetCanvasGenerationPlaceholder { + x: 6_000.0, + y: 6_000.0, + width: 512.0, + height: 512.0, + }), intent_id: Uuid::new_v4().to_string(), generation_id: Uuid::new_v4().to_string(), idempotency_key: Uuid::new_v4().to_string(), @@ -2915,6 +3866,7 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + canonical_reference_repair_attempted: false, api_identity_scheme: None, api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), @@ -2936,13 +3888,183 @@ mod tests { remote_result: None, staged_image_token: Uuid::new_v4().to_string(), staged_draft_revision: None, + candidate_media_ref: None, + candidate_layer_id: None, commit_result: None, error_code: None, + archived_at: None, created_at: now, updated_at: now, } } + #[test] + fn generation_input_uses_the_fixed_camel_case_placement_contract() { + let source_layer_id = Uuid::new_v4().to_string(); + let input: GenerateAssetCanvasImageInput = serde_json::from_value(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedHostRevision": 0, + "expectedDraftRevision": 1, + "draftId": Uuid::new_v4().to_string(), + "intentId": Uuid::new_v4().to_string(), + "generationId": Uuid::new_v4().to_string(), + "idempotencyKey": Uuid::new_v4().to_string(), + "commitId": Uuid::new_v4().to_string(), + "commitIdempotencyKey": Uuid::new_v4().to_string(), + "prompt": "fixed contract", + "aspectRatio": "1:1", + "imageSize": "1K", + "assetKind": "illustration", + "assetName": "合同测试", + "referenceResourceIds": [], + "sourceLayerId": source_layer_id.clone(), + "placeholder": { + "x": 100.5, + "y": 200.25, + "width": 512.0, + "height": 512.0 + } + })) + .expect("deserialize fixed generation placement contract"); + assert_eq!( + input.source_layer_id.as_deref(), + Some(source_layer_id.as_str()) + ); + assert_eq!( + input.placeholder, + Some(AssetCanvasGenerationPlaceholder { + x: 100.5, + y: 200.25, + width: 512.0, + height: 512.0, + }) + ); + } + + #[test] + fn generation_placement_is_persisted_and_candidate_reuses_the_frozen_placeholder() { + let project_id = "generation-placement-project"; + let (directory, draft, candidate_png) = + create_refine_generation_fixture(project_id, "生成占位持久化测试"); + let source_layer_id = draft + .canvas + .primary_selected_layer_id + .clone() + .expect("refine draft source layer"); + let placeholder = AssetCanvasGenerationPlaceholder { + x: 7_125.5, + y: 5_875.25, + width: 720.0, + height: 405.0, + }; + let mut input = generation_input( + directory.path(), + project_id, + &draft, + "保留任务占位并生成候选", + ); + input.source_layer_id = Some(source_layer_id.clone()); + input.placeholder = Some(placeholder.clone()); + + let mut ledger = validate_and_prepare_ledger(directory.path(), &input) + .expect("prepare placement ledger"); + assert_eq!( + ledger.source_layer_id.as_deref(), + Some(source_layer_id.as_str()) + ); + assert_eq!(ledger.placeholder.as_ref(), Some(&placeholder)); + let serialized = fs::read_to_string( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("read serialized placement ledger"); + let serialized: serde_json::Value = + serde_json::from_str(&serialized).expect("parse placement ledger"); + assert_eq!(serialized["sourceLayerId"], source_layer_id); + assert_eq!(serialized["placeholder"]["x"], placeholder.x); + assert_eq!(serialized["placeholder"]["height"], placeholder.height); + + let staged = stage_asset_canvas_image_with_token_at( + directory.path(), + &StageAssetCanvasImageInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: candidate_png, + }, + Some(&ledger.staged_image_token), + ) + .expect("stage placement candidate"); + assert_eq!(staged.status, "staged"); + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::CandidateReady, + None, + ) + .expect("persist candidate-ready placement ledger"); + + let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger) + .expect("persist candidate at frozen placeholder"); + assert_eq!( + record.source_layer_id.as_deref(), + Some(source_layer_id.as_str()) + ); + assert_eq!(record.placeholder.as_ref(), Some(&placeholder)); + let candidate_layer_id = ledger + .candidate_layer_id + .as_deref() + .expect("candidate layer id"); + let candidate_layer = updated + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == candidate_layer_id) + .expect("candidate layer"); + assert_eq!(candidate_layer.x, placeholder.x); + assert_eq!(candidate_layer.y, placeholder.y); + assert_eq!(candidate_layer.width, placeholder.width); + assert_eq!(candidate_layer.height, placeholder.height); + + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ledger after restart") + .expect("placement ledger retained"); + assert_eq!(recovered_ledger.source_layer_id, Some(source_layer_id)); + assert_eq!(recovered_ledger.placeholder, Some(placeholder.clone())); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read draft after restart") + .expect("draft retained after restart"); + let recovered_record = recovered_draft + .generations + .iter() + .find(|record| record.generation_id == input.generation_id) + .expect("public placement record retained"); + assert_eq!(recovered_record.placeholder.as_ref(), Some(&placeholder)); + + let mut unsafe_input = generation_input( + directory.path(), + project_id, + &recovered_draft, + "拒绝越界占位", + ); + unsafe_input.placeholder = Some(AssetCanvasGenerationPlaceholder { + x: 11_900.0, + y: 100.0, + width: 200.0, + height: 200.0, + }); + assert!(validate_and_prepare_ledger(directory.path(), &unsafe_input) + .expect_err("reject out-of-bounds placeholder") + .contains("安全画布边界")); + } + #[tokio::test] async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean() { @@ -3042,14 +4164,15 @@ mod tests { .await }) .await - .expect("generate and commit image"); + .expect("generate candidate image"); server.join().expect("join generation server"); assert_eq!( first.result.generation.phase, - AssetCanvasGenerationStatus::AssetDurableCommitted + AssetCanvasGenerationStatus::CandidateReady ); - assert!(first.result.commit.resource_id.starts_with("local-asset:")); + assert!(first.result.commit.is_none()); + assert_eq!(first.result.images.len(), 1); assert!(progress .iter() .any(|event| event.phase == "generation-accepted")); @@ -3064,28 +4187,28 @@ mod tests { .any(|event| event.phase == "media-downloaded")); assert!(progress .iter() - .any(|event| event.phase == "asset-durable-committed")); + .any(|event| event.phase == "candidate-ready")); assert!(progress .windows(2) .all(|events| events[0].draft_revision < events[1].draft_revision)); assert_eq!( progress.last().map(|event| event.draft_revision), - Some(first.result.commit.draft_revision) + Some(first.result.draft.revision) ); let private = read_generation_ledger(directory.path(), &input.generation_id) .expect("read committed private ledger") .expect("committed private ledger exists"); assert_eq!( private.current_draft_revision, - Some(first.result.commit.draft_revision) + Some(first.result.draft.revision) ); assert_eq!( private.staged_draft_revision, - Some(first.result.commit.draft_revision) + Some(first.result.draft.revision - 1) ); let (staged, _) = read_staged_image_locked(directory.path(), &private.staged_image_token) .expect("read committed staging image"); - assert_eq!(staged.draft_revision, first.result.commit.draft_revision); + assert_eq!(staged.draft_revision, first.result.draft.revision - 1); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 6); @@ -3104,7 +4227,7 @@ mod tests { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await }) .await - .expect("replay committed generation"); + .expect("replay candidate generation"); assert_eq!(replay.result.commit, first.result.commit); assert!(replay.event.is_none()); @@ -3133,18 +4256,14 @@ mod tests { .expect("public draft exists"); let public_draft_json = serde_json::to_string(&public_draft).expect("serialize public draft"); - let manifest_json = - serde_json::to_string(&first.result.commit.manifest).expect("serialize manifest"); + let manifest = validate_asset_canvas_project_identity(directory.path(), project_id) + .expect("read manifest"); + let manifest_json = serde_json::to_string(&manifest).expect("serialize manifest"); let progress_json = serde_json::to_string(&progress).expect("serialize progress"); - let public_event_json = serde_json::to_string(&asset_canvas_committed_public_event( - first.event.as_ref().expect("commit event"), - )) - .expect("serialize public event"); for public in [ public_draft_json.as_str(), manifest_json.as_str(), progress_json.as_str(), - public_event_json.as_str(), ] { assert!(!public.contains(private_prompt)); assert!(!public.contains(api_key)); @@ -3174,8 +4293,439 @@ mod tests { assert!(!progress_json.contains(absolute_project_path.as_ref())); } + #[test] + fn legacy_generation_ledgers_default_archived_at_to_none() { + let project_id = "asset-canvas-legacy-archive-ledger"; + let (directory, draft) = create_generation_fixture(project_id, "旧生成账本归档字段测试"); + let ledger = accepted_ledger(project_id, &draft, "http://127.0.0.1:9", "api-key"); + let mut value = serde_json::to_value(ledger).expect("serialize legacy ledger fixture"); + value + .as_object_mut() + .expect("ledger object") + .remove("archivedAt"); + let decoded: AssetCanvasGenerationLedger = + serde_json::from_value(value).expect("deserialize old ledger without archivedAt"); + assert_eq!(decoded.archived_at, None); + drop(directory); + } + #[tokio::test] - async fn local_refine_uploads_confirms_then_submits_and_preserves_the_source_asset() { + async fn failed_generation_archive_is_revision_safe_durable_and_idempotent() { + let project_id = "asset-canvas-failed-archive-project"; + let (directory, draft) = create_generation_fixture(project_id, "失败生成归档测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "创建一个会明确失败的测试图片", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare failed ledger"); + mark_generation_error( + directory.path(), + &mut ledger, + false, + "generation-rejected", + &mut |_| {}, + ) + .expect("persist failed generation"); + let failed_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read failed draft") + .expect("failed draft exists"); + assert!(failed_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + })); + + let stale_archive_input = ArchiveAssetCanvasGenerationInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_draft_revision: failed_draft.revision - 1, + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + }; + let stale_error = + archive_failed_asset_canvas_generation_at(directory.path(), &stale_archive_input) + .await + .expect_err("stale archive revision must fail"); + assert_eq!(stale_error, "draft-revision-conflict"); + assert_eq!( + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ledger after stale archive") + .expect("failed ledger remains") + .phase, + GenerationLedgerPhase::Failed + ); + + let archive_input = ArchiveAssetCanvasGenerationInput { + expected_draft_revision: failed_draft.revision, + ..stale_archive_input + }; + let archived = archive_failed_asset_canvas_generation_at(directory.path(), &archive_input) + .await + .expect("archive failed generation"); + assert_eq!(archived.phase, "archived"); + assert_eq!(archived.draft.revision, failed_draft.revision + 1); + assert!(!archived + .draft + .generations + .iter() + .any(|record| record.generation_id == input.generation_id)); + let archived_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read archived ledger") + .expect("archived ledger exists"); + assert_eq!(archived_ledger.phase, GenerationLedgerPhase::Archived); + assert_eq!(archived_ledger.archived_at, Some(archived.archived_at)); + + let repeated = archive_failed_asset_canvas_generation_at(directory.path(), &archive_input) + .await + .expect("repeat archive is idempotent"); + assert_eq!(repeated.archived_at, archived.archived_at); + assert_eq!(repeated.draft.revision, archived.draft.revision); + assert!(!repeated + .draft + .generations + .iter() + .any(|record| record.generation_id == input.generation_id)); + + let recovery = with_test_external_editor_credentials( + "http://127.0.0.1:9", + "archive-recovery-api-key", + async { + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + }, + ) + .await + .expect("reload skips archived generation"); + assert!(recovery.result.resumed_generation_ids.is_empty()); + let reloaded = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("reload archived draft") + .expect("archived draft remains"); + assert_eq!(reloaded.revision, archived.draft.revision); + assert!(!reloaded + .generations + .iter() + .any(|record| record.generation_id == input.generation_id)); + } + + #[tokio::test] + async fn generation_archive_rejects_running_and_reconciliation_required_ledgers() { + for (reconciliation, expected_message) in [ + (false, "只有明确失败的素材画布生成可以归档"), + ( + true, + "reconciliation-required: 结果未知的素材画布生成不能归档", + ), + ] { + let project_id = if reconciliation { + "asset-canvas-reconciliation-archive-project" + } else { + "asset-canvas-running-archive-project" + }; + let (directory, draft) = + create_generation_fixture(project_id, "非明确失败生成拒绝归档测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "创建一个不能归档的测试图片", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input) + .expect("prepare non-failed ledger"); + if reconciliation { + mark_generation_error( + directory.path(), + &mut ledger, + true, + "submit-result-unknown", + &mut |_| {}, + ) + .expect("persist reconciliation generation"); + } else { + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running ledger"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("persist running public generation"); + } + let current_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read non-failed draft") + .expect("non-failed draft exists"); + let error = archive_failed_asset_canvas_generation_at( + directory.path(), + &ArchiveAssetCanvasGenerationInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_draft_revision: current_draft.revision, + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + }, + ) + .await + .expect_err("non-failed generation must not archive"); + assert_eq!(error, expected_message); + let persisted = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read rejected archive ledger") + .expect("rejected archive ledger remains"); + assert_eq!(persisted.archived_at, None); + assert_eq!( + persisted.phase, + if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Running + } + ); + } + } + + #[test] + fn canonical_reference_cache_key_binds_every_remote_and_source_identity_dimension() { + let identity = PrivateCanonicalReferenceIdentity { + service_identity_fingerprint: "1".repeat(64), + platform_owner_binding_sha256: "2".repeat(64), + editor_project_id: "editor-project-a".to_string(), + source_sha256: "3".repeat(64), + canonical_asset_kind: "scene".to_string(), + }; + let expected = canonical_reference_cache_key_sha256(&identity).expect("cache key"); + for changed in [ + PrivateCanonicalReferenceIdentity { + service_identity_fingerprint: "4".repeat(64), + ..identity.clone() + }, + PrivateCanonicalReferenceIdentity { + platform_owner_binding_sha256: "5".repeat(64), + ..identity.clone() + }, + PrivateCanonicalReferenceIdentity { + editor_project_id: "editor-project-b".to_string(), + ..identity.clone() + }, + PrivateCanonicalReferenceIdentity { + source_sha256: "6".repeat(64), + ..identity.clone() + }, + PrivateCanonicalReferenceIdentity { + canonical_asset_kind: "character".to_string(), + ..identity.clone() + }, + ] { + assert_ne!( + canonical_reference_cache_key_sha256(&changed).expect("changed cache key"), + expected + ); + } + } + + #[tokio::test] + async fn refine_replaces_legacy_registered_source_with_cached_canonical_reference() { + let project_id = "asset-canvas-canonical-reference-project"; + let project_name = "素材画布 canonical 精修来源测试"; + let legacy_resource_id = "editor-resource-legacy-game-background"; + let (directory, draft, source_png) = create_refine_generation_fixture_with_source( + project_id, + project_name, + "game-background", + Some(legacy_resource_id), + ); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind canonical reference server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("canonical reference address") + ); + let upload_url = format!("{base_url}/oss-upload"); + let object_key = "canonical-references/game-background.png".to_string(); + let server_object_key = object_key.clone(); + let source_png_len = source_png.len(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set canonical reference fixture nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "canonical reference fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture canonical reference request"); + if request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url, + "bucket": "canonical-reference-bucket", + "objectKey": server_object_key.clone(), + "successActionStatus": 204, + "maxSizeBytes": source_png_len, + "formFields": { + "key": server_object_key.clone(), + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/assets/objects/confirm ") + || request.starts_with("POST /api/external/v1/assets/objects/confirm ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": server_object_key.clone(), + "assetObjectId": "canonical-reference-object" + }}}), + ); + } else if request.starts_with("POST /api/editor/projects/remote-project/resources ") + || request.starts_with( + "POST /api/external/v1/editor/projects/remote-project/resources ", + ) + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-canonical-scene", + "projectId": "remote-project", + "objectKey": server_object_key.clone(), + "assetObjectId": "canonical-reference-object" + }}}), + ); + } else { + panic!("unexpected canonical reference request: {request}"); + } + } + }); + + let api_mode = CanvasGenerationApiMode { + api_key: "canonical-reference-private-api-key".to_string(), + }; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("canonical reference client"); + let mut first_input = + generation_input(directory.path(), project_id, &draft, "把背景调整成黄昏光线"); + first_input.asset_kind = "game-background".to_string(); + let mut first_ledger = + validate_and_prepare_ledger(directory.path(), &first_input).expect("first ledger"); + first_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project".to_string(), + asset_folder_id: "remote-folder".to_string(), + canvas_name: project_name.to_string(), + }); + write_generation_ledger(directory.path(), &mut first_ledger) + .expect("persist first context"); + ensure_reference_states( + directory.path(), + &mut first_ledger, + &client, + &base_url, + &api_mode, + ) + .await + .expect("prepare first canonical reference"); + server.join().expect("join canonical reference server"); + + assert_eq!( + first_ledger.resolved_reference_ids, + vec!["editor-resource-canonical-scene".to_string()] + ); + assert!(!first_ledger + .resolved_reference_ids + .iter() + .any(|resource_id| resource_id == legacy_resource_id)); + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + let confirm = requests + .iter() + .find(|request| { + request.starts_with("POST /api/assets/objects/confirm ") + || request.starts_with("POST /api/external/v1/assets/objects/confirm ") + }) + .expect("canonical confirm request"); + assert!(confirm.contains("\"assetKind\":\"scene\"")); + let register = requests + .iter() + .find(|request| request.contains("/remote-project/resources ")) + .expect("canonical registration request"); + assert!(register.contains("\"assetKind\":\"scene\"")); + + let mut second_input = + generation_input(directory.path(), project_id, &draft, "把背景调整成清晨光线"); + second_input.asset_kind = "game-background".to_string(); + let mut second_ledger = + validate_and_prepare_ledger(directory.path(), &second_input).expect("second ledger"); + second_ledger.canvas_context = first_ledger.canvas_context.clone(); + write_generation_ledger(directory.path(), &mut second_ledger) + .expect("persist second context"); + ensure_reference_states( + directory.path(), + &mut second_ledger, + &client, + &base_url, + &api_mode, + ) + .await + .expect("reuse canonical reference cache without remote calls"); + assert_eq!( + second_ledger.resolved_reference_ids, + vec!["editor-resource-canonical-scene".to_string()] + ); + assert_eq!( + second_ledger.reference_states[0].asset_object_id.as_deref(), + Some("canonical-reference-object") + ); + + let material = reference_material_at( + directory.path(), + &second_ledger, + second_ledger + .source_resource_id + .as_deref() + .expect("refine source id"), + ) + .expect("read canonical source material"); + let identity = + canonical_reference_identity(&second_ledger, &base_url, &api_mode, &material) + .expect("canonical cache identity"); + let cache = read_canonical_reference_cache(directory.path(), &identity) + .expect("read canonical cache") + .expect("canonical cache exists"); + assert_eq!(cache.identity.canonical_asset_kind, "scene"); + assert_eq!(cache.object_key, object_key); + let cache_json = serde_json::to_string(&cache).expect("serialize canonical cache"); + assert!(!cache_json.contains("canonical-reference-private-api-key")); + assert!(!cache_json.contains(legacy_resource_id)); + } + + #[tokio::test] + async fn local_refine_uploads_confirms_then_stages_candidate_and_preserves_the_source_asset() { let project_id = "asset-canvas-reference-upload-project"; let project_name = "素材画布本地图片精修测试"; let (directory, draft, source_png) = @@ -3261,7 +4811,9 @@ mod tests { ); } else if request.starts_with("POST /oss-upload ") { write_empty_response(&mut stream, "204 No Content"); - } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { + } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") + || request.starts_with("POST /api/assets/objects/confirm ") + { write_json( &mut stream, "200 OK", @@ -3272,6 +4824,7 @@ mod tests { ); } else if request .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + || request.starts_with("POST /api/editor/projects/remote-project/resources ") { write_json( &mut stream, @@ -3345,7 +4898,8 @@ mod tests { let confirm_index = requests .iter() .position(|request| { - request.starts_with("POST /api/external/v1/assets/objects/confirm ") + request.starts_with("POST /api/assets/objects/confirm ") + || request.starts_with("POST /api/external/v1/assets/objects/confirm ") }) .expect("confirm request exists"); let submit_index = requests @@ -3355,8 +4909,10 @@ mod tests { let register_index = requests .iter() .position(|request| { - request - .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + request.starts_with("POST /api/editor/projects/remote-project/resources ") + || request.starts_with( + "POST /api/external/v1/editor/projects/remote-project/resources ", + ) }) .expect("project resource registration exists"); assert!(ticket_index < upload_index && upload_index < confirm_index); @@ -3371,36 +4927,39 @@ mod tests { .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); assert!(requests[register_index].contains(&format!("\"width\":{source_width}"))); assert!(requests[register_index].contains(&format!("\"height\":{source_height}"))); + assert!(!requests[register_index].contains("\"assetKind\"")); + assert!(requests[confirm_index].contains("\"assetKind\":\"image\"")); assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); + assert!(!requests[submit_index].contains("\"assetKind\"")); assert!(!requests[submit_index].contains("sourceImageSrc")); assert!(!requests[submit_index].contains("sourceResourceId")); - let manifest = execution.result.commit.manifest; - assert_eq!(manifest.assets.len(), 2); + assert!(execution.result.commit.is_none()); + assert_eq!(execution.result.images.len(), 1); + let manifest = validate_asset_canvas_project_identity(directory.path(), project_id) + .expect("read unchanged manifest"); + assert_eq!(manifest.assets.len(), 1); let source = manifest .assets .iter() .find(|asset| asset.id == "source-character") .expect("source asset remains in manifest"); - let derived = manifest - .assets - .iter() - .find(|asset| asset.id == execution.result.commit.asset_id) - .expect("derived asset appended to manifest"); assert_eq!(source.local_path, "assets/source.png"); - assert_ne!(derived.local_path, source.local_path); - assert!(derived - .source - .reference_resource_ids - .contains(&"local-asset:source-character".to_string())); assert_eq!( fs::read(directory.path().join(&source.local_path)).expect("read preserved source"), source_png ); - assert_eq!( - fs::read(directory.path().join(&derived.local_path)).expect("read derived image"), - result_png - ); + assert_eq!(execution.result.draft.canvas.layers.len(), 2); + assert!(execution + .result + .draft + .canvas + .layers + .iter() + .any(|layer| matches!( + layer.media_ref, + AssetCanvasMediaRef::DraftMedia { ref media_type, .. } if media_type == "image/png" + ))); let ledger = read_generation_ledger(directory.path(), &input.generation_id) .expect("read refine ledger") .expect("refine ledger exists"); @@ -3459,7 +5018,8 @@ mod tests { "label": project_name, }]}}}), ); - } else if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + } else if request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { write_json( &mut stream, @@ -3664,9 +5224,21 @@ mod tests { .await .expect("recover accepted generation after confirmation"); server.join().expect("join recovery server"); - assert_eq!(first.result.resumed_generation_ids, vec![generation_id]); + assert_eq!( + first.result.resumed_generation_ids, + vec![generation_id.clone()] + ); assert!(first.result.service_identity_confirmations.is_empty()); - assert_eq!(first.events.len(), 1); + assert!(first.events.is_empty()); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered candidate draft") + .expect("recovered candidate draft exists"); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == generation_id + && record.phase == AssetCanvasGenerationStatus::CandidateReady + && record.output_asset_id.is_none() + })); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 3); assert!(requests.iter().all(|request| request.starts_with("GET "))); @@ -3680,7 +5252,7 @@ mod tests { assert!(second.result.service_identity_confirmations.is_empty()); assert!(second.events.is_empty()); let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); - assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets.len(), 0); } #[tokio::test] @@ -4000,6 +5572,13 @@ mod tests { intent: AssetCanvasIntent::Refine, source_asset_id: Some("source".to_string()), source_resource_id: Some(source_resource_id.clone()), + source_layer_id: None, + placeholder: Some(AssetCanvasGenerationPlaceholder { + x: 7_000.0, + y: 6_000.0, + width: 512.0, + height: 512.0, + }), intent_id: Uuid::new_v4().to_string(), generation_id: Uuid::new_v4().to_string(), idempotency_key: Uuid::new_v4().to_string(), @@ -4039,6 +5618,7 @@ mod tests { "editor-resource-source".to_string(), "objects/style.png".to_string(), ], + canonical_reference_repair_attempted: false, api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), platform_owner_user_id: None, @@ -4058,8 +5638,11 @@ mod tests { remote_result: None, staged_image_token: Uuid::new_v4().to_string(), staged_draft_revision: None, + candidate_media_ref: None, + candidate_layer_id: None, commit_result: None, error_code: None, + archived_at: None, created_at: now, updated_at: now, }; @@ -4069,6 +5652,7 @@ mod tests { serde_json::from_str(&request).expect("parse refine request"); assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); assert_eq!(request["sourceReferenceId"], "editor-resource-source"); + assert!(request.get("assetKind").is_none()); assert!(request.get("sourceImageSrc").is_none()); assert!(request.get("sourceResourceId").is_none()); assert_eq!( @@ -4081,6 +5665,76 @@ mod tests { ); } + #[test] + fn editor_reference_asset_kind_maps_game_agent_kinds_to_quick_edit_taxonomy() { + for (asset_kind, expected) in [ + ("illustration", None), + ("game-art", None), + ("image", None), + ("character", Some("character")), + ("character-art", Some("character")), + ("icon", Some("icon")), + ("icon-spec", Some("icon-spec")), + ("art-spritesheet", Some("icon-spritesheet")), + ("icon-spritesheet", Some("icon-spritesheet")), + ("ui-prototype", Some("ui-design")), + ("ui-design", Some("ui-design")), + ("publication-material", Some("publication-material")), + ("game-background", Some("scene")), + ("scene", Some("scene")), + ("spec", Some("spec")), + ("future-game-kind", None), + ] { + assert_eq!( + editor_reference_asset_kind(asset_kind), + expected, + "unexpected editor resource kind mapping for {asset_kind}" + ); + assert_eq!( + editor_reference_object_asset_kind(asset_kind), + expected.unwrap_or("image"), + "unexpected editor object kind mapping for {asset_kind}" + ); + } + } + + #[test] + fn submit_bad_request_classification_is_specific_and_never_returns_remote_body() { + let unsupported_body = serde_json::json!({ + "error": { + "details": { + "provider": "editor-image-edit", + "message": "当前素材类型不支持图片快速编辑", + "assetKind": "private-game-kind", + } + } + }) + .to_string(); + let unsupported_code = classify_canvas_bad_request(&unsupported_body); + assert_eq!(unsupported_code, "unsupported-source-kind"); + assert!(!sanitized_generation_error(unsupported_code).contains("private-game-kind")); + + let source_body = serde_json::json!({ + "error": { + "details": { + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "sourceReferenceId": "editor-resource-private-id", + "message": "资源不存在", + } + } + }) + .to_string(); + let source_code = classify_canvas_bad_request(&source_body); + assert_eq!(source_code, "source-reference-invalid"); + assert!(!sanitized_generation_error(source_code).contains("editor-resource-private-id")); + + let unknown_body = "unknown field `assetKind`; api_key=private-secret"; + let unknown_code = classify_canvas_bad_request(unknown_body); + assert_eq!(unknown_code, "invalid-generation-request"); + assert!(!sanitized_generation_error(unknown_code).contains("private-secret")); + } + #[test] fn private_generation_ledger_never_serializes_upload_credentials_or_provider_url() { let project_id = "phase-five-private-upload-ledger"; @@ -4244,7 +5898,21 @@ mod tests { }); let (endpoint, request_body) = build_generation_request_snapshot(&ledger).expect("build external request"); + let request: serde_json::Value = + serde_json::from_str(&request_body).expect("parse external generation request"); assert_eq!(endpoint, "/api/external/v1/editor/images/generations"); + assert_eq!(request["prompt"], ledger.prompt); + assert_eq!(request["aspectRatio"], ledger.aspect_ratio); + assert_eq!(request["imageSize"], ledger.image_size); + assert_eq!(request["assetKind"], ledger.asset_kind); + assert_eq!(request["assetLabel"], ledger.asset_name); + assert_eq!(request["projectId"], "external-project"); + assert_eq!(request["assetFolderId"], "external-folder"); + assert_eq!( + request["referenceImageSrcs"], + serde_json::json!(ledger.resolved_reference_ids) + ); + assert!(request.get("sourceReferenceId").is_none()); assert!(is_external_canvas_generation_endpoint(&endpoint)); assert!(!is_external_canvas_generation_endpoint( "/api/editor/images/generations" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index ea302ecea..1867df77c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -115,6 +115,148 @@ fn add_imported_layer(fixture: &Fixture, draft: &AssetCanvasDraft) -> AssetCanva .draft } +fn initialize_refine_fixture_with_resource_id(resource_id: Option<&str>) -> Fixture { + let directory = tempfile::tempdir().expect("create refine fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "候选提交测试项目") + .expect("initialize refine project"); + let source_bytes = png_bytes([200, 20, 40, 255]); + fs::write(directory.path().join("assets/source.png"), source_bytes) + .expect("write refine source asset"); + let manifest_path = directory.path().join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read refine manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "source-asset".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: resource_id.map(str::to_string), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("register refine source asset"); + let draft = create_asset_canvas_draft_at( + directory.path(), + &create_input( + directory.path(), + &Uuid::new_v4().to_string(), + AssetCanvasIntent::Refine, + Some("source-asset".to_string()), + ), + ) + .expect("create refine draft") + .draft; + Fixture { + directory, + draft, + png: png_bytes([20, 200, 40, 255]), + } +} + +fn initialize_refine_fixture() -> Fixture { + initialize_refine_fixture_with_resource_id(None) +} + +fn add_candidate_layer( + fixture: &Fixture, + draft: &AssetCanvasDraft, +) -> (AssetCanvasDraft, String, String) { + let stored = store_asset_canvas_media_at( + fixture.root(), + &StoreAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + ) + .expect("store candidate media"); + let media_ref = stored.media_ref.expect("candidate media ref"); + let media_sha256 = match &media_ref { + AssetCanvasMediaRef::DraftMedia { sha256, .. } => sha256.clone(), + AssetCanvasMediaRef::ProjectAsset { .. } => panic!("candidate must use draft media"), + }; + let layer_id = Uuid::new_v4().to_string(); + let mut canvas = draft.canvas.clone(); + let z_index = canvas + .layers + .iter() + .map(|layer| layer.z_index) + .max() + .unwrap_or(0) + + 1; + canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.clone(), + resource_id: format!("draft-media:{layer_id}"), + title: "候选图片".to_string(), + media_ref, + x: 7000.0, + y: 6000.0, + width: 400.0, + height: 300.0, + original_width: 4.0, + original_height: 3.0, + z_index, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + canvas.selected_layer_ids = vec![layer_id.clone()]; + canvas.primary_selected_layer_id = Some(layer_id.clone()); + let mut updated = update_asset_canvas_draft_at( + fixture.root(), + &UpdateAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: draft.generations.clone(), + }, + ) + .expect("persist candidate layer") + .draft; + let now = asset_canvas_now(); + updated.generations.push(AssetCanvasGenerationRecord { + generation_id: Uuid::new_v4().to_string(), + intent_id: Uuid::new_v4().to_string(), + phase: AssetCanvasGenerationStatus::CandidateReady, + reference_resource_ids: Vec::new(), + output_asset_id: None, + source_layer_id: draft.canvas.primary_selected_layer_id.clone(), + placeholder: Some(AssetCanvasGenerationPlaceholder { + x: 7000.0, + y: 6000.0, + width: 400.0, + height: 300.0, + }), + error_code: None, + created_at: now, + updated_at: now, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }); + write_asset_canvas_draft_locked(fixture.root(), &updated) + .expect("persist authoritative candidate generation"); + (updated, layer_id, media_sha256) +} + fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasImageResult { stage_asset_canvas_image_at( fixture.root(), @@ -150,6 +292,8 @@ fn commit_input( intent: draft.intent.clone(), source_asset_id: draft.source_asset_id.clone(), staged_image_token: staged.staged_image_token.clone().expect("staging token"), + source_layer_id: None, + media_sha256: None, name: "测试素材".to_string(), asset_kind: "illustration".to_string(), reference_resource_ids: Vec::new(), @@ -302,30 +446,238 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { Uuid::new_v4().to_string(), ); let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset"); - let new_asset = match execution.result { + let committed_asset = match execution.result { CommitAssetCanvasResult::Committed { asset, .. } => asset, other => panic!("unexpected refine result: {other:?}"), }; - assert_eq!( - fs::read(fixture.root().join("assets/source.png")).expect("read source"), - source_bytes - ); let manifest = current_asset_canvas_manifest(fixture.root()).expect("read refine manifest"); + assert_eq!(manifest.assets.len(), 1); let source = manifest .assets .iter() .find(|asset| asset.id == "source-asset") .expect("source retained"); + assert_eq!(committed_asset.id, source.id); + assert_eq!(committed_asset.local_path, source.local_path); + assert_ne!(committed_asset.local_path, "assets/source.png"); + assert_eq!(source.media_type, "image/png"); + assert_eq!( + fs::read(fixture.root().join(&source.local_path)).expect("read committed source"), + fixture.png + ); assert_eq!( source.source.resource_id.as_deref(), Some("local-asset:source-asset") ); - assert_ne!(new_asset.id, source.id); - assert_ne!(new_asset.local_path, source.local_path); - assert!(new_asset - .source - .reference_resource_ids - .contains(&"local-asset:source-asset".to_string())); +} + +#[test] +fn candidate_commit_freezes_layer_and_media_identity_and_keeps_draft_editable() { + let fixture = initialize_refine_fixture(); + let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut mismatched = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + mismatched.source_layer_id = Some(candidate_layer_id.clone()); + mismatched.media_sha256 = Some("0".repeat(64)); + assert!(commit_asset_canvas_at(fixture.root(), &mismatched) + .expect_err("reject mismatched candidate digest") + .contains("候选媒体摘要")); + + let commit_id = Uuid::new_v4().to_string(); + let execution = commit_asset_canvas_candidate_at( + fixture.root(), + &CommitAssetCanvasCandidateInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + expected_revision: 0, + expected_draft_revision: draft.revision, + draft_id: draft.draft_id.clone(), + commit_id: commit_id.clone(), + idempotency_key: Uuid::new_v4().to_string(), + source_asset_id: draft.source_asset_id.clone(), + source_layer_id: candidate_layer_id.clone(), + name: "候选最终图".to_string(), + asset_kind: "illustration".to_string(), + reference_resource_ids: Vec::new(), + }, + ) + .expect("commit selected candidate"); + let committed_asset = match execution.result { + CommitAssetCanvasResult::Committed { asset, .. } => asset, + other => panic!("unexpected candidate commit result: {other:?}"), + }; + assert_eq!(committed_asset.id, "source-asset"); + + let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) + .expect("read candidate draft") + .expect("candidate draft retained"); + assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); + assert!(persisted.pending_commit.is_none()); + assert_eq!(persisted.canvas.layers, draft.canvas.layers); + assert_eq!(persisted.generations, draft.generations); + let last_commit = persisted.last_commit.expect("candidate last commit"); + assert_eq!(last_commit.commit_id, commit_id); + assert_eq!(last_commit.asset_id, "source-asset"); + assert_eq!(last_commit.source_layer_id, candidate_layer_id); + assert_eq!(last_commit.media_sha256, media_sha256); + + let serialized = fs::read_to_string( + fixture + .root() + .join(asset_canvas_draft_relative_path(&draft.draft_id)), + ) + .expect("read serialized candidate draft"); + let serialized: serde_json::Value = + serde_json::from_str(&serialized).expect("parse candidate draft"); + assert_eq!(serialized["status"], "editing"); + assert_eq!( + serialized["lastCommit"]["sourceLayerId"], + last_commit.source_layer_id + ); + assert_eq!( + serialized["lastCommit"]["mediaSha256"], + last_commit.media_sha256 + ); +} + +#[test] +fn candidate_commit_preserves_registered_refine_source_identity() { + let registered_resource_id = "editor-resource-registered-refine-source"; + let fixture = initialize_refine_fixture_with_resource_id(Some(registered_resource_id)); + let (draft, candidate_layer_id, _) = add_candidate_layer(&fixture, &fixture.draft); + + let execution = commit_asset_canvas_candidate_at( + fixture.root(), + &CommitAssetCanvasCandidateInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + expected_revision: 0, + expected_draft_revision: draft.revision, + draft_id: draft.draft_id.clone(), + commit_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + source_asset_id: draft.source_asset_id.clone(), + source_layer_id: candidate_layer_id, + name: "已登记来源候选最终图".to_string(), + asset_kind: "illustration".to_string(), + reference_resource_ids: Vec::new(), + }, + ) + .expect("commit candidate with registered source identity"); + let asset = match execution.result { + CommitAssetCanvasResult::Committed { asset, .. } => asset, + other => panic!("unexpected registered source commit result: {other:?}"), + }; + assert_eq!( + asset.source.resource_id.as_deref(), + Some(registered_resource_id) + ); + assert_eq!(asset.id, "source-asset"); +} + +#[test] +fn candidate_commit_recovery_accepts_registered_refine_source_after_revision_install() { + let registered_resource_id = "editor-resource-registered-recovery-source"; + let fixture = initialize_refine_fixture_with_resource_id(Some(registered_resource_id)); + let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + input.source_layer_id = Some(candidate_layer_id.clone()); + input.media_sha256 = Some(media_sha256.clone()); + + assert!(commit_asset_canvas_at_internal( + fixture.root(), + &input, + Some(AssetCanvasCommitFaultStage::RevisionInstalled), + ) + .expect_err("interrupt registered refine commit after revision install") + .starts_with("fault-injected:")); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover registered refine candidate commit"); + assert!(recovered.result.outcomes.iter().any(|outcome| { + outcome.commit_id == input.commit_id + && matches!( + outcome.status, + RecoverAssetCanvasOutcomeStatus::Committed + | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + ) + })); + let manifest = read_manifest(&fixture.root().join(".agent/manifest.json")) + .expect("read recovered registered refine manifest"); + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == "source-asset") + .expect("recovered source asset"); + assert_eq!( + asset.source.resource_id.as_deref(), + Some(registered_resource_id) + ); + let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) + .expect("read recovered registered refine draft") + .expect("recovered registered refine draft retained"); + assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); + let last_commit = persisted + .last_commit + .expect("recovered registered last commit"); + assert_eq!(last_commit.source_layer_id, candidate_layer_id); + assert_eq!(last_commit.media_sha256, media_sha256); +} + +#[test] +fn candidate_commit_recovery_restores_editing_and_last_commit_identity() { + let fixture = initialize_refine_fixture(); + let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + input.source_layer_id = Some(candidate_layer_id.clone()); + input.media_sha256 = Some(media_sha256.clone()); + + assert!(commit_asset_canvas_at_internal( + fixture.root(), + &input, + Some(AssetCanvasCommitFaultStage::LedgerCommitted), + ) + .expect_err("interrupt candidate commit after ledger") + .starts_with("fault-injected:")); + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover candidate commit"); + assert!(recovered.result.outcomes.iter().any(|outcome| { + outcome.commit_id == input.commit_id + && matches!( + outcome.status, + RecoverAssetCanvasOutcomeStatus::Committed + | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + ) + })); + let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) + .expect("read recovered candidate draft") + .expect("recovered candidate draft retained"); + assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); + assert_eq!(persisted.canvas.layers, draft.canvas.layers); + assert_eq!(persisted.generations, draft.generations); + let last_commit = persisted.last_commit.expect("recovered last commit"); + assert_eq!(last_commit.source_layer_id, candidate_layer_id); + assert_eq!(last_commit.media_sha256, media_sha256); } #[test] @@ -705,7 +1057,7 @@ fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { .expect("read committed draft") .draft .expect("committed draft retained"); - assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Committed); + assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Editing); assert_eq!(recovered_draft.revision, draft.revision + 1); assert!(recovered_draft.pending_commit.is_none()); assert_eq!( @@ -1016,6 +1368,8 @@ fn ordinary_draft_update_preserves_backend_authoritative_generation_records() { phase: AssetCanvasGenerationStatus::GenerationRunning, reference_resource_ids: Vec::new(), output_asset_id: None, + source_layer_id: None, + placeholder: None, error_code: None, created_at: 1, updated_at: 2, @@ -1046,3 +1400,152 @@ fn ordinary_draft_update_preserves_backend_authoritative_generation_records() { assert_eq!(updated.draft.generations, authoritative.generations); assert_eq!(updated.draft.canvas.viewport.x, 42.0); } + +#[test] +fn imports_multiple_images_as_one_authoritative_draft_revision() { + let fixture = initialize_fixture(); + let first = fixture.root().join("first.png"); + let second = fixture.root().join("second.png"); + fs::write(&first, png_bytes([10, 20, 30, 255])).expect("write first import"); + fs::write(&second, png_bytes([40, 50, 60, 255])).expect("write second import"); + + let result = import_asset_canvas_images_at( + fixture.root(), + &ImportAssetCanvasImagesInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + viewport_width: 900.0, + viewport_height: 640.0, + }, + &[first, second], + ) + .expect("import image batch"); + + assert_eq!(result.status, ImportAssetCanvasImagesStatus::Imported); + let draft = result.draft.expect("authoritative imported draft"); + assert_eq!(draft.revision, fixture.draft.revision + 1); + assert_eq!(draft.canvas.layers.len(), 2); + assert_eq!(result.imported_layer_ids.len(), 2); + assert_eq!(draft.canvas.selected_layer_ids, result.imported_layer_ids); + assert_eq!( + draft.canvas.primary_selected_layer_id, + result.imported_layer_ids.last().cloned() + ); + assert!(draft + .canvas + .layers + .iter() + .all(|layer| matches!(layer.media_ref, AssetCanvasMediaRef::DraftMedia { .. }))); + assert_eq!( + fs::read_dir( + fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/media/{}", draft.draft_id)) + ) + .expect("read imported media directory") + .count(), + 2 + ); +} + +#[test] +fn rejects_invalid_import_batch_without_partial_media_or_revision_change() { + let fixture = initialize_fixture(); + let valid = fixture.root().join("valid.png"); + let invalid = fixture.root().join("invalid.png"); + fs::write(&valid, &fixture.png).expect("write valid import"); + fs::write(&invalid, b"not an image").expect("write invalid import"); + + let error = import_asset_canvas_images_at( + fixture.root(), + &ImportAssetCanvasImagesInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + viewport_width: 900.0, + viewport_height: 640.0, + }, + &[valid, invalid], + ) + .expect_err("invalid image rejects whole batch"); + assert!(error.contains("PNG、JPEG 和 WebP")); + + let reread = read_asset_canvas_draft_at( + fixture.root(), + &ReadAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + }, + ) + .expect("read unchanged draft") + .draft + .expect("draft remains"); + assert_eq!(reread.revision, fixture.draft.revision); + assert!(reread.canvas.layers.is_empty()); + let media_directory = fixture.root().join(format!( + "{ASSET_CANVAS_ROOT}/media/{}", + fixture.draft.draft_id + )); + assert!(!media_directory.exists()); +} + +#[test] +fn import_revision_overflow_does_not_install_partial_media() { + let fixture = initialize_fixture(); + let image = fixture.root().join("revision-overflow.png"); + fs::write(&image, &fixture.png).expect("write revision overflow import"); + let mut draft = fixture.draft.clone(); + draft.revision = ASSET_CANVAS_MAX_SAFE_INTEGER; + write_asset_canvas_draft_locked(fixture.root(), &draft).expect("write max revision draft"); + + let error = import_asset_canvas_images_at( + fixture.root(), + &ImportAssetCanvasImagesInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + viewport_width: 900.0, + viewport_height: 640.0, + }, + &[image], + ) + .expect_err("revision overflow rejects import before media install"); + assert!(error.contains("revision")); + + let media_directory = fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/media/{}", draft.draft_id)); + assert!(!media_directory.exists()); + let reread = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) + .expect("read max revision draft") + .expect("max revision draft remains"); + assert_eq!(reread, draft); +} + +#[test] +fn import_conflict_returns_authoritative_draft_without_writing_media() { + let fixture = initialize_fixture(); + let image = fixture.root().join("conflict.png"); + fs::write(&image, &fixture.png).expect("write conflict import"); + let result = import_asset_canvas_images_at( + fixture.root(), + &ImportAssetCanvasImagesInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision + 1, + viewport_width: 900.0, + viewport_height: 640.0, + }, + &[image], + ) + .expect("typed import conflict"); + assert_eq!(result.status, ImportAssetCanvasImagesStatus::Conflict); + assert_eq!(result.draft, Some(fixture.draft.clone())); + assert!(result.imported_layer_ids.is_empty()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 06d3d9ba7..bd77f59e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6144,8 +6144,7 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { assert_eq!(preview.media_type, "image/png"); assert_eq!(preview.pixel_width, 1); assert_eq!(preview.pixel_height, 1); - let serialized_preview = - serde_json::to_value(&preview).expect("serialize image preview"); + let serialized_preview = serde_json::to_value(&preview).expect("serialize image preview"); assert_eq!(serialized_preview["pixelWidth"], 1); assert_eq!(serialized_preview["pixelHeight"], 1); diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx deleted file mode 100644 index 3fc6544b7..000000000 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.before-click-refactor.tsx +++ /dev/null @@ -1,2821 +0,0 @@ -/* eslint-disable react-refresh/only-export-components -- The surface exports its host callback contracts and deterministic fixture helpers for integration tests. */ - -import './assetCanvasSurface.css'; - -import { - type CanvasHistoryAction, - type CanvasHistorySnapshot, - type CanvasLayer, - type CanvasViewport, - createMinimapModel, - fitViewportToLayers, - type ImageCanvasDraft, - type ImageCanvasDraftCanvas, - type ImageCanvasGenerationProgress, - type ImageCanvasGenerationProgressPhase, - type ImageCanvasGenerationServiceIdentityConfirmation, - type ImageCanvasHostScope, - type ImageCanvasMediaRef, - moveViewportFromMinimapPointer, - moveViewportFromPan, - removeCanvasLayers, - resizeCanvasLayerBounds, - resolveLayerPointerSelection, - resolveViewportFromWheel, - scaleViewportFromScreenPoint, - transformCanvasLayers, -} from '@genarrative/image-canvas-core'; -import { - CanvasChromeButton, - CanvasToolbar, - CanvasToolbarDivider, - CanvasToolbarGroup, - CanvasViewport as SharedCanvasViewport, - CanvasWorld, - LayerRenderer, - Minimap, - useCanvasHistory, - ZoomControls, -} from '@genarrative/image-canvas-react'; -import { - ArrowLeft, - Check, - ImagePlus, - Maximize2, - Minus, - Plus, - Redo2, - RotateCcw, - Save, - Sparkles, - Trash2, - Undo2, - Upload, - X, -} from 'lucide-react'; -import { - type ChangeEvent, - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { useWalletStore } from '../../stores/useWalletStore'; -import type { - LocalAssetCommittedEvent, - TauriImageCanvasHostAdapter, -} from './tauriImageCanvasHostAdapter'; - -export type AssetCanvasLifecycleState = - | { kind: 'canvas.creating' } - | { kind: 'canvas.editing'; dirty: boolean } - | { - kind: 'canvas.saving'; - stage: 'draft' | 'staging' | 'committing' | 'projecting'; - } - | { kind: 'canvas.recovering' } - | { - kind: 'canvas.generating'; - phase: ImageCanvasGenerationProgressPhase; - } - | { - kind: 'canvas.failed'; - operation: - | 'generation' - | 'draft-save' - | 'asset-commit' - | 'recovery' - | 'cancellation'; - code: string; - message: string; - reconciliationRequired: boolean; - }; - -export type AssetCanvasSaveAttempt = { - saveAttemptId: string; - sessionId: string; - projectId: string; - draftId: string; - commitId: string; -}; - -export type AssetCanvasExitResult = { - draftId: string; - disposition: 'kept' | 'discarded'; -}; - -export type AssetCanvasCommitNotification = { - source: 'command' | 'event'; - projectPath: string; - projectId: string; - draftId: string; - commitId: string; - assetId: string; - manifest: GameCreationAppManifest; - projectRevision: number; - committedProjectRevision: number; - eventId?: string; -}; - -export const ASSET_CANVAS_KIND_OPTIONS = [ - { value: 'game-art', label: '普通游戏美术' }, - { value: 'icon-spec', label: '统一视觉规范' }, - { value: 'ui-prototype', label: '游戏界面原型' }, - { value: 'art-spritesheet', label: '核心美术图集' }, -] as const; - -type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef }; - -type PendingGenerationIdentity = { - saveAttemptId: string; - intentId: string; - generationId: string; - idempotencyKey: string; - commitId: string; - commitIdempotencyKey: string; -}; - -type DragState = - | { - kind: 'pan'; - pointerId: number; - startClientX: number; - startClientY: number; - startViewport: CanvasViewport; - historyAction: CanvasHistoryAction; - historySnapshot: CanvasHistorySnapshot; - changed: boolean; - selectionChanged: boolean; - } - | { - kind: 'move'; - pointerId: number; - startClientX: number; - startClientY: number; - startLayers: RuntimeCanvasLayer[]; - targetIds: string[]; - historyAction: CanvasHistoryAction; - historySnapshot: CanvasHistorySnapshot; - changed: boolean; - selectionChanged: boolean; - } - | { - kind: 'resize'; - pointerId: number; - startClientX: number; - startClientY: number; - startLayers: RuntimeCanvasLayer[]; - layerId: string; - historyAction: CanvasHistoryAction; - historySnapshot: CanvasHistorySnapshot; - changed: boolean; - selectionChanged: false; - }; - -function equalSelectionIds(left: string[], right: string[]) { - return ( - left.length === right.length && - left.every((selectionId, index) => selectionId === right[index]) - ); -} - -function draftMatchesScope( - draft: ImageCanvasDraft, - scope: ImageCanvasHostScope, -) { - return ( - draft.projectId === scope.projectId && - draft.draftId === scope.draftId && - draft.intent === scope.intent && - draft.sourceAssetId === (scope.sourceAssetId ?? null) - ); -} - -export function shouldApplyAssetCanvasDraftCandidate({ - current, - candidate, - minimumRevision, - scope, -}: { - current: ImageCanvasDraft; - candidate: ImageCanvasDraft; - minimumRevision: number; - scope: ImageCanvasHostScope; -}) { - if ( - !draftMatchesScope(candidate, scope) || - candidate.revision < minimumRevision || - candidate.revision < current.revision - ) { - return false; - } - if (candidate.revision > current.revision) { - return true; - } - return JSON.stringify(candidate) === JSON.stringify(current); -} - -const generationPhaseLabels: Record< - ImageCanvasGenerationProgressPhase, - string -> = { - 'confirmation-required': '正在确认生成请求', - 'generation-accepted': '请求已受理,正在排队', - 'generation-running': '正在生成图片', - 'remote-completed': '图片已生成,正在准备下载', - 'media-downloaded': '图片已下载,正在保存到项目', - 'asset-durable-committed': '图片已保存到项目', - 'manifest-projected': '资源已登记,正在更新画布', - 'layout-ready': '资源布局已更新', - selected: '新资源已选中', - failed: '图片生成失败', - 'reconciliation-required': '正在等待原任务恢复', -}; - -const generationPhaseProgress: Record< - ImageCanvasGenerationProgressPhase, - number -> = { - 'confirmation-required': 4, - 'generation-accepted': 12, - 'generation-running': 40, - 'remote-completed': 64, - 'media-downloaded': 84, - 'asset-durable-committed': 100, - 'manifest-projected': 100, - 'layout-ready': 100, - selected: 100, - failed: 0, - 'reconciliation-required': 55, -}; - -function generationFailureTitle(code: string) { - if (code === 'authentication-required') return '登录已失效'; - if (code === 'insufficient-mud-points') return '泥点余额不足'; - if (code === 'platform-service-configuration') return '平台生成服务暂不可用'; - if (code === 'reconciliation-required') return '原生成任务需要恢复'; - return '图片生成未完成'; -} - -export function assetCanvasFailurePresentation( - failure: Extract, -) { - if (failure.operation === 'draft-save') { - return { - ariaLabel: '草稿保存失败', - kicker: '草稿保存', - title: - failure.code === 'draft-revision-conflict' - ? '草稿已在其它窗口更新' - : '草稿暂未保存', - }; - } - if (failure.operation === 'asset-commit') { - return { - ariaLabel: '素材提交未完成', - kicker: '素材提交', - title: failure.reconciliationRequired - ? '素材结果需要安全对账' - : '素材提交未完成', - }; - } - if (failure.operation === 'recovery') { - return { - ariaLabel: '原任务恢复未完成', - kicker: '安全恢复', - title: failure.reconciliationRequired - ? '原任务需要对账' - : '原任务恢复未完成', - }; - } - if (failure.operation === 'cancellation') { - return { - ariaLabel: '草稿处置失败', - kicker: '草稿处置', - title: '未能完成草稿处置', - }; - } - return { - ariaLabel: '图片生成失败', - kicker: 'AI 图片生成', - title: generationFailureTitle(failure.code), - }; -} - -function generationRecoveryStateLabel(state: string) { - if (state === 'prepared') return '请求已冻结,尚未确认受理结果'; - if (state === 'accepted') return '平台已受理'; - if (state === 'running') return '平台处理中'; - if (state === 'reconciliation-required') return '等待安全对账'; - return '等待恢复'; -} - -export type RenderAssetCanvasImage = (input: { - layers: RuntimeCanvasLayer[]; - backgroundColor: string; - mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; - quality: number | null; -}) => Promise; - -function loadImage(src: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error('图片预览无法用于导出')); - image.src = src; - }); -} - -export async function renderAssetCanvasImage({ - layers, - backgroundColor, - mediaType, - quality, -}: Parameters[0]): Promise { - const visible = layers.filter((layer) => !layer.hidden); - if (!visible.length) throw new Error('画布中没有可导出的图片'); - const minX = Math.floor(Math.min(...visible.map((layer) => layer.x))); - const minY = Math.floor(Math.min(...visible.map((layer) => layer.y))); - const maxX = Math.ceil( - Math.max(...visible.map((layer) => layer.x + layer.width)), - ); - const maxY = Math.ceil( - Math.max(...visible.map((layer) => layer.y + layer.height)), - ); - const width = Math.max(1, maxX - minX); - const height = Math.max(1, maxY - minY); - if (width > 16_384 || height > 16_384 || width * height > 268_435_456) { - throw new Error('导出尺寸超过素材画布上限'); - } - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d'); - if (!context) throw new Error('当前 WebView 不支持画布导出'); - context.fillStyle = backgroundColor; - context.fillRect(0, 0, width, height); - for (const layer of [...visible].sort((a, b) => a.zIndex - b.zIndex)) { - const image = await loadImage(layer.src); - context.save(); - const centerX = layer.x - minX + layer.width / 2; - const centerY = layer.y - minY + layer.height / 2; - context.translate(centerX, centerY); - context.scale(layer.flipX ? -1 : 1, layer.flipY ? -1 : 1); - context.drawImage( - image, - -layer.width / 2, - -layer.height / 2, - layer.width, - layer.height, - ); - context.restore(); - } - const blob = await new Promise((resolve, reject) => { - canvas.toBlob( - (value) => - value ? resolve(value) : reject(new Error('图片导出编码失败')), - mediaType, - quality ?? undefined, - ); - }); - if (blob.type && blob.type !== mediaType) { - throw new Error('当前 WebView 不支持所选导出格式'); - } - return new Uint8Array(await blob.arrayBuffer()); -} - -function draftCanvasFromRuntime( - layers: RuntimeCanvasLayer[], - viewport: CanvasViewport, - backgroundColor: string, - selectedLayerIds: string[], -): ImageCanvasDraftCanvas { - const visibleSelected = selectedLayerIds.filter((id) => - layers.some((layer) => layer.id === id && !layer.hidden), - ); - return { - viewport: { ...viewport }, - backgroundColor, - layers: layers.map((layer) => ({ - layerId: layer.id, - resourceId: layer.resourceId, - title: layer.title, - mediaRef: layer.mediaRef, - x: layer.x, - y: layer.y, - width: layer.width, - height: layer.height, - originalWidth: layer.originalWidth, - originalHeight: layer.originalHeight, - zIndex: layer.zIndex, - groupId: layer.groupId ?? null, - hidden: Boolean(layer.hidden), - locked: Boolean(layer.locked), - flipX: Boolean(layer.flipX), - flipY: Boolean(layer.flipY), - })), - selectedLayerIds: visibleSelected, - primarySelectedLayerId: visibleSelected.at(-1) ?? null, - }; -} - -function mediaTypeForFile(file: File) { - if (file.type === 'image/png') return 'image/png' as const; - if (file.type === 'image/jpeg') return 'image/jpeg' as const; - if (file.type === 'image/webp') return 'image/webp' as const; - return null; -} - -export function AssetCanvasSurface({ - host, - scope, - sessionId, - expectedHostRevision, - initialAssetName = '画布素材', - initialAssetKind = 'game-art', - onCancel, - onCommitted, - onSaveAttempt, - renderImage = renderAssetCanvasImage, -}: { - host: TauriImageCanvasHostAdapter; - scope: ImageCanvasHostScope; - sessionId: string; - expectedHostRevision: string; - initialAssetName?: string; - initialAssetKind?: string; - onCancel?: (result: AssetCanvasExitResult) => void; - onCommitted?: (input: AssetCanvasCommitNotification) => void; - onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void; - renderImage?: RenderAssetCanvasImage; -}) { - const resolvedInitialAssetName = initialAssetName.trim() || '画布素材'; - const resolvedInitialAssetKind = initialAssetKind.trim() || 'game-art'; - const stableScope = useMemo( - () => ({ - projectId: scope.projectId, - draftId: scope.draftId, - intent: scope.intent, - sourceAssetId: scope.sourceAssetId, - }), - [scope.draftId, scope.intent, scope.projectId, scope.sourceAssetId], - ); - const [lifecycle, setLifecycle] = useState({ - kind: 'canvas.recovering', - }); - const [draft, setDraft] = useState(null); - const [layers, setLayers] = useState([]); - const [viewport, setViewport] = useState({ - x: 0, - y: 0, - scale: 0.5, - }); - const [backgroundColor, setBackgroundColor] = useState('#f4f4f5'); - const [selectedLayerIds, setSelectedLayerIds] = useState([]); - const [notice, setNotice] = useState(''); - const [assetName, setAssetName] = useState(resolvedInitialAssetName); - const [assetKind, setAssetKind] = useState(resolvedInitialAssetKind); - const [exportMediaType, setExportMediaType] = useState< - 'image/png' | 'image/jpeg' | 'image/webp' - >('image/png'); - const [generationDialog, setGenerationDialog] = useState< - 'edit' | 'confirm' | null - >(null); - const [generationPrompt, setGenerationPrompt] = useState(''); - const [generationAspectRatio, setGenerationAspectRatio] = useState< - '1:1' | '2:3' | '3:2' | '9:16' | '16:9' - >('1:1'); - const [generationImageSize, setGenerationImageSize] = useState< - '0.5K' | '1K' | '2K' - >('1K'); - const [generationReferenceResourceIds, setGenerationReferenceResourceIds] = - useState([]); - const [exitDialogOpen, setExitDialogOpen] = useState(false); - const [exitActionPending, setExitActionPending] = useState(false); - const [serviceIdentityConfirmations, setServiceIdentityConfirmations] = - useState([]); - const [serviceIdentityDialogOpen, setServiceIdentityDialogOpen] = - useState(false); - const [serviceIdentityPending, setServiceIdentityPending] = useState(false); - const [serviceIdentityError, setServiceIdentityError] = useState(''); - const [documentVersion, setDocumentVersion] = useState(0); - const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); - const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); - const viewportElementRef = useRef(null); - const importInputRef = useRef(null); - const layersRef = useRef(layers); - const viewportRef = useRef(viewport); - const backgroundRef = useRef(backgroundColor); - const selectionRef = useRef(selectedLayerIds); - const draftRef = useRef(draft); - const lifecycleRef = useRef(lifecycle); - const documentVersionRef = useRef(documentVersion); - const persistedDocumentVersionRef = useRef(0); - const minimumDraftRevisionRef = useRef(draft?.revision ?? 0); - const epochRef = useRef(0); - const dragRef = useRef(null); - const saveQueueRef = useRef>(Promise.resolve()); - const savePromiseRef = useRef | null>(null); - const hostRevisionRef = useRef(expectedHostRevision); - const previewUrlsRef = useRef(new Set()); - const deliveredEventsRef = useRef(new Set()); - const pendingCommitRef = useRef<{ - commitId: string; - idempotencyKey: string; - documentVersion: number; - } | null>(null); - const pendingGenerationRef = useRef(null); - const generationStartingRef = useRef(false); - const generationFocusEpochRef = useRef(0); - const generationStopButtonRef = useRef(null); - const modalInitialFocusRef = useRef(null); - const generationDialogRef = useRef(generationDialog); - const exitDialogOpenRef = useRef(exitDialogOpen); - const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen); - const modalOpen = - generationDialog !== null || exitDialogOpen || serviceIdentityDialogOpen; - const backgroundInteractionLocked = - lifecycle.kind !== 'canvas.editing' || modalOpen; - const backgroundInteractionLockedRef = useRef(backgroundInteractionLocked); - const onWalletBalanceMayHaveChanged = useWalletStore( - (state) => state.onWalletBalanceMayHaveChanged, - ); - const assetSettingsDisabled = backgroundInteractionLocked; - const inheritedUnknownAssetKind = - stableScope.intent === 'refine' && - !ASSET_CANVAS_KIND_OPTIONS.some((option) => option.value === assetKind); - - layersRef.current = layers; - viewportRef.current = viewport; - backgroundRef.current = backgroundColor; - selectionRef.current = selectedLayerIds; - draftRef.current = draft; - lifecycleRef.current = lifecycle; - generationDialogRef.current = generationDialog; - exitDialogOpenRef.current = exitDialogOpen; - serviceIdentityDialogOpenRef.current = serviceIdentityDialogOpen; - backgroundInteractionLockedRef.current = backgroundInteractionLocked; - documentVersionRef.current = documentVersion; - - const markDirty = useCallback(() => { - if (backgroundInteractionLockedRef.current) return; - setDocumentVersion((value) => { - const next = value + 1; - documentVersionRef.current = next; - return next; - }); - setLifecycle({ kind: 'canvas.editing', dirty: true }); - }, []); - - const applyDraftCandidate = useCallback( - (candidate: ImageCanvasDraft) => { - const current = draftRef.current; - if ( - !current || - !shouldApplyAssetCanvasDraftCandidate({ - current, - candidate, - minimumRevision: minimumDraftRevisionRef.current, - scope: stableScope, - }) - ) { - return false; - } - minimumDraftRevisionRef.current = Math.max( - minimumDraftRevisionRef.current, - candidate.revision, - ); - draftRef.current = candidate; - setDraft(candidate); - return true; - }, - [stableScope], - ); - - const applyGenerationProgressRevision = useCallback( - (progress: ImageCanvasGenerationProgress) => { - const revision = progress.draftRevision; - const currentDraft = draftRef.current; - if ( - typeof revision !== 'number' || - !Number.isSafeInteger(revision) || - revision < 0 || - !currentDraft || - revision <= currentDraft.revision || - revision <= minimumDraftRevisionRef.current - ) { - return; - } - const epoch = epochRef.current; - void host.project.loadDraft(stableScope).then((loaded) => { - if ( - epoch !== epochRef.current || - loaded.status !== 'ok' || - !loaded.value - ) { - return; - } - applyDraftCandidate(loaded.value); - }); - }, - [applyDraftCandidate, host.project, stableScope], - ); - - const canvasHistoryRefs = useMemo( - () => ({ - layersRef, - viewportRef, - selectedLayerIdsRef: selectionRef, - }), - [], - ); - const canvasHistorySetters = useMemo( - () => ({ - setLayers: (nextLayers: CanvasLayer[]) => - setLayers(nextLayers as RuntimeCanvasLayer[]), - setViewport, - setSelectedLayerIds, - }), - [], - ); - const { - canUndo, - canRedo, - getCanvasHistorySnapshot, - captureCanvasHistory, - undoCanvasChange, - redoCanvasChange, - resetCanvasHistory, - } = useCanvasHistory({ - refs: canvasHistoryRefs, - setters: canvasHistorySetters, - allowContentRemovalOnRestore: true, - }); - const captureHistory = useCallback( - (action: CanvasHistoryAction, snapshot?: CanvasHistorySnapshot) => - captureCanvasHistory(action, snapshot ? { snapshot } : undefined), - [captureCanvasHistory], - ); - const undo = useCallback(() => { - if (backgroundInteractionLockedRef.current) return; - if (undoCanvasChange().status === 'success') markDirty(); - }, [markDirty, undoCanvasChange]); - const redo = useCallback(() => { - if (backgroundInteractionLockedRef.current) return; - if (redoCanvasChange().status === 'success') markDirty(); - }, [markDirty, redoCanvasChange]); - - const hydrateDraft = useCallback( - async (nextDraft: ImageCanvasDraft, epoch: number) => { - const runtimeLayers = await Promise.all( - nextDraft.canvas.layers.map(async (layer) => { - const preview = await host.readMediaPreview({ - scope: stableScope, - mediaRef: layer.mediaRef, - }); - if (preview.status !== 'ok') { - throw new Error( - preview.status === 'failed' || - preview.status === 'unsupported-capability' - ? preview.message - : '素材媒体读取发生冲突', - ); - } - if (epoch !== epochRef.current) { - URL.revokeObjectURL(preview.value.previewUrl); - throw new Error('stale-asset-canvas-epoch'); - } - previewUrlsRef.current.add(preview.value.previewUrl); - return { - id: layer.layerId, - resourceId: layer.resourceId, - resourcePersistenceState: 'self-contained-local' as const, - title: layer.title, - src: preview.value.previewUrl, - mediaType: 'image' as const, - x: layer.x, - y: layer.y, - width: layer.width, - height: layer.height, - originalWidth: layer.originalWidth, - originalHeight: layer.originalHeight, - zIndex: layer.zIndex, - sourceType: 'uploaded' as const, - groupId: layer.groupId, - hidden: layer.hidden, - locked: layer.locked, - flipX: layer.flipX, - flipY: layer.flipY, - mediaRef: layer.mediaRef, - } satisfies RuntimeCanvasLayer; - }), - ); - if (epoch !== epochRef.current) return; - minimumDraftRevisionRef.current = nextDraft.revision; - draftRef.current = nextDraft; - setDraft(nextDraft); - setLayers(runtimeLayers); - setViewport(nextDraft.canvas.viewport); - setBackgroundColor(nextDraft.canvas.backgroundColor); - setSelectedLayerIds(nextDraft.canvas.selectedLayerIds); - resetCanvasHistory(); - documentVersionRef.current = 0; - persistedDocumentVersionRef.current = 0; - setDocumentVersion(0); - }, - [host, resetCanvasHistory, stableScope], - ); - - useEffect(() => { - const previewUrls = previewUrlsRef.current; - const epoch = epochRef.current + 1; - epochRef.current = epoch; - saveQueueRef.current = Promise.resolve(); - savePromiseRef.current = null; - pendingCommitRef.current = null; - pendingGenerationRef.current = null; - generationStartingRef.current = false; - dragRef.current = null; - minimumDraftRevisionRef.current = 0; - generationFocusEpochRef.current += 1; - hostRevisionRef.current = expectedHostRevision; - deliveredEventsRef.current.clear(); - setLifecycle({ kind: 'canvas.recovering' }); - setNotice(''); - setServiceIdentityConfirmations([]); - setServiceIdentityDialogOpen(false); - setServiceIdentityPending(false); - setServiceIdentityError(''); - let unlisten: (() => void) | undefined; - void (async () => { - const nextUnlisten = await host.subscribeCommitted((event) => { - if ( - epoch !== epochRef.current || - event.projectId !== stableScope.projectId || - event.draftId !== stableScope.draftId || - deliveredEventsRef.current.has(event.eventId) - ) { - return; - } - deliveredEventsRef.current.add(event.eventId); - onCommitted?.({ - source: 'event', - projectPath: event.projectPath, - projectId: event.projectId, - draftId: event.draftId, - commitId: event.commitId, - assetId: event.asset.id, - manifest: event.manifest, - projectRevision: event.committedProjectRevision, - committedProjectRevision: event.committedProjectRevision, - eventId: event.eventId, - }); - }); - if (epoch !== epochRef.current) { - nextUnlisten(); - return; - } - unlisten = nextUnlisten; - const recovery = await host.recover(); - if (epoch !== epochRef.current) return; - if (recovery.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'recovery', - code: recovery.status === 'failed' ? recovery.code : recovery.status, - message: - recovery.status === 'failed' - ? recovery.message - : '素材画布恢复发生冲突', - reconciliationRequired: - recovery.status === 'failed' && - recovery.code === 'reconciliation-required', - }); - return; - } - hostRevisionRef.current = String(recovery.value.projectRevision); - const loaded = await host.project.loadDraft(stableScope); - if (epoch !== epochRef.current) return; - let nextDraft: ImageCanvasDraft; - if (loaded.status === 'ok' && loaded.value) { - nextDraft = loaded.value; - } else if (loaded.status === 'ok') { - setLifecycle({ kind: 'canvas.creating' }); - const created = await host.project.createDraft(stableScope); - if (epoch !== epochRef.current) return; - if (created.status !== 'ok') { - throw new Error( - created.status === 'failed' - ? created.message - : '素材画布草稿创建冲突', - ); - } - nextDraft = created.value; - } else { - throw new Error( - loaded.status === 'failed' ? loaded.message : '素材画布草稿读取冲突', - ); - } - await hydrateDraft(nextDraft, epoch); - if (epoch !== epochRef.current) return; - const recoveryFocusEpoch = generationFocusEpochRef.current; - const result = await host.generation.recoverImages({ - scope: stableScope, - onProgress: (progress) => { - if ( - epoch === epochRef.current && - recoveryFocusEpoch === generationFocusEpochRef.current - ) { - applyGenerationProgressRevision(progress); - setNotice(`正在恢复图片生成:${progress.phase}`); - } - }, - }); - if ( - epoch !== epochRef.current || - recoveryFocusEpoch !== generationFocusEpochRef.current - ) { - return; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'recovery', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '原生成任务恢复发生冲突', - reconciliationRequired: - result.status === 'failed' && - result.code === 'reconciliation-required', - }); - return; - } - if (result.value.resumedGenerationIds.length) { - setNotice( - `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, - ); - } - setServiceIdentityConfirmations( - result.value.serviceIdentityConfirmations, - ); - setServiceIdentityDialogOpen( - result.value.serviceIdentityConfirmations.length > 0, - ); - setServiceIdentityError(''); - setLifecycle({ kind: 'canvas.editing', dirty: false }); - void onWalletBalanceMayHaveChanged(); - })().catch((error: unknown) => { - if ( - epoch === epochRef.current && - !( - error instanceof Error && error.message === 'stale-asset-canvas-epoch' - ) - ) { - setLifecycle({ - kind: 'canvas.failed', - operation: 'recovery', - code: 'canvas-open-failed', - message: error instanceof Error ? error.message : String(error), - reconciliationRequired: false, - }); - } - }); - return () => { - epochRef.current += 1; - generationFocusEpochRef.current += 1; - unlisten?.(); - for (const url of previewUrls) URL.revokeObjectURL(url); - previewUrls.clear(); - }; - }, [ - applyGenerationProgressRevision, - expectedHostRevision, - host, - hydrateDraft, - onCommitted, - onWalletBalanceMayHaveChanged, - stableScope, - sessionId, - recoveryReloadToken, - ]); - - const confirmCurrentGenerationServiceIdentity = useCallback(async () => { - const confirmation = serviceIdentityConfirmations[0]; - if ( - !confirmation || - serviceIdentityPending || - lifecycleRef.current.kind !== 'canvas.editing' || - !serviceIdentityDialogOpenRef.current || - generationDialogRef.current !== null || - exitDialogOpenRef.current - ) { - return; - } - const epoch = epochRef.current; - const focusEpoch = generationFocusEpochRef.current; - setServiceIdentityPending(true); - setServiceIdentityError(''); - const confirmed = await host.confirmGenerationServiceIdentity({ - scope: stableScope, - confirmation, - }); - if ( - epoch !== epochRef.current || - focusEpoch !== generationFocusEpochRef.current - ) { - return; - } - if (confirmed.status !== 'ok') { - setServiceIdentityPending(false); - setServiceIdentityError( - confirmed.status === 'failed' - ? confirmed.message - : '服务身份确认发生冲突,请重新打开画布后再试', - ); - return; - } - const recovery = await host.generation.recoverImages({ - scope: stableScope, - onProgress: (progress) => { - if ( - epoch === epochRef.current && - focusEpoch === generationFocusEpochRef.current - ) { - applyGenerationProgressRevision(progress); - setNotice(`正在恢复图片生成:${progress.phase}`); - } - }, - }); - if ( - epoch !== epochRef.current || - focusEpoch !== generationFocusEpochRef.current - ) { - return; - } - setServiceIdentityPending(false); - if (recovery.status !== 'ok') { - setServiceIdentityError( - recovery.status === 'failed' - ? recovery.message - : '原生成 operation 恢复发生冲突', - ); - return; - } - setServiceIdentityConfirmations( - recovery.value.serviceIdentityConfirmations, - ); - setServiceIdentityDialogOpen( - recovery.value.serviceIdentityConfirmations.length > 0, - ); - setServiceIdentityError(''); - if (recovery.value.resumedGenerationIds.length) { - setNotice( - `已安全恢复 ${recovery.value.resumedGenerationIds.length} 个原生成 operation`, - ); - } - void onWalletBalanceMayHaveChanged(); - }, [ - applyGenerationProgressRevision, - host, - onWalletBalanceMayHaveChanged, - serviceIdentityConfirmations, - serviceIdentityPending, - stableScope, - ]); - - useEffect(() => { - const element = viewportElementRef.current; - if (!element) return undefined; - const update = () => - setCanvasSize({ - width: element.clientWidth || 900, - height: element.clientHeight || 640, - }); - update(); - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - } - const observer = new ResizeObserver(update); - observer.observe(element); - return () => observer.disconnect(); - }, [draft]); - - const persistDraft = - useCallback(async (): Promise => { - const epoch = epochRef.current; - const requestedVersion = documentVersionRef.current; - const task = saveQueueRef.current.then(async () => { - const currentDraft = draftRef.current; - if (!currentDraft || epoch !== epochRef.current) return null; - const result = await host.project.updateDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - status: 'editing', - canvas: draftCanvasFromRuntime( - layersRef.current, - viewportRef.current, - backgroundRef.current, - selectionRef.current, - ), - generations: currentDraft.generations, - }); - if (epoch !== epochRef.current) return null; - if (result.status === 'conflict') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: 'draft-revision-conflict', - message: '草稿已被另一个窗口更新,请重新打开后继续', - reconciliationRequired: false, - }); - return null; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' ? result.message : '草稿保存失败', - reconciliationRequired: false, - }); - return null; - } - applyDraftCandidate(result.value); - persistedDocumentVersionRef.current = Math.max( - persistedDocumentVersionRef.current, - requestedVersion, - ); - if ( - requestedVersion === documentVersionRef.current && - lifecycleRef.current.kind === 'canvas.editing' - ) { - setLifecycle({ kind: 'canvas.editing', dirty: false }); - } - return result.value; - }); - saveQueueRef.current = task.catch(() => undefined); - return await task; - }, [applyDraftCandidate, host.project, stableScope]); - - useEffect(() => { - if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { - return undefined; - } - const timer = window.setTimeout(() => void persistDraft(), 180); - return () => window.clearTimeout(timer); - }, [documentVersion, draft, lifecycle, persistDraft]); - - useEffect(() => { - const onMove = (event: PointerEvent) => { - if (backgroundInteractionLockedRef.current) { - dragRef.current = null; - return; - } - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - if (drag.kind === 'pan') { - const nextViewport = moveViewportFromPan(drag, { - x: event.clientX, - y: event.clientY, - }); - if ( - nextViewport.x === drag.startViewport.x && - nextViewport.y === drag.startViewport.y && - nextViewport.scale === drag.startViewport.scale - ) { - if (drag.changed) { - setViewport({ ...drag.startViewport }); - } - drag.changed = false; - return; - } - drag.changed = true; - setViewport(nextViewport); - return; - } - const deltaX = - (event.clientX - drag.startClientX) / viewportRef.current.scale; - const deltaY = - (event.clientY - drag.startClientY) / viewportRef.current.scale; - if (drag.kind === 'move') { - if ( - (deltaX === 0 && deltaY === 0) || - !drag.startLayers.some( - (layer) => drag.targetIds.includes(layer.id) && !layer.locked, - ) - ) { - if (drag.changed) { - setLayers(drag.startLayers.map((layer) => ({ ...layer }))); - } - drag.changed = false; - return; - } - drag.changed = true; - const transforms = new Map( - drag.startLayers - .filter((layer) => drag.targetIds.includes(layer.id)) - .map((layer) => [ - layer.id, - { x: layer.x + deltaX, y: layer.y + deltaY }, - ]), - ); - setLayers( - transformCanvasLayers( - drag.startLayers, - transforms, - ) as RuntimeCanvasLayer[], - ); - } else { - const layer = drag.startLayers.find((item) => item.id === drag.layerId); - if (!layer) return; - const bounds = resizeCanvasLayerBounds({ - initial: layer, - handle: 'bottom-right', - deltaX, - deltaY, - preserveAspectRatio: !event.shiftKey, - minSize: 8, - }); - if ( - bounds.x === layer.x && - bounds.y === layer.y && - bounds.width === layer.width && - bounds.height === layer.height - ) { - if (drag.changed) { - setLayers(drag.startLayers.map((item) => ({ ...item }))); - } - drag.changed = false; - return; - } - drag.changed = true; - setLayers( - transformCanvasLayers( - drag.startLayers, - new Map([[drag.layerId, bounds]]), - ) as RuntimeCanvasLayer[], - ); - } - }; - const onUp = (event: PointerEvent) => { - if (backgroundInteractionLockedRef.current) { - dragRef.current = null; - return; - } - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - dragRef.current = null; - if (drag.changed) { - captureHistory(drag.historyAction, drag.historySnapshot); - } - if (drag.changed || drag.selectionChanged) { - markDirty(); - } - }; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); - return () => { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - }; - }, [captureHistory, markDirty]); - - const handleImport = useCallback( - async (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []); - event.target.value = ''; - const currentDraft = draftRef.current; - if ( - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current || - !currentDraft || - !files.length - ) - return; - const epoch = epochRef.current; - const images = await Promise.all( - files.map(async (file) => { - const mediaType = mediaTypeForFile(file); - if (!mediaType) throw new Error('只支持 PNG、JPEG 和 WebP 图片'); - return { - name: file.name, - mediaType, - bytes: new Uint8Array(await file.arrayBuffer()), - }; - }), - ); - if ( - epoch !== epochRef.current || - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current - ) { - return; - } - const imported = await host.asset.importImages({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - images, - }); - if ( - epoch !== epochRef.current || - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current - ) { - if (imported.status === 'ok') { - for (const image of imported.value) - URL.revokeObjectURL(image.previewUrl); - } - return; - } - if (imported.status !== 'ok') { - setNotice( - imported.status === 'failed' - ? imported.message - : '本地图片导入发生冲突', - ); - return; - } - captureHistory({ type: 'upload-image', count: files.length }); - const baseZ = layersRef.current.reduce( - (value, layer) => Math.max(value, layer.zIndex), - -1, - ); - const additions = imported.value.map((image, index) => { - previewUrlsRef.current.add(image.previewUrl); - const source = images[index]; - const layerId = crypto.randomUUID(); - return { - id: layerId, - resourceId: image.resourceId ?? `draft:${layerId}`, - resourcePersistenceState: 'self-contained-local' as const, - title: source?.name ?? `图片 ${index + 1}`, - src: image.previewUrl, - mediaType: 'image' as const, - x: 5800 + index * 36, - y: 5800 + index * 36, - width: - image.mediaRef.kind === 'draft-media' - ? image.mediaRef.pixelWidth - : 480, - height: - image.mediaRef.kind === 'draft-media' - ? image.mediaRef.pixelHeight - : 320, - originalWidth: - image.mediaRef.kind === 'draft-media' - ? image.mediaRef.pixelWidth - : 480, - originalHeight: - image.mediaRef.kind === 'draft-media' - ? image.mediaRef.pixelHeight - : 320, - zIndex: baseZ + index + 1, - sourceType: 'uploaded' as const, - groupId: null, - hidden: false, - locked: false, - flipX: false, - flipY: false, - mediaRef: image.mediaRef, - } satisfies RuntimeCanvasLayer; - }); - setLayers((current) => [...current, ...additions]); - setSelectedLayerIds(additions.map((layer) => layer.id)); - markDirty(); - setNotice(`已导入 ${additions.length} 张图片`); - }, - [captureHistory, host.asset, markDirty, stableScope], - ); - - const deleteSelected = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current || - !selectionRef.current.length - ) - return; - captureHistory({ - type: 'delete-image', - count: selectionRef.current.length, - layerIds: [...selectionRef.current], - }); - setLayers( - (current) => - removeCanvasLayers( - current, - selectionRef.current, - ) as RuntimeCanvasLayer[], - ); - setSelectedLayerIds([]); - markDirty(); - }, [captureHistory, markDirty]); - - const saveAsset = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current || - savePromiseRef.current - ) { - return savePromiseRef.current ?? undefined; - } - const saveEpoch = epochRef.current; - const pending = - pendingCommitRef.current?.documentVersion === documentVersion - ? pendingCommitRef.current - : { - commitId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), - documentVersion, - }; - pendingCommitRef.current = pending; - onSaveAttempt?.({ - saveAttemptId: crypto.randomUUID(), - sessionId, - projectId: stableScope.projectId, - draftId: stableScope.draftId, - commitId: pending.commitId, - }); - const task = (async () => { - const epoch = saveEpoch; - if (!draftRef.current) return; - if (documentVersionRef.current !== persistedDocumentVersionRef.current) { - setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); - const persisted = await persistDraft(); - if (!persisted) return; - } - if (epoch !== epochRef.current || !draftRef.current) return; - setLifecycle({ kind: 'canvas.saving', stage: 'staging' }); - const bytes = await renderImage({ - layers: layersRef.current, - backgroundColor: backgroundRef.current, - mediaType: exportMediaType, - quality: exportMediaType === 'image/png' ? null : 0.92, - }); - if (epoch !== epochRef.current) return; - setLifecycle({ kind: 'canvas.saving', stage: 'committing' }); - const result = await host.completion.commitImage({ - scope: stableScope, - expectedHostRevision: hostRevisionRef.current, - expectedDraftRevision: draftRef.current.revision, - commitId: pending.commitId, - idempotencyKey: pending.idempotencyKey, - name: assetName, - assetKind, - referenceResourceIds: - stableScope.intent === 'refine' && draftRef.current.sourceResourceId - ? [draftRef.current.sourceResourceId] - : [], - mediaType: exportMediaType, - bytes, - }); - if (epoch !== epochRef.current) return; - if (result.status !== 'ok') { - const code = result.status === 'failed' ? result.code : result.status; - if (code === 'rolled-back') { - pendingCommitRef.current = null; - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'asset-commit', - code, - message: - result.status === 'failed' - ? result.message - : '素材保存发生 revision 冲突', - reconciliationRequired: code === 'reconciliation-required', - }); - return; - } - hostRevisionRef.current = result.value.hostRevision; - if (draftRef.current) { - const next = { - ...draftRef.current, - revision: result.value.draftRevision, - status: 'committed' as const, - }; - applyDraftCandidate(next); - } - setLifecycle({ kind: 'canvas.saving', stage: 'projecting' }); - const manifest = result.value.manifest as - | GameCreationAppManifest - | undefined; - if (manifest) { - if (result.value.eventId) { - deliveredEventsRef.current.add(result.value.eventId); - } - onCommitted?.({ - source: 'command', - projectPath: host.projectPath, - projectId: result.value.projectId ?? stableScope.projectId, - draftId: stableScope.draftId, - commitId: result.value.commitId ?? pending.commitId, - assetId: result.value.assetId ?? result.value.resourceId, - manifest, - projectRevision: Number(result.value.hostRevision), - committedProjectRevision: - result.value.committedProjectRevision ?? - Number(result.value.hostRevision), - eventId: result.value.eventId, - }); - } - pendingCommitRef.current = null; - persistedDocumentVersionRef.current = documentVersionRef.current; - setNotice( - result.value.commitStatus === 'already-committed' - ? '素材已提交,本次返回原幂等结果' - : '素材已保存到项目 assets/ 并登记 manifest', - ); - setLifecycle({ kind: 'canvas.editing', dirty: false }); - })().catch((error: unknown) => { - if (epochRef.current !== saveEpoch) return; - setLifecycle({ - kind: 'canvas.failed', - operation: 'asset-commit', - code: 'canvas-save-failed', - message: error instanceof Error ? error.message : String(error), - reconciliationRequired: false, - }); - }); - savePromiseRef.current = task.finally(() => { - savePromiseRef.current = null; - }); - return savePromiseRef.current; - }, [ - applyDraftCandidate, - assetKind, - assetName, - documentVersion, - exportMediaType, - host.completion, - host.projectPath, - onCommitted, - onSaveAttempt, - persistDraft, - renderImage, - sessionId, - stableScope, - ]); - - const discardCanvas = useCallback(() => { - const currentDraft = draftRef.current; - if ( - !currentDraft || - !['canvas.editing', 'canvas.failed'].includes( - lifecycleRef.current.kind, - ) || - !exitDialogOpenRef.current || - generationDialogRef.current !== null || - serviceIdentityDialogOpenRef.current - ) { - return; - } - setExitActionPending(true); - const epoch = epochRef.current; - void host.project - .discardDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - }) - .then((result) => { - if (epoch !== epochRef.current) return; - if (result.status === 'ok') { - setExitDialogOpen(false); - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'discarded', - }); - return; - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'cancellation', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '素材画布取消发生 revision 冲突', - reconciliationRequired: false, - }); - setExitDialogOpen(false); - }) - .catch((error: unknown) => { - if (epoch !== epochRef.current) return; - setLifecycle({ - kind: 'canvas.failed', - operation: 'cancellation', - code: 'canvas-cancel-failed', - message: error instanceof Error ? error.message : String(error), - reconciliationRequired: false, - }); - setExitDialogOpen(false); - }) - .finally(() => { - if (epoch === epochRef.current) setExitActionPending(false); - }); - }, [host.project, onCancel, stableScope]); - - const keepDraftAndExit = useCallback(() => { - if ( - exitActionPending || - !['canvas.editing', 'canvas.failed'].includes( - lifecycleRef.current.kind, - ) || - !exitDialogOpenRef.current || - generationDialogRef.current !== null || - serviceIdentityDialogOpenRef.current - ) { - return; - } - const currentDraft = draftRef.current; - if (!currentDraft) return; - setExitActionPending(true); - const epoch = epochRef.current; - void persistDraft() - .then((persisted) => { - if (epoch !== epochRef.current || !persisted) return; - setExitDialogOpen(false); - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'kept', - }); - }) - .finally(() => { - if (epoch === epochRef.current) setExitActionPending(false); - }); - }, [exitActionPending, onCancel, persistDraft, stableScope.draftId]); - - const requestCanvasExit = useCallback(() => { - const currentDraft = draftRef.current; - if ( - lifecycleRef.current.kind === 'canvas.saving' || - lifecycleRef.current.kind === 'canvas.generating' - ) { - return; - } - if (!currentDraft) { - if ( - lifecycleRef.current.kind === 'canvas.failed' && - lifecycleRef.current.operation === 'recovery' - ) { - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'kept', - }); - } - return; - } - if (documentVersionRef.current !== persistedDocumentVersionRef.current) { - setExitDialogOpen(true); - return; - } - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'kept', - }); - }, [onCancel, stableScope.draftId]); - - const openGenerationDialog = useCallback(() => { - const currentDraft = draftRef.current; - if ( - !currentDraft || - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current - ) { - return; - } - const selectedReferences = layersRef.current - .filter((layer) => selectionRef.current.includes(layer.id)) - .map((layer) => layer.resourceId); - if ( - stableScope.intent === 'refine' && - currentDraft.sourceResourceId && - !selectedReferences.includes(currentDraft.sourceResourceId) - ) { - selectedReferences.push(currentDraft.sourceResourceId); - } - pendingGenerationRef.current = { - saveAttemptId: crypto.randomUUID(), - intentId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), - commitId: crypto.randomUUID(), - commitIdempotencyKey: crypto.randomUUID(), - }; - setGenerationReferenceResourceIds([...new Set(selectedReferences)]); - setGenerationDialog('edit'); - setNotice(''); - }, [stableScope.intent]); - - const closeGenerationDialog = useCallback(() => { - if (generationStartingRef.current) return; - pendingGenerationRef.current = null; - setGenerationDialog(null); - }, []); - - const showGenerationConfirmation = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.editing' || - generationDialogRef.current !== 'edit' || - exitDialogOpenRef.current || - serviceIdentityDialogOpenRef.current - ) { - return; - } - if (!generationPrompt.trim()) { - setNotice('请先填写图片提示词'); - return; - } - setGenerationDialog('confirm'); - }, [generationPrompt]); - - const confirmGeneration = useCallback(() => { - if ( - generationStartingRef.current || - lifecycleRef.current.kind !== 'canvas.editing' || - generationDialogRef.current !== 'confirm' || - exitDialogOpenRef.current || - serviceIdentityDialogOpenRef.current - ) { - return; - } - const identity = pendingGenerationRef.current; - const initialDraft = draftRef.current; - const prompt = generationPrompt.trim(); - if (!identity || !initialDraft || !prompt) return; - generationStartingRef.current = true; - const epoch = epochRef.current; - const focusEpoch = generationFocusEpochRef.current + 1; - generationFocusEpochRef.current = focusEpoch; - const frozenReferences = [...generationReferenceResourceIds]; - const frozenAspectRatio = generationAspectRatio; - const frozenImageSize = generationImageSize; - const frozenAssetKind = assetKind; - const frozenAssetName = assetName; - const needsDraftPersist = - documentVersionRef.current !== persistedDocumentVersionRef.current; - setGenerationDialog(null); - dragRef.current = null; - setLifecycle({ - kind: 'canvas.generating', - phase: 'confirmation-required', - }); - const task = (async () => { - if (needsDraftPersist) { - const persisted = await persistDraft(); - if (!persisted) return; - } - const currentDraft = draftRef.current; - if ( - !currentDraft || - epoch !== epochRef.current || - focusEpoch !== generationFocusEpochRef.current - ) { - return; - } - onSaveAttempt?.({ - saveAttemptId: identity.saveAttemptId, - sessionId, - projectId: stableScope.projectId, - draftId: stableScope.draftId, - commitId: identity.commitId, - }); - const onProgress = (progress: ImageCanvasGenerationProgress) => { - if ( - epoch === epochRef.current && - focusEpoch === generationFocusEpochRef.current && - progress.intentId === identity.intentId && - progress.generationId === identity.generationId - ) { - applyGenerationProgressRevision(progress); - setLifecycle({ kind: 'canvas.generating', phase: progress.phase }); - setNotice(progress.errorCode ?? ''); - } - }; - const result = await host.generation.generateImage({ - scope: stableScope, - expectedHostRevision: hostRevisionRef.current, - expectedDraftRevision: currentDraft.revision, - intentId: identity.intentId, - generationId: identity.generationId, - idempotencyKey: identity.idempotencyKey, - commitId: identity.commitId, - commitIdempotencyKey: identity.commitIdempotencyKey, - prompt, - aspectRatio: frozenAspectRatio, - imageSize: frozenImageSize, - assetKind: frozenAssetKind, - assetName: frozenAssetName, - referenceResourceIds: frozenReferences, - onProgress, - }); - void onWalletBalanceMayHaveChanged(); - if ( - epoch !== epochRef.current || - focusEpoch !== generationFocusEpochRef.current - ) { - return; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: - result.status === 'failed' && - result.code === 'reconciliation-required' - ? 'recovery' - : 'generation', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '图片生成发生 revision 冲突', - reconciliationRequired: - result.status === 'failed' && - result.code === 'reconciliation-required', - }); - return; - } - const { commit } = result.value; - hostRevisionRef.current = commit.hostRevision; - if (draftRef.current) { - const nextDraft = { - ...draftRef.current, - revision: commit.draftRevision, - status: 'committed' as const, - generations: [ - ...draftRef.current.generations.filter( - (record) => record.generationId !== identity.generationId, - ), - result.value.generation, - ], - }; - applyDraftCandidate(nextDraft); - } - if (!deliveredEventsRef.current.has(commit.eventId)) { - deliveredEventsRef.current.add(commit.eventId); - onCommitted?.({ - source: 'command', - projectPath: host.projectPath, - projectId: commit.projectId, - draftId: stableScope.draftId, - commitId: commit.commitId, - assetId: commit.assetId, - manifest: commit.manifest as GameCreationAppManifest, - projectRevision: Number(commit.hostRevision), - committedProjectRevision: commit.committedProjectRevision, - eventId: commit.eventId, - }); - } - pendingGenerationRef.current = null; - persistedDocumentVersionRef.current = documentVersionRef.current; - setLifecycle({ kind: 'canvas.editing', dirty: false }); - setNotice('AI 图片已正式提交并进入资源总览'); - })().catch((error: unknown) => { - if ( - epoch === epochRef.current && - focusEpoch === generationFocusEpochRef.current - ) { - setLifecycle({ - kind: 'canvas.failed', - operation: 'generation', - code: 'canvas-generation-failed', - message: error instanceof Error ? error.message : String(error), - reconciliationRequired: false, - }); - } - }); - void task.finally(() => { - if (generationFocusEpochRef.current === focusEpoch) { - generationStartingRef.current = false; - } - }); - }, [ - applyDraftCandidate, - applyGenerationProgressRevision, - assetKind, - assetName, - generationAspectRatio, - generationImageSize, - generationPrompt, - generationReferenceResourceIds, - host.generation, - host.projectPath, - onCommitted, - onSaveAttempt, - onWalletBalanceMayHaveChanged, - persistDraft, - sessionId, - stableScope, - ]); - - const reopenGenerationAfterFailure = useCallback( - (dialog: 'edit' | 'confirm') => { - if ( - lifecycleRef.current.kind !== 'canvas.failed' || - lifecycleRef.current.operation !== 'generation' - ) - return; - pendingGenerationRef.current = { - saveAttemptId: crypto.randomUUID(), - intentId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), - commitId: crypto.randomUUID(), - commitIdempotencyKey: crypto.randomUUID(), - }; - generationStartingRef.current = false; - setNotice(''); - setLifecycle({ kind: 'canvas.editing', dirty: false }); - setGenerationDialog(dialog); - }, - [], - ); - - const retryDraftSaveAfterFailure = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.failed' || - lifecycleRef.current.operation !== 'draft-save' - ) { - return; - } - setLifecycle({ kind: 'canvas.editing', dirty: true }); - }, []); - - const continueAfterCancellationFailure = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.failed' || - lifecycleRef.current.operation !== 'cancellation' - ) { - return; - } - setExitDialogOpen(false); - setLifecycle({ - kind: 'canvas.editing', - dirty: documentVersionRef.current !== persistedDocumentVersionRef.current, - }); - }, []); - - const retryCanvasRecovery = useCallback(() => { - if ( - lifecycleRef.current.kind !== 'canvas.failed' || - !['recovery', 'asset-commit', 'draft-save'].includes( - lifecycleRef.current.operation, - ) - ) { - return; - } - setLifecycle({ kind: 'canvas.recovering' }); - setRecoveryReloadToken((value) => value + 1); - }, []); - - const stopWaitingForGeneration = useCallback(() => { - generationFocusEpochRef.current += 1; - pendingGenerationRef.current = null; - setGenerationDialog(null); - setNotice('生成仍会在后台使用原 operation 安全对账'); - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'kept', - }); - }, [onCancel, stableScope.draftId]); - - const minimapModel = useMemo( - () => createMinimapModel({ layers, viewport, canvasSize }), - [canvasSize, layers, viewport], - ); - const generationInteractionLocked = lifecycle.kind === 'canvas.generating'; - const activeServiceIdentityConfirmation = - serviceIdentityConfirmations[0] ?? null; - - useEffect(() => { - if (generationInteractionLocked) { - dragRef.current = null; - generationStopButtonRef.current?.focus(); - } - }, [generationInteractionLocked]); - - useEffect(() => { - if (modalOpen) { - modalInitialFocusRef.current?.focus(); - } - }, [generationDialog, modalOpen, serviceIdentityDialogOpen]); - - const failurePresentation = - lifecycle.kind === 'canvas.failed' - ? assetCanvasFailurePresentation(lifecycle) - : null; - - if ( - (!draft && lifecycle.kind !== 'canvas.failed') || - lifecycle.kind === 'canvas.recovering' || - lifecycle.kind === 'canvas.creating' - ) { - return ( -
- - {lifecycle.kind === 'canvas.recovering' - ? '正在恢复画布…' - : '正在创建画布…'} - -
- ); - } - - return ( -
-
- - - - - - - - - - - - {activeServiceIdentityConfirmation ? ( - - ) : null} - - - -
- - - - -
- void handleImport(event)} - /> -
- - {generationDialog ? ( -
-
-
- - {generationDialog === 'edit' ? 'AI 图片生成' : '确认图片生成'} - -
- {generationDialog === 'edit' ? ( -
-