From 502dc231c1e032e3e068d90769324a677ee5448f Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 22 Aug 2026 15:18:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90=E7=B2=BE?= =?UTF-8?q?=E4=BF=AE=E6=8F=90=E4=BA=A4=E4=B8=8E=E4=BA=8B=E5=8A=A1=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一解析精修素材名并剥离历史提交后缀 将候选提交参数校验前移到零副作用阶段 严格识别被后续提交取代的旧事务并增加并发提交门禁 支持生成占位卡拖动和精修候选唯一提交 补充素材画布回归测试与合同文档 --- .../src-tauri/src/project/asset_canvas.rs | 360 +++++++++++++++- .../src/project/asset_canvas/generation.rs | 6 +- .../src/project/asset_canvas_tests.rs | 383 ++++++++++++++++++ .../asset-canvas/AssetCanvasSurface.tsx | 204 +++++++++- .../asset-canvas/assetCanvasNaming.ts | 82 ++++ .../asset-canvas/assetCanvasSurface.css | 7 + .../tauriImageCanvasHostAdapter.ts | 30 +- .../src/view/project-development/index.tsx | 8 +- .../tests/assetCanvasSurface.test.tsx | 254 +++++++++++- docs/project-memory/shared-memory/pitfalls.md | 7 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 9 + 11 files changed, 1307 insertions(+), 43 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasNaming.ts 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 4cf8207f6..11c7283b6 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 @@ -1310,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), @@ -1356,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, @@ -1521,9 +1561,27 @@ pub(crate) fn update_asset_canvas_draft_at( 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. + // 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 { @@ -2443,6 +2501,7 @@ enum AssetCanvasLedgerStatus { Committed, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -2490,6 +2549,7 @@ enum AssetCanvasTransactionStage { EventAttempted, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -2666,6 +2726,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, @@ -3199,6 +3353,13 @@ fn commit_asset_canvas_at_internal( .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) { @@ -3439,6 +3600,8 @@ 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)?; @@ -3581,6 +3744,7 @@ pub(crate) enum RecoverAssetCanvasOutcomeStatus { AlreadyCommitted, RolledBack, ReconciliationRequired, + Superseded, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -3679,6 +3843,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, @@ -3826,6 +4118,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 @@ -3945,6 +4264,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 416508df8..40ea86ade 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 @@ -1069,6 +1069,10 @@ 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(), @@ -1076,7 +1080,7 @@ fn upsert_public_generation_record( reference_resource_ids: references, output_asset_id, source_layer_id: ledger.source_layer_id.clone(), - placeholder: ledger.placeholder.clone(), + placeholder, error_code: ledger.error_code.clone(), created_at: index .and_then(|index| draft.generations.get(index)) 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 1867df77c..0a74b6c93 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 @@ -165,6 +165,57 @@ 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, @@ -432,6 +483,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(), @@ -526,6 +602,40 @@ fn candidate_commit_freezes_layer_and_media_identity_and_keeps_draft_editable() 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 @@ -581,6 +691,37 @@ fn candidate_commit_preserves_registered_refine_source_identity() { 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"; @@ -680,6 +821,248 @@ fn candidate_commit_recovery_restores_editing_and_last_commit_identity() { 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] fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() { let directory = tempfile::tempdir().expect("create refine discovery fixture"); 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 001eb4b8f..8ec8ab4f4 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 @@ -75,6 +75,7 @@ import { import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { useWalletStore } from '../../stores/useWalletStore'; +import { isValidAssetCanvasName } from './assetCanvasNaming'; import type { LocalAssetCommittedEvent, TauriImageCanvasHostAdapter, @@ -151,6 +152,38 @@ type PendingGenerationIdentity = { 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', + ); +} + type RuntimeGenerationTask = { generationId: string; sourceLayerId: string | null; @@ -223,7 +256,7 @@ function formatGenerationTaskDuration( } function generationPlaceholderSize( - aspectRatio: '1:1' | '2:3' | '3:2' | '9:16' | '16:9', + aspectRatio: GenerationAspectRatio, imageSize: '0.5K' | '1K' | '2K', ) { const [ratioWidthText, ratioHeightText] = aspectRatio.split(':'); @@ -347,6 +380,16 @@ type DragState = historySnapshot: CanvasHistorySnapshot; changed: boolean; selectionChanged: false; + } + | { + kind: 'generation'; + pointerId: number; + startClientX: number; + startClientY: number; + startTasks: RuntimeGenerationTask[]; + taskId: string; + changed: boolean; + selectionChanged: false; }; function equalSelectionIds(left: string[], right: string[]) { @@ -450,6 +493,13 @@ export function assetCanvasFailurePresentation( }; } if (failure.operation === 'asset-commit') { + if (failure.code === 'asset-name-invalid') { + return { + ariaLabel: '素材名称无效', + kicker: '输入校验', + title: '素材名称需要修改', + }; + } return { ariaLabel: '素材提交未完成', kicker: '素材提交', @@ -664,9 +714,8 @@ export function AssetCanvasSurface({ string | null >(null); const [generationPrompt, setGenerationPrompt] = useState(''); - const [generationAspectRatio, setGenerationAspectRatio] = useState< - '1:1' | '2:3' | '3:2' | '9:16' | '16:9' - >('1:1'); + const [generationAspectRatio, setGenerationAspectRatio] = + useState('1:1'); const [generationImageSize, setGenerationImageSize] = useState< '0.5K' | '1K' | '2K' >('1K'); @@ -1421,6 +1470,49 @@ export function AssetCanvasSurface({ (event.clientX - drag.startClientX) / viewportRef.current.scale; const deltaY = (event.clientY - drag.startClientY) / viewportRef.current.scale; + if (drag.kind === 'generation') { + const task = drag.startTasks.find( + (item) => item.generationId === drag.taskId, + ); + if (!task?.placeholder) return; + const deltaX = + (event.clientX - drag.startClientX) / viewportRef.current.scale; + const deltaY = + (event.clientY - drag.startClientY) / viewportRef.current.scale; + const start = task.placeholder; + const next = { + ...start, + x: Math.max( + 0, + Math.min(ASSET_CANVAS_WORLD_SIZE - start.width, start.x + deltaX), + ), + y: Math.max( + 0, + Math.min(ASSET_CANVAS_WORLD_SIZE - start.height, start.y + deltaY), + ), + }; + drag.changed = deltaX !== 0 || deltaY !== 0; + setGenerationTasks((current) => + current.map((candidate) => + candidate.generationId === drag.taskId + ? { ...candidate, placeholder: next, updatedAt: Date.now() } + : candidate, + ), + ); + const currentDraft = draftRef.current; + if (currentDraft) { + draftRef.current = { + ...currentDraft, + generations: currentDraft.generations.map((record) => + record.generationId === drag.taskId + ? { ...record, placeholder: next, updatedAt: Date.now() } + : record, + ), + }; + setDraft(draftRef.current); + } + return; + } if (drag.kind === 'move') { if ( (deltaX === 0 && deltaY === 0) || @@ -1490,7 +1582,7 @@ export function AssetCanvasSurface({ if (!drag || drag.pointerId !== event.pointerId) return; suppressNextLayerClickRef.current = drag.kind === 'move' && drag.changed; dragRef.current = null; - if (drag.changed) { + if (drag.changed && drag.kind !== 'generation') { captureHistory(drag.historyAction, drag.historySnapshot); } if (drag.changed || drag.selectionChanged) { @@ -1783,6 +1875,10 @@ export function AssetCanvasSurface({ ) { return savePromiseRef.current ?? undefined; } + if (!isValidAssetCanvasName(assetName)) { + setNotice('素材名称无效,请调整为 1-80 个有效字符'); + return undefined; + } const selectedCandidate = stableScope.intent === 'refine' && quickEditSourceLayerId ? (layersRef.current.find( @@ -2109,6 +2205,12 @@ export function AssetCanvasSurface({ setGenerationReferenceResourceIds([...new Set(selectedReferences)]); generationDialogQuickEditRef.current = quickEdit; if (quickEdit && sourceLayer) { + setGenerationAspectRatio( + generationAspectRatioForOriginalImage( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ), + ); setQuickEditSourceLayerId(sourceLayer.id); setQuickEditOpen(true); setGenerationDialog(null); @@ -2231,6 +2333,30 @@ export function AssetCanvasSurface({ createdAt: Date.now(), updatedAt: Date.now(), }; + const initialGenerationRecord: ImageCanvasGenerationRecord = { + generationId: identity.generationId, + intentId: identity.intentId, + phase: 'generation-running', + referenceResourceIds: [...generationReferenceResourceIds], + outputAssetId: null, + sourceLayerId: sourceLayer?.id ?? null, + placeholder, + errorCode: null, + createdAt: initialTask.createdAt, + updatedAt: initialTask.updatedAt, + }; + const draftWithGeneration = draftRef.current; + if (draftWithGeneration) { + const nextDraft = { + ...draftWithGeneration, + generations: [ + ...draftWithGeneration.generations, + initialGenerationRecord, + ], + }; + draftRef.current = nextDraft; + setDraft(nextDraft); + } setGenerationTasksCollapsed(false); setGenerationTaskTab('active'); setGenerationTasks((current) => [...current, initialTask]); @@ -2243,8 +2369,7 @@ export function AssetCanvasSurface({ const frozenImageSize = generationImageSize; const frozenAssetKind = assetKind; const frozenAssetName = assetName; - const needsDraftPersist = - documentVersionRef.current !== persistedDocumentVersionRef.current; + const needsDraftPersist = true; setGenerationDialog(null); setQuickEditOpen(false); dragRef.current = null; @@ -2484,6 +2609,22 @@ export function AssetCanvasSurface({ setLifecycle({ kind: 'canvas.editing', dirty: true }); }, []); + const continueAfterAssetValidationFailure = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'asset-commit' || + lifecycleRef.current.code !== 'asset-name-invalid' + ) { + return; + } + pendingCommitRef.current = null; + setNotice('素材名称无效,请调整为 1-80 个有效字符'); + setLifecycle({ + kind: 'canvas.editing', + dirty: documentVersionRef.current !== persistedDocumentVersionRef.current, + }); + }, []); + const continueAfterCancellationFailure = useCallback(() => { if ( lifecycleRef.current.kind !== 'canvas.failed' || @@ -2553,8 +2694,8 @@ export function AssetCanvasSurface({ : null) ?? layers.find( (layer) => - layer.mediaRef.kind === 'project-asset' && - layer.mediaRef.assetId === stableScope.sourceAssetId, + stableScope.intent === 'refine' && + layer.resourceId === draft?.sourceResourceId, ) ?? null; @@ -3146,8 +3287,29 @@ export function AssetCanvasSurface({ : '重试保存'} ) : null} - {lifecycle.operation === 'asset-commit' || - lifecycle.operation === 'recovery' ? ( + {lifecycle.operation === 'asset-commit' && + lifecycle.code === 'asset-name-invalid' ? ( + + ) : null} + {lifecycle.operation === 'asset-commit' && + lifecycle.code !== 'asset-name-invalid' ? ( + + ) : null} + {lifecycle.operation === 'recovery' ? (