From 60b1e52d74f3b7049bfffddb4db9a5b4d3a9ca9a Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 24 Aug 2026 10:51:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=B4=A0=E6=9D=90=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E7=94=BB=E5=B8=83=E5=80=99=E9=80=89=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=B8=8E=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增候选图层独立幂等确认命令与前端 FIFO 屏障 补齐生成候选恢复、JPEG EXIF 尺寸和资源依赖线视口处理 调整资源画布布局同步并更新定向测试、技术文档与排障记忆 --- .../src-tauri/src/commands.rs | 8 + .../src-tauri/src/image_inspect.rs | 172 ++++++- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/asset_canvas.rs | 7 +- .../src/project/asset_canvas/generation.rs | 141 +++++- .../asset-canvas/AssetCanvasSurface.tsx | 435 ++++++++++++++---- .../tauriImageCanvasHostAdapter.ts | 26 +- .../ResourceDependencyOverlay.tsx | 24 +- .../src/view/project-development/index.tsx | 1 + .../useProjectResourceCanvasLayout.ts | 16 +- .../tests/ResourceDependencyOverlay.test.ts | 80 ++++ .../tests/assetCanvasSurface.test.tsx | 380 ++++++++++++++- .../useProjectResourceCanvasLayout.test.ts | 45 +- docs/project-memory/shared-memory/pitfalls.md | 7 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 2 +- packages/image-canvas-core/src/ports.ts | 4 + 16 files changed, 1158 insertions(+), 191 deletions(-) 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 cf03f933d..944412196 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 ba8f5516c..5e7bd208d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2382,6 +2382,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 63bc3431b..d71bc382a 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, 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 dc568b67e..8f948e6aa 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 @@ -166,6 +166,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, @@ -1496,7 +1511,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(); @@ -1504,7 +1518,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 @@ -1525,20 +1539,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, @@ -4350,29 +4418,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); @@ -4384,10 +4489,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/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index c7d58ad17..e46c79025 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 @@ -162,7 +162,10 @@ export function resolveQuickEditPanelPosition(input: { panelSize: { width: number; height: number }; }) { const { layer, viewport, canvasSize, panelSize } = input; - const usableWidth = Math.max(0, canvasSize.width - QUICK_EDIT_PANEL_PADDING * 2); + const usableWidth = Math.max( + 0, + canvasSize.width - QUICK_EDIT_PANEL_PADDING * 2, + ); const usableHeight = Math.max( 0, canvasSize.height - QUICK_EDIT_PANEL_PADDING * 2, @@ -172,11 +175,11 @@ export function resolveQuickEditPanelPosition(input: { const anchorLeft = viewport.x + layer.x * viewport.scale; const anchorRight = viewport.x + (layer.x + layer.width) * viewport.scale; const anchorTop = viewport.y + layer.y * viewport.scale; - const anchorBottom = - viewport.y + (layer.y + layer.height) * viewport.scale; + const anchorBottom = viewport.y + (layer.y + layer.height) * viewport.scale; const anchorCenter = (anchorLeft + anchorRight) / 2; const minCenter = QUICK_EDIT_PANEL_PADDING + panelWidth / 2; - const maxCenter = canvasSize.width - QUICK_EDIT_PANEL_PADDING - panelWidth / 2; + const maxCenter = + canvasSize.width - QUICK_EDIT_PANEL_PADDING - panelWidth / 2; const belowTop = anchorBottom + QUICK_EDIT_PANEL_GAP; const aboveTop = anchorTop - QUICK_EDIT_PANEL_GAP - panelHeight; const preferredTop = @@ -858,6 +861,10 @@ export function AssetCanvasSurface({ const dragRef = useRef(null); const suppressNextLayerClickRef = useRef(false); const saveQueueRef = useRef>(Promise.resolve()); + const pendingCandidateAcknowledgementLayerIdsRef = useRef(new Set()); + const candidateAcknowledgementPromiseRef = useRef>( + Promise.resolve(true), + ); const savePromiseRef = useRef | null>(null); const hostRevisionRef = useRef(expectedHostRevision); const previewUrlsRef = useRef(new Set()); @@ -1005,6 +1012,240 @@ export function AssetCanvasSurface({ [stableScope], ); + const enqueueDraftUpdate = useCallback( + async ({ + buildCanvas, + persistedVersion, + }: { + buildCanvas: () => ImageCanvasDraftCanvas; + persistedVersion: number | null; + }): Promise => { + const epoch = epochRef.current; + const task = saveQueueRef.current.then(async () => { + const currentDraft = draftRef.current; + if (!currentDraft || epoch !== epochRef.current) return null; + const result = await host.project.updateDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + status: 'editing', + canvas: buildCanvas(), + generations: currentDraft.generations, + }); + if (epoch !== epochRef.current) return null; + if (result.status === 'conflict') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return null; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' ? result.message : '草稿保存失败', + reconciliationRequired: false, + }); + return null; + } + applyDraftCandidate(result.value); + if (persistedVersion !== null) { + persistedDocumentVersionRef.current = Math.max( + persistedDocumentVersionRef.current, + persistedVersion, + ); + if ( + persistedVersion === documentVersionRef.current && + lifecycleRef.current.kind === 'canvas.editing' + ) { + setLifecycle({ kind: 'canvas.editing', dirty: false }); + } + } + return result.value; + }); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, + [applyDraftCandidate, host.project, stableScope], + ); + + const flushCandidateAcknowledgements = useCallback(() => { + const epoch = epochRef.current; + const task = saveQueueRef.current.then(async (): Promise => { + if (epoch !== epochRef.current) return false; + const layerIds = [...pendingCandidateAcknowledgementLayerIdsRef.current]; + if (!layerIds.length) return true; + pendingCandidateAcknowledgementLayerIdsRef.current.clear(); + const currentDraft = draftRef.current; + if (!currentDraft) return false; + try { + const result = await host.project.acknowledgeCandidateLayers({ + scope: stableScope, + layerIds, + }); + if (epoch !== epochRef.current) return false; + if (result.status !== 'ok') { + for (const layerId of layerIds) { + pendingCandidateAcknowledgementLayerIdsRef.current.add(layerId); + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' ? result.message : '候选图层确认失败', + reconciliationRequired: false, + }); + return false; + } + if (result.value.revision !== currentDraft.revision) { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return false; + } + return true; + } catch (error) { + if (epoch !== epochRef.current) return false; + for (const layerId of layerIds) { + pendingCandidateAcknowledgementLayerIdsRef.current.add(layerId); + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: 'candidate-acknowledgement-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + return false; + } + }); + candidateAcknowledgementPromiseRef.current = task; + saveQueueRef.current = task.then(() => undefined); + return task; + }, [host.project, stableScope]); + + const acknowledgeCandidateLayers = useCallback( + (candidateLayers: readonly ImageCanvasDraftCanvas['layers'][number][]) => { + if (!candidateLayers.length) return Promise.resolve(true); + for (const layer of candidateLayers) { + pendingCandidateAcknowledgementLayerIdsRef.current.add(layer.layerId); + } + return flushCandidateAcknowledgements(); + }, + [flushCandidateAcknowledgements], + ); + + const waitForCandidateAcknowledgements = useCallback(async () => { + let pending = pendingCandidateAcknowledgementLayerIdsRef.current.size + ? flushCandidateAcknowledgements() + : candidateAcknowledgementPromiseRef.current; + let acknowledgementsSettled = false; + while (!acknowledgementsSettled) { + if (!(await pending)) return false; + acknowledgementsSettled = + pending === candidateAcknowledgementPromiseRef.current && + pendingCandidateAcknowledgementLayerIdsRef.current.size === 0; + if (!acknowledgementsSettled) { + pending = candidateAcknowledgementPromiseRef.current; + } + } + return true; + }, [flushCandidateAcknowledgements]); + + const applyRecoveredDraftCandidate = useCallback( + async (candidate: ImageCanvasDraft, epoch: number) => { + if (!applyDraftCandidate(candidate)) return; + const mountedLayerIds = new Set( + layersRef.current.map((layer) => layer.id), + ); + const missingLayers = candidate.canvas.layers.filter( + (layer) => !mountedLayerIds.has(layer.layerId), + ); + if (!missingLayers.length) return; + + const recoveredLayers = await Promise.all( + missingLayers.map(async (layer) => { + try { + const preview = await host.readMediaPreview({ + scope: stableScope, + mediaRef: layer.mediaRef, + }); + if (preview.status !== 'ok') return null; + if (epoch !== epochRef.current) { + URL.revokeObjectURL(preview.value.previewUrl); + return null; + } + return { + draftLayer: layer, + runtimeLayer: runtimeLayerFromDraftLayer( + layer, + preview.value.previewUrl, + ), + }; + } catch { + return null; + } + }), + ); + if (epoch !== epochRef.current) { + for (const recovered of recoveredLayers) { + if (recovered) URL.revokeObjectURL(recovered.runtimeLayer.src); + } + return; + } + + const currentDraftLayerById = new Map( + (draftRef.current?.canvas.layers ?? []).map((layer) => [ + layer.layerId, + layer, + ]), + ); + const currentLayerIds = new Set( + layersRef.current.map((layer) => layer.id), + ); + const additions: RuntimeCanvasLayer[] = []; + for (const recovered of recoveredLayers) { + if (!recovered) continue; + const currentDraftLayer = currentDraftLayerById.get( + recovered.draftLayer.layerId, + ); + if ( + currentLayerIds.has(recovered.runtimeLayer.id) || + !currentDraftLayer || + JSON.stringify(currentDraftLayer) !== + JSON.stringify(recovered.draftLayer) + ) { + URL.revokeObjectURL(recovered.runtimeLayer.src); + continue; + } + currentLayerIds.add(recovered.runtimeLayer.id); + previewUrlsRef.current.add(recovered.runtimeLayer.src); + additions.push(recovered.runtimeLayer); + } + if (!additions.length) return; + const nextLayers = [...layersRef.current, ...additions]; + layersRef.current = nextLayers; + setLayers(nextLayers); + const additionIds = new Set(additions.map((layer) => layer.id)); + acknowledgeCandidateLayers( + candidate.canvas.layers.filter((layer) => + additionIds.has(layer.layerId), + ), + ); + }, + [acknowledgeCandidateLayers, applyDraftCandidate, host, stableScope], + ); + const applyGenerationProgressRevision = useCallback( (progress: ImageCanvasGenerationProgress) => { setGenerationTasks((current) => { @@ -1059,10 +1300,10 @@ export function AssetCanvasSurface({ ) { return; } - applyDraftCandidate(loaded.value); + void applyRecoveredDraftCandidate(loaded.value, epoch); }); }, - [applyDraftCandidate, host.project, stableScope], + [applyRecoveredDraftCandidate, host.project, stableScope], ); const canvasHistoryRefs = useMemo( @@ -1166,6 +1407,8 @@ export function AssetCanvasSurface({ const epoch = epochRef.current + 1; epochRef.current = epoch; saveQueueRef.current = Promise.resolve(); + pendingCandidateAcknowledgementLayerIdsRef.current.clear(); + candidateAcknowledgementPromiseRef.current = Promise.resolve(true); savePromiseRef.current = null; pendingCommitRef.current = null; pendingGenerationRef.current = null; @@ -1262,6 +1505,14 @@ export function AssetCanvasSurface({ await hydrateDraft(nextDraft, epoch); if (epoch !== epochRef.current) return; + if ( + nextDraft.generations.some( + (generation) => generation.phase === 'candidate-ready', + ) + ) { + acknowledgeCandidateLayers(nextDraft.canvas.layers); + } + // 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑, // 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。 setLifecycle({ kind: 'canvas.editing', dirty: false }); @@ -1344,6 +1595,7 @@ export function AssetCanvasSurface({ previewUrls.clear(); }; }, [ + acknowledgeCandidateLayers, applyGenerationProgressRevision, expectedHostRevision, host, @@ -1485,61 +1737,18 @@ export function AssetCanvasSurface({ const persistDraft = useCallback(async (): Promise => { - const epoch = epochRef.current; const requestedVersion = documentVersionRef.current; - const task = saveQueueRef.current.then(async () => { - const currentDraft = draftRef.current; - if (!currentDraft || epoch !== epochRef.current) return null; - const result = await host.project.updateDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - status: 'editing', - canvas: draftCanvasFromRuntime( + return await enqueueDraftUpdate({ + buildCanvas: () => + draftCanvasFromRuntime( layersRef.current, viewportRef.current, backgroundRef.current, selectionRef.current, ), - generations: currentDraft.generations, - }); - if (epoch !== epochRef.current) return null; - if (result.status === 'conflict') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: 'draft-revision-conflict', - message: '草稿已被另一个窗口更新,请重新打开后继续', - reconciliationRequired: false, - }); - return null; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' ? result.message : '草稿保存失败', - reconciliationRequired: false, - }); - return null; - } - applyDraftCandidate(result.value); - persistedDocumentVersionRef.current = Math.max( - persistedDocumentVersionRef.current, - requestedVersion, - ); - if ( - requestedVersion === documentVersionRef.current && - lifecycleRef.current.kind === 'canvas.editing' - ) { - setLifecycle({ kind: 'canvas.editing', dirty: false }); - } - return result.value; + persistedVersion: requestedVersion, }); - saveQueueRef.current = task.catch(() => undefined); - return await task; - }, [applyDraftCandidate, host.project, stableScope]); + }, [enqueueDraftUpdate]); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -1717,6 +1926,7 @@ export function AssetCanvasSurface({ ) { return; } + if (!(await waitForCandidateAcknowledgements())) return; if ( documentVersionRef.current !== persistedDocumentVersionRef.current && !(await persistDraft()) @@ -1837,17 +2047,17 @@ export function AssetCanvasSurface({ host, persistDraft, stableScope, + waitForCandidateAcknowledgements, ]); const handleImport = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ''; - const currentDraft = draftRef.current; if ( lifecycleRef.current.kind !== 'canvas.editing' || backgroundInteractionLockedRef.current || - !currentDraft || + !draftRef.current || !files.length ) return; @@ -1880,6 +2090,16 @@ export function AssetCanvasSurface({ ) { return; } + if (!(await waitForCandidateAcknowledgements())) return; + const currentDraft = draftRef.current; + if ( + !currentDraft || + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + return; + } const imported = await host.asset.importImages({ scope: stableScope, expectedDraftRevision: currentDraft.revision, @@ -1953,7 +2173,13 @@ export function AssetCanvasSurface({ markDirty(); setNotice(`已导入 ${additions.length} 张图片`); }, - [captureHistory, host.asset, markDirty, stableScope], + [ + captureHistory, + host.asset, + markDirty, + stableScope, + waitForCandidateAcknowledgements, + ], ); const deleteCanvasLayer = useCallback( @@ -2027,8 +2253,9 @@ export function AssetCanvasSurface({ const task = (async () => { const epoch = saveEpoch; if (!draftRef.current) return; + setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); + if (!(await waitForCandidateAcknowledgements())) return; if (documentVersionRef.current !== persistedDocumentVersionRef.current) { - setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); const persisted = await persistDraft(); if (!persisted) return; } @@ -2163,6 +2390,7 @@ export function AssetCanvasSurface({ renderImage, sessionId, stableScope, + waitForCandidateAcknowledgements, ]); const discardCanvas = useCallback(() => { @@ -2180,33 +2408,35 @@ export function AssetCanvasSurface({ } setExitActionPending(true); const epoch = epochRef.current; - void host.project - .discardDraft({ + void (async () => { + if (!(await waitForCandidateAcknowledgements())) return; + const acknowledgedDraft = draftRef.current; + if (!acknowledgedDraft || epoch !== epochRef.current) return; + const result = await host.project.discardDraft({ scope: stableScope, - expectedDraftRevision: currentDraft.revision, - }) - .then((result) => { - if (epoch !== epochRef.current) return; - if (result.status === 'ok') { - setExitDialogOpen(false); - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'discarded', - }); - return; - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'cancellation', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '素材画布取消发生 revision 冲突', - reconciliationRequired: false, - }); + expectedDraftRevision: acknowledgedDraft.revision, + }); + if (epoch !== epochRef.current) return; + if (result.status === 'ok') { setExitDialogOpen(false); - }) + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'discarded', + }); + return; + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'cancellation', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '素材画布取消发生 revision 冲突', + reconciliationRequired: false, + }); + setExitDialogOpen(false); + })() .catch((error: unknown) => { if (epoch !== epochRef.current) return; setLifecycle({ @@ -2221,7 +2451,7 @@ export function AssetCanvasSurface({ .finally(() => { if (epoch === epochRef.current) setExitActionPending(false); }); - }, [host.project, onCancel, stableScope]); + }, [host.project, onCancel, stableScope, waitForCandidateAcknowledgements]); const keepDraftAndExit = useCallback(() => { if ( @@ -2468,6 +2698,7 @@ export function AssetCanvasSurface({ dragRef.current = null; setNotice('生成任务已提交,结果会作为新候选加入画布'); const task = (async () => { + if (!(await waitForCandidateAcknowledgements())) return; if (needsDraftPersist) { const persisted = await persistDraft(); if (!persisted) return; @@ -2556,14 +2787,17 @@ export function AssetCanvasSurface({ const imageByResourceId = new Map( result.value.images.map((image) => [image.resourceId, image]), ); - const candidateLayers = result.value.draft.canvas.layers - .filter((layer) => imageByResourceId.has(layer.resourceId)) - .map((layer) => { - const image = imageByResourceId.get(layer.resourceId)!; - previewUrlsRef.current.add(image.previewUrl); - return runtimeLayerFromDraftLayer(layer, image.previewUrl); - }); - const mergedLayerIds = new Set(layersRef.current.map((layer) => layer.id)); + const candidateDraftLayers = result.value.draft.canvas.layers.filter( + (layer) => imageByResourceId.has(layer.resourceId), + ); + const candidateLayers = candidateDraftLayers.map((layer) => { + const image = imageByResourceId.get(layer.resourceId)!; + previewUrlsRef.current.add(image.previewUrl); + return runtimeLayerFromDraftLayer(layer, image.previewUrl); + }); + const mergedLayerIds = new Set( + layersRef.current.map((layer) => layer.id), + ); const appendedCandidateLayers = candidateLayers.filter( (layer) => !mergedLayerIds.has(layer.id), ); @@ -2581,14 +2815,13 @@ export function AssetCanvasSurface({ layersRef.current = nextLayers; setLayers(nextLayers); } - // Serialize behind every already queued autosave, then acknowledge the - // candidate together with the latest local canvas state. This keeps - // edits made while generation was running instead of hydrating the - // generation response's older full-canvas snapshot. - if (!(await persistDraft())) return; + if (!(await acknowledgeCandidateLayers(candidateDraftLayers))) return; pendingGenerationRef.current = null; - persistedDocumentVersionRef.current = documentVersionRef.current; - setLifecycle({ kind: 'canvas.editing', dirty: false }); + setLifecycle({ + kind: 'canvas.editing', + dirty: + documentVersionRef.current !== persistedDocumentVersionRef.current, + }); setNotice('候选图片已加入画布,请选择满意结果设为最终图'); })().catch((error: unknown) => { if ( @@ -2627,6 +2860,7 @@ export function AssetCanvasSurface({ generationReferenceResourceIds, generationTasks, host.generation, + acknowledgeCandidateLayers, applyDraftCandidate, onSaveAttempt, onWalletBalanceMayHaveChanged, @@ -2634,6 +2868,7 @@ export function AssetCanvasSurface({ quickEditSourceLayerId, sessionId, stableScope, + waitForCandidateAcknowledgements, ], ); @@ -2649,6 +2884,7 @@ export function AssetCanvasSurface({ ) { return; } + if (!(await waitForCandidateAcknowledgements())) return; if ( documentVersionRef.current !== persistedDocumentVersionRef.current && !(await persistDraft()) @@ -2695,6 +2931,7 @@ export function AssetCanvasSurface({ host.generation, persistDraft, stableScope, + waitForCandidateAcknowledgements, ], ); 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 c4923eeeb..1bced69b4 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; @@ -135,8 +140,13 @@ export type LocalAssetCommittedEvent = { occurredAt: number; }; -export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & { +export type TauriImageCanvasHostAdapter = Omit< + ImageCanvasHostPort, + 'project' +> & { readonly kind: 'tauri'; + readonly project: ImageCanvasProjectPort & + Required>; readonly projectPath: string; readonly expectedProjectId: string; readMediaPreview(input: { @@ -511,6 +521,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/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 2f866d1a6..ccc0e3f70 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 @@ -4433,6 +4433,7 @@ export default function ProjectDevelopmentView({ cardSizeByResourceId={ resourceCardSizeByResourceId } + canvasViewport={activeResourceCanvasViewport} geometryRevision={`${resourceCanvasNavigationBounds.width}@${resourceCanvasNavigationBounds.height}`} /> ) : null} diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index d589f935c..5ace10696 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -32,7 +32,6 @@ type LayoutScope = { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; - resourceSignature: string; }; type ManualLayoutWriteIntent = { @@ -59,9 +58,8 @@ function createScopeKey( projectPath: string, projectId: string, mode: ProjectResourceCanvasLayoutMode, - resourceSignature: string, ) { - return JSON.stringify([projectPath, projectId, mode, resourceSignature]); + return JSON.stringify([projectPath, projectId, mode]); } export function createResourceSignature(resources: ResourceCanvasItem[]) { @@ -239,12 +237,7 @@ export function useProjectResourceCanvasLayout({ () => [createResourceSignature(resources), topologySignature].join('\n'), [resources, topologySignature], ); - const scopeKey = createScopeKey( - projectPath, - projectId, - mode, - resourceSignature, - ); + const scopeKey = createScopeKey(projectPath, projectId, mode); const fallback = useMemo(() => { const empty = createEmptyResourceCanvasLayout(projectId, mode); return initializationReady || renderFallbackWhileBlocked @@ -277,7 +270,6 @@ export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, - resourceSignature, }); const writeQueueRef = useRef([]); const activeWriteIntentRef = useRef(null); @@ -604,7 +596,6 @@ export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, - resourceSignature, }; scopeRef.current = scope; initializedScopeEpochRef.current = null; @@ -697,7 +688,6 @@ export function useProjectResourceCanvasLayout({ projectPath, rebuildOptimisticLayout, rederiveAutomaticPositions, - resourceSignature, scopeKey, ]); @@ -824,7 +814,7 @@ export function useProjectResourceCanvasLayout({ allResourcesPositioned; return { - layout: scopeMatches ? layout : fallback, + layout: ready ? layout : fallback, notice, saving, ready, diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 9916a5dd8..7b75402e2 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -726,6 +726,86 @@ describe('ResourceDependencyOverlay', () => { } }); + it('culls paged dependency edges from the transformed canvas viewport', async () => { + const graph = graphFixture(); + const originalGetBoundingClientRect = + HTMLElement.prototype.getBoundingClientRect; + const getBoundingClientRect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function getPagedCanvasRect(this: HTMLElement) { + if (this.dataset.resourceSectionScroll === 'art') { + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 600, + bottom: 240, + width: 600, + height: 240, + toJSON: () => ({}), + } as DOMRect; + } + return originalGetBoundingClientRect.call(this); + }); + const view = (canvasViewport: { x: number; y: number; scale: number }) => + React.createElement( + 'div', + { className: 'game-resource-canvas--dependency' }, + React.createElement( + 'div', + { 'data-resource-section-scroll': 'art' }, + React.createElement( + 'div', + { className: 'game-resource-page-world' }, + React.createElement(ResourceDependencyOverlay, { + graph, + positions: [ + position('source:one', 0, 0), + position('target:one', 0, 320), + ], + section: 'art', + visibleResourceIds: new Set(['source:one', 'target:one']), + canvasViewport, + }), + ), + ), + ); + try { + const rendered = render(view({ x: 0, y: 0, scale: 1 })); + const overlay = await screen.findByTestId( + 'resource-dependency-overlay-art', + ); + const selector = '[data-edge-kind="asset-reference"]'; + await waitFor(() => { + expect(overlay.dataset.logicalViewport).toBe('0,0,600,240'); + expect( + overlay + .querySelector(selector) + ?.getAttribute('data-edge-continuation'), + ).toBe('outgoing'); + }); + + rendered.rerender(view({ x: 0, y: -180, scale: 1 })); + await waitFor(() => { + expect(overlay.dataset.logicalViewport).toBe('0,180,600,240'); + expect( + overlay + .querySelector(selector) + ?.getAttribute('data-edge-continuation'), + ).toBe('incoming'); + }); + + rendered.rerender(view({ x: 0, y: -1_000, scale: 2 })); + await waitFor(() => { + expect(overlay.dataset.logicalViewport).toBe('0,500,300,120'); + expect(overlay.querySelector(selector)).toBeNull(); + }); + } finally { + getBoundingClientRect.mockRestore(); + } + }); + it('keeps drag previews in plane-local coordinates', async () => { const getBoundingClientRect = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 31dccd919..79a8bf08b 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -31,8 +31,8 @@ import { import { AssetCanvasSurface, generationAspectRatioForOriginalImage, - resolveQuickEditPanelPosition, type RenderAssetCanvasImage, + resolveQuickEditPanelPosition, shouldApplyAssetCanvasDraftCandidate, } from '../src/features/asset-canvas/AssetCanvasSurface'; import { @@ -175,6 +175,7 @@ function memoryHost(input?: { initialDraft?: ImageCanvasDraft | null; recoverGate?: Deferred; recoverImagesGate?: Deferred; + acknowledgementGate?: Deferred; importGate?: Deferred; commitGate?: Deferred; generationGate?: Deferred; @@ -198,6 +199,9 @@ function memoryHost(input?: { | ((progress: ImageCanvasGenerationProgress) => void) | undefined; const updates: ImageCanvasDraftCanvas[] = []; + const acknowledgementCalls: string[][] = []; + const acknowledgedLayerIds = new Set(); + const acknowledgementWrites = vi.fn(); const commits: Array<{ commitId: string; idempotencyKey: string; @@ -346,6 +350,21 @@ function memoryHost(input?: { return { status: 'ok', value: draft }; }, updateDraft, + async acknowledgeCandidateLayers({ layerIds }) { + acknowledgementCalls.push([...layerIds]); + await input?.acknowledgementGate?.promise; + if (!draft) throw new Error('draft missing'); + const newlyAcknowledged = layerIds.filter( + (layerId) => !acknowledgedLayerIds.has(layerId), + ); + if (newlyAcknowledged.length) { + newlyAcknowledged.forEach((layerId) => + acknowledgedLayerIds.add(layerId), + ); + acknowledgementWrites(); + } + return { status: 'ok' as const, value: draft }; + }, discardDraft, }, asset: { @@ -525,7 +544,8 @@ function memoryHost(input?: { revision: draft.revision + 1, generations: [ ...draft.generations.filter( - (record) => record.generationId !== generationInput.generationId, + (record) => + record.generationId !== generationInput.generationId, ), failedGeneration, ], @@ -742,6 +762,8 @@ function memoryHost(input?: { selectedCandidateCommits, generationCalls, updates, + acknowledgementCalls, + acknowledgementWrites, revokedSubscriptions, nativeImportCalls, loadDraft, @@ -1415,6 +1437,328 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect(memory.recoverImages).toHaveBeenCalledTimes(1); }); + it('初始 hydrate 的候选图层先确认再允许删除持久化', async () => { + const acknowledgementGate = deferred(); + const initialDraft = draftFixture(scope, keyboardCanvas()); + const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = { + ...initialDraft.canvas.layers[0]!, + layerId: 'hydrated-candidate-layer', + resourceId: 'draft-media:hydrated-candidate', + title: '初始恢复候选图', + mediaRef: { + kind: 'draft-media', + mediaId: 'hydrated-candidate', + mediaType: 'image/png', + sha256: '8'.repeat(64), + byteLength: 4, + pixelWidth: 64, + pixelHeight: 64, + }, + zIndex: initialDraft.canvas.layers.length, + }; + const candidateDraft: ImageCanvasDraft = { + ...initialDraft, + canvas: { + ...initialDraft.canvas, + layers: [...initialDraft.canvas.layers, candidateLayer], + }, + generations: [ + { + generationId: 'hydrated-generation', + intentId: 'hydrated-intent', + phase: 'candidate-ready', + referenceResourceIds: [], + outputAssetId: null, + sourceLayerId: null, + placeholder: null, + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + ], + }; + const memory = memoryHost({ + initialDraft: candidateDraft, + acknowledgementGate, + }); + renderSurface(memory.host); + + const candidateButton = await screen.findByRole('button', { + name: '选择图层 初始恢复候选图', + }); + await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1)); + expect(memory.getDraft()?.revision).toBe(candidateDraft.revision); + expect(memory.updateDraft).not.toHaveBeenCalled(); + + fireEvent.click(candidateButton); + const quickEdit = await screen.findByRole('region', { + name: '快速编辑图片', + }); + fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' })); + await act( + async () => new Promise((resolve) => window.setTimeout(resolve, 220)), + ); + expect(memory.updateDraft).not.toHaveBeenCalled(); + expect(memory.getDraft()?.revision).toBe(candidateDraft.revision); + + await act(async () => acknowledgementGate.resolve()); + + await waitFor(() => + expect( + memory.updates.some((canvas) => + canvas.layers.every( + (layer) => layer.layerId !== candidateLayer.layerId, + ), + ), + ).toBe(true), + ); + expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1); + expect(memory.updateDraft).toHaveBeenCalledTimes(1); + expect(memory.getDraft()?.revision).toBe(candidateDraft.revision + 1); + }); + + it('重复打开已确认候选只执行幂等确认且不推进草稿 revision', async () => { + const initialDraft = draftFixture(scope, keyboardCanvas()); + const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = { + ...initialDraft.canvas.layers[0]!, + layerId: 'reopened-candidate-layer', + resourceId: 'draft-media:reopened-candidate', + title: '重复打开候选图', + mediaRef: { + kind: 'draft-media', + mediaId: 'reopened-candidate', + mediaType: 'image/png', + sha256: '7'.repeat(64), + byteLength: 4, + pixelWidth: 64, + pixelHeight: 64, + }, + zIndex: initialDraft.canvas.layers.length, + }; + const candidateDraft: ImageCanvasDraft = { + ...initialDraft, + canvas: { + ...initialDraft.canvas, + layers: [...initialDraft.canvas.layers, candidateLayer], + }, + generations: [ + { + generationId: 'reopened-generation', + intentId: 'reopened-intent', + phase: 'candidate-ready', + referenceResourceIds: [], + outputAssetId: null, + sourceLayerId: null, + placeholder: null, + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + ], + }; + const memory = memoryHost({ initialDraft: candidateDraft }); + const first = renderSurface(memory.host); + await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1)); + expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1); + expect(memory.updateDraft).not.toHaveBeenCalled(); + expect(memory.getDraft()?.revision).toBe(candidateDraft.revision); + + first.unmount(); + renderSurface(memory.host); + await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(2)); + expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1); + expect(memory.updateDraft).not.toHaveBeenCalled(); + expect(memory.getDraft()?.revision).toBe(candidateDraft.revision); + }); + + it('后台恢复新增候选图层时保留本地画布并增量挂载候选图', async () => { + const initialDraft = draftFixture(scope, keyboardCanvas()); + const recoveredLayer: ImageCanvasDraftCanvas['layers'][number] = { + ...initialDraft.canvas.layers[0]!, + layerId: 'recovered-candidate-layer', + resourceId: 'draft-media:recovered-candidate', + title: '后台恢复候选图', + mediaRef: { + kind: 'draft-media', + mediaId: 'recovered-candidate', + mediaType: 'image/png', + sha256: '9'.repeat(64), + byteLength: 4, + pixelWidth: 64, + pixelHeight: 64, + }, + x: 120, + y: 80, + width: 64, + height: 64, + originalWidth: 64, + originalHeight: 64, + zIndex: initialDraft.canvas.layers.length, + }; + const recoveredDraft: ImageCanvasDraft = { + ...initialDraft, + revision: 1, + updatedAt: 2, + canvas: { + ...initialDraft.canvas, + layers: [...initialDraft.canvas.layers, recoveredLayer], + selectedLayerIds: [recoveredLayer.layerId], + primarySelectedLayerId: recoveredLayer.layerId, + }, + generations: [ + { + generationId: 'recovered-generation', + intentId: 'recovered-intent', + phase: 'candidate-ready', + referenceResourceIds: [], + outputAssetId: null, + sourceLayerId: null, + placeholder: null, + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + ], + }; + const memory = memoryHost({ initialDraft }); + renderSurface(memory.host); + await screen.findByText('画布可编辑'); + await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1)); + + memory.setDraft(recoveredDraft); + act(() => { + memory.emitRecoveryProgress({ + intentId: 'recovered-intent', + generationId: 'recovered-generation', + phase: 'candidate-ready', + progress: 100, + errorCode: null, + draftRevision: recoveredDraft.revision, + }); + }); + + expect( + await screen.findByRole('button', { + name: '选择图层 后台恢复候选图', + }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '选择图层 第一层' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '选择图层 第二层' }), + ).toBeTruthy(); + + await waitFor(() => + expect( + memory.acknowledgementCalls.some((layerIds) => + layerIds.includes(recoveredLayer.layerId), + ), + ).toBe(true), + ); + fireEvent.click( + screen.getByRole('button', { + name: '选择图层 后台恢复候选图', + }), + ); + const quickEdit = await screen.findByRole('region', { + name: '快速编辑图片', + }); + fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' })); + await waitFor(() => + expect( + memory.updates.some((canvas) => + canvas.layers.every( + (layer) => layer.layerId !== recoveredLayer.layerId, + ), + ), + ).toBe(true), + ); + expect( + memory.acknowledgementCalls.some((layerIds) => + layerIds.includes(recoveredLayer.layerId), + ), + ).toBe(true); + expect(memory.updateDraft).toHaveBeenCalled(); + }); + + it('候选确认未完成时设为最终图会等待确认屏障', async () => { + const acknowledgementGate = deferred(); + const refineScope: ImageCanvasHostScope = { + ...scope, + intent: 'refine', + sourceAssetId: 'source-asset', + }; + const sourceLayer: ImageCanvasDraftCanvas['layers'][number] = { + ...keyboardCanvas().layers[0]!, + layerId: 'barrier-source-layer', + resourceId: 'local-asset:source-asset', + title: '入口图片', + mediaRef: { kind: 'project-asset', assetId: 'source-asset' }, + }; + const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = { + ...keyboardCanvas().layers[1]!, + layerId: 'barrier-candidate-layer', + resourceId: 'draft-media:barrier-candidate', + title: '待确认候选图', + mediaRef: { + kind: 'draft-media', + mediaId: 'barrier-candidate', + mediaType: 'image/png', + sha256: '6'.repeat(64), + byteLength: 4, + pixelWidth: 64, + pixelHeight: 64, + }, + }; + const initialDraft: ImageCanvasDraft = { + ...draftFixture(refineScope, { + ...emptyCanvas(), + layers: [sourceLayer, candidateLayer], + }), + generations: [ + { + generationId: 'barrier-generation', + intentId: 'barrier-intent', + phase: 'candidate-ready', + referenceResourceIds: [], + outputAssetId: null, + sourceLayerId: sourceLayer.layerId, + placeholder: null, + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + ], + }; + const memory = memoryHost({ initialDraft, acknowledgementGate }); + renderSurface(memory.host, refineScope); + + const candidateButton = await screen.findByRole('button', { + name: '选择图层 待确认候选图', + }); + await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1)); + fireEvent.click(candidateButton); + const quickEdit = await screen.findByRole('region', { + name: '快速编辑图片', + }); + fireEvent.click( + within(quickEdit).getByRole('button', { name: '设为最终图' }), + ); + + await act(async () => Promise.resolve()); + expect(memory.selectedCandidateCommits).toHaveLength(0); + expect(memory.getDraft()?.revision).toBe(initialDraft.revision); + + await act(async () => acknowledgementGate.resolve()); + await waitFor(() => + expect(memory.selectedCandidateCommits).toHaveLength(1), + ); + expect(memory.selectedCandidateCommits[0]?.sourceLayerId).toBe( + candidateLayer.layerId, + ); + }); + it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => { const memory = memoryHost(); renderSurface(memory.host); @@ -3625,6 +3969,38 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect(invokeSpy).toHaveBeenCalledTimes(3); }); + it('Tauri adapter 使用独立命令幂等确认候选且不传 expected revision', async () => { + const draft = draftFixture(scope, keyboardCanvas()); + const invokeSpy = vi.fn(async () => ({ draft })); + const adapter = createTauriImageCanvasHostAdapter({ + projectPath: '/fixture/project', + expectedProjectId: scope.projectId, + expectedHostRevision: 0, + invoke: invokeSpy as unknown as ( + command: string, + args?: Record, + ) => Promise, + }); + + const result = await adapter.project.acknowledgeCandidateLayers({ + scope, + layerIds: ['candidate-one', 'candidate-two'], + }); + + expect(result).toEqual({ status: 'ok', value: draft }); + expect(invokeSpy).toHaveBeenCalledWith( + 'acknowledge_local_project_asset_canvas_candidate_layers', + { + input: { + projectPath: '/fixture/project', + expectedProjectId: scope.projectId, + draftId: scope.draftId, + layerIds: ['candidate-one', 'candidate-two'], + }, + }, + ); + }); + it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => { const accessToken = 'session-token-must-stay-ephemeral'; setStoredAuthAccessToken(accessToken); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 1f4720aad..90ba775d2 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -480,7 +480,7 @@ describe('useProjectResourceCanvasLayout', () => { expect(result.current.saving).toBe(false); }); - it('starts a new signed scope and ignores the old layout read when resources change', async () => { + it('keeps the initial layout read and coordinates the latest resources in the same scope', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); const resolveReads: Array<(layout: ProjectResourceCanvasLayout) => void> = @@ -530,16 +530,8 @@ describe('useProjectResourceCanvasLayout', () => { await waitFor(() => expect(resolveReads).toHaveLength(1)); rerender({ resources: [resourceA, resourceB] }); - await waitFor(() => expect(resolveReads).toHaveLength(2)); await act(async () => { resolveReads[0]?.( - persistedLayout('dependency', 99, [position('resource-a', 900, 900)]), - ); - await Promise.resolve(); - }); - expect(updates).toHaveLength(0); - await act(async () => { - resolveReads[1]?.( persistedLayout('dependency', 1, [position('resource-a', 10, 20)]), ); await Promise.resolve(); @@ -560,10 +552,10 @@ describe('useProjectResourceCanvasLayout', () => { invoke.mock.calls.filter( ([command]) => command === 'read_local_project_resource_canvas_layout', ), - ).toHaveLength(2); + ).toHaveLength(1); }); - it('does not let an old in-flight layout CAS block or overwrite a new signed scope', async () => { + it('keeps an in-flight manual CAS ahead of resource coordination in the same scope', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); const updates: Array<{ @@ -629,31 +621,32 @@ describe('useProjectResourceCanvasLayout', () => { ), ).toBe(true), ); + expect(updates).toHaveLength(1); + + await act(async () => { + resolveFirstUpdate?.({ + status: 'updated', + layout: persistedLayout( + 'dependency', + 2, + structuredClone(updates[0]!.positions), + ), + }); + await Promise.resolve(); + }); + await waitFor(() => expect(updates).toHaveLength(2)); expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([ - 1, 1, + 1, 2, ]); expect( updates[1]?.positions.some( ({ resourceId }) => resourceId === 'resource-b', ), ).toBe(true); - - await act(async () => { - resolveFirstUpdate?.({ - status: 'conflict', - layout: persistedLayout( - 'dependency', - 2, - structuredClone(updates[1]!.positions), - ), - }); - await Promise.resolve(); - }); - expect(result.current.layout.positions).toEqual( expect.arrayContaining([ - expect.objectContaining({ resourceId: 'resource-a', x: 10, y: 20 }), + expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }), expect.objectContaining({ resourceId: 'resource-b' }), ]), ); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9aaff8c8a..f429c8ae9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4839,6 +4839,13 @@ - 门禁:同一资源存在 prepared 或 reconciliation 事务时,新的“设为正式图”提交必须先被拒绝并引导安全恢复,不能继续制造另一笔可能覆盖旧结果的提交。`committed/rolled-back/superseded` 是可继续后续提交的终态。 - 关联坑:正式文件路径会追加 commitId,重新打开 refine 时不能把整个文件 stem 当作下一次素材名,否则多次精修后超过 80 字符并阻塞提交。路径只推导显示名,且必须循环剥离历史 `--` 后缀;确定性名称/用途校验要发生在读取候选与 staging 前,并按输入错误处理而不是进入事务恢复。 +## 素材画布候选确认不能复用普通草稿保存(2026-08-23) + +- 现象:候选挂载后用 fire-and-forget `updateDraft` 确认时,候选本身不增加前端 document version;用户立即设为最终图、导入、生成、归档或放弃草稿会携带旧 revision 与隐藏确认写入竞态。重新打开含 `candidate-ready` 的草稿还会重复保存同一画布、无意义推进 revision,并制造多窗口冲突。 +- 处理:候选确认使用独立幂等宿主操作,在现有草稿锁内只更新匹配当前项目、草稿、generation、仍存在权威图层且尚未确认的私有 ledger,记录当前草稿 revision,但不改写草稿或推进 revision。普通草稿 update 只保护未确认候选,不再顺带确认。 +- 前端门禁:确认任务进入 autosave FIFO,连续候选 ID 合并并串行处理;所有消费 draft revision 的提交、导入、生成、归档和 discard 必须先等待确认屏障。确认失败不得继续 revision-sensitive 操作,重复 hydrate 可以重发但后端必须零写入。 +- 验证:前端覆盖确认 pending 时立即设为最终图和删除,断言提交 / update 在确认完成前均未发生;重复打开不调用 `updateDraft` 且 revision 不变。Rust 覆盖首次确认、重复确认、未知或非候选 ID、普通 update 不确认,以及确认后显式删除。 + ## 隔离 Codex app-server 会误吃代理的 ChatGPT 额度头(2026-08-20) - 现象:同一自定义 Responses endpoint 和 API Key 直接 HTTP 为 200,普通用户 HOME 下的 smoke 也完成,但隔离 `CODEX_HOME/HOME` 的 app-server 在真正发请求前返回 `usageLimitExceeded`,并投影 credits balance 0。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 8d3f8d8c0..b75c5ae86 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -910,7 +910,7 @@ cancelling | A34 | 同资源并发正式提交 | 同一 project/draft/asset 存在 prepared 或 reconciliation 事务时再次提交 | 拒绝新提交并要求先安全恢复;已 committed/rolled-back/superseded 事务不阻塞后续提交 | | A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 | | A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 | -| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发 | 前端先同步 authoritative layers 并保存确认;确认前后端把未确认候选层合回旧保存,重启恢复可从私有 ledger 重建候选层,确认后的显式删除仍允许 | +| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发,或重复打开已确认候选 | 前端把候选确认排入草稿保存 FIFO,并在提交、导入、生成、归档和放弃草稿前等待确认屏障;Tauri 在草稿锁内只为当前权威草稿中仍存在且尚未确认的候选更新私有 ledger,不改写草稿或推进 revision。普通 update 在确认前继续把候选层合回旧保存,重复确认无写入,确认后的显式删除仍允许 | | A38 | 稳定运行入口 | 精修替换已在游戏源码中引用的图片,或继续精修旧版本事务创建的资源 | manifest 指向不可变正式版本,同时原稳定入口路径不变并刷新为新版本字节;新事务可从旧事务 `manifest.before.json` 迁移稳定入口身份,幂等重放和事务恢复会修复缺失或不匹配入口,游戏源码不需要改路径 | 阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。 diff --git a/packages/image-canvas-core/src/ports.ts b/packages/image-canvas-core/src/ports.ts index 35e9a9f4b..d51f4a1d4 100644 --- a/packages/image-canvas-core/src/ports.ts +++ b/packages/image-canvas-core/src/ports.ts @@ -193,6 +193,10 @@ export interface ImageCanvasProjectPort { canvas: ImageCanvasDraft['canvas']; generations: ImageCanvasGenerationRecord[]; }): Promise>; + acknowledgeCandidateLayers?(input: { + scope: ImageCanvasHostScope; + layerIds: string[]; + }): Promise>; discardDraft(input: { scope: ImageCanvasHostScope; expectedDraftRevision: number;