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 922f5acb9..01119ac2c 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,14 @@ pub(crate) fn update_local_project_asset_canvas_draft( update_asset_canvas_draft_at(&root, &input) } +#[tauri::command] +pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers( + input: AcknowledgeAssetCanvasCandidateLayersInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + acknowledge_candidate_layers_at(&root, &input) +} + #[tauri::command] pub(crate) async fn import_local_project_asset_canvas_images( 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 8618b0174..1d23c81c0 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 @@ -396,11 +396,70 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32 } } +#[derive(Clone, Copy)] +enum TiffByteOrder { + LittleEndian, + BigEndian, +} + +fn read_tiff_u16(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { + let end = offset.checked_add(2)?; + let value: [u8; 2] = bytes.get(offset..end)?.try_into().ok()?; + Some(match byte_order { + TiffByteOrder::LittleEndian => u16::from_le_bytes(value), + TiffByteOrder::BigEndian => u16::from_be_bytes(value), + }) +} + +fn read_tiff_u32(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { + let end = offset.checked_add(4)?; + let value: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?; + Some(match byte_order { + TiffByteOrder::LittleEndian => u32::from_le_bytes(value), + TiffByteOrder::BigEndian => u32::from_be_bytes(value), + }) +} + +fn detect_jpeg_exif_orientation(app1_payload: &[u8]) -> Option { + let tiff = app1_payload.strip_prefix(b"Exif\0\0")?; + let byte_order = match tiff.get(..2)? { + b"II" => TiffByteOrder::LittleEndian, + b"MM" => TiffByteOrder::BigEndian, + _ => return None, + }; + if read_tiff_u16(tiff, 2, byte_order)? != 42 { + return None; + } + let ifd_offset = usize::try_from(read_tiff_u32(tiff, 4, byte_order)?).ok()?; + let entry_count = usize::from(read_tiff_u16(tiff, ifd_offset, byte_order)?); + let entries_start = ifd_offset.checked_add(2)?; + let entries_end = entries_start.checked_add(entry_count.checked_mul(12)?)?; + if entries_end > tiff.len() { + return None; + } + for entry_index in 0..entry_count { + let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?; + if read_tiff_u16(tiff, entry_offset, byte_order)? != 0x0112 { + continue; + } + if read_tiff_u16(tiff, entry_offset.checked_add(2)?, byte_order)? != 3 + || read_tiff_u32(tiff, entry_offset.checked_add(4)?, byte_order)? != 1 + { + return None; + } + let orientation = read_tiff_u16(tiff, entry_offset.checked_add(8)?, byte_order)?; + return (1..=8).contains(&orientation).then_some(orientation); + } + None +} + fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { if !bytes.starts_with(&[0xff, 0xd8]) { return None; } let mut index = 2usize; + let mut exif_orientation = None; + let mut dimensions = None; while index + 3 < bytes.len() { if bytes[index] != 0xff { index += 1; @@ -424,21 +483,28 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { if segment_len < 2 || index.checked_add(segment_len)? > bytes.len() { return None; } - if matches!( - marker, - 0xc0 | 0xc1 - | 0xc2 - | 0xc3 - | 0xc5 - | 0xc6 - | 0xc7 - | 0xc9 - | 0xca - | 0xcb - | 0xcd - | 0xce - | 0xcf - ) && segment_len >= 7 + if marker == 0xe1 && exif_orientation.is_none() { + let payload_start = index.checked_add(2)?; + let payload_end = index.checked_add(segment_len)?; + exif_orientation = detect_jpeg_exif_orientation(bytes.get(payload_start..payload_end)?); + } + if dimensions.is_none() + && matches!( + marker, + 0xc0 | 0xc1 + | 0xc2 + | 0xc3 + | 0xc5 + | 0xc6 + | 0xc7 + | 0xc9 + | 0xca + | 0xcb + | 0xcd + | 0xce + | 0xcf + ) + && segment_len >= 7 { let height = u32::from(u16::from_be_bytes([ *bytes.get(index + 3)?, @@ -448,11 +514,17 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { *bytes.get(index + 5)?, *bytes.get(index + 6)?, ])); - return Some((width, height)); + dimensions = Some((width, height)); } index += segment_len; } - None + dimensions.map(|(width, height)| { + if matches!(exif_orientation, Some(5..=8)) { + (height, width) + } else { + (width, height) + } + }) } fn detect_webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { @@ -626,6 +698,72 @@ mod tests { .expect("valid 1x1 png") } + fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec { + let mut bytes = vec![0xff, 0xd8]; + if let Some(payload) = app1_payload { + let segment_len = u16::try_from(payload.len() + 2).expect("APP1 length"); + bytes.extend_from_slice(&[0xff, 0xe1]); + bytes.extend_from_slice(&segment_len.to_be_bytes()); + bytes.extend_from_slice(payload); + } + bytes.extend_from_slice(&[0xff, 0xc0, 0, 7, 8]); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&[0xff, 0xd9]); + bytes + } + + fn exif_orientation_payload(orientation: u16, little_endian: bool) -> Vec { + let mut payload = b"Exif\0\0".to_vec(); + if little_endian { + payload.extend_from_slice(b"II"); + payload.extend_from_slice(&42_u16.to_le_bytes()); + payload.extend_from_slice(&8_u32.to_le_bytes()); + payload.extend_from_slice(&1_u16.to_le_bytes()); + payload.extend_from_slice(&0x0112_u16.to_le_bytes()); + payload.extend_from_slice(&3_u16.to_le_bytes()); + payload.extend_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&orientation.to_le_bytes()); + } else { + payload.extend_from_slice(b"MM"); + payload.extend_from_slice(&42_u16.to_be_bytes()); + payload.extend_from_slice(&8_u32.to_be_bytes()); + payload.extend_from_slice(&1_u16.to_be_bytes()); + payload.extend_from_slice(&0x0112_u16.to_be_bytes()); + payload.extend_from_slice(&3_u16.to_be_bytes()); + payload.extend_from_slice(&1_u32.to_be_bytes()); + payload.extend_from_slice(&orientation.to_be_bytes()); + } + payload.extend_from_slice(&[0, 0]); + payload + } + + #[test] + fn jpeg_dimensions_apply_little_and_big_endian_exif_rotation() { + for (orientation, little_endian) in [(6, true), (8, false)] { + let exif = exif_orientation_payload(orientation, little_endian); + assert_eq!( + detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&exif))), + Some((20, 40)) + ); + } + } + + #[test] + fn jpeg_dimensions_keep_raw_size_for_normal_or_malformed_exif() { + let normal = exif_orientation_payload(1, true); + assert_eq!( + detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&normal))), + Some((40, 20)) + ); + + let malformed = b"Exif\0\0II\x2a\0\xff\xff\xff\x7f"; + assert_eq!( + detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(malformed))), + Some((40, 20)) + ); + } + #[test] fn image_inspect_accepts_magic_bytes_without_trusting_extension() { let root = tempfile::tempdir().expect("temp root"); 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 415bf7454..eb3f8cf6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2366,6 +2366,7 @@ fn main() { read_local_project_asset_canvas_draft, discover_local_project_asset_canvas_draft, update_local_project_asset_canvas_draft, + acknowledge_local_project_asset_canvas_candidate_layers, import_local_project_asset_canvas_images, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, 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 fa913ceec..99c961d92 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 @@ -1560,12 +1560,7 @@ 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 = protect_candidate_layers_before_frontend_update_at( - root, - &draft, - draft.revision, - &input.canvas, - )?; + draft.canvas = protect_candidate_layers_before_frontend_update_at(root, &draft, &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, @@ -4107,100 +4102,117 @@ fn find_superseding_asset_canvas_commit( 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!( + let journals = read_asset_canvas_transaction_journals(root)?; + let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( + root, + &journal.commit_id, + "manifest.after.json", + &journal.manifest_after_sha256, + )?; + let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( + root, + &journal.commit_id, + "project-revision.after.json", + &journal.project_revision_after_sha256, + )?; + let mut pending = vec![(journal.clone(), manifest_after, revision_after)]; + let mut visited = HashSet::from([journal.commit_id.clone()]); + + while let Some((previous, previous_manifest_after, previous_revision_after)) = pending.pop() { + for later in &journals { + if visited.contains(&later.commit_id) + || later.project_id != previous.project_id + || later.draft_id != previous.draft_id + || later.asset_id != previous.asset_id + || later.expected_project_revision != previous.target_project_revision + || later.manifest_before_sha256 != previous.manifest_after_sha256 + || later.project_revision_before_sha256.as_deref() + != Some(previous.project_revision_after_sha256.as_str()) + || later.final_image_relative_path == previous.final_image_relative_path + { + continue; + } + let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else { + continue; + }; + let finalized_link = matches!( later.stage, AssetCanvasTransactionStage::Committed | AssetCanvasTransactionStage::EventAttempted - ) - { - continue; - } + ) && ledger.status == AssetCanvasLedgerStatus::Committed; + let superseded_link = later.stage == AssetCanvasTransactionStage::Superseded + && ledger.status == AssetCanvasLedgerStatus::Superseded; + if !asset_canvas_journal_ledger_identity_matches(later, &ledger) + || (!finalized_link && !superseded_link) + || 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; + } - 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 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, + )?; + if later_manifest_before != previous_manifest_after + || later_revision_before != previous_revision_after + || 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 reaches_current_state = 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; + if reaches_current_state && finalized_link { + let matching_assets = current_manifest + .assets + .iter() + .filter(|asset| asset.id == later.asset_id) + .collect::>(); + if matching_assets.len() == 1 + && matching_assets[0].local_path == later.final_image_relative_path + && matching_assets[0].media_type == later.staged_image.media_type + { + return Ok(true); + } + continue; + } + + visited.insert(later.commit_id.clone()); + pending.push((later.clone(), later_manifest_after, later_revision_after)); } - 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) } @@ -4460,6 +4472,17 @@ fn recover_asset_canvas_transaction_locked( event, )); } + if 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)); + } return 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 ee97bb107..e872f1be9 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 @@ -213,6 +213,21 @@ pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityResult { pub(crate) identity_scheme: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AcknowledgeAssetCanvasCandidateLayersInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) layer_ids: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AcknowledgeAssetCanvasCandidateLayersResult { + pub(crate) draft: AssetCanvasDraft, +} + pub(crate) struct RecoverAssetCanvasGenerationsExecution { pub(crate) result: RecoverAssetCanvasGenerationsResult, pub(crate) events: Vec, @@ -1560,7 +1575,6 @@ fn persist_generation_candidate_at( 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(); @@ -1568,7 +1582,7 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at( if record.phase != AssetCanvasGenerationStatus::CandidateReady { continue; } - let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else { + let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else { continue; }; if ledger.project_id != current_draft.project_id @@ -1589,20 +1603,74 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at( else { continue; }; - if merged_canvas + 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 acknowledge_candidate_layers_at( + root: &Path, + input: &AcknowledgeAssetCanvasCandidateLayersInput, +) -> Result { + validate_uuid_v4(&input.draft_id, "draftId")?; + if input.layer_ids.len() > ASSET_CANVAS_MAX_LAYERS { + return Err("候选图层确认数量已达上限".to_string()); + } + for layer_id in &input.layer_ids { + validate_plain_component(layer_id, "layerId", 512)?; + } + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _draft_guard = 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.project_id != manifest.project_id || draft.draft_id != input.draft_id { + return Err("素材画布候选确认身份不一致".to_string()); + } + + let requested_layer_ids = input + .layer_ids + .iter() + .map(String::as_str) + .collect::>(); + let authoritative_layer_ids = draft + .canvas + .layers + .iter() + .map(|layer| layer.layer_id.as_str()) + .collect::>(); + for record in &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 != 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 requested_layer_ids.contains(&layer_id) && authoritative_layer_ids.contains(layer_id) { + ledger.candidate_layer_acknowledged_revision = Some(draft.revision); + write_generation_ledger(root, &mut ledger)?; + } + } + + Ok(AcknowledgeAssetCanvasCandidateLayersResult { draft }) +} + pub(crate) fn restore_unacknowledged_candidate_layers_at( root: &Path, expected_project_id: &str, @@ -5285,29 +5353,66 @@ mod tests { .iter() .any(|layer| layer.layer_id == candidate_layer_id)); - let acknowledged = update_asset_canvas_draft_at( + let still_unacknowledged = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read protected candidate ledger") + .expect("protected candidate ledger retained"); + assert_eq!( + still_unacknowledged.candidate_layer_acknowledged_revision, + None + ); + + let ignored_acknowledgement = acknowledge_candidate_layers_at( directory.path(), - &UpdateAssetCanvasDraftInput { + &AcknowledgeAssetCanvasCandidateLayersInput { 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(), + layer_ids: vec![source_layer_id.clone(), "unknown-layer".to_string()], }, ) - .expect("acknowledge candidate") - .draft; + .expect("ignore unknown and non-candidate layer acknowledgement"); + assert_eq!(ignored_acknowledgement.draft.revision, protected.revision); + let ignored_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ignored acknowledgement ledger") + .expect("ignored acknowledgement ledger retained"); + assert_eq!(ignored_ledger.candidate_layer_acknowledged_revision, None); + + let acknowledged = acknowledge_candidate_layers_at( + directory.path(), + &AcknowledgeAssetCanvasCandidateLayersInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + layer_ids: vec![candidate_layer_id.to_string()], + }, + ) + .expect("acknowledge candidate"); + assert_eq!(acknowledged.draft.revision, protected.revision); 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) + Some(protected.revision) ); - let mut intentional_delete_canvas = acknowledged.canvas.clone(); + let repeated = acknowledge_candidate_layers_at( + directory.path(), + &AcknowledgeAssetCanvasCandidateLayersInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + layer_ids: vec![candidate_layer_id.to_string()], + }, + ) + .expect("repeat candidate acknowledgement"); + assert_eq!(repeated.draft.revision, protected.revision); + let repeated_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read repeated acknowledgement ledger") + .expect("repeated acknowledgement ledger retained"); + assert_eq!(repeated_ledger, acknowledged_ledger); + + let mut intentional_delete_canvas = acknowledged.draft.canvas.clone(); intentional_delete_canvas .layers .retain(|layer| layer.layer_id != candidate_layer_id); @@ -5319,10 +5424,10 @@ mod tests { 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, + expected_draft_revision: acknowledged.draft.revision, status: AssetCanvasDraftStatus::Editing, canvas: intentional_delete_canvas, - generations: acknowledged.generations.clone(), + generations: acknowledged.draft.generations.clone(), }, ) .expect("allow intentional candidate deletion after acknowledgement") 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 8b2a9d795..d24f2c184 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 @@ -188,7 +188,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) { &fixture, &draft, &staged, - Uuid::new_v4().to_string(), + "f0000000-0000-4000-8000-000000000001".to_string(), Uuid::new_v4().to_string(), ); first_input.source_layer_id = Some(first_layer_id.clone()); @@ -207,7 +207,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) { &fixture, &draft_before_second, &staged, - Uuid::new_v4().to_string(), + "10000000-0000-4000-8000-000000000002".to_string(), Uuid::new_v4().to_string(), ); second_input.source_layer_id = Some(second_layer_id); @@ -1211,6 +1211,124 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit( commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery"); } +#[test] +fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() { + let (fixture, first_commit_id, second_commit_id) = + initialize_refine_fixture_with_later_commit(); + // initialize_refine_fixture_with_later_commit only forces the first + // transaction into the unresolved state. Restore both completed ledgers so + // this test models two successful commits followed by a restart. + for commit_id in [&first_commit_id, &second_commit_id] { + let mut journal = read_asset_canvas_journal(fixture.root(), commit_id) + .expect("read completed transaction journal") + .expect("completed transaction journal"); + let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id) + .expect("read completed transaction ledger") + .expect("completed transaction ledger"); + journal.stage = AssetCanvasTransactionStage::Committed; + ledger.status = AssetCanvasLedgerStatus::Committed; + write_asset_canvas_journal(fixture.root(), &journal) + .expect("restore completed journal stage"); + write_asset_canvas_ledger(fixture.root(), &ledger) + .expect("restore completed ledger status"); + } + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover two completed refine transactions"); + let first_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == first_commit_id) + .expect("first completed outcome"); + let second_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == second_commit_id) + .expect("second completed outcome"); + assert_eq!( + first_outcome.status, + RecoverAssetCanvasOutcomeStatus::Superseded + ); + assert_eq!( + second_outcome.status, + RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + ); + assert!(!recovered.result.outcomes.iter().any(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); + + let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repeat recovery after supersede"); + 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 + })); +} + +#[test] +fn recovery_follows_verified_refine_commit_chain_to_current_state() { + 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 first transaction before third commit") + .expect("first transaction retained"); + let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) + .expect("read first ledger before third commit") + .expect("first ledger retained"); + first_journal.stage = AssetCanvasTransactionStage::Committed; + first_ledger.status = AssetCanvasLedgerStatus::Committed; + write_asset_canvas_journal(fixture.root(), &first_journal) + .expect("restore first journal before third commit"); + write_asset_canvas_ledger(fixture.root(), &first_ledger) + .expect("restore first ledger before third commit"); + let draft_after_second = + read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) + .expect("read draft after second commit") + .expect("draft retained after second commit"); + let (draft_before_third, third_layer_id, third_media_sha256) = + add_candidate_layer(&fixture, &draft_after_second); + 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); + let third_commit_id = third_input.commit_id.clone(); + commit_asset_canvas_at(fixture.root(), &third_input).expect("commit third final image"); + force_refine_transaction_unresolved(&fixture, &first_commit_id); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover three chained refine commits"); + for commit_id in [&first_commit_id, &second_commit_id] { + assert!(recovered.result.outcomes.iter().any(|outcome| { + outcome.commit_id == *commit_id + && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded + })); + } + assert!(recovered.result.outcomes.iter().any(|outcome| { + outcome.commit_id == third_commit_id + && outcome.status == RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + })); + assert!(!recovered.result.outcomes.iter().any(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); + + let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repeat chained refine recovery"); + assert!(!repeated.result.outcomes.iter().any(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); +} + #[test] fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() { let (fixture, first_commit_id, second_commit_id) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 8c587703c..8c87f9403 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024; const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096; const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512; -const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000; +const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000; +const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000; const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100; const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10; @@ -337,11 +338,13 @@ fn validate_resource_layout_positions( if !resource_ids.insert(resource_id) { return Err("资源布局 resourceId 不能重复".to_string()); } - if position.x > RESOURCE_LAYOUT_MAX_COORDINATE + if position.x < RESOURCE_LAYOUT_MIN_COORDINATE + || position.x > RESOURCE_LAYOUT_MAX_COORDINATE + || position.y < RESOURCE_LAYOUT_MIN_COORDINATE || position.y > RESOURCE_LAYOUT_MAX_COORDINATE { return Err(format!( - "资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}" + "资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间" )); } } @@ -526,7 +529,7 @@ mod tests { )) } - fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition { + fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition { ProjectResourceCanvasPosition { resource_id: id.to_string(), section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art, @@ -883,11 +886,27 @@ mod tests { RESOURCE_LAYOUT_MAX_COORDINATE ); - let coordinate_error = update_project_resource_canvas_layout_at( + let lower_boundary = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, "layout-invalid", boundary.layout.revision, + vec![layout_position( + "asset-coordinate-lower-boundary", + RESOURCE_LAYOUT_MIN_COORDINATE, + )], + ) + .expect("lower boundary coordinate must remain valid"); + assert_eq!( + lower_boundary.layout.positions[0].x, + RESOURCE_LAYOUT_MIN_COORDINATE + ); + + let coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + lower_boundary.layout.revision, vec![layout_position( "asset-coordinate", RESOURCE_LAYOUT_MAX_COORDINATE + 1, @@ -895,6 +914,18 @@ mod tests { ) .expect_err("oversized coordinate must fail"); assert!(coordinate_error.contains("坐标")); + let lower_coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + lower_boundary.layout.revision, + vec![layout_position( + "asset-coordinate-below-lower-boundary", + RESOURCE_LAYOUT_MIN_COORDINATE - 1, + )], + ) + .expect_err("coordinate below lower boundary must fail"); + assert!(lower_coordinate_error.contains("坐标")); let id_error = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, 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 04e0e279e..e77e7d7a7 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 @@ -843,6 +843,7 @@ export function AssetCanvasSurface({ const dragRef = useRef(null); const suppressNextLayerClickRef = useRef(false); const saveQueueRef = useRef>(Promise.resolve()); + const pendingCandidateLayerIdsRef = useRef(new Set()); const savePromiseRef = useRef | null>(null); const hostRevisionRef = useRef(expectedHostRevision); const previewUrlsRef = useRef(new Set()); @@ -1172,6 +1173,7 @@ export function AssetCanvasSurface({ const epoch = epochRef.current + 1; epochRef.current = epoch; saveQueueRef.current = Promise.resolve(); + pendingCandidateLayerIdsRef.current.clear(); savePromiseRef.current = null; pendingCommitRef.current = null; pendingGenerationRef.current = null; @@ -1267,6 +1269,28 @@ export function AssetCanvasSurface({ } await hydrateDraft(nextDraft, epoch); if (epoch !== epochRef.current) return; + if ( + nextDraft.generations.some( + (generation) => generation.phase === 'candidate-ready', + ) + ) { + const acknowledged = await host.project.acknowledgeCandidateLayers({ + scope: stableScope, + // The host ignores non-candidate and already acknowledged IDs. This + // closes the restart window without exposing private ledger state. + layerIds: nextDraft.canvas.layers.map((layer) => layer.layerId), + }); + if (epoch !== epochRef.current) return; + if (acknowledged.status !== 'ok') { + throw new Error( + acknowledged.status === 'failed' || + acknowledged.status === 'unsupported-capability' + ? acknowledged.message + : '恢复候选图层确认发生草稿冲突', + ); + } + applyDraftCandidate(acknowledged.value); + } // 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑, // 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。 @@ -1350,6 +1374,7 @@ export function AssetCanvasSurface({ previewUrls.clear(); }; }, [ + applyDraftCandidate, applyGenerationProgressRevision, expectedHostRevision, host, @@ -1463,13 +1488,64 @@ export function AssetCanvasSurface({ return () => observer.disconnect(); }, [draft]); - const persistDraft = - useCallback(async (): Promise => { + const acknowledgePendingCandidateLayers = useCallback( + async (epoch: number): Promise => { + const currentDraft = draftRef.current; + const layerIds = [...pendingCandidateLayerIdsRef.current]; + if (!currentDraft || epoch !== epochRef.current) return null; + if (layerIds.length === 0) return currentDraft; + const result = await host.project.acknowledgeCandidateLayers({ + scope: stableScope, + layerIds, + }); + if (epoch !== epochRef.current) return null; + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' || + result.status === 'unsupported-capability' + ? result.message + : '候选图层确认发生草稿冲突', + reconciliationRequired: false, + }); + return null; + } + for (const layerId of layerIds) { + pendingCandidateLayerIdsRef.current.delete(layerId); + } + applyDraftCandidate(result.value); + return result.value; + }, + [applyDraftCandidate, host.project, stableScope], + ); + + const enqueuePendingCandidateAcknowledgements = useCallback(async () => { + const epoch = epochRef.current; + const task = saveQueueRef.current.then(() => + acknowledgePendingCandidateLayers(epoch), + ); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, [acknowledgePendingCandidateLayers]); + + const persistDraft = useCallback( + async ( + candidateLayerIds: readonly string[] = [], + ): Promise => { const epoch = epochRef.current; const requestedVersion = documentVersionRef.current; const task = saveQueueRef.current.then(async () => { const currentDraft = draftRef.current; if (!currentDraft || epoch !== epochRef.current) return null; + // Register a candidate only when its own save reaches the head of the + // FIFO. Registering at enqueue time would let an older autosave + // acknowledge it before this save has written the latest canvas. + for (const layerId of candidateLayerIds) { + pendingCandidateLayerIdsRef.current.add(layerId); + } const result = await host.project.updateDraft({ scope: stableScope, expectedDraftRevision: currentDraft.revision, @@ -1509,17 +1585,42 @@ export function AssetCanvasSurface({ persistedDocumentVersionRef.current, requestedVersion, ); + const acknowledged = await acknowledgePendingCandidateLayers(epoch); + if (!acknowledged) return null; if ( requestedVersion === documentVersionRef.current && lifecycleRef.current.kind === 'canvas.editing' ) { setLifecycle({ kind: 'canvas.editing', dirty: false }); } - return result.value; + return acknowledged; }); saveQueueRef.current = task.catch(() => undefined); return await task; - }, [applyDraftCandidate, host.project, stableScope]); + }, + [ + acknowledgePendingCandidateLayers, + applyDraftCandidate, + host.project, + stableScope, + ], + ); + + const flushDraftPersistence = useCallback( + async ({ persistDirty = true }: { persistDirty?: boolean } = {}) => { + const epoch = epochRef.current; + await saveQueueRef.current; + if (epoch !== epochRef.current || !draftRef.current) return null; + if ( + persistDirty && + documentVersionRef.current !== persistedDocumentVersionRef.current + ) { + return await persistDraft(); + } + return await enqueuePendingCandidateAcknowledgements(); + }, + [enqueuePendingCandidateAcknowledgements, persistDraft], + ); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -1697,13 +1798,7 @@ export function AssetCanvasSurface({ ) { return; } - if ( - documentVersionRef.current !== persistedDocumentVersionRef.current && - !(await persistDraft()) - ) { - return; - } - const currentDraft = draftRef.current; + const currentDraft = await flushDraftPersistence(); if (!currentDraft || lifecycleRef.current.kind !== 'canvas.editing') return; const epoch = epochRef.current; const historySnapshot = getCanvasHistorySnapshot(); @@ -1809,7 +1904,7 @@ export function AssetCanvasSurface({ captureHistory, getCanvasHistorySnapshot, host, - persistDraft, + flushDraftPersistence, stableScope, ]); @@ -1817,11 +1912,10 @@ export function AssetCanvasSurface({ async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ''; - const currentDraft = draftRef.current; if ( lifecycleRef.current.kind !== 'canvas.editing' || backgroundInteractionLockedRef.current || - !currentDraft || + !draftRef.current || !files.length ) return; @@ -1854,6 +1948,15 @@ export function AssetCanvasSurface({ ) { return; } + const currentDraft = await flushDraftPersistence(); + if ( + !currentDraft || + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + return; + } const imported = await host.asset.importImages({ scope: stableScope, expectedDraftRevision: currentDraft.revision, @@ -1927,7 +2030,7 @@ export function AssetCanvasSurface({ markDirty(); setNotice(`已导入 ${additions.length} 张图片`); }, - [captureHistory, host.asset, markDirty, stableScope], + [captureHistory, flushDraftPersistence, host.asset, markDirty, stableScope], ); const deleteCanvasLayer = useCallback( @@ -2024,12 +2127,11 @@ export function AssetCanvasSurface({ }); const task = (async () => { const epoch = saveEpoch; - if (!draftRef.current) return; if (documentVersionRef.current !== persistedDocumentVersionRef.current) { setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); - const persisted = await persistDraft(); - if (!persisted) return; } + const persisted = await flushDraftPersistence(); + if (!persisted) return; if (epoch !== epochRef.current || !draftRef.current) return; setLifecycle({ kind: 'canvas.saving', stage: 'committing' }); const referenceResourceIds = draftRef.current.sourceResourceId @@ -2156,7 +2258,7 @@ export function AssetCanvasSurface({ hydrateDraft, onCommitted, onSaveAttempt, - persistDraft, + flushDraftPersistence, quickEditSourceLayerId, renderImage, sessionId, @@ -2164,9 +2266,8 @@ export function AssetCanvasSurface({ ]); const discardCanvas = useCallback(() => { - const currentDraft = draftRef.current; if ( - !currentDraft || + !draftRef.current || !['canvas.editing', 'canvas.failed'].includes( lifecycleRef.current.kind, ) || @@ -2178,13 +2279,16 @@ export function AssetCanvasSurface({ } setExitActionPending(true); const epoch = epochRef.current; - void host.project - .discardDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, + void flushDraftPersistence({ persistDirty: false }) + .then((currentDraft) => { + if (!currentDraft || epoch !== epochRef.current) return null; + return host.project.discardDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + }); }) .then((result) => { - if (epoch !== epochRef.current) return; + if (!result || epoch !== epochRef.current) return; if (result.status === 'ok') { setExitDialogOpen(false); onCancel?.({ @@ -2219,7 +2323,7 @@ export function AssetCanvasSurface({ .finally(() => { if (epoch === epochRef.current) setExitActionPending(false); }); - }, [host.project, onCancel, stableScope]); + }, [flushDraftPersistence, host.project, onCancel, stableScope]); const keepDraftAndExit = useCallback(() => { if ( @@ -2237,7 +2341,7 @@ export function AssetCanvasSurface({ if (!currentDraft) return; setExitActionPending(true); const epoch = epochRef.current; - void persistDraft() + void flushDraftPersistence() .then((persisted) => { if (epoch !== epochRef.current || !persisted) return; setExitDialogOpen(false); @@ -2249,7 +2353,7 @@ export function AssetCanvasSurface({ .finally(() => { if (epoch === epochRef.current) setExitActionPending(false); }); - }, [exitActionPending, onCancel, persistDraft, stableScope.draftId]); + }, [exitActionPending, flushDraftPersistence, onCancel, stableScope.draftId]); const requestCanvasExit = useCallback(() => { const currentDraft = draftRef.current; @@ -2586,15 +2690,17 @@ export function AssetCanvasSurface({ return true; }; const task = (async () => { - if (needsDraftPersist) { - const persisted = await persistDraft(); - if (!persisted) { + const persisted = needsDraftPersist + ? await persistDraft() + : await flushDraftPersistence(); + if (!persisted) { + if (needsDraftPersist) { await persistGenerationFailure( 'draft-save-failed', '生成任务占位保存失败', ); - return; } + return; } const currentDraft = draftRef.current; if ( @@ -2755,10 +2861,16 @@ export function AssetCanvasSurface({ setGenerationTasks( mergedGenerations.map(runtimeGenerationTaskFromRecord), ); - // Acknowledge the authoritative candidate with a canvas save. Until - // this first save succeeds, the backend keeps merging the new layer - // back into stale autosaves that were queued while generation ran. - if (!(await persistDraft())) return; + // Save the authoritative candidate, then acknowledge its private ledger + // in the same FIFO. Until both steps succeed, the backend keeps merging + // the new layer back into stale autosaves that were queued during generation. + if ( + !(await persistDraft( + authoritativeLayers.map((layer) => layer.layerId), + )) + ) { + return; + } pendingGenerationRef.current = null; persistedDocumentVersionRef.current = documentVersionRef.current; setLifecycle({ kind: 'canvas.editing', dirty: false }); @@ -2793,6 +2905,7 @@ export function AssetCanvasSurface({ generationPrompt, generationReferenceResourceIds, generationTasks, + flushDraftPersistence, host.generation, onSaveAttempt, onWalletBalanceMayHaveChanged, @@ -2815,13 +2928,7 @@ export function AssetCanvasSurface({ ) { return; } - if ( - documentVersionRef.current !== persistedDocumentVersionRef.current && - !(await persistDraft()) - ) { - return; - } - const currentDraft = draftRef.current; + const currentDraft = await flushDraftPersistence(); if (!currentDraft) return; const epoch = epochRef.current; setArchivingGenerationIds((current) => [...current, generationId]); @@ -2857,9 +2964,9 @@ export function AssetCanvasSurface({ }, [ archivingGenerationIds, + flushDraftPersistence, generationTasks, host.generation, - persistDraft, stableScope, ], ); diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts index bffe6e640..99742ef1f 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -9,6 +9,7 @@ import type { ImageCanvasHostScope, ImageCanvasLocalImportResult, ImageCanvasMediaRef, + ImageCanvasProjectPort, } from '@genarrative/image-canvas-core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; @@ -44,6 +45,10 @@ type DraftUpdateResult = { draft: ImageCanvasDraft; }; +type CandidateLayerAcknowledgementResult = { + draft: ImageCanvasDraft; +}; + type StoredMediaResult = { status: 'stored' | 'conflict'; draftId: string; @@ -144,8 +149,12 @@ export type LocalAssetCommittedEvent = { occurredAt: number; }; -export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & { +export type TauriImageCanvasHostAdapter = Omit< + ImageCanvasHostPort, + 'project' +> & { readonly kind: 'tauri'; + readonly project: ImageCanvasProjectPort; readonly projectPath: string; readonly expectedProjectId: string; readMediaPreview(input: { @@ -545,6 +554,20 @@ export function createTauriImageCanvasHostAdapter(input: { return failure(error); } }, + async acknowledgeCandidateLayers(acknowledgement) { + try { + const result = await invokeInput( + 'acknowledge_local_project_asset_canvas_candidate_layers', + { + ...baseScope(acknowledgement.scope), + layerIds: acknowledgement.layerIds, + }, + ); + return { status: 'ok', value: result.draft }; + } catch (error) { + return failure(error); + } + }, async discardDraft(update) { try { const result = await invokeInput( diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 590ece6b9..6760b2656 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5059,6 +5059,16 @@ iframe.preview-frame { box-shadow: none; } +.game-workbench-stage > .game-resource-manager { + grid-row: 3; + min-height: 0; +} + +.game-workbench-chat { + border-color: var(--platform-line-soft); + box-shadow: var(--platform-nav-active-shadow); +} + .game-workbench-stage[data-resource-view-state^='resources.asset-canvas'], .game-workbench-stage[data-resource-view-state='resources.ui-editor'] { grid-template-rows: auto minmax(0, 1fr); @@ -5205,6 +5215,7 @@ iframe.preview-frame { .game-resource-manager { display: flex; flex-direction: column; + flex: 1 1 auto; min-height: 0; } @@ -5281,6 +5292,8 @@ iframe.preview-frame { .game-resource-canvas { position: relative; flex: 1; + display: flex; + flex-direction: column; min-height: 0; padding: 12px; overflow: auto; @@ -5301,6 +5314,17 @@ iframe.preview-frame { cursor: grabbing; } +.game-resource-canvas--paged { + overflow: hidden; + cursor: grab; + overscroll-behavior: none; + touch-action: none; +} + +.game-resource-canvas--paged:active { + cursor: grabbing; +} + .game-resource-canvas-content { position: relative; display: grid; @@ -5316,6 +5340,246 @@ iframe.preview-frame { min-height: 0; } +.game-resource-canvas-content--paged { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.game-resource-page-shell { + position: relative; + width: 100%; + height: 100%; +} + +.game-resource-outline { + position: absolute; + top: 50%; + left: 12px; + z-index: 4; + display: flex; + flex-direction: column; + gap: 4px; + width: max-content; + max-width: 180px; + max-height: calc(100% - 24px); + padding: 6px; + overflow: auto; + border: 1px solid transparent; + border-radius: 14px; + background: transparent; + box-shadow: none; + opacity: 0.68; + transform: translateY(-50%) scale(0.86); + transform-origin: left center; + transition: + border-color 160ms ease, + opacity 160ms ease, + transform 160ms ease, + box-shadow 160ms ease; + backdrop-filter: none; +} + +.game-resource-outline:hover, +.game-resource-outline:has(> button:focus-visible) { + border-color: var(--platform-nav-active-border); + background: var(--platform-nav-fill); + box-shadow: var(--platform-nav-active-shadow); + opacity: 1; + transform: translateY(-50%) scale(1); + backdrop-filter: blur(10px); +} + +.game-resource-outline button { + display: grid; + grid-template-columns: 0 minmax(0, 1fr); + align-items: center; + gap: 0; + min-height: 34px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--platform-nav-item-text); + cursor: pointer; + text-align: left; + transition: + grid-template-columns 160ms ease, + gap 160ms ease; +} + +.game-resource-outline:hover button, +.game-resource-outline:has(> button:focus-visible) button { + grid-template-columns: 18px minmax(0, 1fr); + gap: 6px; +} + +.game-resource-outline button svg { + width: 0; + min-width: 0; + opacity: 0; + transform: scale(0.65); + transition: + width 160ms ease, + opacity 120ms ease, + transform 160ms ease; +} + +.game-resource-outline:hover button svg, +.game-resource-outline:has(> button:focus-visible) button svg { + width: 14px; + opacity: 1; + transform: scale(1); +} + +.game-resource-outline button:hover, +.game-resource-outline button:focus-visible { + background: var(--platform-nav-item-hover-fill); + outline: 0; +} + +.game-resource-outline button.is-active { + color: var(--platform-nav-item-text-active); +} + +.game-resource-outline:hover button.is-active, +.game-resource-outline:has(> button:focus-visible) button.is-active { + border-color: var(--platform-nav-active-border); + background: var(--platform-nav-active-fill); + box-shadow: var(--platform-nav-active-shadow); +} + +.game-resource-outline button strong { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-page { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; + height: 100%; + min-width: 0; + min-height: 0; +} + +.game-resource-page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + padding: 0 4px; +} + +.game-resource-page-heading span { + display: inline-flex; + align-items: center; + gap: 8px; + color: #6d4b3f; + font-size: 13px; +} + +.game-resource-page-heading strong { + font-size: 15px; +} + +.game-resource-page-heading small { + color: #a98a7c; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.game-resource-page-canvas { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid #ecdfd8; + border-radius: 12px; + background: + radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px, + #fffdfa; +} + +.game-resource-canvas-reset { + position: absolute; + top: 12px; + right: 12px; + z-index: 3; + display: grid; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgb(227 207 197 / 88%); + border-radius: 8px; + background: rgb(255 253 250 / 88%); + color: #8c6252; + cursor: pointer; + place-items: center; + box-shadow: 0 4px 12px rgb(112 70 52 / 10%); + backdrop-filter: blur(4px); +} + +.game-resource-canvas-reset:hover, +.game-resource-canvas-reset:focus-visible { + border-color: #ce7650; + color: #bd5f37; + outline: 2px solid rgb(206 118 80 / 22%); + outline-offset: 1px; +} + +.game-resource-page-world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.game-resource-page-empty { + position: absolute; + inset: 0; + display: grid; + padding: 24px; + color: #b0958a; + font-size: 12px; + place-items: center; + pointer-events: none; +} + +.game-resource-next-page { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 0 14px; + border: 1px solid #e8d6cc; + border-radius: 10px; + background: #fff8f4; + color: #725348; + cursor: pointer; +} + +.game-resource-next-page:hover, +.game-resource-next-page:focus-visible { + border-color: #d6946f; + color: #a9502a; + outline: 0; +} + +.game-resource-next-page span { + color: #a98a7c; + font-size: 10px; +} + +.game-resource-next-page strong { + font-size: 12px; +} + .game-resource-section-stack { display: contents; } @@ -5574,6 +5838,18 @@ iframe.preview-frame { transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); } +.game-resource-card { + cursor: grab; + touch-action: none; +} + +.game-resource-card.is-dragging { + cursor: grabbing; + box-shadow: + 0 14px 32px rgb(195 105 62 / 24%), + 0 0 0 2px rgb(216 115 66 / 24%); +} + .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { @@ -5834,7 +6110,7 @@ iframe.preview-frame { flex: 0 0 auto; } -.game-resource-focus-actions > button:first-child { +.game-resource-focus-action { min-height: 32px; padding: 0 14px; border: 1px solid #d78d69; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index 9be551ba2..fab3365d9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -41,6 +41,7 @@ export type ResourceDependencyOverlayProps = { section?: ProjectResourceCanvasSection; visibleResourceIds: ReadonlySet; cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId; + canvasViewport?: Readonly; geometryRevision?: string; }; @@ -460,6 +461,7 @@ export const ResourceDependencyOverlay = forwardRef< ResourceDependencyOverlayProps >(function ResourceDependencyOverlay( { + canvasViewport, cardSizeByResourceId, geometryRevision = '', graph, @@ -492,10 +494,6 @@ export const ResourceDependencyOverlay = forwardRef< useLayoutEffect(() => { const plane = overlayRef.current?.parentElement; const viewport = plane?.closest(SECTION_SCROLL_SELECTOR); - if (plane?.closest('.game-resource-canvas--dependency')) { - setLogicalViewport(null); - return undefined; - } if (!plane || !viewport) { setLogicalViewport(null); return undefined; @@ -504,16 +502,26 @@ export const ResourceDependencyOverlay = forwardRef< const measure = () => { frameId = null; const parsedScale = Number(plane.dataset.resourceSectionScale); - const scale = + const legacyScale = Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1; + const transformedViewport = plane.closest( + '.game-resource-canvas--dependency', + ) + ? canvasViewport + : undefined; + const scale = transformedViewport?.scale ?? legacyScale; const viewportRect = viewport.getBoundingClientRect(); const width = viewport.clientWidth || viewportRect.width; const height = viewport.clientHeight || viewportRect.height; const next = width > 0 && height > 0 ? { - x: viewport.scrollLeft / scale, - y: viewport.scrollTop / scale, + x: transformedViewport + ? -transformedViewport.x / transformedViewport.scale + : viewport.scrollLeft / scale, + y: transformedViewport + ? -transformedViewport.y / transformedViewport.scale + : viewport.scrollTop / scale, width: width / scale, height: height / scale, } @@ -545,7 +553,7 @@ export const ResourceDependencyOverlay = forwardRef< viewport.removeEventListener('scroll', scheduleMeasure); window.removeEventListener('resize', scheduleMeasure); }; - }, [geometryRevision]); + }, [canvasViewport, geometryRevision]); const rectByResourceId = useMemo(() => { const result = new Map(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 3551689db..ac08c3748 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1,7 +1,6 @@ import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core'; import { type CanvasViewport, - fitViewportToBounds, resolveViewportFromWheel, } from '@genarrative/image-canvas-core'; import { @@ -12,20 +11,15 @@ import { Image, Info, ListFilter, - Maximize2, - Minus, Music2, Pause, Play, - Plus, RotateCcw, Search, Settings2, SlidersHorizontal, Sparkles, X, - ZoomIn, - ZoomOut, } from 'lucide-react'; import { type CSSProperties, @@ -81,11 +75,17 @@ import { type ResourceFocusIntent, } from './projectResourceLiveUpdateModel'; import { - constrainResourceCanvasViewport, createResourceCanvasCardSizeByResourceId, - RESOURCE_CANVAS_BOUNDARY_PADDING, + fitResourceCanvasViewportToContent, + normalizeInfiniteResourceCanvasViewport, + RESOURCE_CANVAS_DRAG_THRESHOLD, + RESOURCE_CANVAS_FIT_PADDING, + RESOURCE_CANVAS_SECTION_MIN_HEIGHT, + RESOURCE_CANVAS_SECTION_MIN_WIDTH, + RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, + resourceCanvasContentBounds, resourceCanvasSectionExtent, } from './resourceCanvasLayoutModel'; import { @@ -100,7 +100,10 @@ import { type ProjectResourceGraphNodeInput, type ProjectResourceGraphReadModel, } from './resourceDependencyGraphModel'; -import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from './ResourceDependencyOverlay'; import { defaultDerivedResourceName, type ProjectResourceEditCapability, @@ -288,19 +291,60 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; -const RESOURCE_DEPENDENCY_SECTION_HEADER_HEIGHT = 48; -const RESOURCE_DEPENDENCY_SECTION_GAP = 56; +type ResourceCanvasViewportByCategory = Record< + ResourceCategory, + CanvasViewport +>; +type ResourceCanvasViewportBySortMode = Record< + ResourceSortMode, + ResourceCanvasViewportByCategory +>; + +function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { + const byCategory = (): ResourceCanvasViewportByCategory => ({ + document: { x: 48, y: 48, scale: 1 }, + art: { x: 48, y: 48, scale: 1 }, + audio: { x: 48, y: 48, scale: 1 }, + code: { x: 48, y: 48, scale: 1 }, + version: { x: 48, y: 48, scale: 1 }, + }); + return { + dependency: byCategory(), + type: byCategory(), + }; +} const RESOURCE_CANVAS_FALLBACK_WIDTH = 900; const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640; +const RESOURCE_CANVAS_MIN_DRAG_COORDINATE = -1_000_000; +const RESOURCE_CANVAS_MAX_DRAG_COORDINATE = 1_000_000; + +function normalizeResourceCanvasDragCoordinate(value: number) { + return Math.min( + RESOURCE_CANVAS_MAX_DRAG_COORDINATE, + Math.max(RESOURCE_CANVAS_MIN_DRAG_COORDINATE, Math.round(value)), + ); +} + +function resourceCanvasViewportElement(element: HTMLElement | null) { + const pageCanvas = element?.querySelector( + '.game-resource-page-canvas', + ); + return pageCanvas ?? element; +} function resourceCanvasElementSize(element: HTMLElement | null) { - const rect = element?.getBoundingClientRect(); + const viewportElement = resourceCanvasViewportElement(element); + const rect = viewportElement?.getBoundingClientRect(); return { width: - element?.clientWidth || rect?.width || RESOURCE_CANVAS_FALLBACK_WIDTH, + viewportElement?.clientWidth || + rect?.width || + RESOURCE_CANVAS_FALLBACK_WIDTH, height: - element?.clientHeight || rect?.height || RESOURCE_CANVAS_FALLBACK_HEIGHT, + viewportElement?.clientHeight || + rect?.height || + RESOURCE_CANVAS_FALLBACK_HEIGHT, }; } @@ -336,20 +380,14 @@ export type ProjectDevelopmentViewProps = { ) => void; }; -const categoryOrder: ResourceCategory[] = [ - 'code', - 'document', - 'version', - 'art', - 'audio', -]; +const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER; const categoryLabels: Record = { code: '游戏代码', - document: '文档', + document: '设计文档', version: '项目版本', art: '美术资源', - audio: '音乐音效资源', + audio: '音乐音效', }; const categoryIcons: Record = { @@ -474,11 +512,16 @@ const ResourceCard = memo(function ResourceCard({ relationState, x, y, + dragging, previewIdentity, preview, cardSize, activeMediaIdentity, onSelect, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, onObservePreview, onRequestPreview, onPreviewDecodeError, @@ -491,11 +534,21 @@ const ResourceCard = memo(function ResourceCard({ relationState: 'version-binding' | null; x: number; y: number; + dragging: boolean; previewIdentity: string; preview: ProjectResourceCardPreviewState; cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string) => void; + onPointerDown: ( + event: ReactPointerEvent, + resource: ProjectResource, + x: number, + y: number, + ) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; onObservePreview: ( element: HTMLElement, resource: ProjectResource, @@ -705,7 +758,7 @@ const ResourceCard = memo(function ResourceCard({ ref={cardRef} className={`game-resource-card${selected ? ' is-selected' : ''}${ relationState ? ` is-relation-${relationState}` : '' - }`} + }${dragging ? ' is-dragging' : ''}`} data-preview-kind={kind} style={ { @@ -715,6 +768,11 @@ const ResourceCard = memo(function ResourceCard({ '--resource-card-height': `${cardSize.height}px`, } as CSSProperties } + onPointerDown={(event) => onPointerDown(event, resource, x, y)} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + onClick={() => onSelect(resource.id)} >