From a5cdd7f9360197b5d25cdc072479b366db2dcd81 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 24 Aug 2026 19:34:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E8=A7=86=E5=8F=A3=E4=B8=8E=E7=94=9F=E6=88=90=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一资源详情操作样式并恢复空态页面滚动 按排序模式和栏目记忆视口并改进复位适配算法 完善候选确认屏障与连续提交恢复对账 补充前后端回归测试并同步产品和技术文档 --- .../src-tauri/src/project/asset_canvas.rs | 193 +++++++------ .../src/project/asset_canvas_tests.rs | 63 ++++- .../asset-canvas/AssetCanvasSurface.tsx | 197 ++++++++++--- .../tauriImageCanvasHostAdapter.ts | 3 +- apps/ai-game-creator-shell/src/styles.css | 2 +- .../src/view/project-development/index.tsx | 134 ++++++--- .../resourceCanvasLayoutModel.ts | 75 ++++- .../appSurface/project-development.suite.ts | 192 ++++++++++++- .../tests/assetCanvasSurface.test.tsx | 259 +++++++++++++++++- .../tests/resourceCanvasLayoutModel.test.ts | 42 +++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 22 +- .../shared-memory/decision-log.md | 12 +- ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 13 +- packages/image-canvas-core/src/ports.ts | 2 +- 14 files changed, 1000 insertions(+), 209 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 7fb97528a..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 @@ -4102,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) } 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 1b7f8e203..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); @@ -1270,6 +1270,65 @@ fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() })); } +#[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/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 56782ad08..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 @@ -154,8 +154,7 @@ export type TauriImageCanvasHostAdapter = Omit< 'project' > & { readonly kind: 'tauri'; - readonly project: ImageCanvasProjectPort & - Required>; + readonly project: ImageCanvasProjectPort; readonly projectPath: string; readonly expectedProjectId: string; readMediaPreview(input: { diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 44d4d3e44..6760b2656 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6110,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/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d78006ebe..f6a6060a1 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 { @@ -77,6 +76,7 @@ import { } from './projectResourceLiveUpdateModel'; import { createResourceCanvasCardSizeByResourceId, + fitResourceCanvasViewportToContent, normalizeInfiniteResourceCanvasViewport, RESOURCE_CANVAS_DRAG_THRESHOLD, RESOURCE_CANVAS_FIT_PADDING, @@ -85,6 +85,7 @@ import { RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, + resourceCanvasContentBounds, resourceCanvasSectionExtent, } from './resourceCanvasLayoutModel'; import { @@ -302,14 +303,22 @@ type ResourceCanvasViewportByCategory = Record< ResourceCategory, CanvasViewport >; +type ResourceCanvasViewportBySortMode = Record< + ResourceSortMode, + ResourceCanvasViewportByCategory +>; -function defaultResourceCanvasViewports(): ResourceCanvasViewportByCategory { - return { +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(), }; } @@ -829,7 +838,7 @@ export default function ProjectDevelopmentView({ const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); const [resourceCanvasViewports, setResourceCanvasViewports] = - useState(defaultResourceCanvasViewports); + useState(defaultResourceCanvasViewports); const [activeResourceCategory, setActiveResourceCategory] = useState(null); const [selectedResourceId, setSelectedResourceId] = useState( @@ -896,7 +905,7 @@ export default function ProjectDevelopmentView({ y: 48, scale: 1, }); - const resourceCanvasFitKeyRef = useRef(null); + const resourceCanvasFitKeysRef = useRef>(new Set()); const resourceCanvasPageWheelRef = useRef<{ activeCategory: ResourceCategory | null; accumulatedDeltaY: number; @@ -1476,8 +1485,28 @@ export default function ProjectDevelopmentView({ activePageExtent.y, ], ); + const activePageResources = activePageCategory + ? (projectResourcesByCategory.get(activePageCategory) ?? []) + : []; + const activePageLayoutReady = activePageResources.every((resource) => + resourcePositionById.has(resource.id), + ); + const resourceCanvasFitBounds = useMemo( + () => + resourceCanvasContentBounds( + activePageCategory + ? (resourcePositionsByCategory.get(activePageCategory) ?? []) + : [], + resourceCardSizeByResourceId, + ), + [ + activePageCategory, + resourceCardSizeByResourceId, + resourcePositionsByCategory, + ], + ); const activeResourceCanvasViewport = activePageCategory - ? resourceCanvasViewports[activePageCategory] + ? resourceCanvasViewports[sortMode][activePageCategory] : { x: 48, y: 48, scale: 1 }; const setResourceCanvasViewport = useCallback( (candidate: CanvasViewport) => { @@ -1488,13 +1517,23 @@ export default function ProjectDevelopmentView({ } const next = normalizeInfiniteResourceCanvasViewport(candidate); resourceCanvasViewportRef.current = next; - setResourceCanvasViewports((current) => - resourceCanvasViewportsEqual(current[activeCategory], next) + setResourceCanvasViewports((current) => { + const currentSortViewports = current[sortMode]; + return resourceCanvasViewportsEqual( + currentSortViewports[activeCategory], + next, + ) ? current - : { ...current, [activeCategory]: next }, - ); + : { + ...current, + [sortMode]: { + ...currentSortViewports, + [activeCategory]: next, + }, + }; + }); }, - [], + [sortMode], ); resourceCanvasViewportRef.current = activeResourceCanvasViewport; const selectedResource = @@ -2019,7 +2058,11 @@ export default function ProjectDevelopmentView({ ]); useLayoutEffect(() => { - if (mode !== 'resources' || !activePageCategory) { + if ( + mode !== 'resources' || + !activePageCategory || + !activePageLayoutReady + ) { return undefined; } const canvas = resourceCanvasRef.current; @@ -2027,38 +2070,46 @@ export default function ProjectDevelopmentView({ return undefined; } const fitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${activePageCategory}`; - const canvasSize = resourceCanvasElementSize(canvas); - if (resourceCanvasFitKeyRef.current !== fitKey) { - resourceCanvasFitKeyRef.current = fitKey; - setResourceCanvasViewport( - fitViewportToBounds({ - bounds: resourceCanvasNavigationBounds, - canvasSize, - padding: RESOURCE_CANVAS_FIT_PADDING, - }), - ); - } else { + const fitInitialViewportOrPreserve = () => { + if (!resourceCanvasFitKeysRef.current.has(fitKey)) { + const viewportElement = resourceCanvasViewportElement(canvas); + const rect = viewportElement?.getBoundingClientRect(); + const measuredWidth = viewportElement?.clientWidth || rect?.width || 0; + const measuredHeight = + viewportElement?.clientHeight || rect?.height || 0; + if (measuredWidth > 0 && measuredHeight > 0) { + resourceCanvasFitKeysRef.current.add(fitKey); + setResourceCanvasViewport( + fitResourceCanvasViewportToContent({ + bounds: resourceCanvasFitBounds, + canvasSize: { width: measuredWidth, height: measuredHeight }, + padding: RESOURCE_CANVAS_FIT_PADDING, + }), + ); + return; + } + } // Resource extents can change after an image finishes measuring or a // card is manually moved. Keep the user's infinite pan/zoom unchanged. setResourceCanvasViewport(resourceCanvasViewportRef.current); - } - const normalizeViewport = () => - setResourceCanvasViewport(resourceCanvasViewportRef.current); + }; + fitInitialViewportOrPreserve(); const observer = window.ResizeObserver - ? new window.ResizeObserver(normalizeViewport) + ? new window.ResizeObserver(fitInitialViewportOrPreserve) : null; observer?.observe(canvas); - window.addEventListener('resize', normalizeViewport); + window.addEventListener('resize', fitInitialViewportOrPreserve); return () => { observer?.disconnect(); - window.removeEventListener('resize', normalizeViewport); + window.removeEventListener('resize', fitInitialViewportOrPreserve); }; }, [ activePageCategory, + activePageLayoutReady, manifest.projectId, mode, projectPath, - resourceCanvasNavigationBounds, + resourceCanvasFitBounds, setResourceCanvasViewport, sortMode, ]); @@ -2199,10 +2250,10 @@ export default function ProjectDevelopmentView({ setFocusedResourceId(null); restoreResourceListScrollRef.current = false; setActiveResourceCategory(null); - setResourceCanvasViewports(defaultResourceCanvasViewports()); - resourceCanvasFitKeyRef.current = null; - resourceCanvasViewportRef.current = - defaultResourceCanvasViewports().document; + const defaultViewports = defaultResourceCanvasViewports(); + setResourceCanvasViewports(defaultViewports); + resourceCanvasFitKeysRef.current.clear(); + resourceCanvasViewportRef.current = defaultViewports.dependency.document; if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) { window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId); } @@ -2653,13 +2704,13 @@ export default function ProjectDevelopmentView({ const resetResourceCanvasViewport = useCallback(() => { const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); setResourceCanvasViewport( - fitViewportToBounds({ - bounds: resourceCanvasNavigationBounds, + fitResourceCanvasViewportToContent({ + bounds: resourceCanvasFitBounds, canvasSize, padding: RESOURCE_CANVAS_FIT_PADDING, }), ); - }, [resourceCanvasNavigationBounds, setResourceCanvasViewport]); + }, [resourceCanvasFitBounds, setResourceCanvasViewport]); const handleResourceCardPointerDown = useCallback( ( @@ -4119,6 +4170,7 @@ export default function ProjectDevelopmentView({ ) ? (