From 44fd29a2515414a710ed31c14c615d32bfe97906 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 22 Aug 2026 16:41:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=A2=AB=E6=97=A7=E4=BF=9D=E5=AD=98=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为生成账本增加候选层首次确认修订记录 未确认候选在普通草稿保存中被后端合并保留 恢复画布时从私有账本重建未确认候选层 前端水合后同步运行时引用并立即确认保存 --- .../src-tauri/src/project/asset_canvas.rs | 7 +- .../src/project/asset_canvas/generation.rs | 276 +++++++++++++++++- .../asset-canvas/AssetCanvasSurface.tsx | 8 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 1 + 4 files changed, 287 insertions(+), 5 deletions(-) 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 11c7283b6..a3b4142db 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,7 +1560,12 @@ pub(crate) fn update_asset_canvas_draft_at( .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; validate_safe_revision(draft.revision, "草稿 revision")?; draft.status = input.status.clone(); - draft.canvas = input.canvas.clone(); + draft.canvas = protect_candidate_layers_before_frontend_update_at( + root, + &draft, + draft.revision, + &input.canvas, + )?; // Generation lifecycle facts remain backend-authoritative, while the card // placement is ordinary canvas state and may be changed by the user. // Merge only placeholders so an older autosave cannot erase phase, output, 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 0526ac54e..9e804fb2b 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 @@ -358,6 +358,8 @@ struct AssetCanvasGenerationLedger { candidate_media_ref: Option, #[serde(default)] candidate_layer_id: Option, + #[serde(default)] + candidate_layer_acknowledged_revision: Option, commit_result: Option, error_code: Option, #[serde(default)] @@ -1397,6 +1399,7 @@ fn persist_generation_candidate_at( ledger.current_draft_revision = Some(draft.revision); ledger.candidate_media_ref = Some(media_ref.clone()); ledger.candidate_layer_id = Some(layer_id); + ledger.candidate_layer_acknowledged_revision = None; write_generation_ledger(root, ledger)?; Ok(( record, @@ -1408,6 +1411,171 @@ 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(); + for record in ¤t_draft.generations { + if record.phase != AssetCanvasGenerationStatus::CandidateReady { + continue; + } + let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else { + continue; + }; + if ledger.project_id != current_draft.project_id + || ledger.draft_id != current_draft.draft_id + || ledger.generation_id != record.generation_id + || ledger.candidate_layer_acknowledged_revision.is_some() + { + continue; + } + let Some(layer_id) = ledger.candidate_layer_id.clone() else { + continue; + }; + let Some(candidate_layer) = current_draft + .canvas + .layers + .iter() + .find(|layer| layer.layer_id == layer_id) + else { + continue; + }; + if merged_canvas + .layers + .iter() + .any(|layer| layer.layer_id == layer_id) + { + ledger.candidate_layer_acknowledged_revision = Some(next_revision); + write_generation_ledger(root, &mut ledger)?; + } else { + merged_canvas.layers.push(candidate_layer.clone()); + } + } + Ok(merged_canvas) +} + +pub(crate) fn restore_unacknowledged_candidate_layers_at( + root: &Path, + expected_project_id: &str, + draft_id: &str, +) -> Result, String> { + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.project_id != manifest.project_id || draft.draft_id != draft_id { + return Err("素材画布候选恢复身份不一致".to_string()); + } + + let mut changed = false; + for record in &draft.generations { + if record.phase != AssetCanvasGenerationStatus::CandidateReady { + continue; + } + let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else { + continue; + }; + if ledger.project_id != draft.project_id + || ledger.draft_id != draft.draft_id + || ledger.generation_id != record.generation_id + || ledger.candidate_layer_acknowledged_revision.is_some() + { + continue; + } + let Some(layer_id) = ledger.candidate_layer_id.as_deref() else { + continue; + }; + if draft + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == layer_id) + { + continue; + } + let AssetCanvasMediaRef::DraftMedia { + media_id, + pixel_width, + pixel_height, + .. + } = ledger + .candidate_media_ref + .as_ref() + .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())? + else { + continue; + }; + let (x, y, width, height) = ledger + .placeholder + .as_ref() + .map(|placeholder| { + ( + placeholder.x, + placeholder.y, + placeholder.width, + placeholder.height, + ) + }) + .unwrap_or_else(|| { + ( + 6000.0 - f64::from(*pixel_width) / 2.0, + 6000.0 - f64::from(*pixel_height) / 2.0, + f64::from(*pixel_width), + f64::from(*pixel_height), + ) + }); + let z_index = draft + .canvas + .layers + .iter() + .map(|layer| layer.z_index) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| "生成候选图层层序已达到上限".to_string())?; + draft.canvas.layers.push(AssetCanvasLayer { + layer_id: layer_id.to_string(), + resource_id: format!("draft-media:{media_id}"), + title: format!("{} 候选", ledger.asset_name), + media_ref: ledger + .candidate_media_ref + .clone() + .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?, + x, + y, + width, + height, + original_width: f64::from(*pixel_width), + original_height: f64::from(*pixel_height), + z_index, + group_id: None, + hidden: false, + locked: false, + flip_x: false, + flip_y: false, + }); + draft.canvas.selected_layer_ids = vec![layer_id.to_string()]; + draft.canvas.primary_selected_layer_id = Some(layer_id.to_string()); + changed = true; + } + + if !changed { + return Ok(None); + } + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(Some(draft)) +} + fn normalized_request_fingerprint( input: &GenerateAssetCanvasImageInput, manifest: &GameCreationAppManifest, @@ -1633,6 +1801,7 @@ fn validate_and_prepare_ledger( staged_draft_revision: None, candidate_media_ref: None, candidate_layer_id: None, + candidate_layer_acknowledged_revision: None, commit_result: None, error_code: None, archived_at: None, @@ -3444,6 +3613,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; let api_mode = CanvasGenerationApiMode { api_key }; @@ -3904,6 +4074,7 @@ mod tests { staged_draft_revision: None, candidate_media_ref: None, candidate_layer_id: None, + candidate_layer_acknowledged_revision: None, commit_result: None, error_code: None, archived_at: None, @@ -4046,15 +4217,111 @@ mod tests { assert_eq!(candidate_layer.width, placeholder.width); assert_eq!(candidate_layer.height, placeholder.height); + // Simulate the observed stale-autosave failure: the authoritative draft + // loses an unacknowledged candidate, then recovery restores it from the + // private ledger before the canvas is hydrated again. + let mut lost_draft = updated.clone(); + lost_draft + .canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + lost_draft.canvas.selected_layer_ids = vec![source_layer_id.clone()]; + lost_draft.canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + write_asset_canvas_draft_locked(directory.path(), &lost_draft) + .expect("write draft without candidate"); + let restored = restore_unacknowledged_candidate_layers_at( + directory.path(), + project_id, + &draft.draft_id, + ) + .expect("restore unacknowledged candidate") + .expect("candidate restored"); + assert!(restored + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + + let mut stale_canvas = restored.canvas.clone(); + stale_canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + stale_canvas.selected_layer_ids = vec![source_layer_id.clone()]; + stale_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + let protected = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: restored.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: stale_canvas, + generations: restored.generations.clone(), + }, + ) + .expect("protect stale autosave") + .draft; + assert!(protected + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + + let acknowledged = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: protected.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: protected.canvas.clone(), + generations: protected.generations.clone(), + }, + ) + .expect("acknowledge candidate") + .draft; + let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read acknowledged ledger") + .expect("acknowledged ledger retained"); + assert_eq!( + acknowledged_ledger.candidate_layer_acknowledged_revision, + Some(acknowledged.revision) + ); + + let mut intentional_delete_canvas = acknowledged.canvas.clone(); + intentional_delete_canvas + .layers + .retain(|layer| layer.layer_id != candidate_layer_id); + intentional_delete_canvas.selected_layer_ids = vec![source_layer_id.clone()]; + intentional_delete_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); + let after_delete = update_asset_canvas_draft_at( + directory.path(), + &UpdateAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: acknowledged.revision, + status: AssetCanvasDraftStatus::Editing, + canvas: intentional_delete_canvas, + generations: acknowledged.generations.clone(), + }, + ) + .expect("allow intentional candidate deletion after acknowledgement") + .draft; + assert!(!after_delete + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id)); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) .expect("read ledger after restart") .expect("placement ledger retained"); assert_eq!(recovered_ledger.source_layer_id, Some(source_layer_id)); assert_eq!(recovered_ledger.placeholder, Some(placeholder.clone())); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read draft after restart") - .expect("draft retained after restart"); + let recovered_draft = after_delete; let recovered_record = recovered_draft .generations .iter() @@ -5824,6 +6091,7 @@ mod tests { staged_draft_revision: None, candidate_media_ref: None, candidate_layer_id: None, + candidate_layer_acknowledged_revision: None, commit_result: None, error_code: None, archived_at: None, 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 8ec8ab4f4..b6679e652 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 @@ -1056,6 +1056,10 @@ export function AssetCanvasSurface({ if (epoch !== epochRef.current) return; minimumDraftRevisionRef.current = nextDraft.revision; draftRef.current = nextDraft; + layersRef.current = runtimeLayers; + viewportRef.current = nextDraft.canvas.viewport; + backgroundRef.current = nextDraft.canvas.backgroundColor; + selectionRef.current = nextDraft.canvas.selectedLayerIds; setDraft(nextDraft); setGenerationTasks( nextDraft.generations.map(runtimeGenerationTaskFromRecord), @@ -2463,6 +2467,10 @@ export function AssetCanvasSurface({ URL.revokeObjectURL(image.previewUrl), ); await hydrateDraft(result.value.draft, epoch); + // 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; pendingGenerationRef.current = null; persistedDocumentVersionRef.current = documentVersionRef.current; setLifecycle({ kind: 'canvas.editing', dirty: false }); diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 08b8f0313..af5ec8273 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -910,6 +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 重建候选层,确认后的显式删除仍允许 | 阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。