diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 368886a8e..bdeae5b95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -64,6 +64,7 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ DIRECT_CODEX_BACKGROUND_ASSET_PATH, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, ]; +const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art"; const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ ("game/index.html", "game-entry", "text/html"), ("game/style.css", "game-style", "text/css"), @@ -1872,7 +1873,7 @@ fn direct_taonier_art_generation_runtime_context( _ => return Err("直连美术生成请求包含未声明的输出路径".to_string()), }; Ok(PlatformArtGenerationRuntimeContext { - agent_id: "direct-codex-art".to_string(), + agent_id: DIRECT_CODEX_ART_AGENT_ID.to_string(), task_id: format!("direct-codex-art-{stage}"), session_id: project_id, run_id: stage.to_string(), @@ -2607,6 +2608,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( json_string_field(resource, "objectKey").as_deref(), &asset_object_id, )?; + emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID); emit_direct_game_creator_progress( root, "art.spritesheet.recovered", @@ -2744,6 +2746,7 @@ async fn generate_direct_taonier_art_asset_at( } Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)), }; + emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID); if generated.asset.local_path != output_path { return Err(format!( "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 03e134242..3d36dd892 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -261,10 +261,11 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, - emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, - generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, + game_creator_agent_runtime_update_event, generate_local_game_draft_at, + install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 659a13c2f..bae479e11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -235,6 +235,18 @@ fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Resu .map_err(|error| format!("发送 manifest 失效事件失败:{error}")) } +pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str) { + let event = GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), + }; + if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() { + let _ = app.emit("game-creator-manifest-invalidated", event); + return; + } + let _ = relay_game_creator_manifest_invalidation(root, agent_id); +} + pub(crate) fn game_creator_agent_runtime_update_event( root: &Path, runtime: AgentRuntimeResult, 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 e6167542d..922f5acb9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -623,6 +623,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, @@ -639,6 +678,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, @@ -662,6 +709,14 @@ pub(crate) async fn generate_local_project_asset_canvas_image( Ok(execution.result) } +#[tauri::command] +pub(crate) async fn finalize_local_project_asset_canvas_generation_failure( + input: FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + finalize_asset_canvas_generation_failure_at(&root, &input).await +} + #[tauri::command] pub(crate) async fn recover_local_project_asset_canvas_generations( app: tauri::AppHandle, @@ -728,6 +783,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/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 752160a6e..8618b0174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) path: String, pub(crate) media_type: String, pub(crate) byte_len: u64, + pub(crate) pixel_width: u32, + pub(crate) pixel_height: u32, pub(crate) data_url: String, } @@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage { pub(crate) sha256: String, pub(crate) byte_len: u64, pub(crate) media_type: &'static str, + pixel_width: u32, + pixel_height: u32, bytes: Vec, } @@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, + pixel_width: image.pixel_width, + pixel_height: image.pixel_height, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation( sha256, byte_len: bytes.len() as u64, media_type, + pixel_width: width, + pixel_height: height, bytes, }) } 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 deb01da59..415bf7454 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2366,8 +2366,11 @@ 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, + finalize_local_project_asset_canvas_generation_failure, + 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, @@ -2375,6 +2378,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 467de49c3..fa913ceec 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 = @@ -1195,18 +1310,35 @@ pub(crate) fn create_asset_canvas_draft_at( .source_asset_id .as_deref() .ok_or_else(|| "refine 草稿必须指定 sourceAssetId".to_string())?; - let (asset, _bytes, width, height) = + let (asset, bytes, width, height) = project_asset_image(root, &manifest, source_asset_id)?; let resource_id = asset_canvas_source_resource_id(asset); + let media_id = new_asset_canvas_token()?; + let media_type = asset.media_type.clone(); + let source_media_ref = AssetCanvasMediaRef::DraftMedia { + media_id: media_id.clone(), + media_type: media_type.clone(), + sha256: asset_canvas_sha256(&bytes), + byte_length: bytes.len() as u64, + pixel_width: width, + pixel_height: height, + }; + let media_path = resolve_local_project_path( + root, + &format!( + "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{}", + input.draft_id, + media_extension(&media_type)? + ), + )?; + install_new_asset_canvas_file(&media_path, &bytes, "精修源图片快照")?; let layer_id = Uuid::new_v4().to_string(); let mut canvas = default_asset_canvas_state(); canvas.layers.push(AssetCanvasLayer { layer_id: layer_id.clone(), resource_id: resource_id.clone(), title: asset.id.clone(), - media_ref: AssetCanvasMediaRef::ProjectAsset { - asset_id: asset.id.clone(), - }, + media_ref: source_media_ref, x: 6000.0 - f64::from(width) / 2.0, y: 6000.0 - f64::from(height) / 2.0, width: f64::from(width), @@ -1241,7 +1373,30 @@ pub(crate) fn create_asset_canvas_draft_at( created_at: now, updated_at: now, }; - write_asset_canvas_draft_locked(root, &draft)?; + if let Err(error) = write_asset_canvas_draft_locked(root, &draft) { + if let Some(source_layer) = draft.canvas.layers.first() { + if let AssetCanvasMediaRef::DraftMedia { + media_id, + media_type, + .. + } = &source_layer.media_ref + { + if let Ok(extension) = media_extension(media_type) { + let media_path = resolve_local_project_path( + root, + &format!( + "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", + draft.draft_id + ), + ); + if let Ok(media_path) = media_path { + let _ = fs::remove_file(media_path); + } + } + } + } + return Err(error); + } Ok(CreateAssetCanvasDraftResult { status: CreateAssetCanvasDraftStatus::Created, draft, @@ -1405,10 +1560,33 @@ pub(crate) fn update_asset_canvas_draft_at( .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; validate_safe_revision(draft.revision, "草稿 revision")?; draft.status = input.status.clone(); - draft.canvas = input.canvas.clone(); - // Generation records are advanced by the Rust generation ledger. A regular - // canvas autosave may carry an older frontend snapshot and must never erase - // accepted/running/reconciliation facts written by the backend. + draft.canvas = protect_candidate_layers_before_frontend_update_at( + root, + &draft, + draft.revision, + &input.canvas, + )?; + // Generation lifecycle facts remain backend-authoritative, while the card + // placement is ordinary canvas state and may be changed by the user. + // Merge only placeholders so an older autosave cannot erase phase, output, + // error, or identity facts written by the generation ledger. + for incoming in &input.generations { + if let Some(current) = draft + .generations + .iter_mut() + .find(|record| record.generation_id == incoming.generation_id) + { + if current.intent_id != incoming.intent_id { + return Err("generationId 已绑定到不同 intentId".to_string()); + } + current.placeholder = incoming.placeholder.clone(); + } else { + if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { + return Err("素材画布生成记录数量已达上限".to_string()); + } + draft.generations.push(incoming.clone()); + } + } draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; Ok(UpdateAssetCanvasDraftResult { @@ -1417,6 +1595,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 +1635,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 +1691,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, @@ -1699,7 +2221,7 @@ fn stage_asset_canvas_image_with_token_at( }) } -fn rebind_asset_canvas_staged_image_revision_at( +fn rebind_asset_canvas_staged_image_revision_locked( root: &Path, staged_image_token: &str, expected_project_id: &str, @@ -1707,11 +2229,6 @@ fn rebind_asset_canvas_staged_image_revision_at( expected_previous_revision: u64, next_revision: u64, ) -> Result<(), String> { - validate_uuid_v4(staged_image_token, "stagedImageToken")?; - validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; - validate_safe_revision(next_revision, "staging 新草稿 revision")?; - validate_asset_canvas_project_identity(root, expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; @@ -1738,6 +2255,29 @@ fn rebind_asset_canvas_staged_image_revision_at( ) } +fn rebind_asset_canvas_staged_image_revision_at( + root: &Path, + staged_image_token: &str, + expected_project_id: &str, + draft_id: &str, + expected_previous_revision: u64, + next_revision: u64, +) -> Result<(), String> { + validate_uuid_v4(staged_image_token, "stagedImageToken")?; + validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; + validate_safe_revision(next_revision, "staging 新草稿 revision")?; + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + staged_image_token, + expected_project_id, + draft_id, + expected_previous_revision, + next_revision, + ) +} + fn draft_media_relative_path( draft_id: &str, media_ref: &AssetCanvasMediaRef, @@ -1824,12 +2364,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 { @@ -2014,6 +2575,7 @@ enum AssetCanvasLedgerStatus { Committed, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -2037,6 +2599,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, @@ -2057,6 +2623,7 @@ enum AssetCanvasTransactionStage { EventAttempted, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -2074,9 +2641,17 @@ 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, + #[serde(default)] + runtime_entry_relative_path: Option, + #[serde(default)] + runtime_entry_refreshed: bool, manifest_before_sha256: String, manifest_after_sha256: String, project_revision_before_sha256: Option, @@ -2100,6 +2675,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], @@ -2227,6 +2804,100 @@ fn write_asset_canvas_journal( ) } +fn read_asset_canvas_transaction_journals( + root: &Path, +) -> Result, String> { + let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/transactions"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err("读取素材画布 transaction 目录失败".to_string()), + }; + let mut journals = Vec::new(); + for entry in entries { + let entry = entry.map_err(|_| "读取素材画布 transaction 失败".to_string())?; + if journals.len() >= ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES { + return Err("素材画布 transaction 数量超限".to_string()); + } + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|_| "读取素材画布 transaction 元数据失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("素材画布 transaction 必须是普通目录".to_string()); + } + let commit_id = entry.file_name().to_string_lossy().into_owned(); + validate_uuid_v4(&commit_id, "transaction commitId")?; + let Some(journal) = read_asset_canvas_journal(root, &commit_id)? else { + if read_asset_canvas_ledger(root, &commit_id)?.is_some() { + return Err("素材画布 transaction 缺少 journal".to_string()); + } + continue; + }; + if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION + || journal.commit_id != commit_id + { + return Err("素材画布 transaction journal 身份无效".to_string()); + } + journals.push(journal); + } + Ok(journals) +} + +fn asset_canvas_journal_ledger_identity_matches( + journal: &AssetCanvasTransactionJournal, + ledger: &AssetCanvasCommitLedger, +) -> bool { + ledger.schema_version == ASSET_CANVAS_COMMIT_SCHEMA_VERSION + && ledger.project_id == journal.project_id + && ledger.draft_id == journal.draft_id + && ledger.commit_id == journal.commit_id + && ledger.idempotency_key == journal.idempotency_key + && ledger.request_fingerprint == journal.request_fingerprint + && ledger.expected_project_revision == journal.expected_project_revision + && ledger.expected_draft_revision == journal.expected_draft_revision + && ledger.source_layer_id == journal.source_layer_id + && ledger.media_sha256 == journal.media_sha256 + && ledger.event_id == journal.event_id +} + +fn ensure_no_unresolved_asset_canvas_commit_for_asset( + root: &Path, + project_id: &str, + draft_id: &str, + asset_id: &str, + except_commit_id: &str, +) -> Result<(), String> { + for journal in read_asset_canvas_transaction_journals(root)? { + if journal.project_id != project_id + || journal.draft_id != draft_id + || journal.asset_id != asset_id + || journal.commit_id == except_commit_id + { + continue; + } + let ledger = read_asset_canvas_ledger(root, &journal.commit_id)?; + let unresolved = match ledger.as_ref() { + None => true, + Some(ledger) => { + if !asset_canvas_journal_ledger_identity_matches(&journal, ledger) { + return Err("素材画布 journal 与 ledger 身份不一致".to_string()); + } + matches!( + ledger.status, + AssetCanvasLedgerStatus::Prepared + | AssetCanvasLedgerStatus::ReconciliationRequired + ) + } + }; + if unresolved { + return Err( + "该资源存在未收尾的正式图提交,必须先安全恢复或完成对账后才能设为正式图" + .to_string(), + ); + } + } + Ok(()) +} + fn find_ledger_by_idempotency_key( root: &Path, key: &str, @@ -2373,6 +3044,157 @@ fn asset_canvas_conflict( } } +fn replace_asset_canvas_runtime_entry( + 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} 目录失败"))?; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("{label} 必须是普通文件")); + } + } + let temp_path = path.with_file_name(format!(".asset-canvas-runtime-{}.tmp", Uuid::new_v4())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&temp_path) + .map_err(|_| format!("创建 {label} 临时文件失败"))?; + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&temp_path); + return Err(format!("写入 {label} 临时文件失败:{error}")); + } + drop(file); + fs::rename(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + 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(()) +} + +fn asset_canvas_runtime_entry_path( + root: &Path, + manifest_before: &GameCreationAppManifest, + asset_id: &str, +) -> Result, String> { + let mut historical = None; + for journal in read_asset_canvas_transaction_journals(root)? { + if journal.asset_id != asset_id { + continue; + } + let candidate_path = match journal.runtime_entry_relative_path.clone() { + Some(path) => Some(path), + None => { + let journal_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot( + root, + &journal.commit_id, + "manifest.before.json", + &journal.manifest_before_sha256, + )?; + journal_manifest_before + .assets + .iter() + .find(|asset| asset.id == asset_id) + .map(|asset| asset.local_path.clone()) + .filter(|path| !path.starts_with("assets/canvas/")) + } + }; + let Some(path) = candidate_path else { + continue; + }; + let is_newer = historical + .as_ref() + .is_none_or(|(_, revision): &(String, u64)| { + journal.target_project_revision > *revision + }); + if is_newer { + historical = Some((path, journal.target_project_revision)); + } + } + if let Some((path, _)) = historical { + return Ok(Some(path)); + } + + let Some(source) = manifest_before + .assets + .iter() + .find(|asset| asset.id == asset_id) + else { + return Ok(None); + }; + if source.local_path.starts_with("assets/canvas/") { + return Ok(None); + } + resolve_local_project_path(root, &source.local_path)?; + Ok(Some(source.local_path.clone())) +} + +fn runtime_entry_matches_journal( + root: &Path, + journal: &AssetCanvasTransactionJournal, +) -> Result { + let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { + return Ok(true); + }; + let path = resolve_local_project_path(root, relative)?; + if !path.is_file() { + return Ok(false); + } + Ok(open_and_validate_image_file( + &path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + ) + .is_ok_and(|(bytes, width, height)| { + bytes.len() as u64 == journal.staged_image.byte_length + && width == journal.staged_image.pixel_width + && height == journal.staged_image.pixel_height + })) +} + +fn refresh_runtime_entry_for_journal( + root: &Path, + journal: &mut AssetCanvasTransactionJournal, +) -> Result<(), String> { + let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { + return Ok(()); + }; + if runtime_entry_matches_journal(root, journal)? { + if journal.runtime_entry_refreshed { + return Ok(()); + } + journal.runtime_entry_refreshed = true; + journal.updated_at = asset_canvas_now(); + return write_asset_canvas_journal(root, journal); + } + let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + let (bytes, _, _) = open_and_validate_image_file( + &final_path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + )?; + let runtime_path = resolve_local_project_path(root, relative)?; + replace_asset_canvas_runtime_entry(&runtime_path, &bytes, "素材运行入口")?; + journal.runtime_entry_refreshed = true; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, journal) +} + fn verify_committed_asset_canvas_state( root: &Path, journal: &AssetCanvasTransactionJournal, @@ -2414,10 +3236,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()); } @@ -2541,6 +3364,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)?; } @@ -2613,6 +3447,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, @@ -2632,6 +3468,9 @@ fn commit_asset_canvas_at_internal( return Err("commitId 或 idempotencyKey 已绑定到不同提交请求".to_string()); } if ledger.status == AssetCanvasLedgerStatus::Committed { + if let Some(mut journal) = read_asset_canvas_journal(root, &input.commit_id)? { + refresh_runtime_entry_for_journal(root, &mut journal)?; + } return committed_result_from_ledger(root, ledger, manifest, current_revision.revision); } if matches!( @@ -2682,6 +3521,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 { @@ -2720,8 +3589,23 @@ 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)); + ensure_no_unresolved_asset_canvas_commit_for_asset( + root, + &manifest.project_id, + &draft.draft_id, + &asset_id, + &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)?; @@ -2730,43 +3614,60 @@ 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 runtime_entry_relative_path = if input.intent == AssetCanvasIntent::Refine { + asset_canvas_runtime_entry_path(root, &manifest_before, &asset_id)? + } else { + None }; - let asset = GameCreationAppAssetManifestEntry { - id: asset_id.clone(), - kind: asset_kind, - media_type: staged.media_type.clone(), - local_path: final_relative_path.clone(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source, + 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(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + source, + }; + manifest.assets.push(asset.clone()); + asset }; - manifest.assets.push(asset.clone()); let mut target_revision_state = current_revision.clone(); target_revision_state.revision = target_project_revision; @@ -2801,6 +3702,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, @@ -2821,9 +3724,13 @@ 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, + runtime_entry_relative_path: runtime_entry_relative_path.clone(), + runtime_entry_refreshed: false, manifest_before_sha256: asset_canvas_sha256(&manifest_before_bytes), manifest_after_sha256: asset_canvas_sha256(&manifest_after_bytes), project_revision_before_sha256: Some(asset_canvas_sha256(&revision_before_bytes)), @@ -2900,7 +3807,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(), @@ -2908,12 +3815,15 @@ 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)?; journal.stage = AssetCanvasTransactionStage::Committed; journal.updated_at = asset_canvas_now(); write_asset_canvas_journal(root, &journal)?; + refresh_runtime_entry_for_journal(root, &mut journal)?; Ok(CommitAssetCanvasExecution { result: CommitAssetCanvasResult::Committed { @@ -2939,6 +3849,101 @@ 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 { + let _name = validate_asset_canvas_name(&input.name)?; + let _asset_kind = validate_asset_kind(&input.asset_kind)?; + 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, @@ -2992,6 +3997,7 @@ pub(crate) enum RecoverAssetCanvasOutcomeStatus { AlreadyCommitted, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -3090,6 +4096,134 @@ fn final_image_state_for_journal( } } +fn find_superseding_asset_canvas_commit( + root: &Path, + journal: &AssetCanvasTransactionJournal, + current_manifest: &GameCreationAppManifest, + current_manifest_sha256: &str, + current_revision: &AgentRuntimeProjectRevision, + current_revision_sha256: &str, +) -> Result { + if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches { + return Ok(false); + } + for later in read_asset_canvas_transaction_journals(root)? { + if later.commit_id == journal.commit_id + || later.project_id != journal.project_id + || later.draft_id != journal.draft_id + || later.asset_id != journal.asset_id + { + continue; + } + let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else { + continue; + }; + if !asset_canvas_journal_ledger_identity_matches(&later, &ledger) + || ledger.status != AssetCanvasLedgerStatus::Committed + || ledger.committed_project_revision != Some(later.target_project_revision) + || ledger.committed_draft_revision != Some(later.target_draft_revision) + || ledger.asset_id.as_deref() != Some(later.asset_id.as_str()) + || ledger.event_payload.is_none() + { + continue; + } + if later.expected_project_revision != journal.target_project_revision + || later.manifest_before_sha256 != journal.manifest_after_sha256 + || later.project_revision_before_sha256.as_deref() + != Some(journal.project_revision_after_sha256.as_str()) + || later.final_image_relative_path == journal.final_image_relative_path + || !matches!( + later.stage, + AssetCanvasTransactionStage::Committed + | AssetCanvasTransactionStage::EventAttempted + ) + { + continue; + } + + let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot( + root, + &later.commit_id, + "manifest.before.json", + &later.manifest_before_sha256, + )?; + let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( + root, + &later.commit_id, + "project-revision.before.json", + later + .project_revision_before_sha256 + .as_deref() + .ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?, + )?; + let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( + root, + &later.commit_id, + "manifest.after.json", + &later.manifest_after_sha256, + )?; + let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( + root, + &later.commit_id, + "project-revision.after.json", + &later.project_revision_after_sha256, + )?; + let later_manifest_before_sha256 = + asset_canvas_sha256(&asset_canvas_json_bytes(&later_manifest_before)?); + let later_revision_before_sha256 = + asset_canvas_sha256(&asset_canvas_json_bytes(&later_revision_before)?); + if later_manifest_before_sha256 != journal.manifest_after_sha256 + || later_revision_before_sha256 != journal.project_revision_after_sha256 + || current_manifest != &later_manifest_after + || current_manifest_sha256 != later.manifest_after_sha256 + || current_revision != &later_revision_after + || current_revision_sha256 != later.project_revision_after_sha256 + || current_revision.revision != later.target_project_revision + || later_revision_after.revision != later.target_project_revision + || later.target_project_revision != later.expected_project_revision.saturating_add(1) + || final_image_state_for_journal(root, &later)? != AssetCanvasFinalImageState::Matches + { + continue; + } + + let matching_assets = current_manifest + .assets + .iter() + .filter(|asset| asset.id == later.asset_id) + .collect::>(); + if matching_assets.len() != 1 { + continue; + } + let asset = matching_assets[0]; + if asset.local_path != later.final_image_relative_path + || asset.media_type != later.staged_image.media_type + { + continue; + } + return Ok(true); + } + Ok(false) +} + +fn mark_asset_canvas_superseded_locked( + root: &Path, + mut journal: AssetCanvasTransactionJournal, + mut ledger: AssetCanvasCommitLedger, +) -> Result { + journal.stage = AssetCanvasTransactionStage::Superseded; + journal.updated_at = asset_canvas_now(); + ledger.status = AssetCanvasLedgerStatus::Superseded; + ledger.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, &journal)?; + write_asset_canvas_ledger(root, &ledger)?; + Ok(RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::Superseded, + event_id: Some(journal.event_id), + asset_id: None, + }) +} + fn mark_asset_canvas_reconciliation_locked( root: &Path, mut journal: AssetCanvasTransactionJournal, @@ -3158,24 +4292,36 @@ 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)?; } + refresh_runtime_entry_for_journal(root, &mut journal)?; journal.stage = AssetCanvasTransactionStage::Committed; journal.updated_at = asset_canvas_now(); write_asset_canvas_journal(root, &journal)?; @@ -3192,7 +4338,7 @@ fn finish_recovered_asset_canvas_commit_locked( fn recover_asset_canvas_transaction_locked( root: &Path, - journal: AssetCanvasTransactionJournal, + mut journal: AssetCanvasTransactionJournal, ) -> Result<(RecoverAssetCanvasOutcome, Option), String> { if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION { return Err("不支持的素材画布 transaction schema".to_string()); @@ -3204,6 +4350,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()); } @@ -3224,6 +4372,33 @@ fn recover_asset_canvas_transaction_locked( let final_image_state = final_image_state_for_journal(root, &journal)?; let file_matches = final_image_state == AssetCanvasFinalImageState::Matches; + if ledger.status == AssetCanvasLedgerStatus::Superseded + && journal.stage == AssetCanvasTransactionStage::Superseded + { + return Ok(( + RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::Superseded, + event_id: Some(journal.event_id), + asset_id: None, + }, + None, + )); + } + if ledger.status == AssetCanvasLedgerStatus::RolledBack + && journal.stage == AssetCanvasTransactionStage::RolledBack + { + return Ok(( + RecoverAssetCanvasOutcome { + commit_id: journal.commit_id, + status: RecoverAssetCanvasOutcomeStatus::RolledBack, + event_id: Some(journal.event_id), + asset_id: None, + }, + None, + )); + } + if ledger.status == AssetCanvasLedgerStatus::Committed { let asset_present = current_manifest.assets.iter().any(|asset| { asset.id == journal.asset_id && asset.local_path == journal.final_image_relative_path @@ -3232,27 +4407,42 @@ fn recover_asset_canvas_transaction_locked( && asset_present && current_revision.revision >= journal.target_project_revision { + refresh_runtime_entry_for_journal(root, &mut journal)?; 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)?; } @@ -3329,6 +4519,31 @@ fn recover_asset_canvas_transaction_locked( ) .map(|(outcome, event)| (outcome, Some(event))); } + let unresolved = matches!( + journal.stage, + AssetCanvasTransactionStage::Prepared + | AssetCanvasTransactionStage::FileInstalled + | AssetCanvasTransactionStage::ManifestInstalled + | AssetCanvasTransactionStage::RevisionInstalled + | AssetCanvasTransactionStage::Verified + | AssetCanvasTransactionStage::ReconciliationRequired + ) && matches!( + ledger.status, + AssetCanvasLedgerStatus::Prepared | AssetCanvasLedgerStatus::ReconciliationRequired + ); + if unresolved + && find_superseding_asset_canvas_commit( + root, + &journal, + ¤t_manifest, + ¤t_manifest_sha, + ¤t_revision, + ¤t_revision_sha, + )? + { + return mark_asset_canvas_superseded_locked(root, journal, ledger) + .map(|outcome| (outcome, None)); + } mark_asset_canvas_reconciliation_locked(root, journal, ledger).map(|outcome| (outcome, None)) } 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 44ddd69b6..ee97bb107 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 @@ -69,6 +69,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)] @@ -79,6 +83,45 @@ 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, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureInput { + 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, + pub(crate) intent_id: String, + pub(crate) error_code: String, + pub(crate) reconciliation_required: bool, +} + +#[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, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureResult { + pub(crate) disposition: String, + pub(crate) draft: AssetCanvasDraft, +} + #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { @@ -105,6 +148,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 { @@ -124,8 +174,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 { @@ -176,8 +228,14 @@ enum GenerationLedgerPhase { Running, RemoteCompleted, MediaDownloaded, + CandidateReady, + AssetCommitSettlementPending, AssetDurableCommitted, + FailureSettlementPending, + ReconciliationSettlementPending, Failed, + ArchivePending, + Archived, ReconciliationRequired, } @@ -191,8 +249,14 @@ impl GenerationLedgerPhase { Self::Running => "running", Self::RemoteCompleted => "remote-completed", Self::MediaDownloaded => "media-downloaded", + Self::CandidateReady => "candidate-ready", + Self::AssetCommitSettlementPending => "asset-commit-settlement-pending", Self::AssetDurableCommitted => "asset-durable-committed", + Self::FailureSettlementPending => "failure-settlement-pending", + Self::ReconciliationSettlementPending => "reconciliation-settlement-pending", Self::Failed => "failed", + Self::ArchivePending => "archive-pending", + Self::Archived => "archived", Self::ReconciliationRequired => "reconciliation-required", } } @@ -267,7 +331,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, @@ -277,6 +341,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, @@ -295,6 +363,8 @@ struct AssetCanvasGenerationLedger { reference_states: Vec, resolved_reference_ids: Vec, #[serde(default)] + #[serde(alias = "canonicalReferenceRepairAttempted")] + reference_binding_repair_attempted: bool, access_scheme: Option, #[serde(default)] api_identity_scheme: Option, @@ -317,8 +387,16 @@ struct AssetCanvasGenerationLedger { staged_image_token: String, #[serde(default)] staged_draft_revision: Option, + #[serde(default)] + candidate_media_ref: Option, + #[serde(default)] + candidate_layer_id: Option, + #[serde(default)] + candidate_layer_acknowledged_revision: Option, commit_result: Option, error_code: Option, + #[serde(default)] + archived_at: Option, created_at: u64, updated_at: u64, } @@ -330,6 +408,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, @@ -793,10 +873,16 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option { Some(AssetCanvasGenerationStatus::MediaDownloaded) } + GenerationLedgerPhase::CandidateReady => Some(AssetCanvasGenerationStatus::CandidateReady), + GenerationLedgerPhase::AssetCommitSettlementPending => None, GenerationLedgerPhase::AssetDurableCommitted => { Some(AssetCanvasGenerationStatus::AssetDurableCommitted) } + GenerationLedgerPhase::FailureSettlementPending + | GenerationLedgerPhase::ReconciliationSettlementPending => None, GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed), + GenerationLedgerPhase::ArchivePending => None, + GenerationLedgerPhase::Archived => None, GenerationLedgerPhase::ReconciliationRequired => { Some(AssetCanvasGenerationStatus::ReconciliationRequired) } @@ -812,6 +898,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", @@ -824,6 +911,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, @@ -855,6 +943,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 @@ -927,6 +1021,32 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() return Err("素材画布私有生成参考状态无效".to_string()); } } + match (&ledger.phase, ledger.archived_at) { + ( + GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived, + Some(archived_at), + ) => { + validate_safe_revision(archived_at, "archivedAt")?; + } + (GenerationLedgerPhase::ArchivePending, None) => { + return Err("待归档的素材画布生成账本缺少 archivedAt".to_string()); + } + (GenerationLedgerPhase::Archived, None) => { + return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); + } + (_, Some(_)) => { + return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string()); + } + (_, None) => {} + } + if matches!( + ledger.phase, + GenerationLedgerPhase::FailureSettlementPending + | GenerationLedgerPhase::ReconciliationSettlementPending + ) && ledger.error_code.is_none() + { + return Err("待结算的素材画布生成账本缺少 errorCode".to_string()); + } if let (Some(body), Some(expected_sha)) = ( ledger.request_body_json.as_deref(), ledger.request_body_sha256.as_deref(), @@ -1070,13 +1190,12 @@ fn ensure_generation_draft_active( validate_generation_draft_active(ledger, &draft) } -fn upsert_public_generation_record( +fn upsert_public_generation_record_locked( root: &Path, ledger: &AssetCanvasGenerationLedger, ) -> Result<(AssetCanvasGenerationRecord, u64), String> { let phase = public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?; - 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())?; validate_generation_draft_for_public_phase(ledger, &draft)?; @@ -1090,12 +1209,18 @@ fn upsert_public_generation_record( .generations .iter() .position(|record| record.generation_id == ledger.generation_id); + let placeholder = index + .and_then(|index| draft.generations.get(index)) + .and_then(|record| record.placeholder.clone()) + .or_else(|| ledger.placeholder.clone()); let record = AssetCanvasGenerationRecord { generation_id: ledger.generation_id.clone(), intent_id: ledger.intent_id.clone(), phase, reference_resource_ids: references, output_asset_id, + source_layer_id: ledger.source_layer_id.clone(), + placeholder, error_code: ledger.error_code.clone(), created_at: index .and_then(|index| draft.generations.get(index)) @@ -1122,7 +1247,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 @@ -1135,6 +1263,14 @@ fn upsert_public_generation_record( Ok((record, draft.revision)) } +fn upsert_public_generation_record( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, u64), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + upsert_public_generation_record_locked(root, ledger) +} + fn publish_public_phase( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1200,6 +1336,392 @@ 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); + ledger.candidate_layer_acknowledged_revision = None; + write_generation_ledger(root, ledger)?; + Ok(( + record, + draft, + AssetCanvasGeneratedImage { + media_ref, + resource_id, + }, + )) +} + +pub(crate) fn protect_candidate_layers_before_frontend_update_at( + root: &Path, + current_draft: &AssetCanvasDraft, + next_revision: u64, + incoming_canvas: &AssetCanvasState, +) -> Result { + let mut merged_canvas = incoming_canvas.clone(); + for record in ¤t_draft.generations { + if record.phase != AssetCanvasGenerationStatus::CandidateReady { + continue; + } + let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else { + continue; + }; + if ledger.project_id != current_draft.project_id + || ledger.draft_id != current_draft.draft_id + || ledger.generation_id != record.generation_id + || ledger.candidate_layer_acknowledged_revision.is_some() + { + continue; + } + let Some(layer_id) = ledger.candidate_layer_id.clone() else { + continue; + }; + let Some(candidate_layer) = current_draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == layer_id) + else { + continue; + }; + if merged_canvas + .layers + .iter() + .any(|layer| layer.layer_id == layer_id) + { + ledger.candidate_layer_acknowledged_revision = Some(next_revision); + write_generation_ledger(root, &mut ledger)?; + } else { + merged_canvas.layers.push(candidate_layer.clone()); + } + } + Ok(merged_canvas) +} + +pub(crate) fn restore_unacknowledged_candidate_layers_at( + root: &Path, + expected_project_id: &str, + draft_id: &str, +) -> Result, String> { + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.project_id != manifest.project_id || draft.draft_id != draft_id { + return Err("素材画布候选恢复身份不一致".to_string()); + } + + let mut changed = false; + for record in &draft.generations { + if record.phase != AssetCanvasGenerationStatus::CandidateReady { + continue; + } + let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else { + continue; + }; + if ledger.project_id != draft.project_id + || ledger.draft_id != draft.draft_id + || ledger.generation_id != record.generation_id + || ledger.candidate_layer_acknowledged_revision.is_some() + { + continue; + } + let Some(layer_id) = ledger.candidate_layer_id.as_deref() else { + continue; + }; + if draft + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == layer_id) + { + continue; + } + let AssetCanvasMediaRef::DraftMedia { + media_id, + pixel_width, + pixel_height, + .. + } = ledger + .candidate_media_ref + .as_ref() + .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())? + else { + continue; + }; + let (x, y, width, height) = ledger + .placeholder + .as_ref() + .map(|placeholder| { + ( + placeholder.x, + placeholder.y, + placeholder.width, + placeholder.height, + ) + }) + .unwrap_or_else(|| { + ( + 6000.0 - f64::from(*pixel_width) / 2.0, + 6000.0 - f64::from(*pixel_height) / 2.0, + f64::from(*pixel_width), + f64::from(*pixel_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.to_string(), + resource_id: format!("draft-media:{media_id}"), + title: format!("{} 候选", ledger.asset_name), + media_ref: ledger + .candidate_media_ref + .clone() + .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?, + x, + y, + width, + height, + original_width: f64::from(*pixel_width), + original_height: f64::from(*pixel_height), + z_index, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + draft.canvas.selected_layer_ids = vec![layer_id.to_string()]; + draft.canvas.primary_selected_layer_id = Some(layer_id.to_string()); + changed = true; + } + + if !changed { + return Ok(None); + } + 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(Some(draft)) +} + fn normalized_request_fingerprint( input: &GenerateAssetCanvasImageInput, manifest: &GameCreationAppManifest, @@ -1214,6 +1736,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, @@ -1307,11 +1831,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 @@ -1372,6 +1913,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(), @@ -1388,6 +1931,7 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + reference_binding_repair_attempted: false, access_scheme: None, api_identity_scheme: None, api_identity_fingerprint: None, @@ -1404,12 +1948,20 @@ 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, + candidate_layer_acknowledged_revision: None, commit_result: None, error_code: None, + archived_at: None, created_at: now, updated_at: now, }; - initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; + if api_base_url.is_empty() && platform_session.is_none() { + write_generation_ledger(root, &mut ledger)?; + } else { + initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; + } Ok(ledger) } @@ -1497,7 +2049,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, @@ -1657,12 +2209,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, resource_binding_key: &str, @@ -1677,14 +2249,33 @@ async fn register_uploaded_image_reference( let height = material .height .ok_or(ReferencePreparationError::MaterialInvalid)?; - let asset_kind = material - .asset_kind - .as_deref() - .unwrap_or(ledger.asset_kind.as_str()); let endpoint = format!( "/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": "game-creator-account-binding", + "operationId": ledger.generation_id, + "localAssetId": material.local_asset_id, + "sourceSha256": material.sha256, + }, + }); + let registered_asset_kind = + editor_reference_asset_kind(source_asset_kind).unwrap_or(source_asset_kind); + request_body + .as_object_mut() + .ok_or(ReferencePreparationError::Internal)? + .insert( + "assetKind".to_string(), + serde_json::json!(registered_asset_kind), + ); let payload = external_editor_json_request( authorize_canvas_request( client @@ -1696,20 +2287,7 @@ async fn register_uploaded_image_reference( "Idempotency-Key", format!("game-creator-resource-{resource_binding_key}"), ) - .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": asset_kind, - "generationInputs": { - "source": "game-creator-account-binding", - "localAssetId": material.local_asset_id, - "sourceSha256": material.sha256, - }, - })), + .json(&request_body), api_mode, ), "登记参考图片项目资源", @@ -1723,6 +2301,55 @@ async fn register_uploaded_image_reference( json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) } +fn invalidate_reference_binding_for_ledger( + root: &Path, + ledger: &AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> 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 source_identity = new_external_editor_source_identity( + material + .local_asset_id + .as_deref() + .ok_or_else(|| "精修生成缺少本地源素材 ID".to_string())?, + material + .sha256 + .as_deref() + .ok_or_else(|| "精修生成缺少本地源摘要".to_string())?, + material + .media_type + .as_deref() + .ok_or_else(|| "精修生成缺少本地源媒体类型".to_string())?, + material + .asset_kind + .as_deref() + .ok_or_else(|| "精修生成缺少本地源素材类型".to_string())?, + )?; + let context = ledger + .canvas_context + .as_ref() + .ok_or_else(|| "精修生成缺少 External canvas context".to_string())?; + let access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; + access.validate_frozen_session()?; + let principal = external_editor_binding_principal(&access)?; + invalidate_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &context.project_id, + &source_identity, + ) +} async fn ensure_reference_states( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1783,6 +2410,11 @@ async fn ensure_reference_states( (None, _, _, None) => None, _ => return Err(ReferencePreparationError::MaterialInvalid), }; + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()) + .to_string(); let index = match ledger .reference_states .iter() @@ -1869,11 +2501,8 @@ async fn ensure_reference_states( { continue; } - // A legacy prepared refine ledger may carry an objectKey. It cannot - // be sent as sourceReferenceId, so re-register it before submission. - ledger.reference_states[index].stable_reference = None; - write_generation_ledger(root, ledger)?; } + 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; @@ -1910,6 +2539,7 @@ async fn ensure_reference_states( api_mode, ledger, &material, + &source_asset_kind, &object_key, &asset_object_id, resource_binding_key @@ -1995,6 +2625,7 @@ async fn ensure_reference_states( api_mode, ledger, &material, + &source_asset_kind, &ticket.object_key, &asset_object_id, resource_binding_key @@ -2036,6 +2667,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 @@ -2145,6 +2777,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), @@ -2216,6 +2852,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, @@ -2229,7 +2867,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(), @@ -2242,7 +2882,272 @@ fn committed_execution_from_private_result( commit_status: committed.commit_status.clone(), manifest, event_id: committed.event_id.clone(), - }, + }), + }, + event: None, + }) +} + +fn private_commit_result_from_execution( + execution: &CommitAssetCanvasExecution, +) -> Result { + match &execution.result { + CommitAssetCanvasResult::Committed { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "committed".to_string(), + event_id: event_id.clone(), + }), + CommitAssetCanvasResult::AlreadyCommitted { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "already-committed".to_string(), + event_id: event_id.clone(), + }), + _ => Err("commit-reconciliation-required".to_string()), + } +} + +fn validate_asset_commit_settlement_evidence( + root: &Path, + generation_ledger: &AssetCanvasGenerationLedger, + committed: &PrivateCommitResult, + manifest: &GameCreationAppManifest, +) -> Result<(), String> { + let invalid = || "待提交结算的生成账本 commit 回执与阶段三事务不一致".to_string(); + if committed.project_id != generation_ledger.project_id + || committed.commit_id != generation_ledger.commit_id + || !matches!( + committed.commit_status.as_str(), + "committed" | "already-committed" + ) + || committed.host_revision < committed.committed_project_revision + { + return Err(invalid()); + } + let commit_ledger = + read_asset_canvas_ledger(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + let committed_draft_revision = commit_ledger.committed_draft_revision.ok_or_else(invalid)?; + let committed_project_revision = commit_ledger + .committed_project_revision + .ok_or_else(invalid)?; + if commit_ledger.status != AssetCanvasLedgerStatus::Committed + || commit_ledger.project_id != generation_ledger.project_id + || commit_ledger.draft_id != generation_ledger.draft_id + || commit_ledger.commit_id != generation_ledger.commit_id + || commit_ledger.idempotency_key != generation_ledger.commit_idempotency_key + || commit_ledger.expected_project_revision != generation_ledger.expected_host_revision + || committed_project_revision != committed.committed_project_revision + || commit_ledger.expected_project_revision.checked_add(1) + != Some(committed_project_revision) + || committed_draft_revision != committed.draft_revision + || commit_ledger.expected_draft_revision.checked_add(1) != Some(committed_draft_revision) + || commit_ledger.asset_id.as_deref() != Some(committed.asset_id.as_str()) + || commit_ledger.event_id != committed.event_id + { + return Err(invalid()); + } + let journal = + read_asset_canvas_journal(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + if !matches!( + journal.stage, + AssetCanvasTransactionStage::Committed | AssetCanvasTransactionStage::EventAttempted + ) || journal.project_id != commit_ledger.project_id + || journal.draft_id != commit_ledger.draft_id + || journal.commit_id != commit_ledger.commit_id + || journal.idempotency_key != commit_ledger.idempotency_key + || journal.request_fingerprint != commit_ledger.request_fingerprint + || journal.expected_project_revision != commit_ledger.expected_project_revision + || journal.target_project_revision != committed_project_revision + || journal.expected_draft_revision != commit_ledger.expected_draft_revision + || journal.target_draft_revision != committed_draft_revision + || journal.asset_id != committed.asset_id + || journal.event_id != committed.event_id + { + return Err(invalid()); + } + let mut assets = manifest + .assets + .iter() + .filter(|asset| asset.id == committed.asset_id); + let asset = assets.next().ok_or_else(invalid)?; + if assets.next().is_some() + || asset + .source + .resource_id + .as_deref() + .unwrap_or(asset.id.as_str()) + != committed.resource_id + { + return Err(invalid()); + } + let event = commit_ledger.event_payload.as_ref().ok_or_else(invalid)?; + if event.project_id != generation_ledger.project_id + || event.draft_id != generation_ledger.draft_id + || event.commit_id != generation_ledger.commit_id + || event.idempotency_key != generation_ledger.commit_idempotency_key + || event.event_id != committed.event_id + || event.committed_project_revision != committed_project_revision + || event.asset != *asset + { + return Err(invalid()); + } + Ok(()) +} + +fn finish_asset_commit_settlement_pending_locked( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + if ledger.phase != GenerationLedgerPhase::AssetCommitSettlementPending { + return Err("素材画布生成账本不在待提交结算阶段".to_string()); + } + let committed = ledger + .commit_result + .clone() + .ok_or_else(|| "待提交结算的生成账本缺少本地 commit 回执".to_string())?; + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + validate_asset_commit_settlement_evidence(root, ledger, &committed, &manifest)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + let existing = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .map(|record| { + let matches_private_commit = record.intent_id == ledger.intent_id + && record.output_asset_id.as_deref() == Some(committed.asset_id.as_str()) + && record.error_code.is_none(); + if record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && !matches_private_commit + { + return Err("公开提交终态与私有生成账本不一致".to_string()); + } + Ok( + (record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && matches_private_commit) + .then(|| record.clone()), + ) + }) + .transpose()? + .flatten(); + let (record, draft_revision) = if let Some(record) = existing { + (record, draft.revision) + } else { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + upsert_public_generation_record_locked(root, &projection)? + }; + if ledger.staged_draft_revision.is_some() { + let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + ledger.current_draft_revision = Some(draft_revision); + if let Some(commit_result) = ledger.commit_result.as_mut() { + commit_result.draft_revision = draft_revision; + } + ledger.phase = GenerationLedgerPhase::AssetDurableCommitted; + write_generation_ledger(root, ledger)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + Ok((record, draft)) +} + +fn finish_asset_commit_settlement_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + let (record, draft) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + finish_asset_commit_settlement_pending_locked(root, ledger)? + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok((record, draft)) +} + +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, }) @@ -2254,22 +3159,227 @@ fn mark_generation_error( reconciliation: bool, error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(), String> { + mark_generation_error_with_pending_hook(root, ledger, reconciliation, error_code, emit, || { + Ok(()) + }) +} + +fn mark_generation_error_with_pending_hook( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), + after_pending: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { // Cancellation is authoritative and must not be overwritten by a late // network/commit error. The private ledger keeps its last durable phase as // evidence while all public projection and candidate persistence stop. - ensure_generation_draft_active(root, ledger)?; - set_private_phase( - root, - ledger, - if reconciliation { - GenerationLedgerPhase::ReconciliationRequired + let (_, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + if pending_failure_settlement_target(&ledger.phase).is_some() { + finish_generation_failure_settlement_pending_locked(root, ledger)? } else { - GenerationLedgerPhase::Failed + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft)?; + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationSettlementPending + } else { + GenerationLedgerPhase::FailureSettlementPending + }; + ledger.error_code = Some(error_code.to_string()); + ledger.current_draft_revision = Some(draft.revision); + write_generation_ledger(root, ledger)?; + after_pending()?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + } + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(()) +} + +fn pending_failure_settlement_target( + phase: &GenerationLedgerPhase, +) -> Option<(GenerationLedgerPhase, &'static str)> { + match phase { + GenerationLedgerPhase::FailureSettlementPending => { + Some((GenerationLedgerPhase::Failed, "failed")) + } + GenerationLedgerPhase::ReconciliationSettlementPending => Some(( + GenerationLedgerPhase::ReconciliationRequired, + "reconciliation-required", + )), + _ => None, + } +} + +fn finish_generation_failure_settlement_pending_locked( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result< + ( + FinalizeAssetCanvasGenerationFailureResult, + AssetCanvasGenerationRecord, + ), + String, +> { + let (target_phase, disposition) = pending_failure_settlement_target(&ledger.phase) + .ok_or_else(|| "素材画布生成账本不在待失败结算阶段".to_string())?; + let error_code = ledger + .error_code + .clone() + .ok_or_else(|| "待结算的素材画布生成账本缺少 errorCode".to_string())?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft)?; + let target_public_phase = + public_phase(&target_phase).expect("failure settlement target phase is public"); + let existing = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .filter(|record| { + record.intent_id == ledger.intent_id + && record.phase == target_public_phase + && record.error_code.as_deref() == Some(error_code.as_str()) + }) + .cloned(); + let (record, draft_revision) = if let Some(record) = existing { + (record, draft.revision) + } else { + let mut projection = ledger.clone(); + projection.phase = target_phase.clone(); + upsert_public_generation_record_locked(root, &projection)? + }; + if ledger.staged_draft_revision.is_some() { + let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + ledger.current_draft_revision = Some(draft_revision); + ledger.phase = target_phase; + write_generation_ledger(root, ledger)?; + draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + Ok(( + FinalizeAssetCanvasGenerationFailureResult { + disposition: disposition.to_string(), + draft, }, - Some(error_code), - )?; - publish_public_phase(root, ledger, emit).map(|_| ()) + record, + )) +} + +fn finish_generation_failure_settlement_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let (result, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(result) +} + +fn settle_generation_failure_at_expected_revision( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + expected_draft_revision: u64, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let (result, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + if pending_failure_settlement_target(&ledger.phase).is_some() { + finish_generation_failure_settlement_pending_locked(root, ledger)? + } else { + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + if matches!( + ledger.phase, + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetCommitSettlementPending + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::ArchivePending + | GenerationLedgerPhase::Archived + ) { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "already-terminal".to_string(), + draft, + }); + } + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + let target_phase = if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }; + let already_settled = ledger.phase == target_phase + && ledger.error_code.as_deref() == Some(error_code) + && draft.generations.iter().any(|record| { + record.generation_id == ledger.generation_id + && record.intent_id == ledger.intent_id + && record.phase + == public_phase(&target_phase).expect("failure phase is public") + && record.error_code.as_deref() == Some(error_code) + }); + if already_settled { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: if reconciliation { + "reconciliation-required".to_string() + } else { + "failed".to_string() + }, + draft, + }); + } + validate_generation_draft_active(ledger, &draft)?; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationSettlementPending + } else { + GenerationLedgerPhase::FailureSettlementPending + }; + ledger.error_code = Some(error_code.to_string()); + ledger.current_draft_revision = Some(expected_draft_revision); + write_generation_ledger(root, ledger)?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + } + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(result) } fn sanitized_generation_error(code: &str) -> String { @@ -2291,6 +3401,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(), @@ -2360,6 +3475,37 @@ async fn wait_for_canvas_generation_result( wait_for_external_generation_result_with_access(client, access, submission_payload).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) { @@ -2372,6 +3518,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() @@ -2414,7 +3563,39 @@ fn preserve_submit_reconciliation( ) -> bool { reconciliation || (code == "authentication-required" - && phase == &GenerationLedgerPhase::ReconciliationRequired) + && matches!( + phase, + GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired + )) +} + +fn finish_archive_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + if ledger.phase != GenerationLedgerPhase::ArchivePending { + return Err("素材画布生成账本不在待归档阶段".to_string()); + } + let _draft_guard = 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 let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id) + { + draft.generations.remove(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)?; + } + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, ledger)?; + Ok(draft) } async fn reconcile_generation( @@ -2425,11 +3606,37 @@ 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::AssetCommitSettlementPending { + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; + return committed_execution_from_private_result(root, &ledger); + } if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } - bind_generation_platform_owner(&ledger, platform_session)?; + if ledger.phase == GenerationLedgerPhase::Archived { + return Err("素材画布失败生成已归档".to_string()); + } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + finish_archive_pending_at(root, &mut ledger)?; + return Err("素材画布失败生成已归档".to_string()); + } + if pending_failure_settlement_target(&ledger.phase).is_some() { + let reconciliation = ledger.phase == GenerationLedgerPhase::ReconciliationSettlementPending; + let code = ledger + .error_code + .clone() + .unwrap_or_else(|| "generation-failed".to_string()); + finish_generation_failure_settlement_pending_at(root, &mut ledger, emit)?; + return Err(sanitized_classified_generation_error(&code, reconciliation)); + } migrate_retryable_credential_failure(root, &mut ledger)?; + if !generation_has_service_identity_evidence(&ledger) { + initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; + } + bind_generation_platform_owner(&ledger, platform_session)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), @@ -2514,8 +3721,7 @@ async fn reconcile_generation( "platform-service-configuration" }; if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -2537,8 +3743,7 @@ async fn reconcile_generation( { let code = error.code(); if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -2592,6 +3797,40 @@ 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.reference_binding_repair_attempted + && !reconciliation + { + ledger.reference_binding_repair_attempted = true; + if invalidate_reference_binding_for_ledger( + root, + &ledger, + api_base_url, + api_mode, + platform_session, + )? { + 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; + } + } if code == "authentication-required" && !reconciliation { set_private_phase( root, @@ -2784,11 +4023,57 @@ async fn reconcile_generation( } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; + 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", + )); + } + let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::CandidateReady, + None, + )?; + 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)? { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } let commit = with_frozen_generation_platform_session_commit(platform_session, || { - ensure_generation_draft_active(root, &ledger)?; - if !synchronize_staged_image_revision(root, &mut ledger)? { - return Err("commit-reconciliation-required: 暂存素材已失效".to_string()); - } commit_asset_canvas_at( root, &CommitAssetCanvasInput { @@ -2804,6 +4089,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(), @@ -2825,64 +4112,11 @@ async fn reconcile_generation( return Err(sanitized_generation_error("commit-reconciliation-required")); } }; - let private_commit = match &execution.result { - CommitAssetCanvasResult::Committed { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "committed".to_string(), - event_id: event_id.clone(), - }, - CommitAssetCanvasResult::AlreadyCommitted { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "already-committed".to_string(), - event_id: event_id.clone(), - }, - _ => { - mark_generation_error( - root, - &mut ledger, - true, - "commit-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error("commit-reconciliation-required")); + let private_commit = match private_commit_result_from_execution(&execution) { + Ok(commit) => commit, + Err(code) => { + mark_generation_error(root, &mut ledger, true, &code, emit)?; + return Err(sanitized_generation_error(&code)); } }; ledger.current_draft_revision = Some(private_commit.draft_revision); @@ -2890,17 +4124,14 @@ async fn reconcile_generation( set_private_phase( root, &mut ledger, - GenerationLedgerPhase::AssetDurableCommitted, + GenerationLedgerPhase::AssetCommitSettlementPending, None, )?; - let generation = publish_public_phase(root, &mut ledger, emit)?; + let (generation, committed_draft) = + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; let final_draft_revision = ledger .current_draft_revision .expect("public commit projection must advance the draft revision"); - if let Some(committed) = ledger.commit_result.as_mut() { - committed.draft_revision = final_draft_revision; - } - write_generation_ledger(root, &mut ledger)?; let committed = ledger .commit_result .as_ref() @@ -2910,7 +4141,8 @@ async fn reconcile_generation( result: GenerateAssetCanvasImageResult { generation, images: Vec::new(), - commit: AssetCanvasGenerationCommitResult { + draft: committed_draft, + commit: Some(AssetCanvasGenerationCommitResult { resource_id: committed.resource_id.clone(), asset_id: committed.asset_id.clone(), project_id: committed.project_id.clone(), @@ -2921,7 +4153,7 @@ async fn reconcile_generation( commit_status: committed.commit_status.clone(), manifest, event_id: committed.event_id.clone(), - }, + }), }, event: execution.event, }); @@ -2955,8 +4187,17 @@ pub(crate) async fn generate_asset_canvas_image_at( enforce_project_permission_policy(root, "canvas.asset_generate")?; let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) - .map_err(|_| sanitized_generation_error("configuration-missing"))?; + let (api_base_url, api_key, platform_session) = + match resolve_canvas_sync_api_credentials(None, None) { + Ok(value) => value, + Err(_) => { + let mut ledger = validate_and_prepare_ledger(root, input, "", None)?; + let code = "configuration-missing"; + let mut no_op_emit = |_event: AssetCanvasGenerationProgressEvent| {}; + mark_generation_error(root, &mut ledger, false, code, &mut no_op_emit)?; + return Err(sanitized_generation_error(code)); + } + }; let ledger = validate_and_prepare_ledger(root, input, &api_base_url, platform_session.as_ref())?; let api_mode = CanvasGenerationApiMode { api_key }; @@ -2971,6 +4212,186 @@ pub(crate) async fn generate_asset_canvas_image_at( .await } +pub(crate) async fn finalize_asset_canvas_generation_failure_at( + root: &Path, + input: &FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.generation_id, "generationId")?; + validate_uuid_v4(&input.intent_id, "intentId")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + validate_plain_component(&input.error_code, "errorCode", 128)?; + 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; + + if let Some(mut ledger) = read_generation_ledger(root, &input.generation_id)? { + if ledger.project_id != manifest.project_id + || ledger.draft_id != input.draft_id + || ledger.generation_id != input.generation_id + || ledger.intent_id != input.intent_id + { + return Err("生成失败结算对应的账本身份无效".to_string()); + } + let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; + return settle_generation_failure_at_expected_revision( + root, + &mut ledger, + input.expected_draft_revision, + input.reconciliation_required, + &input.error_code, + &mut emit, + ); + } + + 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.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == input.generation_id) + else { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }); + }; + let record = &draft.generations[index]; + if record.intent_id != input.intent_id { + return Err("生成失败结算对应的公开记录身份无效".to_string()); + } + if !matches!( + record.phase, + AssetCanvasGenerationStatus::GenerationAccepted + | AssetCanvasGenerationStatus::GenerationRunning + ) { + return Err("没有私有账本的生成记录已进入不可降级终态".to_string()); + } + draft.generations.remove(index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.status = AssetCanvasDraftStatus::Editing; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }) +} + +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 ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, &mut ledger)?; + 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, + }); + } + let generation_index = + generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".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::ArchivePending; + ledger.archived_at = Some(asset_canvas_now()); + write_generation_ledger(root, &mut ledger)?; + } + GenerationLedgerPhase::ArchivePending => {} + 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)?; + if ledger.phase == GenerationLedgerPhase::ArchivePending { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, &mut ledger)?; + } + 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, @@ -3036,12 +4457,31 @@ pub(crate) async fn recover_asset_canvas_generations_at( } let mut recoverable_generation_ids = Vec::new(); for generation_id in generation_ids { - let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; - if ledger.project_id == input.expected_project_id - && ledger.draft_id == input.draft_id - && ledger.phase != GenerationLedgerPhase::AssetDurableCommitted + if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id { + continue; + } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + let _ = finish_archive_pending_at(root, &mut ledger); + continue; + } + if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + finish_asset_commit_settlement_pending_at(root, &mut ledger, &mut emit)?; + continue; + } + if pending_failure_settlement_target(&ledger.phase).is_some() { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + finish_generation_failure_settlement_pending_at(root, &mut ledger, &mut emit)?; + continue; + } + if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted && (ledger.phase != GenerationLedgerPhase::Failed || retryable_credential_failure_phase(&ledger).is_some()) { @@ -3049,6 +4489,11 @@ pub(crate) async fn recover_asset_canvas_generations_at( } } if recoverable_generation_ids.is_empty() { + restore_unacknowledged_candidate_layers_at( + root, + &input.expected_project_id, + &input.draft_id, + )?; return Ok(RecoverAssetCanvasGenerationsExecution { result: RecoverAssetCanvasGenerationsResult { resumed_generation_ids: Vec::new(), @@ -3057,6 +4502,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; let api_mode = CanvasGenerationApiMode { api_key }; @@ -3076,7 +4522,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; @@ -3397,6 +4846,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 = @@ -3410,7 +4868,7 @@ 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(), image_sequence_frames: None, @@ -3418,7 +4876,7 @@ mod tests { 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, @@ -3471,9 +4929,99 @@ 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, + }), } } + fn prepare_asset_commit_settlement_pending_fixture( + project_id: &str, + project_name: &str, + ) -> ( + tempfile::TempDir, + AssetCanvasDraft, + GenerateAssetCanvasImageInput, + AssetCanvasGenerationLedger, + u64, + ) { + let (directory, draft) = create_generation_fixture(project_id, project_name); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证正式资源提交结算可以跨重启恢复", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + 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: test_png_with_color([82, 116, 210, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage generated image before commit"); + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + ) + .expect("persist downloaded phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish downloaded phase"); + let media_draft_revision = ledger + .current_draft_revision + .expect("downloaded projection advances draft revision"); + let execution = commit_asset_canvas_at( + directory.path(), + &CommitAssetCanvasInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: media_draft_revision, + draft_id: draft.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + 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(), + generation_provenance: None, + }, + ) + .expect("commit generated image before settlement"); + let private_commit = + private_commit_result_from_execution(&execution).expect("read private commit result"); + let committed_draft_revision = private_commit.draft_revision; + ledger.current_draft_revision = Some(committed_draft_revision); + ledger.commit_result = Some(private_commit); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::AssetCommitSettlementPending, + None, + ) + .expect("persist asset commit settlement journal"); + (directory, draft, input, ledger, committed_draft_revision) + } + fn accepted_ledger( project_id: &str, draft: &AssetCanvasDraft, @@ -3495,6 +5043,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(), @@ -3511,6 +5066,7 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + reference_binding_repair_attempted: false, access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: None, api_identity_fingerprint: Some( @@ -3533,13 +5089,470 @@ 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, + candidate_layer_acknowledged_revision: 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, "", None) + .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); + + // Simulate the observed stale-autosave failure: the authoritative draft + // loses an unacknowledged candidate, then recovery restores it from the + // private ledger before the canvas is hydrated again. + let mut lost_draft = updated.clone(); + lost_draft + .canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + lost_draft.canvas.selected_layer_ids = vec![source_layer_id.clone()]; + lost_draft.canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + write_asset_canvas_draft_locked(directory.path(), &lost_draft) + .expect("write draft without candidate"); + let restored = restore_unacknowledged_candidate_layers_at( + directory.path(), + project_id, + &draft.draft_id, + ) + .expect("restore unacknowledged candidate") + .expect("candidate restored"); + assert!(restored + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + + let mut stale_canvas = restored.canvas.clone(); + stale_canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + stale_canvas.selected_layer_ids = vec![source_layer_id.clone()]; + stale_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + let protected = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + 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: restored.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: stale_canvas, + generations: restored.generations.clone(), + }, + ) + .expect("protect stale autosave") + .draft; + assert!(protected + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + + let acknowledged = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + 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: protected.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: protected.canvas.clone(), + generations: protected.generations.clone(), + }, + ) + .expect("acknowledge candidate") + .draft; + let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read acknowledged ledger") + .expect("acknowledged ledger retained"); + assert_eq!( + acknowledged_ledger.candidate_layer_acknowledged_revision, + Some(acknowledged.revision) + ); + + let mut intentional_delete_canvas = acknowledged.canvas.clone(); + intentional_delete_canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + intentional_delete_canvas.selected_layer_ids = vec![source_layer_id.clone()]; + intentional_delete_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + let after_delete = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + 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: acknowledged.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: intentional_delete_canvas, + generations: acknowledged.generations.clone(), + }, + ) + .expect("allow intentional candidate deletion after acknowledgement") + .draft; + assert!(!after_delete + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + + 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 = after_delete; + 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, "", None) + .expect_err("reject out-of-bounds placeholder") + .contains("安全画布边界") + ); + } + + #[tokio::test] + async fn context_authentication_required_replays_the_same_generation_after_refresh() { + let project_id = "phase-five-auth-refresh-project"; + let project_name = "阶段五登录刷新重放测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth refresh server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("auth refresh address") + ); + let signed_url = format!("{base_url}/auth-refresh.png"); + let server_signed_url = signed_url.clone(); + let png = test_png(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set auth refresh fixture nonblocking"); + for request_index in 0..9 { + let mut stream = accept_generation_fixture_connection( + &listener, + "auth refresh fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture auth refresh request"); + if request_index == 0 { + assert!(request.starts_with("GET /api/external/v1/editor/projects ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer expired-key")); + write_json( + &mut stream, + "401 Unauthorized", + serde_json::json!({"error": {"code": "authentication-required"}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "auth-refresh-remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": { + "projectId": "auth-refresh-remote-project", + "title": project_name, + }}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "auth-refresh-remote-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": { + "folderId": "auth-refresh-remote-folder", + "label": project_name, + }}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "auth-refresh-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request + .starts_with("GET /api/external/v1/generations/auth-refresh-operation ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "auth-refresh-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "auth-refresh-resource", + "objectKey": "generated/auth-refresh.png", + "assetObjectId": "auth-refresh-object", + }} + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /auth-refresh.png ") { + write_png(&mut stream, &png); + } else { + panic!("unexpected auth refresh request: {request}"); + } + } + }); + + let input = generation_input( + directory.path(), + project_id, + &draft, + "刷新登录态后继续原图片生成", + ); + let mut first_progress = Vec::new(); + let first_error = + match with_test_external_editor_credentials(&base_url, "expired-key", async { + generate_asset_canvas_image_at(directory.path(), &input, |event| { + first_progress.push(event) + }) + .await + }) + .await + { + Ok(_) => panic!("first request must expose authentication failure"), + Err(error) => error, + }; + assert!( + first_error.contains("登录已失效") || first_error.contains("API Key 无效"), + "authentication failure must remain recognizable without exposing credentials" + ); + assert_eq!( + first_progress.last().map(|event| event.phase.as_str()), + Some("reconciliation-required") + ); + let recoverable = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read authentication ledger") + .expect("authentication ledger exists"); + assert_eq!( + recoverable.phase, + GenerationLedgerPhase::ReconciliationRequired + ); + assert_eq!( + recoverable.error_code.as_deref(), + Some("authentication-required") + ); + assert!(recoverable.operation_id.is_none()); + assert!(recoverable.request_body_json.is_none()); + + let recovered = with_test_external_editor_credentials(&base_url, "refreshed-key", async { + generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await + }) + .await + .expect("refreshed credentials replay the original generation"); + server.join().expect("join auth refresh server"); + assert_eq!( + recovered.result.generation.phase, + AssetCanvasGenerationStatus::CandidateReady + ); + assert_eq!( + recovered.result.generation.generation_id, + input.generation_id + ); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 9); + assert!(requests[1] + .to_ascii_lowercase() + .contains("authorization: bearer refreshed-key")); + let submits = requests + .iter() + .filter(|request| { + request.starts_with("POST /api/external/v1/editor/images/generations ") + }) + .collect::>(); + assert_eq!(submits.len(), 1); + assert!(submits[0] + .to_ascii_lowercase() + .contains(&format!("idempotency-key: {}", input.idempotency_key))); + } + #[tokio::test] async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean() { @@ -3648,14 +5661,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")); @@ -3670,28 +5684,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); @@ -3710,7 +5724,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()); @@ -3739,18 +5753,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)); @@ -3780,8 +5790,928 @@ 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 generation_failure_settlement_is_authoritative_cas_and_handles_missing_ledger() { + let project_id = "asset-canvas-generation-failure-settlement"; + 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, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Accepted, + None, + ) + .expect("persist accepted phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish accepted phase"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let stale = FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_draft_revision: running_draft.revision - 1, + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + error_code: "generation-rejected".to_string(), + reconciliation_required: false, + }; + assert_eq!( + finalize_asset_canvas_generation_failure_at(directory.path(), &stale) + .await + .expect_err("stale failure settlement must fail CAS"), + "draft-revision-conflict" + ); + let draft_after_stale_settlement = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read draft after stale failure settlement") + .expect("draft remains after stale failure settlement"); + assert_eq!(draft_after_stale_settlement, running_draft); + let ledger_after_stale_settlement = + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ledger after stale failure settlement") + .expect("ledger remains after stale failure settlement"); + assert_eq!( + ledger_after_stale_settlement.phase, + GenerationLedgerPhase::Running + ); + assert_eq!(ledger_after_stale_settlement.error_code, None); + + let settle = FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: running_draft.revision, + ..stale.clone() + }; + let settled = finalize_asset_canvas_generation_failure_at(directory.path(), &settle) + .await + .expect("settle failed generation"); + assert_eq!(settled.disposition, "failed"); + assert_eq!(settled.draft.revision, running_draft.revision + 1); + assert!(settled.draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + + let replay = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: settled.draft.revision, + ..settle.clone() + }, + ) + .await + .expect("failure settlement is idempotent"); + assert_eq!(replay.draft.revision, settled.draft.revision); + assert_eq!(replay.disposition, "failed"); + + let (directory, draft) = create_generation_fixture( + "asset-canvas-generation-failure-no-ledger", + "无账本失败结算测试", + ); + let input = generation_input( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft, + "无账本失败占位", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare placeholder"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist placeholder running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish placeholder running phase"); + let placeholder_draft = read_asset_canvas_draft_locked( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft.draft_id, + ) + .expect("read placeholder draft") + .expect("placeholder draft exists"); + fs::remove_file( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("remove private ledger before settlement"); + let settled = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: "asset-canvas-generation-failure-no-ledger".to_string(), + expected_draft_revision: placeholder_draft.revision, + draft_id: draft.draft_id, + generation_id: input.generation_id, + intent_id: input.intent_id, + error_code: "canvas-generation-failed".to_string(), + reconciliation_required: false, + }, + ) + .await + .expect("settle pre-ledger failure"); + assert_eq!(settled.disposition, "not-started"); + assert!(settled.draft.generations.is_empty()); + assert_eq!(settled.draft.status, AssetCanvasDraftStatus::Editing); + } + + #[tokio::test] + async fn internal_generation_error_recovers_after_pending_ledger_cut() { + let project_id = "asset-canvas-internal-error-pending"; + let (directory, draft) = create_generation_fixture(project_id, "内部失败 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复生成流程内部失败的 pending 结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let error = mark_generation_error_with_pending_hook( + directory.path(), + &mut ledger, + false, + "generation-rejected", + &mut |_| {}, + || Err("fault-injected:failure-settlement-pending".to_string()), + ) + .expect_err("fault after pending ledger must interrupt settlement"); + assert_eq!(error, "fault-injected:failure-settlement-pending"); + let interrupted_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read interrupted internal failure ledger") + .expect("interrupted internal failure ledger exists"); + assert_eq!( + interrupted_ledger.phase, + GenerationLedgerPhase::FailureSettlementPending + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read interrupted internal failure draft") + .expect("interrupted internal failure draft exists"); + assert_eq!(interrupted_draft.revision, running_draft.revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::GenerationRunning + })); + + 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 + .expect("recover internal generation error settlement"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered internal failure ledger") + .expect("recovered internal failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered internal failure draft") + .expect("recovered internal failure draft exists"); + assert_eq!(recovered_draft.revision, running_draft.revision + 1); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + } + + #[tokio::test] + async fn pending_generation_failure_settlement_recovers_across_all_durable_write_cuts() { + let project_id = "asset-canvas-failure-pending-before-public"; + let (directory, draft) = create_generation_fixture(project_id, "失败结算 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复尚未写入公开草稿的失败结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + ledger.phase = GenerationLedgerPhase::FailureSettlementPending; + ledger.error_code = Some("generation-rejected".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist failure settlement journal"); + + let mut progress = Vec::new(); + let recovered = 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(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending failure settlement before public projection"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered failure ledger") + .expect("recovered failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered failure draft") + .expect("recovered failure draft exists"); + assert_eq!(recovered_draft.revision, running_draft.revision + 1); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "failed"); + + let project_id = "asset-canvas-reconciliation-pending-after-public"; + let (directory, draft) = create_generation_fixture(project_id, "对账结算 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复已写公开草稿但未写终态账本的对账结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + 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: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png(), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage reconciliation candidate"); + assert_eq!(staged.status, "staged"); + ledger.phase = GenerationLedgerPhase::ReconciliationSettlementPending; + ledger.error_code = Some("poll-result-unknown".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist reconciliation settlement journal"); + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::ReconciliationRequired; + let (_, projected_revision) = + upsert_public_generation_record(directory.path(), &projection) + .expect("simulate public projection before terminal ledger write"); + let (staged_before_recovery, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before reconciliation recovery"); + assert_eq!( + staged_before_recovery.draft_revision, + running_draft.revision + ); + + let mut progress = Vec::new(); + 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(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending reconciliation after public projection"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered reconciliation ledger") + .expect("recovered reconciliation ledger exists"); + assert_eq!( + recovered_ledger.phase, + GenerationLedgerPhase::ReconciliationRequired + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered reconciliation draft") + .expect("recovered reconciliation draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read rebound staging after reconciliation recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::ReconciliationRequired + && record.error_code.as_deref() == Some("poll-result-unknown") + })); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "reconciliation-required"); + + let project_id = "asset-canvas-failure-pending-after-staging"; + let (directory, draft) = + create_generation_fixture(project_id, "失败结算 staging 后恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复已推进 staging 但未写终态账本的失败结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + 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: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png_with_color([22, 44, 66, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage failure candidate"); + assert_eq!(staged.status, "staged"); + ledger.phase = GenerationLedgerPhase::FailureSettlementPending; + ledger.error_code = Some("generation-rejected".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist failure settlement journal"); + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::Failed; + let (_, projected_revision) = + upsert_public_generation_record(directory.path(), &projection) + .expect("simulate failure public projection before staging revision"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + project_id, + &draft.draft_id, + running_draft.revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + + let mut progress = Vec::new(); + 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(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending failure after staging revision"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered failure ledger") + .expect("recovered failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered failure draft") + .expect("recovered failure draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging after terminal ledger recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "failed"); + } + + #[tokio::test] + async fn pending_asset_commit_settlement_recovers_across_all_durable_write_cuts() { + for cut in ["before-public", "after-public", "after-staging"] { + let project_id = format!("asset-commit-settlement-{cut}"); + let (directory, draft, input, ledger, committed_draft_revision) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交 pending 恢复测试", + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read interrupted commit draft") + .expect("interrupted commit draft exists"); + assert_eq!(interrupted_draft.revision, committed_draft_revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + + let mut projected_revision = committed_draft_revision + 1; + if cut != "before-public" { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + let (_, revision) = upsert_public_generation_record(directory.path(), &projection) + .expect("simulate public commit projection before terminal ledger write"); + projected_revision = revision; + } + if cut == "after-staging" { + let (metadata, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before simulated rebind"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + &project_id, + &draft.draft_id, + metadata.draft_revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + } + + let mut progress = Vec::new(); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending asset commit settlement"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered commit ledger") + .expect("recovered commit ledger exists"); + assert_eq!( + recovered_ledger.phase, + GenerationLedgerPhase::AssetDurableCommitted + ); + assert_eq!( + recovered_ledger.current_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger + .commit_result + .as_ref() + .expect("recovered commit result") + .draft_revision, + projected_revision + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read recovered commit draft") + .expect("recovered commit draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + assert_eq!( + recovered_draft + .generations + .iter() + .filter(|record| record.generation_id == input.generation_id) + .count(), + 1 + ); + let recovered_generation = recovered_draft + .generations + .iter() + .find(|record| record.generation_id == input.generation_id) + .expect("recovered public generation"); + assert_eq!( + recovered_generation.phase, + AssetCanvasGenerationStatus::AssetDurableCommitted + ); + assert_eq!( + recovered_generation.output_asset_id.as_deref(), + recovered_ledger + .commit_result + .as_ref() + .map(|result| result.asset_id.as_str()) + ); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read recovered commit staging"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "asset-durable-committed"); + } + } + + #[tokio::test] + async fn pending_asset_commit_settlement_rejects_mismatched_commit_evidence() { + for mismatch in ["private-receipt", "commit-ledger", "transaction-journal"] { + let project_id = format!("asset-commit-evidence-{mismatch}"); + let (directory, draft, input, mut generation_ledger, _) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交证据错配测试", + ); + match mismatch { + "private-receipt" => { + generation_ledger + .commit_result + .as_mut() + .expect("pending commit receipt") + .commit_id = Uuid::new_v4().to_string(); + write_generation_ledger(directory.path(), &mut generation_ledger) + .expect("persist mismatched private receipt"); + } + "commit-ledger" => { + let mut commit_ledger = + read_asset_canvas_ledger(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three commit ledger") + .expect("stage-three commit ledger exists"); + commit_ledger.idempotency_key = Uuid::new_v4().to_string(); + write_asset_canvas_ledger(directory.path(), &commit_ledger) + .expect("persist mismatched stage-three commit ledger"); + } + "transaction-journal" => { + let mut journal = + read_asset_canvas_journal(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three transaction journal") + .expect("stage-three transaction journal exists"); + journal.asset_id = "mismatched-asset".to_string(); + write_asset_canvas_journal(directory.path(), &journal) + .expect("persist mismatched stage-three transaction journal"); + } + _ => unreachable!(), + } + + let error = match recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + { + Ok(_) => panic!("mismatched commit evidence must fail closed"), + Err(error) => error, + }; + assert!(error.contains("commit 回执与阶段三事务不一致")); + let retained_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retained generation ledger") + .expect("retained generation ledger exists"); + assert_eq!( + retained_ledger.phase, + GenerationLedgerPhase::AssetCommitSettlementPending + ); + let retained_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read retained draft") + .expect("retained draft exists"); + assert!(retained_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + } + } + + #[tokio::test] + 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, "", None) + .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)); + + let mut pending = archived_ledger.clone(); + pending.phase = GenerationLedgerPhase::ArchivePending; + pending.archived_at = Some(archived.archived_at); + let mut pending_draft = archived.draft.clone(); + pending_draft.generations.push(AssetCanvasGenerationRecord { + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + phase: AssetCanvasGenerationStatus::Failed, + reference_resource_ids: Vec::new(), + output_asset_id: None, + source_layer_id: None, + placeholder: None, + error_code: Some("generation-rejected".to_string()), + created_at: 1, + updated_at: 1, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }); + pending_draft.revision += 1; + write_asset_canvas_draft_locked(directory.path(), &pending_draft) + .expect("write pending archive draft"); + write_generation_ledger(directory.path(), &mut pending) + .expect("write pending archive ledger"); + let pending_recovery = 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 + .expect("recover pending archive"); + assert!(pending_recovery.result.resumed_generation_ids.is_empty()); + assert_eq!( + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered pending archive ledger") + .expect("recovered pending archive ledger exists") + .phase, + GenerationLedgerPhase::Archived + ); + assert!( + !read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered pending archive draft") + .expect("recovered pending archive draft exists") + .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, "", None) + .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 + } + ); + } + } + + #[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) = @@ -3867,7 +6797,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", @@ -3878,6 +6810,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, @@ -3960,7 +6893,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 @@ -3970,8 +6904,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); @@ -3986,36 +6922,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\":\"illustration\"")); + 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"); @@ -4616,7 +7555,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, @@ -4821,9 +7761,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 "))); @@ -4837,7 +7789,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); } #[test] @@ -5412,6 +8364,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(), @@ -5451,6 +8410,7 @@ mod tests { "editor-resource-source".to_string(), "objects/style.png".to_string(), ], + reference_binding_repair_attempted: false, access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), @@ -5471,8 +8431,12 @@ 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, + candidate_layer_acknowledged_revision: None, commit_result: None, error_code: None, + archived_at: None, created_at: now, updated_at: now, }; @@ -5482,6 +8446,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("assetKind").is_none(), "图片精修请求不得携带仅属于资源登记的 assetKind 字段" @@ -5498,6 +8463,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"; @@ -5620,7 +8655,7 @@ mod tests { assert_eq!(progress[0].error_code.as_deref(), Some(code)); assert!(sanitized_classified_generation_error(code, reconciliation) .starts_with("reconciliation-required:")); - assert!(!preserve_submit_reconciliation( + assert!(preserve_submit_reconciliation( &GenerationLedgerPhase::Prepared, code, false, @@ -5630,6 +8665,11 @@ mod tests { code, false, )); + assert!(!preserve_submit_reconciliation( + &GenerationLedgerPhase::Prepared, + "permission-denied", + false, + )); } #[tokio::test] @@ -5667,7 +8707,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" @@ -5749,11 +8803,19 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); - assert!( - read_generation_ledger(directory.path(), &input.generation_id) - .expect("read missing-login generation ledger") - .is_none() - ); + assert!(public_draft + .generations + .iter() + .any(|record| record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("configuration-missing"))); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retryable generation ledger") + .expect("missing-login ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); + assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); + assert_eq!(ledger.idempotency_key, input.idempotency_key); + assert_eq!(ledger.commit_id, input.commit_id); let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); let base_url = format!( @@ -5861,12 +8923,12 @@ mod tests { resumed.platform_owner_user_id.as_deref(), Some("original-login-owner") ); - assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted); + assert_eq!(resumed.phase, GenerationLedgerPhase::CandidateReady); assert_eq!(resumed.error_code, None); - assert!(resumed.commit_result.is_some()); + assert!(resumed.commit_result.is_none()); let manifest = current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); - assert_eq!(manifest.assets.len(), 1); + assert!(manifest.assets.is_empty()); } #[tokio::test] @@ -6007,7 +9069,7 @@ mod tests { let ledger = read_generation_ledger(directory.path(), &generation_id) .expect("read session gate ledger") .expect("session gate ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired); assert!(ledger.operation_id.is_none()); } @@ -6187,11 +9249,8 @@ mod tests { ) .expect("read resumed accepted ledger") .expect("resumed accepted ledger exists"); - assert_eq!( - committed.phase, - GenerationLedgerPhase::AssetDurableCommitted - ); - assert!(committed.commit_result.is_some()); + assert_eq!(committed.phase, GenerationLedgerPhase::CandidateReady); + assert!(committed.candidate_media_ref.is_some()); } #[test] 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 be228ae29..8b2a9d795 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,201 @@ 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(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + 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 force_refine_transaction_unresolved(fixture: &Fixture, commit_id: &str) { + let mut journal = read_asset_canvas_journal(fixture.root(), commit_id) + .expect("read transaction to mark unresolved") + .expect("transaction journal retained"); + let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id) + .expect("read transaction ledger") + .expect("transaction ledger retained"); + journal.stage = AssetCanvasTransactionStage::ReconciliationRequired; + ledger.status = AssetCanvasLedgerStatus::ReconciliationRequired; + write_asset_canvas_journal(fixture.root(), &journal).expect("mark journal unresolved"); + write_asset_canvas_ledger(fixture.root(), &ledger).expect("mark ledger unresolved"); +} + +fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) { + let fixture = initialize_refine_fixture(); + let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut first_input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + first_input.source_layer_id = Some(first_layer_id.clone()); + first_input.media_sha256 = Some(first_media_sha256); + let first_commit_id = first_input.commit_id.clone(); + commit_asset_canvas_at(fixture.root(), &first_input).expect("commit first final image"); + + let draft_after_first = + read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) + .expect("read draft after first commit") + .expect("draft retained after first commit"); + let (draft_before_second, second_layer_id, second_media_sha256) = + add_candidate_layer(&fixture, &draft_after_first); + let staged = stage_image(&fixture, &draft_before_second); + let mut second_input = commit_input( + &fixture, + &draft_before_second, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + second_input.source_layer_id = Some(second_layer_id); + second_input.media_sha256 = Some(second_media_sha256); + let second_commit_id = second_input.commit_id.clone(); + commit_asset_canvas_at(fixture.root(), &second_input).expect("commit second final image"); + force_refine_transaction_unresolved(&fixture, &first_commit_id); + (fixture, first_commit_id, second_commit_id) +} + +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(), @@ -295,6 +490,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(), @@ -435,6 +632,31 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { ) .expect("create refine draft") .draft; + let source_layer = draft.canvas.layers.first().expect("refine source layer"); + match &source_layer.media_ref { + AssetCanvasMediaRef::DraftMedia { + media_id, + media_type, + sha256, + .. + } => { + let source_path = directory.path().join(format!( + ".agent/workbench/asset-canvas/media/{}/{}.{}", + draft.draft_id, + media_id, + media_extension(media_type).expect("source extension") + )); + assert_eq!( + fs::read(source_path).expect("read source snapshot"), + source_bytes + ); + assert_eq!(sha256, &asset_canvas_sha256(&source_bytes)); + } + AssetCanvasMediaRef::ProjectAsset { .. } => { + panic!("refine source must be immutable snapshot") + } + } + assert_eq!(source_layer.resource_id, "local-asset:source-asset"); let fixture = Fixture { directory, draft: draft.clone(), @@ -448,31 +670,697 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { Uuid::new_v4().to_string(), Uuid::new_v4().to_string(), ); + fs::create_dir_all(fixture.root().join("game")).expect("create game directory"); + fs::write( + fixture.root().join("game/game.js"), + "const backgroundImage = '../assets/source.png';\n", + ) + .expect("write stable game resource reference"); let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset"); - let new_asset = match execution.result { + assert!(fixture.root().join("game/game.js").is_file()); + 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!( + fs::read(fixture.root().join("assets/source.png")).expect("read stable runtime entry"), + fixture.png + ); + assert_eq!( + fs::read_to_string(fixture.root().join("game/game.js")) + .expect("read stable game resource reference"), + "const backgroundImage = '../assets/source.png';\n" + ); + let journal = read_asset_canvas_journal(fixture.root(), &input.commit_id) + .expect("read runtime entry journal") + .expect("runtime entry journal retained"); + assert_eq!( + journal.runtime_entry_relative_path.as_deref(), + Some("assets/source.png") + ); + assert!(journal.runtime_entry_refreshed); + + fs::write( + fixture.root().join("assets/source.png"), + png_bytes([90, 120, 150, 255]), + ) + .expect("corrupt stable runtime entry"); + recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repair stable runtime entry"); + assert_eq!( + fs::read(fixture.root().join("assets/source.png")) + .expect("read repaired stable runtime entry"), + 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 legacy_refine_transaction_preserves_runtime_entry_for_next_commit() { + let directory = tempfile::tempdir().expect("create legacy runtime entry fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "旧事务精修项目") + .expect("initialize project"); + fs::write( + directory.path().join("assets/source.png"), + png_bytes([190, 40, 90, 255]), + ) + .expect("write source asset"); + let manifest_path = directory.path().join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read 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(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + 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 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; + let fixture = Fixture { + directory, + draft: draft.clone(), + png: png_bytes([30, 90, 210, 255]), + }; + let staged = stage_image(&fixture, &draft); + let first_input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + commit_asset_canvas_at(fixture.root(), &first_input) + .expect("commit legacy runtime entry image"); + + let mut legacy_journal = read_asset_canvas_journal(fixture.root(), &first_input.commit_id) + .expect("read legacy journal") + .expect("legacy journal retained"); + legacy_journal.runtime_entry_relative_path = None; + legacy_journal.runtime_entry_refreshed = false; + write_asset_canvas_journal(fixture.root(), &legacy_journal) + .expect("persist legacy journal shape"); + fs::write( + fixture.root().join("assets/source.png"), + png_bytes([1, 2, 3, 255]), + ) + .expect("corrupt legacy runtime entry"); + + let draft_after_first = + read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) + .expect("read draft after legacy commit") + .expect("draft retained after legacy commit"); + let second_png = png_bytes([210, 180, 40, 255]); + let staged_second = stage_asset_canvas_image_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft_after_first.revision, + media_type: "image/png".to_string(), + bytes: second_png.clone(), + }, + ) + .expect("stage second legacy image"); + let second_input = commit_input( + &fixture, + &draft_after_first, + &staged_second, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + commit_asset_canvas_at(fixture.root(), &second_input) + .expect("commit after legacy runtime entry migration"); + + assert_eq!( + fs::read(fixture.root().join("assets/source.png")).expect("read migrated runtime entry"), + second_png + ); + let second_journal = read_asset_canvas_journal(fixture.root(), &second_input.commit_id) + .expect("read migrated journal") + .expect("migrated journal retained"); + assert_eq!( + second_journal.runtime_entry_relative_path.as_deref(), + Some("assets/source.png") + ); + assert!(second_journal.runtime_entry_refreshed); +} + +#[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 source_layer = persisted + .canvas + .layers + .iter() + .find(|layer| layer.resource_id == "local-asset:source-asset") + .expect("immutable refine source layer retained"); + let source_media = read_asset_canvas_media_at( + fixture.root(), + &ReadAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: persisted.draft_id.clone(), + media_ref: source_layer.media_ref.clone(), + }, + ) + .expect("read immutable refine source layer"); + assert_eq!(source_media.bytes, png_bytes([200, 20, 40, 255])); + let candidate_layer = persisted + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == candidate_layer_id) + .expect("candidate layer retained"); + let candidate_media = read_asset_canvas_media_at( + fixture.root(), + &ReadAssetCanvasMediaInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: persisted.draft_id.clone(), + media_ref: candidate_layer.media_ref.clone(), + }, + ) + .expect("read selected final candidate"); + assert_eq!(candidate_media.bytes, fixture.png); + + 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 invalid_candidate_name_fails_before_staging_side_effects() { + let fixture = initialize_refine_fixture(); + let (draft, candidate_layer_id, _) = add_candidate_layer(&fixture, &fixture.draft); + let error = 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: "长".repeat(81), + asset_kind: "illustration".to_string(), + reference_resource_ids: Vec::new(), + }, + ) + .expect_err("reject invalid candidate name before staging"); + assert_eq!(error, "素材名称无效"); + let staging = fixture.root().join(".agent/workbench/asset-canvas/staging"); + match fs::read_dir(&staging) { + Ok(entries) => assert_eq!(entries.count(), 0), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("read candidate staging directory failed: {error}"), + } +} + +#[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] +fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit() { + let (fixture, first_commit_id, second_commit_id) = + initialize_refine_fixture_with_later_commit(); + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover superseded refine transaction"); + let first_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == first_commit_id) + .expect("first recovery outcome"); + let second_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == second_commit_id) + .expect("second recovery outcome"); + assert_eq!( + first_outcome.status, + RecoverAssetCanvasOutcomeStatus::Superseded + ); + assert_eq!( + second_outcome.status, + RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + ); + + let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) + .expect("read superseded journal") + .expect("superseded journal retained"); + let first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) + .expect("read superseded ledger") + .expect("superseded ledger retained"); + assert_eq!(first_journal.stage, AssetCanvasTransactionStage::Superseded); + assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::Superseded); + assert!(fixture + .root() + .join(&first_journal.final_image_relative_path) + .is_file()); + + let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) + .expect("read current journal") + .expect("current journal retained"); + let manifest = current_asset_canvas_manifest(fixture.root()).expect("read current manifest"); + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == "source-asset") + .expect("current refine source asset"); + assert_eq!(asset.local_path, second_journal.final_image_relative_path); + let persisted = + read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) + .expect("read current draft") + .expect("current draft retained"); + assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); + assert_eq!( + persisted + .last_commit + .as_ref() + .map(|last| last.commit_id.as_str()), + Some(second_commit_id.as_str()) + ); + + let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repeat superseded recovery"); + assert!(repeated.result.outcomes.iter().any(|outcome| { + outcome.commit_id == first_commit_id + && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded + })); + assert!(!repeated + .result + .outcomes + .iter() + .any(|outcome| outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired)); + + let (draft_before_third, third_layer_id, third_media_sha256) = + add_candidate_layer(&fixture, &persisted); + let staged = stage_image(&fixture, &draft_before_third); + let mut third_input = commit_input( + &fixture, + &draft_before_third, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + third_input.source_layer_id = Some(third_layer_id); + third_input.media_sha256 = Some(third_media_sha256); + commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery"); +} + +#[test] +fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() { + let (fixture, first_commit_id, second_commit_id) = + initialize_refine_fixture_with_later_commit(); + let mut second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) + .expect("read later journal") + .expect("later journal retained"); + second_journal.expected_project_revision += 1; + write_asset_canvas_journal(fixture.root(), &second_journal) + .expect("break later transaction chain"); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover broken later chain"); + let first_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == first_commit_id) + .expect("first recovery outcome"); + assert_eq!( + first_outcome.status, + RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + ); + let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) + .expect("read retained first journal") + .expect("retained first journal"); + assert!(fixture + .root() + .join(&first_journal.final_image_relative_path) + .is_file()); +} + +#[test] +fn recovery_does_not_supersede_when_later_final_image_is_changed() { + let (fixture, _first_commit_id, second_commit_id) = + initialize_refine_fixture_with_later_commit(); + let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) + .expect("read later journal") + .expect("later journal retained"); + let replacement = png_bytes([240, 100, 30, 255]); + fs::write( + fixture + .root() + .join(&second_journal.final_image_relative_path), + &replacement, + ) + .expect("replace later final image"); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover changed later final image"); + assert!(recovered.result.outcomes.iter().all(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); + assert_eq!( + fs::read( + fixture + .root() + .join(&second_journal.final_image_relative_path) + ) + .expect("retain unknown later final image"), + replacement + ); +} + +#[test] +fn recovery_keeps_rolled_back_transaction_rolled_back_when_later_commit_exists() { + let (fixture, first_commit_id, _second_commit_id) = + initialize_refine_fixture_with_later_commit(); + let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) + .expect("read rolled-back journal") + .expect("rolled-back journal retained"); + let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) + .expect("read rolled-back ledger") + .expect("rolled-back ledger retained"); + first_journal.stage = AssetCanvasTransactionStage::RolledBack; + first_ledger.status = AssetCanvasLedgerStatus::RolledBack; + write_asset_canvas_journal(fixture.root(), &first_journal) + .expect("persist rolled-back journal"); + write_asset_canvas_ledger(fixture.root(), &first_ledger).expect("persist rolled-back ledger"); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover with rolled-back predecessor"); + let first_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == first_commit_id) + .expect("rolled-back recovery outcome"); + assert_eq!( + first_outcome.status, + RecoverAssetCanvasOutcomeStatus::RolledBack + ); + assert_eq!(first_journal.stage, AssetCanvasTransactionStage::RolledBack); + assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::RolledBack); +} + +#[test] +fn unresolved_refine_commit_blocks_and_then_clears_after_recovery() { + let fixture = initialize_refine_fixture(); + let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let mut first_input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + first_input.source_layer_id = Some(first_layer_id); + first_input.media_sha256 = Some(first_media_sha256); + commit_asset_canvas_at_internal( + fixture.root(), + &first_input, + Some(AssetCanvasCommitFaultStage::FileInstalled), + ) + .expect_err("interrupt first refine commit"); + + let draft_before_second = + read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) + .expect("read draft after interruption") + .expect("draft retained after interruption"); + let (draft_before_second, second_layer_id, second_media_sha256) = + add_candidate_layer(&fixture, &draft_before_second); + let staged = stage_image(&fixture, &draft_before_second); + let mut second_input = commit_input( + &fixture, + &draft_before_second, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + second_input.source_layer_id = Some(second_layer_id); + second_input.media_sha256 = Some(second_media_sha256); + let error = commit_asset_canvas_at(fixture.root(), &second_input) + .expect_err("block same-asset commit before recovery"); + assert!(error.contains("未收尾的正式图提交"), "{error}"); + assert!( + read_asset_canvas_ledger(fixture.root(), &second_input.commit_id) + .expect("check blocked ledger") + .is_none() + ); + assert!(!fixture + .root() + .join(".agent/workbench/asset-canvas/transactions") + .join(&second_input.commit_id) + .exists()); + + recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover interrupted first commit"); + commit_asset_canvas_at(fixture.root(), &second_input).expect("commit after safe recovery"); } #[test] @@ -854,7 +1742,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!( @@ -1167,6 +2055,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, @@ -1197,3 +2087,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/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 293cf7f8b..690c95b45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -14,6 +14,8 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; +const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -30,6 +32,10 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) path: String, pub(crate) media_type: String, pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) pixel_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) pixel_height: Option, pub(crate) data_url: String, } @@ -196,14 +202,135 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let media_type = detect_project_media_type(&normalized, &bytes, kind)?; cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), byte_len: bytes.len() as u64, + pixel_width: dimensions.map(|(width, _)| width), + pixel_height: dimensions.map(|(_, height)| height), data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, }) } +fn detect_project_art_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> { + let dimensions = match media_type { + "image/gif" => detect_gif_dimensions(bytes), + "image/bmp" => detect_bmp_dimensions(bytes), + "image/avif" => detect_ispe_dimensions(bytes), + "image/svg+xml" => detect_svg_dimensions(bytes), + _ => None, + }?; + let (width, height) = dimensions; + let pixels = u64::from(width).checked_mul(u64::from(height))?; + if width == 0 + || height == 0 + || width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return None; + } + Some((width, height)) +} + +fn detect_gif_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 10 || !matches!(&bytes[..6], b"GIF87a" | b"GIF89a") { + return None; + } + Some(( + u32::from(u16::from_le_bytes([bytes[6], bytes[7]])), + u32::from(u16::from_le_bytes([bytes[8], bytes[9]])), + )) +} + +fn detect_bmp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 26 || &bytes[..2] != b"BM" { + return None; + } + let width = i32::from_le_bytes(bytes[18..22].try_into().ok()?); + let height = i32::from_le_bytes(bytes[22..26].try_into().ok()?); + Some((width.unsigned_abs(), height.unsigned_abs())) +} + +fn detect_ispe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let marker = b"ispe"; + let index = bytes + .windows(marker.len()) + .position(|window| window == marker)?; + if index < 4 || index.checked_add(16)? > bytes.len() { + return None; + } + Some(( + u32::from_be_bytes(bytes[index + 8..index + 12].try_into().ok()?), + u32::from_be_bytes(bytes[index + 12..index + 16].try_into().ok()?), + )) +} + +fn detect_svg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let text = std::str::from_utf8(bytes).ok()?; + let lower = text.to_ascii_lowercase(); + let start = lower.find("')? + start; + let root = &text[start..end]; + let width = svg_length_attribute(root, "width"); + let height = svg_length_attribute(root, "height"); + if let (Some(width), Some(height)) = (width, height) { + return Some((width, height)); + } + let view_box = svg_attribute(root, "viewbox")?; + let mut values = view_box + .split(|character: char| character.is_ascii_whitespace() || character == ',') + .filter_map(|value| value.parse::().ok()); + let _min_x = values.next()?; + let _min_y = values.next()?; + let width = values.next()?; + let height = values.next()?; + Some((safe_svg_dimension(width)?, safe_svg_dimension(height)?)) +} + +fn svg_length_attribute(root: &str, name: &str) -> Option { + let value = svg_attribute(root, name)?; + let value = value.trim().strip_suffix("px").unwrap_or(value.trim()); + safe_svg_dimension(value.parse::().ok()?) +} + +fn svg_attribute<'a>(root: &'a str, name: &str) -> Option<&'a str> { + let lower = root.to_ascii_lowercase(); + let mut offset = 0; + while let Some(relative) = lower[offset..].find(name) { + let start = offset + relative; + let before = start + .checked_sub(1) + .and_then(|index| lower.as_bytes().get(index)); + let after = lower.as_bytes().get(start + name.len()); + if before.is_some_and(|byte| byte.is_ascii_whitespace()) + && after.is_some_and(|byte| *byte == b'=' || byte.is_ascii_whitespace()) + { + let rest = root[start + name.len()..].trim_start(); + let rest = rest.strip_prefix('=')?.trim_start(); + let quote = rest.chars().next()?; + if quote == '\'' || quote == '"' { + let value = &rest[quote.len_utf8()..]; + let end = value.find(quote)?; + return Some(&value[..end]); + } + } + offset = start + name.len(); + } + None +} + +fn safe_svg_dimension(value: f64) -> Option { + if !value.is_finite() || value <= 0.0 || value > f64::from(PROJECT_MEDIA_PREVIEW_MAX_DIMENSION) + { + return None; + } + Some(value.round() as u32) +} + fn encode_project_resource_preview_data_url( media_type: &str, bytes: &[u8], @@ -539,6 +666,8 @@ mod tests { ) .expect("safe svg"); assert_eq!(preview.media_type, "image/svg+xml"); + assert_eq!(preview.pixel_width, None); + assert_eq!(preview.pixel_height, None); assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); assert!(load_local_project_media_preview( root.path(), @@ -554,6 +683,54 @@ mod tests { .is_err()); } + #[test] + fn media_preview_reports_safe_dimensions_for_extended_art_images() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&320_u16.to_le_bytes()); + gif.extend_from_slice(&180_u16.to_le_bytes()); + gif.extend_from_slice(&[0; 4]); + fs::write(root.path().join("assets/scene.gif"), gif).expect("gif"); + + let mut bmp = vec![0_u8; 26]; + bmp[..2].copy_from_slice(b"BM"); + bmp[18..22].copy_from_slice(&640_i32.to_le_bytes()); + bmp[22..26].copy_from_slice(&(-360_i32).to_le_bytes()); + fs::write(root.path().join("assets/scene.bmp"), bmp).expect("bmp"); + + let mut avif = vec![0_u8; 32]; + avif[4..8].copy_from_slice(b"ftyp"); + avif[8..12].copy_from_slice(b"avif"); + avif[16..20].copy_from_slice(b"ispe"); + avif[24..28].copy_from_slice(&1920_u32.to_be_bytes()); + avif[28..32].copy_from_slice(&1080_u32.to_be_bytes()); + fs::write(root.path().join("assets/scene.avif"), avif).expect("avif"); + + fs::write( + root.path().join("assets/scene.svg"), + r#""#, + ) + .expect("svg"); + + for (path, expected_type, expected_dimensions) in [ + ("assets/scene.gif", "image/gif", Some((320, 180))), + ("assets/scene.bmp", "image/bmp", Some((640, 360))), + ("assets/scene.avif", "image/avif", Some((1920, 1080))), + ("assets/scene.svg", "image/svg+xml", Some((800, 600))), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Art) + .expect("extended image preview"); + assert_eq!(preview.media_type, expected_type); + assert_eq!( + preview.pixel_width.zip(preview.pixel_height), + expected_dimensions + ); + } + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 628dec413..dfc64b43f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -105,6 +105,33 @@ fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() { fs::remove_dir_all(root).ok(); } +#[test] +fn direct_codex_art_commit_relays_standalone_manifest_invalidation() { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + let root = unique_project_path(); + let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind direct Codex manifest invalidation relay fixture"); + let relay_port = relay_listener + .local_addr() + .expect("read direct Codex manifest invalidation relay fixture address") + .port(); + let relay_token = "c".repeat(64); + sink_guard + .configure(relay_port, &relay_token) + .expect("configure direct Codex manifest invalidation relay fixture"); + + emit_game_creator_manifest_invalidated(&root, "direct-codex-art"); + + let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener) + .expect("receive direct Codex manifest invalidation relay within deadline"); + let relay: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&relay_payload) + .expect("parse direct Codex manifest invalidation relay"); + assert_eq!(relay.token, relay_token); + assert_eq!(relay.event.project_path, root.to_string_lossy()); + assert_eq!(relay.event.agent_id, "direct-codex-art"); +} + #[test] fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() { let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) 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 eacec8589..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 @@ -6142,6 +6142,11 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation) .expect("auto preview"); 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"); + assert_eq!(serialized_preview["pixelWidth"], 1); + assert_eq!(serialized_preview["pixelHeight"], 1); fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image"); let unregistered_error = read_local_project_image_preview_at( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index b1dc5ec60..ddf21796e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6412,14 +6412,21 @@ export function App({ ); } } finally { - setChatAgentBusy(false); - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId) - ) { - resetDirectCodexTurn(); + try { + if (localProjectPathRef.current === directProjectPath) { + await refreshManifest(directProjectPath); + } + } finally { + setChatAgentBusy(false); + setDirectCodexProgress(''); + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId) + ) { + resetDirectCodexTurn(); + } } } return; @@ -11565,8 +11572,8 @@ export function App({ ); const projectSupervisorHasConversationControls = Boolean( projectSupervisorRuntime?.pendingToolAction || - projectSupervisorRuntime?.userInputRequest || - projectSupervisorNeedsUserInput, + projectSupervisorRuntime?.userInputRequest || + projectSupervisorNeedsUserInput, ); const visibleAgentConversationMessages = latestVisibleItems( agentConversationMessages, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d02b130b7..f86844dac 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -365,7 +365,11 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; + | 'streaming' + | 'ready' + | 'committed' + | 'discarded' + | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -486,13 +490,22 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - 'openai_responses' | 'openai_chat' | 'anthropic'; + | 'openai_responses' + | 'openai_chat' + | 'anthropic'; export type GameCreatorAgentMode = - 'codex_app_server' | 'codex_cli' | 'provider'; + | 'codex_app_server' + | 'codex_cli' + | 'provider'; export type RuntimeLlmProviderPresetId = - 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; + | 'custom' + | 'openai' + | 'deepseek' + | 'anthropic' + | 'ark'; export type RuntimeAgentLlmProviderPresetId = - 'inherit' | RuntimeLlmProviderPresetId; + | 'inherit' + | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { apiKey: string; @@ -795,7 +808,12 @@ export interface AgentProgressEvent { } export type GameCreatorDirectTurnUpdateStatus = - 'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed'; + | 'accepted' + | 'running' + | 'streaming' + | 'finalizing' + | 'completed' + | 'failed'; export type GameCreatorDirectTurnActivity = | 'request-accepted' diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 765c7bf85..04e0e279e 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -13,6 +13,7 @@ import { type ImageCanvasDraftCanvas, type ImageCanvasGenerationProgress, type ImageCanvasGenerationProgressPhase, + type ImageCanvasGenerationRecord, type ImageCanvasGenerationServiceIdentityConfirmation, type ImageCanvasHostScope, type ImageCanvasMediaRef, @@ -21,12 +22,14 @@ import { removeCanvasLayers, resizeCanvasLayerBounds, resolveLayerPointerSelection, + resolveQuickEditFocusViewport, resolveViewportFromWheel, scaleViewportFromScreenPoint, transformCanvasLayers, } from '@genarrative/image-canvas-core'; import { CanvasChromeButton, + CanvasPortal, CanvasToolbar, CanvasToolbarDivider, CanvasToolbarGroup, @@ -40,7 +43,13 @@ import { import { ArrowLeft, Check, + CircleCheck, + CircleX, + Clock3, ImagePlus, + ListChecks, + LoaderCircle, + LocateFixed, Maximize2, Minus, Plus, @@ -55,6 +64,7 @@ import { } from 'lucide-react'; import { type ChangeEvent, + type CSSProperties, type PointerEvent as ReactPointerEvent, useCallback, useEffect, @@ -66,6 +76,7 @@ import { import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { useWalletStore } from '../../stores/useWalletStore'; +import { isValidAssetCanvasName } from './assetCanvasNaming'; import type { LocalAssetCommittedEvent, TauriImageCanvasHostAdapter, @@ -140,6 +151,231 @@ type PendingGenerationIdentity = { commitIdempotencyKey: string; }; +const ASSET_CANVAS_WORLD_SIZE = 12_000; + +const GENERATION_ASPECT_RATIOS = ['1:1', '2:3', '3:2', '9:16', '16:9'] as const; + +type GenerationAspectRatio = (typeof GENERATION_ASPECT_RATIOS)[number]; + +function numericAspectRatio(aspectRatio: GenerationAspectRatio) { + const [width = 1, height = 1] = aspectRatio.split(':').map(Number); + return width / height; +} + +export function generationAspectRatioForOriginalImage( + width: number, + height: number, +): GenerationAspectRatio { + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return '1:1'; + } + const originalRatio = width / height; + return GENERATION_ASPECT_RATIOS.reduce( + (closest, candidate) => + Math.abs(Math.log(originalRatio / numericAspectRatio(candidate))) < + Math.abs(Math.log(originalRatio / numericAspectRatio(closest))) + ? candidate + : closest, + '1:1', + ); +} + +export function resolveQuickEditPanelPosition(input: { + anchorX: number; + belowTop: number; + aboveTop: number; + canvasSize: { width: number; height: number }; + panelSize: { width: number; height: number }; + edgePadding?: number; +}) { + const edgePadding = input.edgePadding ?? 12; + const panelWidth = Math.max(0, input.panelSize.width); + const panelHeight = Math.max(0, input.panelSize.height); + const minCenterX = edgePadding + panelWidth / 2; + const maxCenterX = Math.max( + minCenterX, + input.canvasSize.width - edgePadding - panelWidth / 2, + ); + const maxTop = Math.max( + edgePadding, + input.canvasSize.height - edgePadding - panelHeight, + ); + const top = + input.belowTop + panelHeight <= input.canvasSize.height - edgePadding + ? input.belowTop + : input.aboveTop; + return { + left: Math.min(Math.max(input.anchorX, minCenterX), maxCenterX), + top: Math.min(Math.max(top, edgePadding), maxTop), + }; +} + +type RuntimeGenerationTask = { + generationId: string; + sourceLayerId: string | null; + placeholder: { x: number; y: number; width: number; height: number } | null; + phase: ImageCanvasGenerationProgressPhase; + progress: number | null; + errorCode: string | null; + createdAt: number; + updatedAt: number; +}; + +type GenerationTaskVisualStatus = 'pending' | 'running' | 'failed' | 'done'; + +function generationTaskVisualStatus( + phase: ImageCanvasGenerationProgressPhase, +): GenerationTaskVisualStatus { + if (phase === 'failed') return 'failed'; + if ( + phase === 'candidate-ready' || + phase === 'asset-durable-committed' || + phase === 'manifest-projected' || + phase === 'layout-ready' || + phase === 'selected' + ) { + return 'done'; + } + if (phase === 'confirmation-required' || phase === 'generation-accepted') { + return 'pending'; + } + return 'running'; +} + +function generationTaskStatusLabel(status: GenerationTaskVisualStatus) { + if (status === 'pending') return '排队中'; + if (status === 'running') return '生成中'; + if (status === 'failed') return '失败'; + return '已完成'; +} + +function generationTaskStatusIcon(status: GenerationTaskVisualStatus) { + if (status === 'pending') return